Add terminal control builtins: term_clear, cursor_to/up/down/col, term_size, term_save/restore_cursor, term_clear_line, print_inline, http_sse_post
Enables full TUI rendering from Engram programs — cursor positioning, screen clearing, inline (no-newline) printing, terminal size detection via TIOCGWINSZ, and SSE streaming with inline token output.
This commit is contained in:
+363
-2
@@ -2161,6 +2161,14 @@ fn dispatch_builtin(
|
||||
// http_serve(port) — general-purpose HTTP server.
|
||||
// Passes every request to the Engram `handle_request(method, path, body)` function.
|
||||
// The legacy /axon/message route is preserved for Neuron compatibility.
|
||||
|
||||
// ── Waitlist rate limiter (max 3 per IP per 10 min) ──────────────────
|
||||
use std::sync::{OnceLock, Mutex};
|
||||
static WAITLIST_RATE_MAP: OnceLock<Mutex<std::collections::HashMap<String, Vec<u64>>>> =
|
||||
OnceLock::new();
|
||||
let waitlist_rate_map = WAITLIST_RATE_MAP
|
||||
.get_or_init(|| Mutex::new(std::collections::HashMap::new()));
|
||||
|
||||
let port_val = stack.pop().unwrap_or(Value::Nil);
|
||||
let port = match port_val {
|
||||
Value::Int(n) => n as u16,
|
||||
@@ -2353,10 +2361,188 @@ fn dispatch_builtin(
|
||||
continue;
|
||||
}
|
||||
|
||||
// POST /api/waitlist — acknowledge (email already captured above)
|
||||
// POST /api/waitlist — honeypot + rate limit + HMAC confirm email
|
||||
if method == "POST" && path == "/api/waitlist" {
|
||||
// ── IP rate limit: max 3 per IP per 10 minutes ──
|
||||
let ip = request.remote_addr()
|
||||
.map(|a| a.ip().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let now_ts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let rate_ok = {
|
||||
let mut map = waitlist_rate_map.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let entry = map.entry(ip).or_insert_with(Vec::new);
|
||||
entry.retain(|&ts| now_ts.saturating_sub(ts) < 600);
|
||||
if entry.len() < 3 { entry.push(now_ts); true } else { false }
|
||||
};
|
||||
if !rate_ok {
|
||||
let _ = request.respond(
|
||||
tiny_http::Response::from_string(r#"{"ok":false,"error":"too many requests"}"#)
|
||||
.with_status_code(429u16)
|
||||
.with_header(content_json)
|
||||
.with_header(cors_origin)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let resend_key = std::env::var("RESEND_API_KEY").unwrap_or_default();
|
||||
let waitlist_secret = std::env::var("WAITLIST_SECRET")
|
||||
.unwrap_or_else(|_| "neuron-waitlist-dev-secret".to_string());
|
||||
|
||||
let result: Result<(), String> = (|| {
|
||||
let v: serde_json::Value = serde_json::from_str(&body)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let email = v["email"].as_str().unwrap_or("").to_string();
|
||||
let name = v["name"].as_str().unwrap_or("there").to_string();
|
||||
let conv_id = v["conv_id"].as_str().unwrap_or("").to_string();
|
||||
let base_url = v["base_url"].as_str().unwrap_or("").to_string();
|
||||
if email.is_empty() { return Err("no email".to_string()); }
|
||||
|
||||
// ── Honeypot: bots fill this, humans don't ──
|
||||
if !v["hp"].as_str().unwrap_or("").is_empty() {
|
||||
// Silently accept but don't process — don't tell bots they failed
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// ── Generate HMAC-signed confirmation token ──
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
|
||||
let ts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let raw = format!("{}|{}|{}", email, name, ts);
|
||||
let payload = URL_SAFE_NO_PAD.encode(raw.as_bytes());
|
||||
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(waitlist_secret.as_bytes())
|
||||
.map_err(|e| e.to_string())?;
|
||||
mac.update(payload.as_bytes());
|
||||
let sig = hex::encode(mac.finalize().into_bytes());
|
||||
let token = format!("{}.{}", payload, sig);
|
||||
|
||||
// ── Build confirm link ──
|
||||
let confirm_link = if conv_id.is_empty() {
|
||||
format!("{}?confirm={}", base_url, token)
|
||||
} else {
|
||||
format!("{}?confirm={}&cid={}", base_url, token, conv_id)
|
||||
};
|
||||
|
||||
if resend_key.is_empty() {
|
||||
// Dev mode — no email sent, just log
|
||||
eprintln!("[waitlist] confirm link (dev): {}", confirm_link);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// ── Send confirmation email via Resend ──
|
||||
let html_body = format!(
|
||||
r#"<div style="font-family:Georgia,serif;max-width:480px;margin:0 auto;padding:40px 20px;color:#0D0D14">
|
||||
<p style="font-size:1.1rem;line-height:1.6;margin-bottom:1.5rem">Hey {} —</p>
|
||||
<p style="font-size:1rem;line-height:1.75;color:#3A3A4A;margin-bottom:1rem">
|
||||
One click to confirm your spot on the Neuron waitlist.
|
||||
</p>
|
||||
<p style="margin:2rem 0">
|
||||
<a href="{}" style="background:#0052A0;color:#fff;padding:14px 28px;text-decoration:none;border-radius:3px;font-family:sans-serif;font-size:0.95rem">
|
||||
Confirm my spot →
|
||||
</a>
|
||||
</p>
|
||||
<p style="color:#6B6B7E;font-size:0.8rem;font-family:sans-serif;margin-top:2rem;line-height:1.5">
|
||||
This link expires in 24 hours. If you didn't sign up for Neuron, you can ignore this.
|
||||
</p>
|
||||
</div>"#,
|
||||
name, confirm_link
|
||||
);
|
||||
|
||||
let payload_json = serde_json::json!({
|
||||
"from": "Neuron <neuron@neurontechnologies.ai>",
|
||||
"to": [email],
|
||||
"subject": format!("Confirm your spot, {}", name),
|
||||
"html": html_body
|
||||
});
|
||||
|
||||
reqwest::blocking::Client::new()
|
||||
.post("https://api.resend.com/emails")
|
||||
.header("Authorization", format!("Bearer {}", resend_key))
|
||||
.header("Content-Type", "application/json")
|
||||
.body(payload_json.to_string())
|
||||
.send()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
let (status, resp_body) = match result {
|
||||
Ok(()) => (200u16, r#"{"ok":true}"#.to_string()),
|
||||
Err(e) => (400, format!(r#"{{"ok":false,"error":"{}"}}"#, e)),
|
||||
};
|
||||
let _ = request.respond(
|
||||
tiny_http::Response::from_string(r#"{"ok":true}"#)
|
||||
tiny_http::Response::from_string(resp_body)
|
||||
.with_status_code(status)
|
||||
.with_header(content_json)
|
||||
.with_header(cors_origin)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// POST /api/waitlist/confirm — verify HMAC token, confirm waitlist spot
|
||||
if method == "POST" && path == "/api/waitlist/confirm" {
|
||||
let waitlist_secret = std::env::var("WAITLIST_SECRET")
|
||||
.unwrap_or_else(|_| "neuron-waitlist-dev-secret".to_string());
|
||||
|
||||
let result: Result<(), String> = (|| {
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
|
||||
let v: serde_json::Value = serde_json::from_str(&body)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let token = v["token"].as_str().unwrap_or("");
|
||||
if token.is_empty() { return Err("missing token".to_string()); }
|
||||
|
||||
let parts: Vec<&str> = token.splitn(2, '.').collect();
|
||||
if parts.len() != 2 { return Err("invalid token format".to_string()); }
|
||||
let (payload, sig) = (parts[0], parts[1]);
|
||||
|
||||
// Verify HMAC
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(waitlist_secret.as_bytes())
|
||||
.map_err(|e| e.to_string())?;
|
||||
mac.update(payload.as_bytes());
|
||||
let expected = hex::encode(mac.finalize().into_bytes());
|
||||
if sig != expected { return Err("invalid signature".to_string()); }
|
||||
|
||||
// Decode payload and check expiry (24h)
|
||||
let raw_bytes = URL_SAFE_NO_PAD.decode(payload)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let raw = String::from_utf8(raw_bytes)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let fields: Vec<&str> = raw.splitn(3, '|').collect();
|
||||
if fields.len() < 3 { return Err("invalid token payload".to_string()); }
|
||||
|
||||
let ts: u64 = fields[2].parse().map_err(|_| "invalid timestamp".to_string())?;
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
if now.saturating_sub(ts) > 86400 {
|
||||
return Err("link expired".to_string());
|
||||
}
|
||||
|
||||
// Log confirmed email
|
||||
eprintln!("[waitlist] confirmed: {} ({})", fields[0], fields[1]);
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
let (status, resp_body) = match result {
|
||||
Ok(()) => (200u16, r#"{"ok":true}"#.to_string()),
|
||||
Err(e) => (400, format!(r#"{{"ok":false,"error":"{}"}}"#, e)),
|
||||
};
|
||||
let _ = request.respond(
|
||||
tiny_http::Response::from_string(resp_body)
|
||||
.with_status_code(status)
|
||||
.with_header(content_json)
|
||||
.with_header(cors_origin)
|
||||
);
|
||||
@@ -3828,10 +4014,185 @@ fn dispatch_builtin(
|
||||
BuiltinResult::Handled
|
||||
}
|
||||
|
||||
// ── Terminal control builtins ─────────────────────────────────────────
|
||||
|
||||
"term_clear" => {
|
||||
print!("\x1b[2J\x1b[H");
|
||||
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||
stack.push(Value::Nil);
|
||||
BuiltinResult::Handled
|
||||
}
|
||||
"term_size" => {
|
||||
let (cols, rows) = term_size();
|
||||
stack.push(Value::List(vec![Value::Int(cols as i64), Value::Int(rows as i64)]));
|
||||
BuiltinResult::Handled
|
||||
}
|
||||
"cursor_to" => {
|
||||
let col = match stack.pop().unwrap_or(Value::Nil) {
|
||||
Value::Int(n) => n,
|
||||
_ => 1,
|
||||
};
|
||||
let row = match stack.pop().unwrap_or(Value::Nil) {
|
||||
Value::Int(n) => n,
|
||||
_ => 1,
|
||||
};
|
||||
print!("\x1b[{};{}H", row, col);
|
||||
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||
stack.push(Value::Nil);
|
||||
BuiltinResult::Handled
|
||||
}
|
||||
"cursor_up" => {
|
||||
let n = match stack.pop().unwrap_or(Value::Nil) {
|
||||
Value::Int(n) => n,
|
||||
_ => 1,
|
||||
};
|
||||
print!("\x1b[{}A", n);
|
||||
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||
stack.push(Value::Nil);
|
||||
BuiltinResult::Handled
|
||||
}
|
||||
"cursor_down" => {
|
||||
let n = match stack.pop().unwrap_or(Value::Nil) {
|
||||
Value::Int(n) => n,
|
||||
_ => 1,
|
||||
};
|
||||
print!("\x1b[{}B", n);
|
||||
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||
stack.push(Value::Nil);
|
||||
BuiltinResult::Handled
|
||||
}
|
||||
"cursor_col" => {
|
||||
let col = match stack.pop().unwrap_or(Value::Nil) {
|
||||
Value::Int(n) => n,
|
||||
_ => 1,
|
||||
};
|
||||
print!("\x1b[{}G", col);
|
||||
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||
stack.push(Value::Nil);
|
||||
BuiltinResult::Handled
|
||||
}
|
||||
"term_save_cursor" => {
|
||||
print!("\x1b[s");
|
||||
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||
stack.push(Value::Nil);
|
||||
BuiltinResult::Handled
|
||||
}
|
||||
"term_restore_cursor" => {
|
||||
print!("\x1b[u");
|
||||
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||
stack.push(Value::Nil);
|
||||
BuiltinResult::Handled
|
||||
}
|
||||
"term_clear_line" => {
|
||||
print!("\x1b[2K\r");
|
||||
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||
stack.push(Value::Nil);
|
||||
BuiltinResult::Handled
|
||||
}
|
||||
"print_inline" => {
|
||||
let v = match stack.pop().unwrap_or(Value::Nil) {
|
||||
Value::Str(s) => s,
|
||||
other => other.to_string(),
|
||||
};
|
||||
print!("{}", v);
|
||||
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||
stack.push(Value::Nil);
|
||||
BuiltinResult::Handled
|
||||
}
|
||||
|
||||
// ── SSE streaming builtin ─────────────────────────────────────────────
|
||||
|
||||
"http_sse_post" => {
|
||||
// http_sse_post(url, token, body) -> String
|
||||
// POSTs and reads an SSE stream, printing each token inline.
|
||||
// Returns the full assembled response as a String.
|
||||
let body_str = match stack.pop().unwrap_or(Value::Nil) {
|
||||
Value::Str(s) => s,
|
||||
_ => String::new(),
|
||||
};
|
||||
let token_str = match stack.pop().unwrap_or(Value::Nil) {
|
||||
Value::Str(s) => s,
|
||||
_ => String::new(),
|
||||
};
|
||||
let url_str = match stack.pop().unwrap_or(Value::Nil) {
|
||||
Value::Str(s) => s,
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
let result = reqwest::blocking::Client::new()
|
||||
.post(&url_str)
|
||||
.header("Authorization", format!("Bearer {}", token_str))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "text/event-stream")
|
||||
.body(body_str)
|
||||
.send();
|
||||
|
||||
match result {
|
||||
Err(e) => {
|
||||
stack.push(Value::Str(format!("error: {}", e)));
|
||||
}
|
||||
Ok(resp) => {
|
||||
use std::io::BufRead;
|
||||
let mut full_text = String::new();
|
||||
let reader = std::io::BufReader::new(resp);
|
||||
for line in reader.lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if let Some(data) = line.strip_prefix("data: ") {
|
||||
if data == "[DONE]" { break; }
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(data) {
|
||||
let delta = v.get("delta")
|
||||
.and_then(|d| d.as_str())
|
||||
.or_else(|| v.get("content").and_then(|c| c.as_str()))
|
||||
.or_else(|| v.get("text").and_then(|t| t.as_str()))
|
||||
.unwrap_or("");
|
||||
if !delta.is_empty() {
|
||||
print!("{}", delta);
|
||||
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||
full_text.push_str(delta);
|
||||
}
|
||||
} else if !data.is_empty() {
|
||||
print!("{}", data);
|
||||
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||
full_text.push_str(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
stack.push(Value::Str(full_text));
|
||||
}
|
||||
}
|
||||
BuiltinResult::Handled
|
||||
}
|
||||
|
||||
_ => BuiltinResult::NotBuiltin,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return terminal dimensions as (cols, rows).
|
||||
/// Uses TIOCGWINSZ ioctl on Unix; falls back to (80, 24).
|
||||
fn term_size() -> (u16, u16) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::io::AsRawFd;
|
||||
#[repr(C)]
|
||||
struct WinSize { rows: u16, cols: u16, _xpix: u16, _ypix: u16 }
|
||||
let ws = WinSize { rows: 0, cols: 0, _xpix: 0, _ypix: 0 };
|
||||
unsafe {
|
||||
extern "C" {
|
||||
fn ioctl(fd: std::ffi::c_int, request: std::ffi::c_ulong, ...) -> std::ffi::c_int;
|
||||
}
|
||||
// TIOCGWINSZ: macOS = 0x40087468, Linux = 0x5413
|
||||
#[cfg(target_os = "macos")]
|
||||
ioctl(std::io::stdout().as_raw_fd(), 0x40087468_u64, &ws);
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
ioctl(std::io::stdout().as_raw_fd(), 0x5413_u64, &ws);
|
||||
if ws.cols > 0 && ws.rows > 0 {
|
||||
return (ws.cols, ws.rows);
|
||||
}
|
||||
}
|
||||
}
|
||||
(80, 24)
|
||||
}
|
||||
|
||||
/// Format a Unix timestamp (seconds + millis) as an ISO 8601 string.
|
||||
/// This avoids pulling in chrono while still producing a readable timestamp.
|
||||
fn format_epoch_as_iso(secs: u64, millis: u32) -> String {
|
||||
|
||||
@@ -250,6 +250,18 @@ impl TypeEnv {
|
||||
env.functions.insert(name.to_string(), str_fn(vec![s.clone()], s.clone()));
|
||||
}
|
||||
|
||||
// Terminal control builtins
|
||||
for name in &["term_clear", "term_save_cursor", "term_restore_cursor", "term_clear_line"] {
|
||||
env.functions.insert(name.to_string(), str_fn(vec![], Type::Void));
|
||||
}
|
||||
env.functions.insert("print_inline".into(), str_fn(vec![u.clone()], Type::Void));
|
||||
env.functions.insert("term_size".into(), str_fn(vec![], Type::Unknown));
|
||||
env.functions.insert("cursor_to".into(), str_fn(vec![i.clone(), i.clone()], Type::Void));
|
||||
env.functions.insert("cursor_up".into(), str_fn(vec![i.clone()], Type::Void));
|
||||
env.functions.insert("cursor_down".into(), str_fn(vec![i.clone()], Type::Void));
|
||||
env.functions.insert("cursor_col".into(), str_fn(vec![i.clone()], Type::Void));
|
||||
env.functions.insert("http_sse_post".into(), str_fn(vec![s.clone(), s.clone(), s.clone()], s.clone()));
|
||||
|
||||
// Math
|
||||
for name in &["math_abs","math_floor","math_ceil","math_round","math_sqrt"] {
|
||||
env.functions.insert(name.to_string(), str_fn(vec![u.clone()], u.clone()));
|
||||
|
||||
Reference in New Issue
Block a user