use std::collections::hash_map::DefaultHasher;
use std::fs;
use std::hash::{Hash, Hasher};
use std::io::BufRead;
use std::os::unix::net::UnixStream;
#[cfg(unix)]
use std::os::unix::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
use anyhow::{anyhow, bail, Context, Result};
#[cfg(unix)]
use libc;
use serde_json;
use super::{DaemonHandle, DaemonHost, InstanceProfile};
use crate::tui::{self, LogSource, LogTx, ServiceStatus, TuiEvent};
const HEALTH_TIMEOUT: Duration = Duration::from_secs(240);
const HEALTH_POLL: Duration = Duration::from_secs(2);
const HTTP_READY_TIMEOUT: Duration = Duration::from_secs(60);
const VITE_READY_TIMEOUT: Duration = Duration::from_secs(30);
#[cfg(unix)]
fn pids_listening_on(port: u16) -> Vec<i32> {
let arg = format!("-iTCP:{port}");
Command::new("lsof")
.args(["-sTCP:LISTEN", "-t", "-P", "-n", &arg])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| {
String::from_utf8_lossy(&o.stdout)
.lines()
.filter_map(|l| l.trim().parse::<i32>().ok())
.collect()
})
.unwrap_or_default()
}
#[cfg(not(unix))]
fn pids_listening_on(_port: u16) -> Vec<i32> {
Vec::new()
}
fn tee_daemon_stream<R: std::io::Read + Send + 'static>(
reader: R,
prefix: String,
log_file: Option<std::sync::Arc<std::sync::Mutex<std::fs::File>>>,
tui_tx: Option<LogTx>,
is_stderr: bool,
) {
use std::io::Write as _;
std::thread::spawn(move || {
for line in std::io::BufReader::new(reader).lines().map_while(Result::ok) {
let formatted = format!("{prefix}{line}");
if let Some(file) = &log_file {
if let Ok(mut f) = file.lock() {
let _ = writeln!(f, "{formatted}");
}
}
if let Some(tx) = &tui_tx {
let _ = tx.send(TuiEvent::Log(crate::tui::LogEntry {
source: LogSource::Daemon,
line: formatted,
}));
} else if is_stderr {
eprintln!("{formatted}");
} else {
println!("{formatted}");
}
}
});
}
const CLIENT_NODE_PORT_OFFSET: u16 = 300;
pub struct MonorepoHost {
monorepo_path: PathBuf,
profile: InstanceProfile,
socket_override: Option<PathBuf>,
dev_dir_override: Option<PathBuf>,
log_tx: Option<LogTx>,
client_node: bool,
child: Mutex<Option<Child>>,
ui_child: Mutex<Option<Child>>,
env_file: Mutex<Option<PathBuf>>,
}
impl MonorepoHost {
pub fn new(
monorepo_path: PathBuf,
profile: InstanceProfile,
socket_override: Option<PathBuf>,
dev_dir_override: Option<PathBuf>,
log_tx: Option<LogTx>,
client_node: bool,
) -> Self {
let profile = if client_node {
InstanceProfile {
name: format!("{}-pwa", profile.name),
http_port: profile.http_port + CLIENT_NODE_PORT_OFFSET,
https_port: profile.https_port + CLIENT_NODE_PORT_OFFSET,
p2p_port: profile.p2p_port + CLIENT_NODE_PORT_OFFSET,
ui_port: profile.ui_port + CLIENT_NODE_PORT_OFFSET,
env_file_name: profile.env_file_name,
}
} else {
profile
};
Self {
monorepo_path,
profile,
socket_override,
dev_dir_override,
log_tx,
client_node,
child: Mutex::new(None),
ui_child: Mutex::new(None),
env_file: Mutex::new(None),
}
}
fn syslog(&self, msg: impl Into<String>) {
tui::sys_log(self.log_tx.as_ref(), msg);
}
fn spawn_daemon(&self, env_file: &Path) -> Result<Child> {
let binary_path = self.monorepo_path.join("target/debug/node-server");
if !binary_path.exists() {
bail!(
"daemon binary missing at {} — the earlier `cargo build -p node-server` \
step should have produced it. Run `cargo build -p node-server \
--features agentic_payments` in {} to recover.",
binary_path.display(),
self.monorepo_path.display()
);
}
let mut cmd = Command::new(&binary_path);
cmd.args(["--env"])
.arg(env_file)
.current_dir(&self.monorepo_path)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
#[cfg(unix)]
cmd.process_group(0);
let mut child = cmd
.spawn()
.with_context(|| format!("spawn {}", binary_path.display()))?;
let log_file_path = env_file
.parent()
.map(|d| d.join("daemon.log"))
.unwrap_or_else(|| PathBuf::from("daemon.log"));
let log_file: Option<std::sync::Arc<std::sync::Mutex<std::fs::File>>> =
std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&log_file_path)
.ok()
.map(|f| std::sync::Arc::new(std::sync::Mutex::new(f)));
if log_file.is_some() {
self.syslog(format!("→ daemon log: {}", log_file_path.display()));
} else {
self.syslog(format!(
"⚠ could not open daemon log file at {} — output will only show in TUI/terminal",
log_file_path.display()
));
}
let prefix = format!("[{}] ", self.profile.name);
if let Some(stdout) = child.stdout.take() {
tee_daemon_stream(
stdout,
prefix.clone(),
log_file.clone(),
self.log_tx.clone(),
false,
);
}
if let Some(stderr) = child.stderr.take() {
tee_daemon_stream(stderr, prefix, log_file, self.log_tx.clone(), true);
}
Ok(child)
}
fn spawn_ui(&self) -> Option<Child> {
if self.client_node {
return None;
}
let ui_dir = self.monorepo_path.join("system/ui");
if !ui_dir.join("package.json").exists() {
return None;
}
let yarn = if which_bin("yarn") { "yarn" } else { "npm" };
let ui_port = self.profile.ui_port;
let api_port = self.profile.http_port;
self.syslog(format!(
"→ starting UI dev server ({yarn} dev --port {ui_port}) in {}",
ui_dir.display()
));
let mut cmd = Command::new(yarn);
cmd.args(["dev", "--port", &ui_port.to_string()])
.env("VITE_BACKEND_PORT", api_port.to_string())
.current_dir(&ui_dir)
.stdin(Stdio::null());
if self.log_tx.is_some() {
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
} else {
cmd.stdout(Stdio::inherit()).stderr(Stdio::inherit());
}
#[cfg(unix)]
cmd.process_group(0);
match cmd.spawn() {
Ok(mut child) => {
if let Some(tx) = &self.log_tx {
let prefix = format!("[{}] ", self.profile.name);
if let Some(stdout) = child.stdout.take() {
let tx = tx.clone();
let p = prefix.clone();
std::thread::spawn(move || {
for line in std::io::BufReader::new(stdout).lines().map_while(Result::ok) {
let _ = tx.send(TuiEvent::Log(crate::tui::LogEntry {
source: LogSource::UiServer,
line: format!("{p}{line}"),
}));
}
});
}
if let Some(stderr) = child.stderr.take() {
let tx = tx.clone();
let p = prefix.clone();
std::thread::spawn(move || {
for line in std::io::BufReader::new(stderr).lines().map_while(Result::ok) {
let _ = tx.send(TuiEvent::Log(crate::tui::LogEntry {
source: LogSource::UiServer,
line: format!("{p}{line}"),
}));
}
});
}
}
tui::update_status(
self.log_tx.as_ref(),
LogSource::UiServer,
ServiceStatus::Ready,
Some(format!("[{}] http://localhost:{ui_port}", self.profile.name)),
);
self.syslog(format!(
"✓ UI available at http://localhost:{ui_port} (Vite, hot reload) \
or http://localhost:{api_port} (backend-served, requires `yarn build` in system/ui)"
));
Some(child)
}
Err(e) => {
self.syslog(format!(
"⚠ could not start UI dev server: {e} — run manually: \
cd system/ui && VITE_BACKEND_PORT={api_port} {yarn} dev --port {ui_port}"
));
tui::update_status(
self.log_tx.as_ref(),
LogSource::UiServer,
ServiceStatus::Disabled,
Some(format!("[{}]", self.profile.name)),
);
None
}
}
}
fn shutdown_daemon_only(&self) {
if let Some(mut child) = self.child.lock().unwrap().take() {
let pid = child.id() as i32;
#[cfg(unix)]
unsafe {
libc::kill(-pid, libc::SIGTERM);
}
let t = Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) if t.elapsed() > Duration::from_secs(10) => {
#[cfg(unix)]
unsafe {
libc::kill(-pid, libc::SIGKILL);
}
let _ = child.wait();
break;
}
Ok(None) => std::thread::sleep(Duration::from_millis(200)),
Err(_) => break,
}
}
}
}
}
impl MonorepoHost {
#[cfg(unix)]
fn free_port(&self, port: u16, label: &str) {
let pids = pids_listening_on(port);
if pids.is_empty() {
return;
}
self.syslog(format!(
"→ freeing {label} port {port} from stale pid(s) {}…",
pids.iter()
.map(i32::to_string)
.collect::<Vec<_>>()
.join(",")
));
for pid in &pids {
unsafe {
libc::kill(*pid, libc::SIGTERM);
}
}
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
if pids_listening_on(port).is_empty() {
self.syslog(format!("✓ {label} port {port} freed"));
return;
}
std::thread::sleep(Duration::from_millis(200));
}
let remaining = pids_listening_on(port);
if !remaining.is_empty() {
self.syslog(format!(
"⚠ {label} port {port} still held after SIGTERM — escalating to SIGKILL"
));
for pid in &remaining {
unsafe {
libc::kill(*pid, libc::SIGKILL);
}
}
}
}
#[cfg(not(unix))]
fn free_port(&self, _port: u16, _label: &str) {}
fn force_dev_http_only(&self, db_path: &Path) {
if !db_path.exists() {
return;
}
let conn = match rusqlite::Connection::open(db_path) {
Ok(c) => c,
Err(e) => {
self.syslog(format!("⚠ could not open dev DB to pin HTTP: {e}"));
return;
}
};
let has_settings = conn
.query_row(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='settings'",
[],
|_| Ok(()),
)
.is_ok();
if !has_settings {
return;
}
match conn.execute(
"UPDATE settings SET value='false' WHERE key='ssl_enabled' AND value!='false'",
[],
) {
Ok(n) if n > 0 => self
.syslog("✓ pinned dev daemon to HTTP (ssl_enabled=false in dev DB)".to_string()),
Ok(_) => {}
Err(e) => self.syslog(format!("⚠ could not pin ssl_enabled=false: {e}")),
}
}
fn wait_http_ready(&self, port: u16) -> Result<()> {
let url = format!("http://127.0.0.1:{port}/api/healthz");
let started = Instant::now();
self.syslog(format!("→ waiting for daemon HTTP on {url}…"));
loop {
let ready = match ureq::get(&url)
.timeout(Duration::from_secs(2))
.call()
{
Ok(_) => true,
Err(ureq::Error::Status(code, _)) => code < 500,
Err(_) => false,
};
if ready {
self.syslog(format!(
"✓ daemon HTTP ready ({}ms)",
started.elapsed().as_millis()
));
return Ok(());
}
if let Some(child) = self.child.lock().unwrap().as_mut() {
if let Ok(Some(status)) = child.try_wait() {
bail!(
"daemon exited with {status} while waiting for HTTP readiness on {url}"
);
}
}
if started.elapsed() >= HTTP_READY_TIMEOUT {
bail!(
"daemon HTTP server not responding on {} after {}s",
url,
HTTP_READY_TIMEOUT.as_secs()
);
}
std::thread::sleep(Duration::from_millis(300));
}
}
fn verify_ws_proxy(&self, ui_port: u16) -> Result<()> {
use std::io::{Read, Write};
use std::net::TcpStream;
let addr_candidates: Vec<std::net::SocketAddr> = std::net::ToSocketAddrs::to_socket_addrs(
&format!("localhost:{ui_port}"),
)
.map(|iter| iter.collect())
.unwrap_or_default();
if addr_candidates.is_empty() {
bail!("could not resolve localhost:{ui_port} for WS probe");
}
let req = format!(
"GET /api/ws HTTP/1.1\r\n\
Host: localhost:{ui_port}\r\n\
Upgrade: websocket\r\n\
Connection: Upgrade\r\n\
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\
Sec-WebSocket-Version: 13\r\n\
\r\n"
);
let mut last_err = String::from("no candidate addresses tried");
for addr in &addr_candidates {
match TcpStream::connect_timeout(addr, Duration::from_secs(2)) {
Ok(mut sock) => {
let _ = sock.set_read_timeout(Some(Duration::from_secs(2)));
let _ = sock.write_all(req.as_bytes());
let mut buf = [0u8; 256];
match sock.read(&mut buf) {
Ok(n) if n > 0 => {
let response = String::from_utf8_lossy(&buf[..n]);
let first_line =
response.lines().next().unwrap_or("").to_string();
if first_line.contains("101") {
self.syslog(format!(
"✓ ws proxy verified: localhost:{ui_port}/api/ws → backend ({first_line})"
));
return Ok(());
}
last_err = format!(
"{addr} replied {first_line} (expected 101 Switching Protocols)"
);
}
Ok(_) => last_err = format!("{addr} closed without responding"),
Err(e) => last_err = format!("{addr} read: {e}"),
}
}
Err(e) => last_err = format!("{addr} connect: {e}"),
}
}
bail!("ws proxy probe failed: {last_err}");
}
fn verify_proxy(&self, ui_port: u16, http_port: u16) -> Result<()> {
let urls = [
format!("http://localhost:{ui_port}/api/healthz"),
format!("http://127.0.0.1:{ui_port}/api/healthz"),
format!("http://[::1]:{ui_port}/api/healthz"),
];
let started = Instant::now();
let mut last_err = String::new();
loop {
for url in &urls {
match ureq::get(url).timeout(Duration::from_secs(2)).call() {
Ok(resp) => {
self.syslog(format!(
"✓ proxy verified: {url} → 127.0.0.1:{http_port} (status {})",
resp.status()
));
return Ok(());
}
Err(ureq::Error::Status(code, _)) if code < 500 => {
self.syslog(format!(
"✓ proxy verified: {url} → 127.0.0.1:{http_port} (status {code})"
));
return Ok(());
}
Err(e) => last_err = format!("{url}: {e}"),
}
}
if started.elapsed() >= VITE_READY_TIMEOUT {
bail!(
"vite proxy unreachable on port {ui_port} after {}s (last: {last_err}) — \
check that VITE_BACKEND_PORT was set when vite started, \
and that the daemon is listening on 127.0.0.1:{http_port}",
VITE_READY_TIMEOUT.as_secs()
);
}
std::thread::sleep(Duration::from_millis(400));
}
}
fn run_build_cmd(
&self,
cmd: &mut Command,
log_source: LogSource,
) -> Result<(std::process::ExitStatus, Vec<String>)> {
use std::sync::mpsc;
cmd.stdout(Stdio::null()).stderr(Stdio::piped());
let mut child = cmd.spawn()?;
let stderr = child.stderr.take().expect("piped");
let log_tx = self.log_tx.clone();
let (tx, rx) = mpsc::channel::<Vec<String>>();
std::thread::spawn(move || {
let mut lines = Vec::new();
for line in std::io::BufReader::new(stderr).lines().map_while(Result::ok) {
if let Some(ref ltx) = log_tx {
let _ = ltx.send(TuiEvent::Log(crate::tui::LogEntry {
source: log_source,
line: line.clone(),
}));
} else {
eprintln!("{line}");
}
lines.push(line);
}
let _ = tx.send(lines);
});
let status = loop {
match child.try_wait()? {
Some(s) => break s,
None => {
if crate::commands::dev::is_cancelled() {
let _ = child.kill();
let _ = child.wait();
let _lines = rx.recv().unwrap_or_default();
bail!("build cancelled");
}
std::thread::sleep(Duration::from_millis(50));
}
}
};
let lines = rx.recv().unwrap_or_default();
Ok((status, lines))
}
}
#[derive(Debug, serde::Deserialize)]
pub struct ControlRequest {
pub action: String,
#[serde(default)]
pub build: Option<String>,
#[serde(default)]
pub ts: u64,
}
impl DaemonHost for MonorepoHost {
fn instance_name(&self) -> &str {
&self.profile.name
}
fn rebuild_daemon_binary(&self) -> Result<()> {
let binary_path = self.monorepo_path.join("target/debug/node-server");
if binary_path.exists() {
let _ = fs::remove_file(&binary_path);
}
self.syslog("→ rebuilding daemon: cargo build -p node-server");
tui::update_status(
self.log_tx.as_ref(),
LogSource::Daemon,
ServiceStatus::Building,
Some("cargo build (external request)".into()),
);
let (status, errors) = self
.run_build_cmd(
Command::new("cargo")
.args(["build", "-p", "node-server", "--features", "agentic_payments"])
.current_dir(&self.monorepo_path)
.stdin(Stdio::null()),
LogSource::Daemon,
)
.with_context(|| "spawn `cargo build -p node-server`")?;
if !status.success() {
tui::update_status(
self.log_tx.as_ref(),
LogSource::Daemon,
ServiceStatus::Failed("cargo build failed".into()),
None,
);
let detail = if errors.is_empty() {
String::new()
} else {
format!("\n\n{}", errors.join("\n"))
};
bail!("cargo build -p node-server failed (exit {}){detail}", status);
}
Ok(())
}
fn take_control_request(&self) -> Option<ControlRequest> {
let env_dir = monorepo_env_dir(&self.monorepo_path, &self.profile.name).ok()?;
let path = env_dir.join("control/request.json");
let text = fs::read_to_string(&path).ok()?;
let _ = fs::remove_file(&path);
match serde_json::from_str::<ControlRequest>(&text) {
Ok(req) => Some(req),
Err(e) => {
self.syslog(format!("⚠ ignoring malformed control request: {e}"));
None
}
}
}
fn write_control_result(&self, ts: u64, ok: bool, message: &str) {
let Ok(env_dir) = monorepo_env_dir(&self.monorepo_path, &self.profile.name) else {
return;
};
let dir = env_dir.join("control");
let _ = fs::create_dir_all(&dir);
let body = serde_json::json!({ "ts": ts, "ok": ok, "message": message });
let tmp = dir.join("last-result.json.tmp");
if fs::write(&tmp, body.to_string()).is_ok() {
let _ = fs::rename(&tmp, dir.join("last-result.json"));
}
}
fn ensure_running(&self) -> Result<DaemonHandle> {
let cargo_toml = self.monorepo_path.join("Cargo.toml");
if !cargo_toml.exists() {
bail!(
"{} doesn't look like a monorepo root (no Cargo.toml found)",
self.monorepo_path.display()
);
}
let manifest = fs::read_to_string(&cargo_toml)
.with_context(|| format!("read {}", cargo_toml.display()))?;
let has_server = manifest.contains("\"system/server\"")
|| manifest.contains("system/server")
|| manifest.contains("\"apps/server\"")
|| manifest.contains("apps/server");
if !has_server {
bail!(
"{}/Cargo.toml does not include 'system/server' — is this the right path?",
self.monorepo_path.display()
);
}
let env_dir = monorepo_env_dir(&self.monorepo_path, &self.profile.name)?;
fs::create_dir_all(&env_dir).ok();
let pid_file = env_dir.join("daemon.pid");
if let Ok(contents) = fs::read_to_string(&pid_file) {
if let Ok(old_pid) = contents.trim().parse::<i32>() {
let alive = unsafe { libc::kill(old_pid, 0) == 0 };
if alive {
self.syslog(format!(
"→ previous instance found (PID {old_pid}), sending SIGTERM…"
));
unsafe {
libc::kill(-old_pid, libc::SIGTERM);
}
let deadline = Instant::now();
loop {
std::thread::sleep(Duration::from_millis(200));
if unsafe { libc::kill(old_pid, 0) } != 0 {
self.syslog(format!(
"✓ previous instance exited ({}ms)",
deadline.elapsed().as_millis()
));
break;
}
if deadline.elapsed() > Duration::from_secs(15) {
self.syslog("⚠ previous instance did not exit after 15s, SIGKILL");
unsafe {
libc::kill(-old_pid, libc::SIGKILL);
}
break;
}
}
}
let _ = fs::remove_file(&pid_file);
}
}
self.free_port(self.profile.http_port, "backend");
self.free_port(self.profile.https_port, "https");
self.free_port(self.profile.ui_port, "ui");
let vite_pid_file = env_dir.join("vite.pid");
if let Ok(contents) = fs::read_to_string(&vite_pid_file) {
if let Ok(old_pid) = contents.trim().parse::<i32>() {
let alive = unsafe { libc::kill(old_pid, 0) == 0 };
if alive {
self.syslog(format!(
"→ previous vite found (PID {old_pid}), sending SIGTERM…"
));
unsafe {
libc::kill(-old_pid, libc::SIGTERM);
}
}
}
let _ = fs::remove_file(&vite_pid_file);
}
let socket_path = self
.socket_override
.clone()
.unwrap_or_else(|| env_dir.join("control.sock"));
let dev_dir = self
.dev_dir_override
.clone()
.unwrap_or_else(|| env_dir.join("dev-apps"));
let db_path = env_dir.join("dev.db");
fs::create_dir_all(&dev_dir).ok();
let env_file = env_dir.join("daemon.env");
let base_env_path = self
.monorepo_path
.join("system/server")
.join(&self.profile.env_file_name);
if !base_env_path.exists() {
bail!(
"missing base env file for instance '{}' — expected {}.\n\
Create it (typically a copy of an example env), or pick a different \
instance via --instances.",
self.profile.name,
base_env_path.display()
);
}
let base_env = fs::read_to_string(&base_env_path)
.with_context(|| format!("read base env {}", base_env_path.display()))?;
let log_dir = env_dir.join("logs");
fs::create_dir_all(&log_dir).ok();
let ldk_dir = env_dir.join("ldk_data");
fs::create_dir_all(&ldk_dir).ok();
let api_port = self.profile.http_port;
let apt_apps_dir = self.monorepo_path.join("modules");
let static_dir = self.monorepo_path.join("system/ui/dist");
let pwa_static_dir = self.monorepo_path.join("system/pwa/dist");
let https_port = self.profile.https_port;
let p2p_port = self.profile.p2p_port;
let mut pairs: Vec<(&str, String, &str)> = vec![
("APT_APPS_DIR", apt_apps_dir.display().to_string(),
"load builtin apps from the monorepo's modules/ instead of /usr/lib/node/apps"),
("NODE_DEV_APPS_DIR", dev_dir.display().to_string(),
"stage dep apps into a per-instance cache dir so alice/bob don't collide"),
("DATABASE_URL", format!("sqlite://{}", db_path.display()),
"per-instance dev DB under cache dir"),
("NODE_IPC_SOCKET", socket_path.display().to_string(),
"per-instance IPC socket the orchestrator polls for readiness"),
("SERVER_ADDRESS", format!("0.0.0.0:{api_port}"),
"profile-specific HTTP port (alice=3001, bob=3002, …) — bind 0.0.0.0 so peer-to-peer fetches via the LAN IP advertised through gossip resolve (loopback-only binding broke api_store catalog mirroring between alice and bob)"),
("HTTPS_SERVER_ADDRESS", format!("0.0.0.0:{https_port}"),
"profile-specific HTTPS/TLS port (alice=4431, bob=4432, …) so two nodes don't both bind the default :443 and collide; the advertised port is derived from this, so peers resolve it via gossip"),
("LDK_LISTENING_ADDRESS", format!("127.0.0.1:{p2p_port}"),
"profile-specific LDK P2P listen port (InstanceProfile::p2p_port, offset by CLIENT_NODE_PORT_OFFSET in --client-node mode) — without this override the daemon falls back to whatever LDK_LISTENING_ADDRESS the base env file hardcodes, so a --client-node lane and a legacy lane for the SAME instance would both try to bind the same LDK port (#1529)"),
("APPLICATION_LOG_DIR_PATH", log_dir.display().to_string(),
"per-instance application logs under cache dir"),
("DATA_DIR_PATH", ldk_dir.display().to_string(),
"per-instance LDK data dir under cache dir"),
("SIGNER_SEED_PATH", format!("{}/signer_seed.hex", ldk_dir.display()),
"per-instance LDK signer seed under cache dir"),
("STATIC_DIR_PATH", static_dir.display().to_string(),
"absolute path to monorepo/system/ui/dist so backend serves UI on its port"),
("NODE_ALLOW_DEV_SOCKET_PATH", "1".to_string(),
"accept standalone socket paths outside /run/ (dev cache dirs aren't /run/-writable)"),
("AUTO_SELF_SIGNED_TLS", "false".to_string(),
"keep the dev daemon on plain HTTP — the daemon otherwise auto-provisions a self-signed cert and sets ssl_enabled=true once it has a routable LAN IP, which makes the API HTTPS-only and turns the HTTP port (that node-app dev's health check + Vite proxy target) into a redirect-only listener. force_dev_http_only clears any stale flag; this stops it re-enabling on boot"),
("OTA_HTTP_PORT", (api_port + 10000).to_string(),
"esp32-bridge's dedicated OTA HTTP listen port, per-instance (alice=13001, bob=13002). Its default (the node's main port, 3001) makes whichever node boots first squat the OTHER node's main port; the second node's pre-boot free_port(3001) then SIGTERMs the first node's whole daemon. Offset by 10000 so it never overlaps any instance's http/https/ui port (the ports free_port clears), so the two daemons can't kill each other"),
("ENABLE_TEST_HARNESS", "true".to_string(),
"register the loopback-only /api/v2/internal/test/* routes (notably seed-peer). The harness cross-seeds each node's IP pool with the other's 127.0.0.1:<port> via POST /api/v2/internal/test/seed-peer; without this the route falls through to the SPA handler (HTML, not JSON) and the seed silently fails, so peer discovery finds no active HTTP endpoint and every inter-node flow (friend/conversation/L402 proxy — and cross-node trace propagation) breaks in the local harness"),
("NODE_HTTP_DIRECT_TCP", "true".to_string(),
"node-server serves only over its Unix socket when fronted by node-provisioning (PR #1463); node-app dev runs the daemon alone, so bind TCP on SERVER_ADDRESS directly"),
];
if self.client_node {
pairs.push((
"DEVELOPMENT_MODE",
"true".to_string(),
"required for CLIENT_NODE_PWA_ENABLED to take effect — parse_client_node_pwa_enabled gates on is_development",
));
pairs.push((
"CLIENT_NODE_PWA_ENABLED",
"true".to_string(),
"serve the client-node PWA (system/pwa/dist) instead of the legacy system/ui bundle, and apply its stricter CSP (core/adapters-network/src/http/server/ui.rs)",
));
pairs.push((
"CLIENT_NODE_PWA_STATIC_DIR_PATH",
pwa_static_dir.display().to_string(),
"absolute path to monorepo/system/pwa/dist — selected over STATIC_DIR_PATH via select_static_assets_dir when CLIENT_NODE_PWA_ENABLED=true",
));
}
let mut overrides = String::from(
"# ============================================================\n\
# node-app dev overrides (auto-generated — do not edit)\n\
#\n\
# These keys are computed at orchestrator startup and MUST win\n\
# over the base env file below for per-instance isolation. The\n\
# base env file (alice.env / bob.env) is the source of truth\n\
# for everything else — edit it directly if you need to change\n\
# other settings.\n\
#\n\
# dotenvy is first-wins, so this block has to come first.\n\
# ============================================================\n",
);
for (key, _, reason) in &pairs {
overrides.push_str(&format!("# {key}: {reason}\n"));
}
overrides.push('\n');
for (key, value, _) in &pairs {
overrides.push_str(&format!("{key}={value}\n"));
}
overrides.push_str(&format!(
"\n# ============================================================\n\
# Base config (system/server/{env_file_name})\n\
# ============================================================\n",
env_file_name = self.profile.env_file_name,
));
fs::write(&env_file, overrides + &base_env)
.with_context(|| format!("write {}", env_file.display()))?;
*self.env_file.lock().unwrap() = Some(env_file.clone());
tui::update_daemon_env_file(
self.log_tx.as_ref(),
&self.profile.name,
env_file.clone(),
);
let _ = fs::remove_file(&socket_path);
if self.client_node {
ensure_pwa_dist_built(&self.monorepo_path, self.log_tx.clone());
} else {
ensure_ui_dist_built(&self.monorepo_path, self.log_tx.clone());
}
ensure_builtin_apps_built(&self.monorepo_path, self.log_tx.clone());
let binary_path = self.monorepo_path.join("target/debug/node-server");
if DAEMON_BUILD_ONCE.get().is_none() {
let has_make = Command::new("make")
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if has_make {
self.syslog("→ building mandatory builtin apps: make builtin-apps CARGO_PROFILE=debug");
let t0 = Instant::now();
let (status, _) = self.run_build_cmd(
Command::new("make")
.args(["builtin-apps", "CARGO_PROFILE=debug"])
.current_dir(&self.monorepo_path)
.stdin(Stdio::null()),
LogSource::System,
)
.with_context(|| "spawn `make builtin-apps`")?;
let builtins_ms = t0.elapsed().as_millis() as u64;
if !status.success() {
self.syslog(format!(
"⚠ `make builtin-apps` failed (exit {}) — some platform apps may not load",
status
));
}
tui::update_status(
self.log_tx.as_ref(),
LogSource::Build,
ServiceStatus::Ready,
Some(format!("builtins:{}ms", builtins_ms)),
);
} else {
self.syslog("⚠ `make` not found — skipping builtin-apps build");
}
if binary_path.exists() {
let _ = fs::remove_file(&binary_path);
}
self.syslog(format!(
"→ building daemon: cargo build -p node-server (cwd={})",
self.monorepo_path.display()
));
tui::update_status(
self.log_tx.as_ref(),
LogSource::Daemon,
ServiceStatus::Building,
Some(format!("cargo build (cwd={})", self.monorepo_path.display())),
);
let (build_status, build_errors) = self
.run_build_cmd(
Command::new("cargo")
.args(["build", "-p", "node-server", "--features", "agentic_payments"])
.current_dir(&self.monorepo_path)
.stdin(Stdio::null()),
LogSource::Daemon,
)
.with_context(|| "spawn `cargo build -p node-server`")?;
if !build_status.success() {
tui::update_status(
self.log_tx.as_ref(),
LogSource::Daemon,
ServiceStatus::Failed("cargo build failed".into()),
None,
);
let detail = if build_errors.is_empty() {
String::new()
} else {
format!("\n\n{}", build_errors.join("\n"))
};
bail!(
"cargo build -p node-server failed (exit {}){detail}",
build_status
);
}
let _ = DAEMON_BUILD_ONCE.set(());
} else {
self.syslog(format!(
"→ reusing daemon binary built by a prior instance this run ({})",
binary_path.display()
));
if !binary_path.exists() {
bail!(
"daemon binary missing at {} — the first instance's build should have \
produced it. Rerun `node-app dev`.",
binary_path.display()
);
}
}
self.syslog(format!(
"→ spawning daemon (env={})",
env_file.display()
));
tui::update_status(
self.log_tx.as_ref(),
LogSource::Daemon,
ServiceStatus::Starting,
Some(format!("[{}] api=:{} ipc={}", self.profile.name, self.profile.http_port, socket_path.display())),
);
self.force_dev_http_only(&db_path);
let child = self.spawn_daemon(&env_file)?;
let child_pid = child.id();
if let Err(e) = fs::write(&pid_file, child_pid.to_string()) {
self.syslog(format!("⚠ could not write PID file: {e}"));
}
*self.child.lock().unwrap() = Some(child);
let started = Instant::now();
loop {
if UnixStream::connect(&socket_path).is_ok() {
self.syslog(format!(
"✓ IPC socket up at {} ({}s) — waiting for apps…",
socket_path.display(),
started.elapsed().as_secs()
));
break;
}
if started.elapsed() >= HEALTH_TIMEOUT {
self.shutdown();
tui::update_status(
self.log_tx.as_ref(),
LogSource::Daemon,
ServiceStatus::Failed(format!(
"socket not connectable after {}s",
HEALTH_TIMEOUT.as_secs()
)),
None,
);
return Err(anyhow!(
"daemon did not come up within {}s (socket at {} not connectable).",
HEALTH_TIMEOUT.as_secs(),
socket_path.display()
));
}
if let Some(child) = self.child.lock().unwrap().as_mut() {
if let Ok(Some(status)) = child.try_wait() {
tui::update_status(
self.log_tx.as_ref(),
LogSource::Daemon,
ServiceStatus::Failed(format!("exited {}", status)),
None,
);
return Err(anyhow!("daemon exited with {} before becoming ready", status));
}
}
std::thread::sleep(HEALTH_POLL);
}
self.wait_http_ready(self.profile.http_port)?;
if self.client_node {
self.syslog(format!(
"✓ client-node PWA available at http://localhost:{} (backend-served from {})",
self.profile.http_port,
self.monorepo_path.join("system/pwa/dist").display()
));
tui::update_status(
self.log_tx.as_ref(),
LogSource::UiServer,
ServiceStatus::Disabled,
Some("client-node PWA (backend-served, no Vite HMR)".into()),
);
}
let ui_child = self.spawn_ui();
if let Some(ref c) = ui_child {
let _ = fs::write(&vite_pid_file, c.id().to_string());
if let Err(e) = self.verify_proxy(self.profile.ui_port, self.profile.http_port) {
self.syslog(format!("⚠ proxy verification failed: {e:#}"));
tui::update_status(
self.log_tx.as_ref(),
LogSource::UiServer,
ServiceStatus::Failed("proxy not forwarding /api".into()),
None,
);
} else if let Err(e) = self.verify_ws_proxy(self.profile.ui_port) {
self.syslog(format!("⚠ ws proxy verification failed: {e:#}"));
tui::update_status(
self.log_tx.as_ref(),
LogSource::UiServer,
ServiceStatus::Failed("ws proxy /api/ws not upgrading".into()),
None,
);
}
}
*self.ui_child.lock().unwrap() = ui_child;
let apps_started = Instant::now();
self.syslog("→ waiting for critical apps (device-registry, core-storage)…");
loop {
let all_ready = ipc_list_app_statuses(&socket_path)
.map(|statuses| {
["device-registry", "core-storage"].iter().all(|name| {
statuses
.get(*name)
.map(|s| s == "active" || s == "running" || s == "lazy")
.unwrap_or(false)
})
})
.unwrap_or(false);
if all_ready {
self.syslog(format!(
"✓ critical apps ready ({}s)",
apps_started.elapsed().as_secs()
));
tui::update_status(
self.log_tx.as_ref(),
LogSource::Daemon,
ServiceStatus::Ready,
Some(format!(
"[{}] api=http://localhost:{} ui=http://localhost:{}",
self.profile.name, self.profile.http_port, self.profile.ui_port
)),
);
break;
}
if apps_started.elapsed() >= Duration::from_secs(60) {
self.syslog("⚠ critical apps not fully loaded after 60s — proceeding anyway");
tui::update_status(
self.log_tx.as_ref(),
LogSource::Daemon,
ServiceStatus::Ready,
Some("api ready (some apps slow)".into()),
);
break;
}
if let Some(child) = self.child.lock().unwrap().as_mut() {
if let Ok(Some(status)) = child.try_wait() {
return Err(anyhow!("daemon exited with {} while waiting for apps", status));
}
}
std::thread::sleep(Duration::from_secs(2));
}
Ok(DaemonHandle {
name: self.profile.name.clone(),
banner: if self.client_node {
format!(
"monorepo daemon [{}] (cargo run from {}, client-node PWA=http://localhost:{} \
[backend-served, no Vite HMR; shared platform deps], socket={})",
self.profile.name,
self.monorepo_path.display(),
self.profile.http_port,
socket_path.display()
)
} else {
format!(
"monorepo daemon [{}] (cargo run from {}, api=http://localhost:{}, \
ui=http://localhost:{}, socket={})",
self.profile.name,
self.monorepo_path.display(),
self.profile.http_port,
self.profile.ui_port,
socket_path.display()
)
},
socket_path,
dev_dir,
api_base_url: Some(format!("http://127.0.0.1:{}", self.profile.http_port)),
})
}
fn tail_logs(&self, app_name: &str) {
let log_tx = match &self.log_tx {
Some(tx) => tx.clone(),
None => return,
};
let env_dir = match monorepo_env_dir(&self.monorepo_path, &self.profile.name) {
Ok(d) => d,
Err(_) => return,
};
let log_file = env_dir
.join("ldk_data")
.join("logs")
.join("apps")
.join(app_name)
.join("app.log");
let prefix = format!("[{node}][{app}] ", node = self.profile.name, app = app_name);
std::thread::spawn(move || {
use std::io::{BufRead, BufReader, Seek, SeekFrom};
let deadline = std::time::Instant::now();
loop {
if log_file.exists() {
break;
}
if deadline.elapsed() > std::time::Duration::from_secs(5) {
return;
}
std::thread::sleep(std::time::Duration::from_millis(200));
}
let file = match fs::File::open(&log_file) {
Ok(f) => f,
Err(_) => return,
};
let mut reader = BufReader::new(file);
let _ = reader.seek(SeekFrom::Start(0));
loop {
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) => {
std::thread::sleep(std::time::Duration::from_millis(100));
}
Ok(_) => {
let trimmed =
line.trim_end_matches('\n').trim_end_matches('\r');
if !trimmed.is_empty()
&& log_tx
.send(TuiEvent::Log(crate::tui::LogEntry {
source: LogSource::App,
line: format!("{prefix}{trimmed}"),
}))
.is_err()
{
return; }
}
Err(_) => return,
}
}
});
}
fn shutdown(&self) {
if let Ok(env_dir) = monorepo_env_dir(&self.monorepo_path, &self.profile.name) {
let _ = fs::remove_file(env_dir.join("daemon.pid"));
}
if let Some(mut child) = self.child.lock().unwrap().take() {
let pid = child.id() as i32;
self.syslog(format!("→ shutting down monorepo daemon (PID {pid})…"));
#[cfg(unix)]
unsafe {
libc::kill(-pid, libc::SIGTERM);
}
let t = Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => {
self.syslog(format!(
"✓ daemon exited ({}ms)",
t.elapsed().as_millis()
));
break;
}
Ok(None) if t.elapsed() >= Duration::from_secs(10) => {
self.syslog("⚠ daemon did not exit after 10s, SIGKILL");
#[cfg(unix)]
unsafe {
libc::kill(-pid, libc::SIGKILL);
}
let _ = child.wait();
break;
}
Ok(None) => std::thread::sleep(Duration::from_millis(200)),
Err(e) => {
self.syslog(format!("⚠ daemon wait error: {e}"));
break;
}
}
}
}
if let Some(mut ui) = self.ui_child.lock().unwrap().take() {
let pid = ui.id() as i32;
self.syslog(format!("→ shutting down UI dev server (PID {pid})…"));
#[cfg(unix)]
unsafe {
libc::kill(-pid, libc::SIGTERM);
}
let t = Instant::now();
loop {
match ui.try_wait() {
Ok(Some(_)) => break,
Ok(None) if t.elapsed() >= Duration::from_secs(5) => {
#[cfg(unix)]
unsafe {
libc::kill(-pid, libc::SIGKILL);
}
let _ = ui.wait();
break;
}
Ok(None) => std::thread::sleep(Duration::from_millis(200)),
Err(_) => break,
}
}
}
}
fn restart(&self) -> Result<()> {
let env_file = self
.env_file
.lock()
.unwrap()
.clone()
.ok_or_else(|| anyhow!("daemon was never started — cannot restart"))?;
self.syslog("→ restart: stopping daemon…");
tui::update_status(
self.log_tx.as_ref(),
LogSource::Daemon,
ServiceStatus::Starting,
Some("restarting…".into()),
);
self.shutdown_daemon_only();
self.free_port(self.profile.http_port, "backend");
let socket_path = self
.socket_override
.clone()
.unwrap_or_else(|| {
monorepo_env_dir(&self.monorepo_path, &self.profile.name)
.ok()
.map(|d| d.join("control.sock"))
.unwrap_or_else(|| PathBuf::from("/tmp/node-control.sock"))
});
let _ = fs::remove_file(&socket_path);
self.syslog("→ restart: spawning daemon…");
let child = self.spawn_daemon(&env_file)?;
let child_pid = child.id();
*self.child.lock().unwrap() = Some(child);
if let Ok(env_dir) = monorepo_env_dir(&self.monorepo_path, &self.profile.name) {
let _ = fs::write(env_dir.join("daemon.pid"), child_pid.to_string());
}
let t = Instant::now();
loop {
if UnixStream::connect(&socket_path).is_ok() {
if let Err(e) = self.wait_http_ready(self.profile.http_port) {
tui::update_status(
self.log_tx.as_ref(),
LogSource::Daemon,
ServiceStatus::Failed("HTTP not ready after restart".into()),
None,
);
bail!("daemon restart: HTTP readiness failed: {e:#}");
}
self.syslog(format!(
"✓ daemon restarted ({}s)",
t.elapsed().as_secs()
));
tui::update_status(
self.log_tx.as_ref(),
LogSource::Daemon,
ServiceStatus::Ready,
Some("restarted".into()),
);
return Ok(());
}
if t.elapsed() > HEALTH_TIMEOUT {
tui::update_status(
self.log_tx.as_ref(),
LogSource::Daemon,
ServiceStatus::Failed("socket not connectable after restart".into()),
None,
);
bail!("daemon did not come up after restart");
}
if let Some(child) = self.child.lock().unwrap().as_mut() {
if let Ok(Some(status)) = child.try_wait() {
tui::update_status(
self.log_tx.as_ref(),
LogSource::Daemon,
ServiceStatus::Failed(format!("exited {}", status)),
None,
);
bail!("daemon exited with {} during restart", status);
}
}
std::thread::sleep(HEALTH_POLL);
}
}
fn pre_start_dev_dir(&self) -> Option<PathBuf> {
monorepo_dev_dir(&self.monorepo_path, &self.profile.name, self.dev_dir_override.as_deref()).ok()
}
}
static DAEMON_BUILD_ONCE: OnceLock<()> = OnceLock::new();
static UI_BUILD_ONCE: OnceLock<()> = OnceLock::new();
fn ensure_ui_dist_built(monorepo_path: &Path, log_tx: Option<LogTx>) {
if UI_BUILD_ONCE.set(()).is_err() {
return; }
let ui_dir = monorepo_path.join("system/ui");
if !ui_dir.join("package.json").exists() {
tui::sys_log(
log_tx.as_ref(),
format!(
"→ skip UI dist build: {} not found",
ui_dir.join("package.json").display()
),
);
return;
}
let pkg_mgr = if which_bin("yarn") { "yarn" } else { "npm" };
let args: &[&str] = if pkg_mgr == "yarn" {
&["build"]
} else {
&["run", "build"]
};
tui::sys_log(
log_tx.as_ref(),
format!(
"→ rebuilding UI dist in background ({} {}) — daemon's port will serve fresh bundle once done",
pkg_mgr,
args.join(" ")
),
);
tui::update_status(
log_tx.as_ref(),
LogSource::Build,
ServiceStatus::Building,
Some(format!("{} build (ui)", pkg_mgr)),
);
let ui_dir_clone = ui_dir.clone();
let log_tx_clone = log_tx.clone();
std::thread::spawn(move || {
let mut cmd = Command::new(pkg_mgr);
cmd.args(args).current_dir(&ui_dir_clone).stdin(Stdio::null());
if log_tx_clone.is_some() {
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
} else {
cmd.stdout(Stdio::inherit()).stderr(Stdio::inherit());
}
let started = Instant::now();
match cmd.spawn() {
Ok(mut child) => {
if let Some(tx) = &log_tx_clone {
if let Some(stdout) = child.stdout.take() {
let tx = tx.clone();
std::thread::spawn(move || {
for line in std::io::BufReader::new(stdout).lines().map_while(Result::ok) {
let _ = tx.send(TuiEvent::Log(crate::tui::LogEntry {
source: LogSource::Build,
line: format!("[ui-build] {line}"),
}));
}
});
}
if let Some(stderr) = child.stderr.take() {
let tx = tx.clone();
std::thread::spawn(move || {
for line in std::io::BufReader::new(stderr).lines().map_while(Result::ok) {
let _ = tx.send(TuiEvent::Log(crate::tui::LogEntry {
source: LogSource::Build,
line: format!("[ui-build] {line}"),
}));
}
});
}
}
match child.wait() {
Ok(status) if status.success() => {
tui::sys_log(
log_tx_clone.as_ref(),
format!(
"✓ UI dist rebuilt in {:.1}s",
started.elapsed().as_secs_f32()
),
);
tui::update_status(
log_tx_clone.as_ref(),
LogSource::Build,
ServiceStatus::Ready,
Some("ui dist fresh".into()),
);
}
Ok(status) => {
tui::sys_log(
log_tx_clone.as_ref(),
format!(
"✗ UI dist build failed (exit {}); daemon port will serve stale bundle. \
Use http://localhost:5173/5174 (Vite, HMR) until this is fixed.",
status
),
);
tui::update_status(
log_tx_clone.as_ref(),
LogSource::Build,
ServiceStatus::Failed("ui build failed".into()),
None,
);
}
Err(e) => {
tui::sys_log(
log_tx_clone.as_ref(),
format!("✗ UI dist build wait error: {e}"),
);
}
}
}
Err(e) => {
tui::sys_log(
log_tx_clone.as_ref(),
format!(
"✗ could not spawn {} build for UI dist: {e}",
pkg_mgr
),
);
}
}
});
}
static PWA_BUILD_ONCE: OnceLock<()> = OnceLock::new();
fn ensure_pwa_dist_built(monorepo_path: &Path, log_tx: Option<LogTx>) {
if PWA_BUILD_ONCE.set(()).is_err() {
return; }
let pwa_dir = monorepo_path.join("system/pwa");
if !pwa_dir.join("package.json").exists() {
tui::sys_log(
log_tx.as_ref(),
format!(
"→ skip PWA dist build: {} not found",
pwa_dir.join("package.json").display()
),
);
return;
}
let pkg_mgr = if which_bin("yarn") { "yarn" } else { "npm" };
let args: &[&str] = if pkg_mgr == "yarn" {
&["build"]
} else {
&["run", "build"]
};
tui::sys_log(
log_tx.as_ref(),
format!(
"→ rebuilding client-node PWA dist in background ({} {}) — daemon's port will serve fresh bundle once done",
pkg_mgr,
args.join(" ")
),
);
tui::update_status(
log_tx.as_ref(),
LogSource::Build,
ServiceStatus::Building,
Some(format!("{} build (pwa)", pkg_mgr)),
);
let pwa_dir_clone = pwa_dir.clone();
let log_tx_clone = log_tx.clone();
std::thread::spawn(move || {
let mut cmd = Command::new(pkg_mgr);
cmd.args(args).current_dir(&pwa_dir_clone).stdin(Stdio::null());
if log_tx_clone.is_some() {
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
} else {
cmd.stdout(Stdio::inherit()).stderr(Stdio::inherit());
}
let started = Instant::now();
match cmd.spawn() {
Ok(mut child) => {
if let Some(tx) = &log_tx_clone {
if let Some(stdout) = child.stdout.take() {
let tx = tx.clone();
std::thread::spawn(move || {
for line in std::io::BufReader::new(stdout).lines().map_while(Result::ok) {
let _ = tx.send(TuiEvent::Log(crate::tui::LogEntry {
source: LogSource::Build,
line: format!("[pwa-build] {line}"),
}));
}
});
}
if let Some(stderr) = child.stderr.take() {
let tx = tx.clone();
std::thread::spawn(move || {
for line in std::io::BufReader::new(stderr).lines().map_while(Result::ok) {
let _ = tx.send(TuiEvent::Log(crate::tui::LogEntry {
source: LogSource::Build,
line: format!("[pwa-build] {line}"),
}));
}
});
}
}
match child.wait() {
Ok(status) if status.success() => {
tui::sys_log(
log_tx_clone.as_ref(),
format!(
"✓ PWA dist rebuilt in {:.1}s",
started.elapsed().as_secs_f32()
),
);
tui::update_status(
log_tx_clone.as_ref(),
LogSource::Build,
ServiceStatus::Ready,
Some("pwa dist fresh".into()),
);
}
Ok(status) => {
tui::sys_log(
log_tx_clone.as_ref(),
format!(
"✗ PWA dist build failed (exit {}); daemon port will serve the stale (or missing) bundle.",
status
),
);
tui::update_status(
log_tx_clone.as_ref(),
LogSource::Build,
ServiceStatus::Failed("pwa build failed".into()),
None,
);
}
Err(e) => {
tui::sys_log(
log_tx_clone.as_ref(),
format!("✗ PWA dist build wait error: {e}"),
);
}
}
}
Err(e) => {
tui::sys_log(
log_tx_clone.as_ref(),
format!(
"✗ could not spawn {} build for PWA dist: {e}",
pkg_mgr
),
);
}
}
});
}
static BUILTIN_APPS_BUILD_ONCE: OnceLock<()> = OnceLock::new();
fn ensure_builtin_apps_built(monorepo_path: &Path, log_tx: Option<LogTx>) {
if BUILTIN_APPS_BUILD_ONCE.set(()).is_err() {
return; }
let makefile = monorepo_path.join("Makefile");
if !makefile.exists() {
tui::sys_log(
log_tx.as_ref(),
format!("→ skip builtin-apps build: {} not found", makefile.display()),
);
return;
}
if !which_bin("make") {
tui::sys_log(
log_tx.as_ref(),
"→ skip builtin-apps build: `make` not on PATH",
);
return;
}
tui::sys_log(
log_tx.as_ref(),
"→ rebuilding builtin apps in background (make builtin-apps) — cargo is incremental, only changed modules pay full cost",
);
tui::update_status(
log_tx.as_ref(),
LogSource::Build,
ServiceStatus::Building,
Some("make builtin-apps".into()),
);
let monorepo_clone = monorepo_path.to_path_buf();
let log_tx_clone = log_tx.clone();
std::thread::spawn(move || {
let mut cmd = Command::new("make");
cmd.arg("builtin-apps")
.current_dir(&monorepo_clone)
.stdin(Stdio::null());
if log_tx_clone.is_some() {
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
} else {
cmd.stdout(Stdio::inherit()).stderr(Stdio::inherit());
}
let started = Instant::now();
match cmd.spawn() {
Ok(mut child) => {
if let Some(tx) = &log_tx_clone {
if let Some(stdout) = child.stdout.take() {
let tx = tx.clone();
std::thread::spawn(move || {
for line in std::io::BufReader::new(stdout).lines().map_while(Result::ok) {
let _ = tx.send(TuiEvent::Log(crate::tui::LogEntry {
source: LogSource::Build,
line: format!("[builtin-apps] {line}"),
}));
}
});
}
if let Some(stderr) = child.stderr.take() {
let tx = tx.clone();
std::thread::spawn(move || {
for line in std::io::BufReader::new(stderr).lines().map_while(Result::ok) {
let _ = tx.send(TuiEvent::Log(crate::tui::LogEntry {
source: LogSource::Build,
line: format!("[builtin-apps] {line}"),
}));
}
});
}
}
match child.wait() {
Ok(status) if status.success() => {
tui::sys_log(
log_tx_clone.as_ref(),
format!(
"✓ builtin apps rebuilt in {:.1}s — daemon will pick up changes on next start/reload",
started.elapsed().as_secs_f32()
),
);
tui::update_status(
log_tx_clone.as_ref(),
LogSource::Build,
ServiceStatus::Ready,
Some("builtin apps fresh".into()),
);
}
Ok(status) => {
tui::sys_log(
log_tx_clone.as_ref(),
format!(
"✗ builtin-apps build failed (exit {}); daemon will dlopen the previously-built .dylibs and may be missing capabilities you've added since.",
status
),
);
tui::update_status(
log_tx_clone.as_ref(),
LogSource::Build,
ServiceStatus::Failed("builtin-apps build failed".into()),
None,
);
}
Err(e) => {
tui::sys_log(
log_tx_clone.as_ref(),
format!("✗ builtin-apps build wait error: {e}"),
);
}
}
}
Err(e) => {
tui::sys_log(
log_tx_clone.as_ref(),
format!("✗ could not spawn make builtin-apps: {e}"),
);
}
}
});
}
fn monorepo_env_dir(monorepo_path: &Path, instance_name: &str) -> Result<PathBuf> {
let cache_dir = cache_root()?;
let path_hash = {
let mut h = DefaultHasher::new();
monorepo_path
.canonicalize()
.unwrap_or_else(|_| monorepo_path.to_path_buf())
.hash(&mut h);
instance_name.hash(&mut h);
h.finish()
};
Ok(cache_dir.join(format!("monorepo-{:x}", path_hash)))
}
pub fn monorepo_dev_dir(monorepo_path: &Path, instance_name: &str, dev_dir_override: Option<&Path>) -> Result<PathBuf> {
if let Some(override_path) = dev_dir_override {
return Ok(override_path.to_path_buf());
}
Ok(monorepo_env_dir(monorepo_path, instance_name)?.join("dev-apps"))
}
fn ipc_list_app_statuses(
socket_path: &std::path::Path,
) -> Result<std::collections::HashMap<String, String>> {
use std::io::{BufRead, BufReader, Write};
let mut stream = UnixStream::connect(socket_path)
.with_context(|| "connect to IPC socket")?;
stream.set_read_timeout(Some(Duration::from_secs(5))).ok();
stream.set_write_timeout(Some(Duration::from_secs(5))).ok();
let request = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "app.list",
"params": {}
});
let mut line = serde_json::to_string(&request).unwrap();
line.push('\n');
stream.write_all(line.as_bytes())?;
let reader = BufReader::new(&stream);
let response_line = reader
.lines()
.next()
.ok_or_else(|| anyhow!("no response"))??;
let v: serde_json::Value = serde_json::from_str(&response_line)?;
let mut map = std::collections::HashMap::new();
if let Some(apps) = v.pointer("/result/apps").and_then(|a| a.as_array()) {
for app in apps {
if let (Some(name), Some(status)) = (
app.get("name").and_then(|n| n.as_str()),
app.get("status").and_then(|s| s.as_str()),
) {
map.insert(name.to_string(), status.to_string());
}
}
}
Ok(map)
}
fn which_bin(bin: &str) -> bool {
std::env::var_os("PATH")
.map(|path| std::env::split_paths(&path).any(|dir| dir.join(bin).is_file()))
.unwrap_or(false)
}
fn cache_root() -> Result<PathBuf> {
if let Ok(c) = std::env::var("XDG_CACHE_HOME") {
if !c.is_empty() {
return Ok(PathBuf::from(c).join("node-app"));
}
}
let home = std::env::var_os("HOME").ok_or_else(|| anyhow!("$HOME not set"))?;
Ok(PathBuf::from(home).join(".cache/node-app"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn client_node_offsets_p2p_port_like_the_other_ports() {
let legacy = MonorepoHost::new(
PathBuf::from("/tmp/monorepo"),
InstanceProfile::alice(),
None,
None,
None,
false,
);
let client_node = MonorepoHost::new(
PathBuf::from("/tmp/monorepo"),
InstanceProfile::alice(),
None,
None,
None,
true,
);
assert_eq!(legacy.profile.p2p_port, InstanceProfile::alice().p2p_port);
assert_eq!(
client_node.profile.p2p_port,
InstanceProfile::alice().p2p_port + CLIENT_NODE_PORT_OFFSET
);
assert_ne!(
legacy.profile.p2p_port, client_node.profile.p2p_port,
"legacy and --client-node lanes for the same instance must derive \
distinct LDK P2P ports or they'll contend for LDK_LISTENING_ADDRESS"
);
assert_eq!(
client_node.profile.http_port,
legacy.profile.http_port + CLIENT_NODE_PORT_OFFSET
);
assert_eq!(
client_node.profile.https_port,
legacy.profile.https_port + CLIENT_NODE_PORT_OFFSET
);
assert_eq!(
client_node.profile.ui_port,
legacy.profile.ui_port + CLIENT_NODE_PORT_OFFSET
);
}
#[test]
fn client_node_alice_p2p_port_does_not_collide_with_plain_bob() {
let alice_client_node = MonorepoHost::new(
PathBuf::from("/tmp/monorepo"),
InstanceProfile::alice(),
None,
None,
None,
true,
);
let bob_legacy = MonorepoHost::new(
PathBuf::from("/tmp/monorepo"),
InstanceProfile::bob(),
None,
None,
None,
false,
);
assert_ne!(
alice_client_node.profile.p2p_port,
bob_legacy.profile.p2p_port
);
}
}