app
Adom DS2SF - Datasheet to Symbol and Footprint
Public Made by Adomby adom
Datasheet to symbol + footprint + provenance JSON, with a live dashboard: pins, groups, land pattern, and the datasheet page every fact came from.
master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
//! CLI verbs for the dashboard: serve / show / ls / ui / settings / shutdown.
//! Every verb that changes server state carries the ai-thread name as
//! `aiThread` in the JSON it POSTs (app-creator-ai-thread-name).
use crate::io::{err, hint, note, ok};
use crate::server::{self, ServeOpts, APP, DEFAULT_PORT, VERSION, WEBVIEW_TAB};
use serde_json::{json, Value};
use std::time::{Duration, Instant};
static TARGET: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
pub fn set_target(t: Option<String>) {
*TARGET.lock().unwrap() = t;
}
pub fn parse_port(p: &str) -> u16 {
if p.eq_ignore_ascii_case("auto") {
server::free_port()
} else {
p.parse().unwrap_or(DEFAULT_PORT)
}
}
pub fn require_thread(t: Option<String>, verb: &str) -> Result<String, i32> {
match t {
Some(t) if !t.trim().is_empty() => Ok(t.trim().to_string()),
_ => {
err("caller_identity_required: this app requires every AI caller to give its ai-thread name.");
hint(format!(
"adom-ds2sf --ai-thread \"<name>\" {verb} (a short name a HUMAN recognizes for THIS conversation, like 'ds2sf' or 'stm32 board review'; reuse it on every Adom tool call)"
));
Err(2)
}
}
}
fn spawn_detached(port: u16, thread: &str, keep_alive: bool, ttl: Option<f64>) -> std::io::Result<()> {
use std::os::unix::process::CommandExt;
let exe = std::env::current_exe()?;
let mut cmd = std::process::Command::new(exe);
cmd.args(["serve", "--port", &port.to_string(), "--ai-thread", thread, "--detached-child"]);
if keep_alive {
cmd.arg("--keep-alive");
}
if let Some(t) = ttl {
cmd.args(["--ttl", &t.to_string()]);
}
cmd.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.process_group(0);
cmd.spawn().map(|_| ())
}
/// Make sure a current-version instance answers on `port`; returns Ok(port) once it does.
pub fn ensure_running(port: u16, thread: &str, keep_alive: bool, ttl: Option<f64>) -> Result<u16, String> {
if let Some(h) = server::probe(port) {
let v = h.get("version").and_then(|v| v.as_str()).unwrap_or("?").to_string();
if v == VERSION {
return Ok(port);
}
note(format!("an older adom-ds2sf (v{v}) owns port {port}; retiring it for v{VERSION}"));
let _ = server::post_json(port, "/shutdown", &json!({
"aiThread": "adom-ds2sf housekeeping",
"reason": format!("version drift: replacing v{v} with v{VERSION} on port {port}")
}));
let t0 = Instant::now();
while server::probe(port).is_some() && t0.elapsed() < Duration::from_secs(4) {
std::thread::sleep(Duration::from_millis(200));
}
}
spawn_detached(port, thread, keep_alive, ttl).map_err(|e| format!("could not spawn the server: {e}"))?;
let t0 = Instant::now();
while t0.elapsed() < Duration::from_secs(20) {
std::thread::sleep(Duration::from_millis(250));
if server::probe(port).is_some() {
return Ok(port);
}
}
Err(format!("server did not answer on port {port} within 20s"))
}
pub fn serve(ai_thread: Option<String>, port_s: &str, print_url: bool, detached_child: bool, keep_alive: bool, ttl: Option<f64>) -> i32 {
let thread = match require_thread(ai_thread, "serve") { Ok(t) => t, Err(c) => return c };
let port = parse_port(port_s);
if detached_child {
return match server::run(ServeOpts { port, ai_thread: thread, keep_alive, ttl_hours: ttl }) {
Ok(()) => 0,
Err(e) => { err(e); 1 }
};
}
// Idempotent, version-aware (dock contract + lifecycle Rule 3).
if let Some(h) = server::probe(port) {
let v = h.get("version").and_then(|v| v.as_str()).unwrap_or("?");
let owner = h.get("thread").and_then(|v| v.as_str()).unwrap_or("?");
if v == VERSION {
if print_url {
println!("{}", server::direct_url(port));
} else {
ok(format!("already running on port {port} (v{v}, thread \"{owner}\"). Use that instance: {}", server::proxy_url_for_port(port)));
}
return 0;
}
}
if print_url {
return match ensure_running(port, &thread, keep_alive, ttl) {
Ok(p) => { println!("{}", server::direct_url(p)); 0 }
Err(e) => { err(e); hint("adom-ds2sf --ai-thread \"<name>\" serve (foreground, to see the bind error)"); 1 }
};
}
match server::run(ServeOpts { port, ai_thread: thread, keep_alive, ttl_hours: ttl }) {
Ok(()) => 0,
Err(e) => { err(e); hint(format!("adom-ds2sf ls to see who owns port {port}, or --port auto")); 1 }
}
}
fn bridge(thread: &str, verb: &str, body: &Value) -> Result<Value, String> {
let mut cmd = std::process::Command::new("adom-bridge");
cmd.args(["--ai-thread", thread]);
if let Some(t) = TARGET.lock().unwrap().clone() {
cmd.args(["--target", &t]);
}
let out = cmd.args([verb, &body.to_string()])
.output()
.map_err(|e| format!("could not run adom-bridge: {e}"))?;
let text = String::from_utf8_lossy(&out.stdout).to_string();
let v: Value = serde_json::from_str(text.trim()).unwrap_or(json!({ "raw": text }));
if out.status.success() && v.get("ok").and_then(|o| o.as_bool()).unwrap_or(true) {
Ok(v)
} else {
Err(v.get("error").and_then(|e| e.as_str()).map(String::from).unwrap_or_else(|| String::from_utf8_lossy(&out.stderr).to_string()))
}
}
pub fn show(ai_thread: Option<String>, surface: Option<String>, port_s: &str) -> i32 {
let thread = match require_thread(ai_thread, "show") { Ok(t) => t, Err(c) => return c };
let port = parse_port(port_s);
let port = match ensure_running(port, &thread, false, None) {
Ok(p) => p,
Err(e) => { err(e); return 1; }
};
let settings = server::settings();
let surface = surface.unwrap_or_else(|| settings.get("default_surface").and_then(|s| s.as_str()).unwrap_or("wv").to_string());
let url = server::proxy_url_for_port(port);
match surface.as_str() {
"wv" => {
let name = std::env::var("ADOM_DOCK_WEBVIEW").ok().filter(|s| !s.is_empty()).unwrap_or_else(|| WEBVIEW_TAB.to_string());
let st = std::process::Command::new("timeout")
.args(["20", "adom-cli", "hydrogen", "webview", "open-or-refresh", "--name", &name, "--url", &url])
.output();
match st {
Ok(o) if o.status.success() => { ok(format!("dashboard open in Hydrogen webview tab \"{name}\": {url}")); 0 }
Ok(o) => {
err(format!("adom-cli could not open the webview: {}", String::from_utf8_lossy(&o.stderr).trim()));
hint(format!("adom-ds2sf --ai-thread \"{thread}\" show --surface pup (or open {url} yourself)"));
1
}
Err(e) => { err(format!("adom-cli not runnable: {e}")); hint(format!("open {url} in a browser, or `show --surface pup`")); 1 }
}
}
"pup" => {
// Reuse the window when the session is already open: navigate it and flash
// the taskbar instead of spawning a second one (app-creator-surfaces).
if bridge(&thread, "pup_navigate", &json!({ "sessionId": "adom-ds2sf", "url": url })).is_ok() {
let _ = bridge(&thread, "pup_alert_window", &json!({ "sessionId": "adom-ds2sf" }));
ok(format!("dashboard refreshed in the existing pup window (session adom-ds2sf): {url}"));
return 0;
}
let body = json!({ "sessionId": "adom-ds2sf", "profile": "adom-ds2sf", "url": url });
match bridge(&thread, "pup_open_window", &body) {
Ok(_) => {
std::thread::sleep(Duration::from_millis(1200));
let _ = bridge(&thread, "desktop_set_window_state", &json!({ "titleContains": "DS2SF", "state": "maximize" }));
ok(format!("dashboard open in pup (session adom-ds2sf, background, maximized): {url}"));
0
}
Err(e) => { err(format!("pup_open_window failed: {e}")); hint("adom-bridge --ai-thread \"<name>\" pup_readiness to see whether the desktop bridge is up"); 1 }
}
}
"hbw" => {
let r = server::post_json_host("host.docker.internal", 47084, "/hdbw/open", &json!({ "url": url }));
match r {
Some(_) => { ok(format!("dashboard open in Hydrogen browser window: {url}")); 0 }
None => { err("Hydrogen control port did not answer (hbw needs Adom Hydrogen)"); hint("adom-ds2sf show --surface wv"); 1 }
}
}
"nb" => {
match bridge(&thread, "nbrowser_open_tab", &json!({ "url": url, "background": settings.get("open_in_background").and_then(|b| b.as_bool()).unwrap_or(true) })) {
Ok(_) => { ok(format!("dashboard open in your signed-in browser: {url}")); 0 }
Err(e) => { err(format!("native browser open failed: {e}")); hint(format!("open {url} yourself, or `show --surface pup`")); 1 }
}
}
other => {
err(format!("unknown surface {other:?}"));
hint("valid: wv (Hydrogen webview tab), pup (AI's Chrome on the desktop), hbw (Hydrogen browser window), nb (your signed-in browser)");
2
}
}
}
pub fn ls() -> i32 {
let rows = server::list_instances();
if rows.is_empty() {
ok("no adom-ds2sf instances running");
hint("adom-ds2sf --ai-thread \"<name>\" show");
return 0;
}
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
println!("{:<9}{:<7}{:<28}{:<10}{:<8}url", "pid", "port", "thread", "version", "idle");
let mut drift = false;
for r in &rows {
let v = r.get("version").and_then(|x| x.as_str()).unwrap_or("?");
let last = r.get("lastActivity").and_then(|x| x.as_u64()).unwrap_or(now);
let idle = now.saturating_sub(last);
let idle_s = if idle < 3600 { format!("{}m", idle / 60) } else { format!("{:.1}h", idle as f64 / 3600.0) };
println!("{:<9}{:<7}{:<28}{:<10}{:<8}{}",
r.get("pid").and_then(|x| x.as_u64()).unwrap_or(0),
r.get("port").and_then(|x| x.as_u64()).unwrap_or(0),
r.get("thread").and_then(|x| x.as_str()).unwrap_or("?"),
v, idle_s,
r.get("proxyUrl").and_then(|x| x.as_str()).unwrap_or(""));
if v != VERSION {
drift = true;
crate::io::warn(format!("port {} runs {v}, disk has {VERSION}. Shut it down and respawn: adom-ds2sf --ai-thread \"<name>\" shutdown --port {} --reason \"stale version after update\"",
r.get("port").and_then(|x| x.as_u64()).unwrap_or(0), r.get("port").and_then(|x| x.as_u64()).unwrap_or(0)));
}
}
if drift { 1 } else { 0 }
}
fn need_server(port: u16) -> Result<(), i32> {
if server::probe(port).is_none() {
err(format!("no {APP} dashboard answering on port {port}"));
hint(format!("adom-ds2sf --ai-thread \"<name>\" show (starts it and opens the tab), or `adom-ds2sf ls`"));
return Err(1);
}
Ok(())
}
fn report(r: Option<Value>) -> i32 {
match r {
Some(v) if v.get("ok").and_then(|o| o.as_bool()).unwrap_or(true) => {
ok(v.to_string());
0
}
Some(v) => {
err(v.get("error").and_then(|e| e.as_str()).unwrap_or("request refused"));
if let Some(h) = v.get("hint").and_then(|h| h.as_str()) {
hint(h);
}
1
}
None => { err("no response from the dashboard"); 1 }
}
}
pub fn ui(ai_thread: Option<String>, op: crate::cli::UiOp, port_s: &str) -> i32 {
use crate::cli::UiOp;
let port = parse_port(port_s);
if let Err(c) = need_server(port) { return c; }
// Read-only verbs never need identity.
match &op {
UiOp::State => return report(server::get_json(port, "/state")),
UiOp::Console => {
return match server::get_json(port, "/console") {
Some(v) => { for l in v.get("lines").and_then(|l| l.as_array()).cloned().unwrap_or_default() { println!("{}", l.as_str().unwrap_or("")); } 0 }
None => { err("no response"); 1 }
}
}
UiOp::Log => {
return match server::get_json(port, "/log") {
Some(v) => { for l in v.get("lines").and_then(|l| l.as_array()).cloned().unwrap_or_default() { println!("{}", l.as_str().unwrap_or("")); } if let Some(j) = v.get("job").filter(|j| !j.is_null()) { note(format!("running: {j}")); } 0 }
None => { err("no response"); 1 }
}
}
_ => {}
}
let thread = match require_thread(ai_thread, "ui <verb>") { Ok(t) => t, Err(c) => return c };
let (path, body) = match op {
UiOp::Toast { text, kind, sticky } => ("/ui/toast", json!({ "text": text, "kind": kind, "sticky": sticky })),
UiOp::Click { selector, label } => ("/ui/click", json!({ "selector": selector, "label": label })),
UiOp::Reveal { selector, label } => ("/ui/reveal", json!({ "selector": selector, "label": label })),
UiOp::Cursor { selector, label } => ("/ui/cursor", json!({ "selector": selector, "label": label })),
UiOp::Tab { name } => ("/ui/tab", json!({ "tab": name })),
UiOp::Reload => ("/ui/reload", json!({})),
UiOp::Eval { js } => ("/ui/eval", json!({ "js": js })),
UiOp::Select { mpn } => ("/select", json!({ "mpn": mpn })),
UiOp::Extract { mpn, force, pdf, model, symbol_only, footprint_only } => ("/extract", json!({
"mpn": mpn, "force": force, "pdf": pdf, "model": model, "symbolOnly": symbol_only, "footprintOnly": footprint_only })),
UiOp::State | UiOp::Console | UiOp::Log => unreachable!(),
};
let mut body = body;
body["aiThread"] = json!(thread);
report(server::post_json(port, path, &body))
}
pub fn settings(ai_thread: Option<String>, op: crate::cli::SettingsOp, port_s: &str) -> i32 {
use crate::cli::SettingsOp;
let port = parse_port(port_s);
match op {
SettingsOp::Get => {
// Works with or without a running server (reads the same file).
match server::get_json(port, "/settings") {
Some(v) => { println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()); 0 }
None => { println!("{}", serde_json::to_string_pretty(&json!({ "settings": server::settings(), "version": VERSION, "_note": "no server running; showing the on-disk file" })).unwrap_or_default()); 0 }
}
}
SettingsOp::Set { key, value } => {
let thread = match require_thread(ai_thread, "settings set <key> <value>") { Ok(t) => t, Err(c) => return c };
let v: Value = serde_json::from_str(&value).unwrap_or(Value::String(value.clone()));
if let Err(c) = need_server(port) { return c; }
report(server::post_json(port, "/settings", &json!({ key: v, "aiThread": thread })))
}
}
}
pub fn shutdown(ai_thread: Option<String>, reason: Option<String>, port_s: &str) -> i32 {
let thread = match require_thread(ai_thread, "shutdown") { Ok(t) => t, Err(c) => return c };
let port = parse_port(port_s);
if let Err(c) = need_server(port) { return c; }
let r = server::post_json(port, "/shutdown", &json!({ "aiThread": thread, "reason": reason.unwrap_or_default() }));
report(r)
}