use std::collections::hash_map::DefaultHasher;
use std::fs;
use std::hash::{Hash, Hasher};
use std::io::BufRead;
use std::net::{Ipv4Addr, UdpSocket};
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::{Condvar, Mutex, OnceLock};
use std::time::{Duration, Instant};
use anyhow::{anyhow, bail, Context, Result};
#[cfg(unix)]
use libc;
use rand::RngCore;
use serde_json;
use super::minimal_core;
use super::{DaemonHandle, DaemonHost, InstanceProfile};
use crate::commands::harness::ports;
use crate::commands::harness::state::HarnessState;
use crate::tui::{self, LogSource, LogTx, ServiceStatus, TuiEvent};
fn fresh_hex_secret(bytes: usize) -> String {
let mut buf = vec![0u8; bytes];
rand::thread_rng().fill_bytes(&mut buf);
hex::encode(buf)
}
pub(crate) fn materialize_base_env_from_example(base_env_path: &Path) -> Result<()> {
let example_path = PathBuf::from(format!("{}.example", base_env_path.display()));
if !example_path.exists() {
bail!(
"no template at {} to generate {} from — this should ship in the repo \
(system/server/{{alice,bob}}.env.example); if it's missing, something is wrong \
with this checkout.",
example_path.display(),
base_env_path.display()
);
}
let template = fs::read_to_string(&example_path)
.with_context(|| format!("read {}", example_path.display()))?;
let materialized = template
.replace("__GENERATE_FRESH_JWT_SECRET__", &fresh_hex_secret(32))
.replace(
"__GENERATE_FRESH_REFRESH_TOKEN_SECRET__",
&fresh_hex_secret(32),
)
.replace(
"__GENERATE_FRESH_LIBP2P_IDENTITY_SEED__",
&fresh_hex_secret(32),
);
fs::write(base_env_path, materialized)
.with_context(|| format!("write generated base env {}", base_env_path.display()))?;
eprintln!(
"harness: generated {} from {} with a fresh JWT secret, refresh-token secret, and \
libp2p identity seed for this worktree (gap 0c/0e) — edit it directly if you need \
non-default dev settings; delete it to regenerate.",
base_env_path.display(),
example_path.display()
);
Ok(())
}
fn pwa_dir_env_pair(monorepo_path: &Path) -> (&'static str, String, &'static str) {
(
"NODE_PWA_DIR",
monorepo_path.join("system/pwa/dist").display().to_string(),
"absolute path to monorepo/system/pwa/dist — the Client Node PWA bundle \
the backend always serves at / (there is no enable flag any more); \
NODE_PWA_DIR is the only variable node-server reads for this \
(system/server/src/config.rs)",
)
}
pub(crate) fn strip_inline_comments(env: &str) -> String {
let mut out = String::with_capacity(env.len());
for line in env.lines() {
let trimmed = line.trim_start();
let is_assignment = !trimmed.starts_with('#') && trimmed.contains('=');
if !is_assignment {
out.push_str(line);
out.push('\n');
continue;
}
let mut quote: Option<char> = None;
let mut previous_is_space = false;
let mut end = line.len();
let value_start = line.find('=').map(|i| i + 1).unwrap_or(0);
for (i, c) in line.char_indices().skip_while(|(i, _)| *i < value_start) {
match (quote, c) {
(None, '"') | (None, '\'') => quote = Some(c),
(Some(open), c) if c == open => quote = None,
(None, '#') if previous_is_space => {
end = i;
break;
}
_ => {}
}
previous_is_space = c.is_whitespace();
}
out.push_str(line[..end].trim_end());
out.push('\n');
}
out
}
pub(crate) struct InstanceLayout {
pub db_path: PathBuf,
pub data_dir: PathBuf,
pub log_dir: PathBuf,
pub runtime_socket: PathBuf,
pub tls_state: PathBuf,
pub server_seed: PathBuf,
}
impl InstanceLayout {
pub(crate) fn under(env_dir: &Path) -> Self {
let data_dir = env_dir.join("data");
Self {
db_path: env_dir.join("dev.db"),
log_dir: env_dir.join("logs"),
runtime_socket: env_dir.join("runtime.sock"),
tls_state: env_dir.join("tls.json"),
server_seed: data_dir.join("server_seed.enc"),
data_dir,
}
}
}
pub(crate) struct DaemonEnvInputs<'a> {
pub monorepo_path: &'a Path,
pub layout: &'a InstanceLayout,
pub dev_dir: &'a Path,
pub control_socket: &'a Path,
pub api_port: u16,
pub https_port: u16,
pub p2p_port: u16,
pub client_node: bool,
pub lan_origin: &'a str,
}
pub(crate) fn daemon_env_pairs(i: &DaemonEnvInputs<'_>) -> Vec<(&'static str, String, &'static str)> {
let layout = i.layout;
let ssl = minimal_core::LocalCaFiles::under(&layout.data_dir);
let mut pairs: Vec<(&'static str, String, &'static str)> = vec![
("APT_APPS_DIR", i.monorepo_path.join("modules").display().to_string(),
"load apps from the monorepo's modules/ (the extracted node-app-* checkouts) instead of /usr/lib/node/apps"),
("NODE_DEV_APPS_DIR", i.dev_dir.display().to_string(),
"per-instance dev-apps root (shadows modules/); standalone manifests are staged here"),
("NODE_DB_PATH", layout.db_path.display().to_string(),
"per-instance host database under the cache dir (default /var/lib/node/node.db)"),
("NODE_DATA_DIR", layout.data_dir.display().to_string(),
"per-instance data dir: <data>/apps/<name> app state, the server seed, ssl/ (default /var/lib/node)"),
("DATA_DIR_PATH", layout.data_dir.display().to_string(),
"pre-cut name the minimal core still reads as a fallback (setup-token dir, server seed); pinned so the base file's relative ./data_<name> cannot win"),
("NODE_HTTP_BIND", format!("0.0.0.0:{}", i.api_port),
"plain-HTTP listener the harness probes, agent auth and `invoke` use (alice=3001, bob=3002, lanes offset); 0.0.0.0 so a peer can reach it on the LAN address gossip advertises"),
("NODE_SERVER_PORT", i.api_port.to_string(),
"the port apps are told the host listens on; kept equal to NODE_HTTP_BIND's"),
("NODE_IPC_SOCKET", i.control_socket.display().to_string(),
"per-instance control socket the orchestrator polls for readiness"),
("NODE_RUNTIME_SOCKET", layout.runtime_socket.display().to_string(),
"per-instance runtime socket node-provisioning proxies HTTPS to (default /run/node/runtime.sock)"),
("NODE_APP_LOG_DIR", layout.log_dir.display().to_string(),
"per-instance app logs under the cache dir (default /var/log/node/apps)"),
("NODE_APP_MILESTONES_PATH", layout.log_dir.join("app-milestones.json").display().to_string(),
"per-instance app startup milestones (default /var/lib/node/app-milestones.json, unwritable here)"),
("NODE_SERVER_SEED_PATH", layout.server_seed.display().to_string(),
"the dev server seed this tool mints (core.signer.* / did onboarding need it; the minimal core never mints one)"),
("DEVELOPMENT_MODE", "true".to_string(),
"the dev server seed is encrypted under the development-mode key, which node-adapters-signer uses only when this is true"),
("NODE_TLS_STATE_PATH", layout.tls_state.display().to_string(),
"per-instance TLS handoff manifest node-server publishes and node-provisioning polls (default /run/node/tls.json)"),
("NODE_TLS_CERTIFICATE_PATH", ssl.leaf_certificate.display().to_string(),
"the local leaf node-server reissues from the dev local CA at boot"),
("NODE_TLS_PRIVATE_KEY_PATH", ssl.leaf_key.display().to_string(),
"its key"),
("NODE_TLS_LOCAL_CA_PATH", ssl.ca_certificate.display().to_string(),
"the dev local CA, served at /.well-known/node-ca/install"),
pwa_dir_env_pair(i.monorepo_path),
("NODE_GOSSIP_DB_PATH", layout.db_path.display().to_string(),
"node-app-gossip reads the discovery index from the host database; its default is the pre-rename /var/lib/node/bulletin_board.db (#3089)"),
("LDK_LISTENING_ADDRESS", format!("127.0.0.1:{}", i.p2p_port),
"per-instance LDK P2P port (offset in --client-node lanes) so two lanes for one instance never contend for it (#1529)"),
("NODE_ALLOW_DEV_SOCKET_PATH", "1".to_string(),
"accept standalone socket paths outside /run/ (dev cache dirs aren't /run/-writable)"),
("WEBV1_ENABLED", "true".to_string(),
"WebV1 content mesh on (its default on a device too); peers are seeded at runtime"),
("WEBV1_DATA_DIR", layout.data_dir.join("webv1").display().to_string(),
"per-instance WebV1 data dir — alice/bob must not share one"),
];
if i.client_node {
pairs.push((
"NODE_TLS_TERMINATOR_ADDRESS",
format!("0.0.0.0:{}", i.https_port),
"the TLS door node-provisioning binds for this lane; node-server advertises https://<its leaf's SANs>:<this port> and admits them for CORS",
));
pairs.push((
"CLIENT_NODE_LAN_ORIGINS",
i.lan_origin.to_string(),
"the origin a browser should load: the machine's primary private LAN IPv4 on the TLS door (a SAN of the leaf, and an origin the node admits for CORS); 127.0.0.1 when none is detectable",
));
}
pairs
}
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}");
}
}
});
}
pub(crate) 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>>,
provisioning_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,
port_override: Option<ports::PortSet>,
) -> Self {
let profile = if client_node {
let ports = port_override.unwrap_or_else(|| {
ports::PortSet::base_for(
&profile.name,
profile.http_port,
profile.https_port,
profile.p2p_port,
profile.ui_port,
)
});
InstanceProfile {
name: lane_instance_name(&profile.name, true),
http_port: ports.http,
https_port: ports.https,
p2p_port: ports.p2p,
ui_port: ports.ui,
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),
provisioning_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 = daemon_binary_path(&self.monorepo_path);
if !binary_path.exists() {
bail!(
"daemon binary not found at {} ({}). If a `cargo build -p node-server` ran \
above and failed, its error is the real cause — scroll up. If it reported \
success, the binary was removed or moved after that build finished; rerun \
`cargo build -p node-server --features agentic_payments` in {} (with the \
same CARGO_TARGET_DIR, if you have one set) and confirm the binary lands at \
the path above.",
binary_path.display(),
target_dir_diagnostic(&self.monorepo_path),
self.monorepo_path.display()
);
}
let mut cmd = Command::new(&binary_path);
cmd.arg(minimal_core::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_provisioning(&self, env_dir: &Path, socket_path: &Path) -> Result<Child> {
let binary = provisioning_binary_path(&self.monorepo_path);
if !binary.exists() {
bail!(
"node-provisioning binary not found at {} ({}). The --client-node lane needs \
it for HTTPS: node-server has no TLS listener of its own. Build it with \
`cargo build -p node-provisioning` (same CARGO_TARGET_DIR).",
binary.display(),
target_dir_diagnostic(&self.monorepo_path),
);
}
let layout = InstanceLayout::under(env_dir);
let provisioning_dir = env_dir.join("provisioning");
fs::create_dir_all(provisioning_dir.join("ui")).ok();
minimal_core::serve_wifi_setup_socket(&provisioning_dir.join("wifi.sock"))?;
let env = minimal_core::provisioning_env(&minimal_core::ProvisioningLayout {
env_dir,
http_port: self.profile.ui_port,
https_port: self.profile.https_port,
runtime_socket: &layout.runtime_socket,
control_socket: socket_path,
tls_state: &layout.tls_state,
data_dir: &layout.data_dir,
});
let mut cmd = Command::new(&binary);
cmd.envs(env.iter().map(|(k, v)| (*k, v.as_str())))
.current_dir(env_dir)
.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.display()))?;
let _ = fs::write(env_dir.join("provisioning.pid"), child.id().to_string());
let log_file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(env_dir.join("provisioning.log"))
.ok()
.map(|f| std::sync::Arc::new(std::sync::Mutex::new(f)));
let prefix = format!("[{}-provisioning] ", 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);
}
self.syslog(format!(
"→ node-provisioning fronting [{}]: https=0.0.0.0:{} → {} (log: {})",
self.profile.name,
self.profile.https_port,
layout.runtime_socket.display(),
env_dir.join("provisioning.log").display()
));
Ok(child)
}
fn stop_provisioning(&self) {
if let Some(mut child) = self.provisioning_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(_)) | Err(_) => break,
Ok(None) if t.elapsed() > Duration::from_secs(8) => {
#[cfg(unix)]
unsafe {
libc::kill(-pid, libc::SIGKILL);
}
let _ = child.wait();
break;
}
Ok(None) => std::thread::sleep(Duration::from_millis(200)),
}
}
}
if let Ok(env_dir) = monorepo_env_dir(&self.monorepo_path, &self.profile.name) {
let _ = fs::remove_file(env_dir.join("provisioning.pid"));
}
}
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) {
self.stop_provisioning();
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 Drop for MonorepoHost {
fn drop(&mut self) {
let owns_children = self.child.get_mut().map(|c| c.is_some()).unwrap_or(false)
|| self
.provisioning_child
.get_mut()
.map(|c| c.is_some())
.unwrap_or(false);
if owns_children {
self.shutdown();
}
}
}
impl MonorepoHost {
#[cfg(unix)]
fn free_port(&self, port: u16, label: &str) -> Result<()> {
let pids = pids_listening_on(port);
if pids.is_empty() {
return Ok(());
}
let expected_env_path = monorepo_env_dir(&self.monorepo_path, &self.profile.name)
.ok()
.map(|dir| dir.join("daemon.env"));
let state = HarnessState::load_for(&self.monorepo_path).ok();
for pid in &pids {
let holder = ports::describe_pid(*pid as u32);
if let ports::PortOwnership::Foreign(h) =
ports::classify_port_holder(holder, expected_env_path.as_deref(), state.as_ref())
{
bail!(
"refusing to free {label} port {port}: it is held by pid {} (cwd {}, \
env {}), which this invocation did not start and cannot attribute to \
itself. If you own that process and it's a stale harness node, stop it \
with `node-app harness down` (or its own supervisor) first, then re-run. \
This command will never kill a process it cannot attribute to itself.",
h.pid,
h.cwd.display(),
h.env_path
.as_deref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "<unknown>".into()),
);
}
}
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 Ok(());
}
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);
}
}
}
Ok(())
}
#[cfg(not(unix))]
fn free_port(&self, _port: u16, _label: &str) -> Result<()> {
Ok(())
}
fn wait_http_ready(&self, port: u16) -> Result<()> {
let url = format!("http://127.0.0.1:{port}/health");
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 wait_tcp_ready(&self, port: u16, label: &str) -> Result<()> {
let addr = format!("127.0.0.1:{port}");
let started = Instant::now();
self.syslog(format!("→ waiting for {label} on {addr}…"));
loop {
if std::net::TcpStream::connect_timeout(
&addr.parse().expect("127.0.0.1:<port> always parses"),
Duration::from_secs(2),
)
.is_ok()
{
self.syslog(format!(
"✓ {label} 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 {label} on {addr}");
}
}
if started.elapsed() >= HTTP_READY_TIMEOUT {
bail!(
"{label} not accepting connections on {} after {}s",
addr,
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,
}
fn detect_primary_lan_ip() -> Option<Ipv4Addr> {
let socket = UdpSocket::bind("0.0.0.0:0").ok()?;
socket.connect("8.8.8.8:80").ok()?;
match socket.local_addr().ok()?.ip() {
std::net::IpAddr::V4(ip) => Some(ip),
std::net::IpAddr::V6(_) => None,
}
}
fn is_dev_cert_permitted_lan_ip(ip: Ipv4Addr) -> bool {
let [a, b, _, _] = ip.octets();
match a {
10 | 127 => true,
172 => (16..=31).contains(&b),
192 => b == 168,
169 => b == 254,
_ => false,
}
}
fn lan_origin_ip(detected: Option<Ipv4Addr>) -> Ipv4Addr {
match detected {
Some(ip) if is_dev_cert_permitted_lan_ip(ip) && !ip.is_loopback() => ip,
_ => Ipv4Addr::LOCALHOST,
}
}
fn client_node_browser_origin(https_port: u16, lan_ip: Ipv4Addr) -> String {
format!("https://{lan_ip}:{https_port}")
}
impl DaemonHost for MonorepoHost {
fn instance_name(&self) -> &str {
&self.profile.name
}
fn rebuild_daemon_binary(&self) -> Result<()> {
let binary_path = daemon_binary_path(&self.monorepo_path);
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);
}
}
let provisioning_pid_file = env_dir.join("provisioning.pid");
if let Ok(contents) = fs::read_to_string(&provisioning_pid_file) {
if let Ok(old_pid) = contents.trim().parse::<i32>() {
if unsafe { libc::kill(old_pid, 0) == 0 } {
self.syslog(format!(
"→ previous node-provisioning found (PID {old_pid}), sending SIGTERM…"
));
unsafe {
libc::kill(-old_pid, libc::SIGTERM);
}
let t = Instant::now();
while unsafe { libc::kill(old_pid, 0) } == 0 && t.elapsed() < Duration::from_secs(8) {
std::thread::sleep(Duration::from_millis(200));
}
if unsafe { libc::kill(old_pid, 0) } == 0 {
unsafe {
libc::kill(-old_pid, libc::SIGKILL);
}
}
}
}
let _ = fs::remove_file(&provisioning_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"));
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() {
materialize_base_env_from_example(&base_env_path).with_context(|| {
format!(
"no base env file for instance '{}' at {}, and generating one from its \
.env.example template also failed — pick a different instance via \
--instances, or create {} by hand from {}.example.",
self.profile.name,
base_env_path.display(),
base_env_path.display(),
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 apt_apps_dir = self.monorepo_path.join("modules");
let https_port = self.profile.https_port;
let lan_ip = if self.client_node {
lan_origin_ip(detect_primary_lan_ip())
} else {
Ipv4Addr::LOCALHOST
};
let lan_origin = client_node_browser_origin(https_port, lan_ip);
let layout = InstanceLayout::under(&env_dir);
fs::create_dir_all(&layout.log_dir).ok();
fs::create_dir_all(&layout.data_dir).ok();
let mut pairs = daemon_env_pairs(&DaemonEnvInputs {
monorepo_path: &self.monorepo_path,
layout: &layout,
dev_dir: &dev_dir,
control_socket: &socket_path,
api_port: self.profile.http_port,
https_port,
p2p_port: self.profile.p2p_port,
client_node: self.client_node,
lan_origin: &lan_origin,
});
if minimal_core::ensure_dev_server_seed(&layout.server_seed)
.with_context(|| format!("write a dev server seed at {}", layout.server_seed.display()))?
{
self.syslog(format!("→ minted a dev server seed at {}", layout.server_seed.display()));
}
if self.client_node
&& minimal_core::ensure_dev_local_ca(&layout.data_dir, &self.profile.name)
.with_context(|| format!("create a dev local CA under {}", layout.data_dir.display()))?
{
self.syslog(format!(
"→ created a dev local CA under {} (the daemon mints its TLS leaf from it at boot)",
layout.data_dir.display()
));
}
if std::env::var_os("NODE_BURGER_BIN").is_none() {
let checkouts = burger_dev_checkouts(
&self.monorepo_path,
std::env::var_os(BURGER_DIR_ENV),
git_common_dir(&self.monorepo_path).as_deref(),
);
if let Some(binary) = dev_burger_binary(&checkouts) {
pairs.push((
"NODE_BURGER_BIN",
binary.display().to_string(),
"locally built node-app-burger (NODE_APP_BURGER_DIR, else the main checkout's sibling node-app-burger, else modules/node-app-burger; honoring CARGO_TARGET_DIR) so app_type=burger apps load through the Burger runtime host in dev (Burger Plan 02, Contract C1)",
));
}
}
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 + &strip_inline_comments(&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);
ensure_yarn_install(&self.monorepo_path)?;
let pwa_build_handle = ensure_pwa_dist_built(&self.monorepo_path, self.log_tx.clone());
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 = daemon_binary_path(&self.monorepo_path);
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 -p node-provisioning (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", "-p", "node-provisioning"])
.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 not found at {} ({}), even though a prior instance in \
this same run already reported `cargo build -p node-server` \
succeeding. The build and this lookup resolve to the same directory, \
so something removed the binary after that build finished (or \
CARGO_TARGET_DIR changed between the build and now) — this is not the \
build failing. Rerun `node-app dev`.",
binary_path.display(),
target_dir_diagnostic(&self.monorepo_path),
);
}
}
match pwa_build_handle.join() {
Ok(Ok(())) => {}
Ok(Err(e)) => return Err(e).context("PWA dist build"),
Err(_) => bail!("PWA dist build thread panicked"),
}
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())),
);
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 {
let provisioning = self.spawn_provisioning(&env_dir, &socket_path)?;
*self.provisioning_child.lock().unwrap() = Some(provisioning);
match self.wait_tcp_ready(self.profile.https_port, "client-node HTTPS") {
Ok(()) => {
self.syslog(format!(
"✓ client-node PWA available at {lan_origin} via node-provisioning — \
load it there, NOT at https://127.0.0.1 or https://localhost: the node \
never admits a loopback origin for CORS, and the dev local CA may not \
vouch for `localhost` (bundle served from {}; browsers must trust {} \
or ignore certificate errors)",
self.monorepo_path.join("system/pwa/dist").display(),
minimal_core::LocalCaFiles::under(&InstanceLayout::under(&env_dir).data_dir)
.ca_certificate
.display()
));
tui::update_status(
self.log_tx.as_ref(),
LogSource::UiServer,
ServiceStatus::Ready,
Some(format!("{lan_origin} (node-provisioning, no Vite HMR)")),
);
}
Err(e) => {
tui::update_status(
self.log_tx.as_ref(),
LogSource::UiServer,
ServiceStatus::Failed("client-node HTTPS listener unreachable".into()),
None,
);
self.shutdown();
bail!(
"client-node HTTPS listener on {} never came up: {e:#}. node-provisioning \
binds it once node-server's TLS handoff manifest ({}) reports \
\"ready\" — check that file, then {} and {}.",
self.profile.https_port,
InstanceLayout::under(&env_dir).tls_state.display(),
env_dir.join("provisioning.log").display(),
env_dir.join("daemon.log").display(),
);
}
}
}
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 statuses = ipc_list_app_statuses(&socket_path);
if let Ok(None) = statuses {
self.syslog(
"→ this core reports no app status over its control socket; \
readiness is proven by onboarding (did) and the node id (ldk-node) instead",
);
break;
}
let all_ready = statuses
.map(|statuses| {
let statuses = statuses.unwrap_or_default();
["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(),
builtin_apps_dir: Some(apt_apps_dir.clone()),
banner: if self.client_node {
format!(
"monorepo daemon [{}] (cargo run from {}, api=http://127.0.0.1:{} (plain), \
client-node PWA={lan_origin} (node-provisioning, dev local CA) \
[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) {
self.stop_provisioning();
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:#}");
}
if self.client_node {
let env_dir = monorepo_env_dir(&self.monorepo_path, &self.profile.name)?;
let provisioning = self.spawn_provisioning(&env_dir, &socket_path)?;
*self.provisioning_child.lock().unwrap() = Some(provisioning);
}
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 YARN_INSTALL_ONCE: OnceLock<()> = OnceLock::new();
fn ensure_yarn_install(monorepo_path: &Path) -> Result<()> {
if YARN_INSTALL_ONCE.set(()).is_err() {
return Ok(()); }
if monorepo_path.join("node_modules").is_dir() {
return Ok(());
}
if !which_bin("yarn") {
bail!(
"{} has no node_modules and `yarn` is not on PATH. This monorepo's root \
package.json declares yarn workspaces (system/pwa, system/ui, client/*, ...) \
that `npm install` cannot resolve the same way — install yarn \
(https://yarnpkg.com, this repo pins {}) and re-run, or run `yarn install` \
yourself from {} before retrying.",
monorepo_path.display(),
"packageManager: yarn@4.18.0 (see package.json)",
monorepo_path.display()
);
}
eprintln!(
"harness: {} has no node_modules yet — running `yarn install` once (installs every \
yarn workspace: system/pwa, system/ui, client/*, ...) before building the PWA/UI \
dist bundles. This is a one-time cost for a fresh checkout.",
monorepo_path.display()
);
let status = Command::new("yarn")
.arg("install")
.current_dir(monorepo_path)
.status()
.with_context(|| format!("failed to spawn `yarn install` in {}", monorepo_path.display()))?;
if !status.success() {
bail!(
"`yarn install` failed ({status}) in {}. Neither the PWA nor the legacy UI dist \
bundle can build without it, so the daemon would boot serving a 404/blank page at \
`/` and `/v1/`. Remedy: run `cd {} && yarn install` and inspect the output above \
for the actual failure.",
monorepo_path.display(),
monorepo_path.display()
);
}
eprintln!("harness: yarn install complete — building PWA/UI dist bundles next");
Ok(())
}
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();
struct PwaBuildState {
outcome: Mutex<Option<Result<bool, String>>>,
done: Condvar,
}
static PWA_BUILD_STATE: OnceLock<PwaBuildState> = OnceLock::new();
fn pwa_build_state() -> &'static PwaBuildState {
PWA_BUILD_STATE.get_or_init(|| PwaBuildState {
outcome: Mutex::new(None),
done: Condvar::new(),
})
}
pub fn pwa_dist_build_outcome() -> Option<bool> {
PWA_BUILD_STATE.get()?.outcome.lock().unwrap().clone()?.ok()
}
fn pwa_package_json_exists(pwa_dir: &Path) -> bool {
pwa_dir.join("package.json").exists()
}
fn run_pwa_build_command(
pwa_dir: &Path,
program: &str,
args: &[&str],
log_tx: Option<&LogTx>,
) -> Result<()> {
let mut cmd = Command::new(program);
cmd.args(args).current_dir(pwa_dir).stdin(Stdio::null());
if log_tx.is_some() {
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
} else {
cmd.stdout(Stdio::inherit()).stderr(Stdio::inherit());
}
let started = Instant::now();
let mut child = cmd
.spawn()
.with_context(|| format!("spawn PWA dist build: {program} {}", args.join(" ")))?;
if let Some(tx) = log_tx {
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}"),
}));
}
});
}
}
let status = child
.wait()
.with_context(|| format!("wait for PWA dist build: {program} {}", args.join(" ")))?;
if !status.success() {
tui::update_status(
log_tx,
LogSource::Build,
ServiceStatus::Failed("pwa build failed".into()),
None,
);
bail!(
"PWA dist build failed (exit {status}) — `{program} {}` in {} after {:.1}s. Run it \
directly to see the real error, fix it, then rerun `harness up` / `node-app dev`. \
The daemon is NOT started while its bundle is broken: `resolve_pwa_root` decides \
PWA-vs-legacy once at boot, so a stale/missing dist would otherwise silently \
downgrade `/` to the legacy UI instead of failing loudly.",
args.join(" "),
pwa_dir.display(),
started.elapsed().as_secs_f32(),
);
}
tui::sys_log(
log_tx,
format!("✓ PWA dist rebuilt in {:.1}s", started.elapsed().as_secs_f32()),
);
tui::update_status(
log_tx,
LogSource::Build,
ServiceStatus::Ready,
Some("pwa dist fresh".into()),
);
Ok(())
}
fn ensure_pwa_dist_built(
monorepo_path: &Path,
log_tx: Option<LogTx>,
) -> std::thread::JoinHandle<Result<()>> {
let is_leader = PWA_BUILD_ONCE.set(()).is_ok();
let monorepo_path = monorepo_path.to_path_buf();
std::thread::spawn(move || -> Result<()> {
let state = pwa_build_state();
if !is_leader {
let mut guard = state.outcome.lock().unwrap();
while guard.is_none() {
guard = state.done.wait(guard).unwrap();
}
return match guard.clone().unwrap() {
Ok(_) => Ok(()),
Err(msg) => Err(anyhow!(msg)),
};
}
let pwa_dir = monorepo_path.join("system/pwa");
if !pwa_package_json_exists(&pwa_dir) {
tui::sys_log(
log_tx.as_ref(),
format!(
"→ skip PWA dist build: {} not found",
pwa_dir.join("package.json").display()
),
);
*state.outcome.lock().unwrap() = Some(Ok(false));
state.done.notify_all();
return Ok(());
}
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 ({pkg_mgr} {}) — bring-up waits for this to \
finish before starting the daemon",
args.join(" "),
),
);
tui::update_status(
log_tx.as_ref(),
LogSource::Build,
ServiceStatus::Building,
Some(format!("{pkg_mgr} build (pwa)")),
);
let result = run_pwa_build_command(&pwa_dir, pkg_mgr, args, log_tx.as_ref());
*state.outcome.lock().unwrap() = Some(match &result {
Ok(()) => Ok(true),
Err(e) => Err(format!("{e:#}")),
});
state.done.notify_all();
result
})
}
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 lane_instance_name(base: &str, client_node: bool) -> String {
if client_node {
format!("{base}-pwa")
} else {
base.to_string()
}
}
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<Option<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)?;
if v.pointer("/error/code").and_then(|c| c.as_i64()) == Some(-32601) {
return Ok(None);
}
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(Some(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 daemon_binary_path(monorepo_path: &Path) -> PathBuf {
effective_target_dir(monorepo_path).join("debug/node-server")
}
fn provisioning_binary_path(monorepo_path: &Path) -> PathBuf {
effective_target_dir(monorepo_path).join("debug/node-provisioning")
}
fn effective_target_dir(monorepo_path: &Path) -> PathBuf {
if let Some(dir) = std::env::var_os("CARGO_TARGET_DIR").filter(|d| !d.is_empty()) {
let dir = PathBuf::from(dir);
return if dir.is_absolute() { dir } else { monorepo_path.join(dir) };
}
std::process::Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()))
.args(["metadata", "--format-version", "1", "--no-deps"])
.current_dir(monorepo_path)
.output()
.ok()
.filter(|out| out.status.success())
.and_then(|out| serde_json::from_slice::<serde_json::Value>(&out.stdout).ok())
.and_then(|meta| {
meta.get("target_directory")
.and_then(|v| v.as_str())
.map(PathBuf::from)
})
.unwrap_or_else(|| monorepo_path.join("target"))
}
fn target_dir_diagnostic(monorepo_path: &Path) -> String {
match std::env::var("CARGO_TARGET_DIR") {
Ok(v) if !v.is_empty() => format!("CARGO_TARGET_DIR={v}, honored for both the build and this lookup"),
_ => format!(
"CARGO_TARGET_DIR is not set; cargo resolves the target dir to {} \
(a cargo config's `build.target-dir` can move it off <monorepo>/target)",
effective_target_dir(monorepo_path).display()
),
}
}
pub(crate) const BURGER_DIR_ENV: &str = "NODE_APP_BURGER_DIR";
pub(crate) fn git_common_dir(repo_path: &Path) -> Option<PathBuf> {
let output = Command::new("git")
.args(["rev-parse", "--path-format=absolute", "--git-common-dir"])
.current_dir(repo_path)
.stderr(Stdio::null())
.output()
.ok()
.filter(|output| output.status.success())?;
let path = String::from_utf8(output.stdout).ok()?;
let path = path.trim();
(!path.is_empty()).then(|| PathBuf::from(path))
}
pub(crate) fn burger_dev_checkouts(
monorepo_path: &Path,
dir_override: Option<std::ffi::OsString>,
git_common_dir: Option<&Path>,
) -> Vec<PathBuf> {
let mut checkouts = Vec::with_capacity(3);
if let Some(dir) = dir_override.filter(|dir| !dir.is_empty()) {
checkouts.push(PathBuf::from(dir));
}
if let Some(workspace) = git_common_dir
.and_then(Path::parent)
.and_then(Path::parent)
{
checkouts.push(workspace.join("node-app-burger"));
}
checkouts.push(monorepo_path.join("modules").join("node-app-burger"));
checkouts
}
pub(crate) fn dev_burger_binary(checkouts: &[PathBuf]) -> Option<PathBuf> {
checkouts
.iter()
.filter(|checkout| checkout.join("Cargo.toml").is_file())
.find_map(|checkout| {
let local = checkout.join("target").join("debug").join("node-app-burger");
if local.is_file() {
return Some(local);
}
let redirected = effective_target_dir(checkout)
.join("debug")
.join("node-app-burger");
redirected.is_file().then_some(redirected)
})
}
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::*;
fn env_pairs(client_node: bool) -> std::collections::HashMap<&'static str, String> {
let env_dir = Path::new("/c/node-app/monorepo-1");
let layout = InstanceLayout::under(env_dir);
daemon_env_pairs(&DaemonEnvInputs {
monorepo_path: Path::new("/src/node"),
layout: &layout,
dev_dir: &env_dir.join("dev-apps"),
control_socket: &env_dir.join("control.sock"),
api_port: 3301,
https_port: 4731,
p2p_port: 10035,
client_node,
lan_origin: "https://192.168.1.5:4731",
})
.into_iter()
.map(|(key, value, _)| (key, value))
.collect()
}
#[test]
fn the_daemon_env_uses_the_names_the_minimal_core_reads() {
let env = env_pairs(false);
let at = |key: &str| env.get(key).unwrap_or_else(|| panic!("{key} not written")).as_str();
assert_eq!(at("NODE_DB_PATH"), "/c/node-app/monorepo-1/dev.db");
assert_eq!(at("NODE_DATA_DIR"), "/c/node-app/monorepo-1/data");
assert_eq!(at("NODE_HTTP_BIND"), "0.0.0.0:3301");
assert_eq!(at("NODE_SERVER_PORT"), "3301");
assert_eq!(at("NODE_APP_LOG_DIR"), "/c/node-app/monorepo-1/logs");
assert_eq!(at("NODE_RUNTIME_SOCKET"), "/c/node-app/monorepo-1/runtime.sock");
assert_eq!(at("NODE_IPC_SOCKET"), "/c/node-app/monorepo-1/control.sock");
assert_eq!(at("NODE_TLS_STATE_PATH"), "/c/node-app/monorepo-1/tls.json");
assert_eq!(at("NODE_TLS_CERTIFICATE_PATH"), "/c/node-app/monorepo-1/data/ssl/local.crt");
assert_eq!(at("NODE_TLS_PRIVATE_KEY_PATH"), "/c/node-app/monorepo-1/data/ssl/local.key");
assert_eq!(at("NODE_TLS_LOCAL_CA_PATH"), "/c/node-app/monorepo-1/data/ssl/local-ca.crt");
assert_eq!(at("NODE_SERVER_SEED_PATH"), "/c/node-app/monorepo-1/data/server_seed.enc");
assert_eq!(at("NODE_APP_MILESTONES_PATH"), "/c/node-app/monorepo-1/logs/app-milestones.json");
assert_eq!(at("NODE_GOSSIP_DB_PATH"), at("NODE_DB_PATH"));
assert_eq!(at("DEVELOPMENT_MODE"), "true");
assert_eq!(at("DATA_DIR_PATH"), at("NODE_DATA_DIR"));
assert_eq!(at("APT_APPS_DIR"), "/src/node/modules");
for dead in [
"DATABASE_URL",
"SERVER_ADDRESS",
"HTTPS_SERVER_ADDRESS",
"APPLICATION_LOG_DIR_PATH",
"SIGNER_SEED_PATH",
"NODE_RUNTIME_TLS_STATE",
"AUTO_SELF_SIGNED_TLS",
"NODE_HTTP_DIRECT_TCP",
"NODE_HTTP_DIRECT_TLS",
"NODE_HTTP_DIRECT_TLS_DUAL_LISTEN",
"CLIENT_NODE_DEV_TRUST_SELF_SIGNED",
"ENABLE_TEST_HARNESS",
] {
assert!(!env.contains_key(dead), "{dead} is a pre-cut name nothing reads any more");
}
for path in env.values().filter(|v| v.starts_with('/')) {
assert!(!path.starts_with("/var") && !path.starts_with("/run"), "{path}");
}
assert!(!env.contains_key("NODE_TLS_TERMINATOR_ADDRESS"));
}
#[test]
fn only_the_client_node_lane_advertises_a_tls_door() {
let env = env_pairs(true);
assert_eq!(env["NODE_TLS_TERMINATOR_ADDRESS"], "0.0.0.0:4731");
assert_eq!(env["CLIENT_NODE_LAN_ORIGINS"], "https://192.168.1.5:4731");
assert!(!env.contains_key("CLIENT_NODE_WAN_ORIGIN"));
let plain = env_pairs(false);
assert!(!plain.contains_key("CLIENT_NODE_WAN_ORIGIN"));
assert!(!plain.contains_key("CLIENT_NODE_LAN_ORIGINS"));
}
#[test]
fn inline_comments_are_stripped_from_base_env_values() {
let base = "# header comment\n\
LDK_NETWORK=\"regtest\" # bitcoin, testnet, signet, regtest\n\
NODE_WORKER_THREADS=8 # tokio worker threads\n\
COLOR=#fff\n\
HASHY=a#b\n\
QUOTED=\"keep # this\"\n\
\n\
PLAIN=value\n";
let stripped = strip_inline_comments(base);
let lines: Vec<&str> = stripped.lines().collect();
assert_eq!(
lines,
vec![
"# header comment",
"LDK_NETWORK=\"regtest\"",
"NODE_WORKER_THREADS=8",
"COLOR=#fff",
"HASHY=a#b",
"QUOTED=\"keep # this\"",
"",
"PLAIN=value",
]
);
}
#[test]
fn pwa_dir_env_pair_uses_the_variable_node_server_reads() {
let (key, value, _) = pwa_dir_env_pair(Path::new("/tmp/monorepo"));
assert_eq!(key, "NODE_PWA_DIR");
assert_ne!(key, "CLIENT_NODE_PWA_STATIC_DIR_PATH");
assert_eq!(value, "/tmp/monorepo/system/pwa/dist");
}
#[test]
fn the_client_node_browser_origin_is_the_lan_address_never_localhost() {
let origin = client_node_browser_origin(4731, Ipv4Addr::new(192, 168, 1, 20));
assert_eq!(origin, "https://192.168.1.20:4731");
assert!(!origin.contains("localhost"));
}
#[test]
fn lan_origin_ip_uses_a_permitted_private_address() {
for ip in [
Ipv4Addr::new(10, 0, 0, 7),
Ipv4Addr::new(172, 16, 0, 1),
Ipv4Addr::new(172, 31, 255, 254),
Ipv4Addr::new(192, 168, 1, 20),
Ipv4Addr::new(169, 254, 10, 10),
] {
assert_eq!(lan_origin_ip(Some(ip)), ip, "{ip} is inside the CA's permitted subtrees");
}
}
#[test]
fn lan_origin_ip_falls_back_to_loopback_when_the_cert_cannot_name_it() {
assert_eq!(lan_origin_ip(None), Ipv4Addr::LOCALHOST);
assert_eq!(lan_origin_ip(Some(Ipv4Addr::new(8, 8, 8, 8))), Ipv4Addr::LOCALHOST);
assert_eq!(lan_origin_ip(Some(Ipv4Addr::new(172, 32, 0, 1))), Ipv4Addr::LOCALHOST);
assert_eq!(lan_origin_ip(Some(Ipv4Addr::new(100, 64, 0, 1))), Ipv4Addr::LOCALHOST);
assert_eq!(lan_origin_ip(Some(Ipv4Addr::LOCALHOST)), Ipv4Addr::LOCALHOST);
}
#[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,
None,
);
let client_node = MonorepoHost::new(
PathBuf::from("/tmp/monorepo"),
InstanceProfile::alice(),
None,
None,
None,
true,
None,
);
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,
None,
);
let bob_legacy = MonorepoHost::new(
PathBuf::from("/tmp/monorepo"),
InstanceProfile::bob(),
None,
None,
None,
false,
None,
);
assert_ne!(
alice_client_node.profile.p2p_port,
bob_legacy.profile.p2p_port
);
}
#[test]
fn materialize_base_env_from_example_substitutes_fresh_secrets() {
let dir = std::env::temp_dir().join(format!(
"harness-materialize-env-{}-{}",
std::process::id(),
fresh_hex_secret(4)
));
std::fs::create_dir_all(&dir).unwrap();
let example_path = dir.join("alice.env.example");
std::fs::write(
&example_path,
"JWT_SECRET=__GENERATE_FRESH_JWT_SECRET__\n\
REFRESH_TOKEN_SECRET=__GENERATE_FRESH_REFRESH_TOKEN_SECRET__\n\
LIBP2P_IDENTITY_SEED=__GENERATE_FRESH_LIBP2P_IDENTITY_SEED__\n\
LDK_NETWORK=\"regtest\"\n",
)
.unwrap();
let base_env_path = dir.join("alice.env");
materialize_base_env_from_example(&base_env_path).unwrap();
let first = std::fs::read_to_string(&base_env_path).unwrap();
assert!(
!first.contains("__GENERATE_FRESH_"),
"every placeholder must be substituted: {first}"
);
assert!(first.contains("LDK_NETWORK=\"regtest\""));
let base_env_path_2 = dir.join("alice-2.env");
std::fs::copy(&example_path, dir.join("alice-2.env.example")).unwrap();
materialize_base_env_from_example(&base_env_path_2).unwrap();
let second = std::fs::read_to_string(&base_env_path_2).unwrap();
assert_ne!(
first, second,
"two independent materializations must not produce identical secrets"
);
std::fs::remove_dir_all(&dir).ok();
}
static CARGO_TARGET_DIR_LOCK: Mutex<()> = Mutex::new(());
fn with_cargo_target_dir<T>(value: Option<&str>, f: impl FnOnce() -> T) -> T {
let _guard = CARGO_TARGET_DIR_LOCK.lock().unwrap();
let previous = std::env::var_os("CARGO_TARGET_DIR");
match value {
Some(v) => std::env::set_var("CARGO_TARGET_DIR", v),
None => std::env::remove_var("CARGO_TARGET_DIR"),
}
let result = f();
match previous {
Some(v) => std::env::set_var("CARGO_TARGET_DIR", v),
None => std::env::remove_var("CARGO_TARGET_DIR"),
}
result
}
#[test]
fn daemon_binary_path_defaults_to_monorepo_target_when_unset() {
with_cargo_target_dir(None, || {
assert_eq!(
daemon_binary_path(Path::new("/tmp/monorepo")),
PathBuf::from("/tmp/monorepo/target/debug/node-server")
);
});
}
#[test]
fn daemon_binary_path_honors_an_absolute_cargo_target_dir() {
with_cargo_target_dir(Some("/var/tmp/shared-cargo-target"), || {
assert_eq!(
daemon_binary_path(Path::new("/tmp/monorepo")),
PathBuf::from("/var/tmp/shared-cargo-target/debug/node-server")
);
});
}
#[test]
fn daemon_binary_path_resolves_a_relative_cargo_target_dir_against_the_monorepo_path() {
with_cargo_target_dir(Some("build-out"), || {
assert_eq!(
daemon_binary_path(Path::new("/tmp/monorepo")),
PathBuf::from("/tmp/monorepo/build-out/debug/node-server")
);
});
}
#[test]
fn target_dir_diagnostic_names_the_variable_when_set() {
with_cargo_target_dir(Some("/var/tmp/shared-cargo-target"), || {
let msg = target_dir_diagnostic(Path::new("/tmp/monorepo"));
assert!(msg.contains("CARGO_TARGET_DIR"));
assert!(msg.contains("/var/tmp/shared-cargo-target"));
});
}
#[test]
fn target_dir_diagnostic_says_unset_when_absent() {
with_cargo_target_dir(None, || {
let msg = target_dir_diagnostic(Path::new("/tmp/monorepo"));
assert!(msg.contains("not set"));
});
}
#[test]
fn effective_target_dir_follows_a_cargo_config_build_target_dir() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let out = root.join("configured-target");
std::fs::write(
root.join("Cargo.toml"),
"[package]\nname = \"probe\"\nversion = \"0.0.0\"\nedition = \"2021\"\n",
)
.unwrap();
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/lib.rs"), "").unwrap();
std::fs::create_dir_all(root.join(".cargo")).unwrap();
std::fs::write(
root.join(".cargo/config.toml"),
format!("[build]\ntarget-dir = \"{}\"\n", out.display()),
)
.unwrap();
with_cargo_target_dir(None, || {
let resolved = effective_target_dir(root);
assert_eq!(
resolved.canonicalize().ok(),
out.canonicalize().ok(),
"expected the config's target-dir ({}), got {}",
out.display(),
resolved.display()
);
});
}
#[test]
fn effective_target_dir_prefers_the_env_var_over_config() {
with_cargo_target_dir(Some("/var/tmp/env-wins"), || {
assert_eq!(
effective_target_dir(Path::new("/tmp/monorepo")),
PathBuf::from("/var/tmp/env-wins")
);
});
}
#[test]
#[cfg(unix)]
fn free_port_refuses_to_kill_a_holder_it_cannot_attribute_to_itself() {
use std::net::TcpListener;
let Ok(listener) = TcpListener::bind("127.0.0.1:0") else {
return; };
let port = listener.local_addr().unwrap().port();
let host = MonorepoHost::new(
PathBuf::from("/tmp/definitely-not-a-real-monorepo-for-free-port-test"),
InstanceProfile::alice(),
None,
None,
None,
false,
None,
);
if pids_listening_on(port).is_empty() {
drop(listener);
return;
}
let result = host.free_port(port, "test");
assert!(
result.is_err(),
"a holder this invocation cannot attribute to itself must never be killed silently"
);
assert!(
TcpListener::bind(("127.0.0.1", port)).is_err(),
"port should still be held after a refused free_port"
);
drop(listener);
}
#[test]
fn a_failed_pwa_build_aborts_bring_up() {
let dir = std::env::temp_dir();
let result = run_pwa_build_command(&dir, "false", &[], None);
assert!(result.is_err(), "a failed PWA build must abort, not warn");
let msg = format!("{:#}", result.unwrap_err());
assert!(msg.contains("PWA"), "error must name the PWA build: {msg}");
}
#[test]
fn a_successful_pwa_build_command_returns_ok() {
let dir = std::env::temp_dir();
run_pwa_build_command(&dir, "true", &[], None).expect("`true` always succeeds");
}
#[test]
fn pwa_package_json_presence_detects_absence() {
let dir = tempfile::tempdir().expect("tempdir");
assert!(!pwa_package_json_exists(dir.path()));
std::fs::write(dir.path().join("package.json"), "{}").unwrap();
assert!(pwa_package_json_exists(dir.path()));
}
fn burger_checkout(dir: &Path) -> PathBuf {
std::fs::create_dir_all(dir).unwrap();
std::fs::write(
dir.join("Cargo.toml"),
"[package]\nname = \"node-app-burger\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
)
.unwrap();
dir.to_path_buf()
}
fn built_binary(target_dir: &Path) -> PathBuf {
let binary = target_dir.join("debug/node-app-burger");
std::fs::create_dir_all(binary.parent().unwrap()).unwrap();
std::fs::write(&binary, b"").unwrap();
binary
}
fn git(dir: &Path, args: &[&str]) {
let status = Command::new("git")
.args([
"-c", "user.name=burger-test",
"-c", "user.email=burger-test@example.invalid",
"-c", "commit.gpgsign=false",
])
.args(args)
.current_dir(dir)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.expect("git is installed");
assert!(status.success(), "git {args:?} failed in {}", dir.display());
}
#[test]
fn burger_dev_checkouts_follow_the_c1_order() {
let monorepo = Path::new("/src/node/.claude/worktrees/feature");
let common = Path::new("/src/node/.git");
assert_eq!(
burger_dev_checkouts(monorepo, Some("/custom/node-app-burger".into()), Some(common)),
vec![
PathBuf::from("/custom/node-app-burger"),
PathBuf::from("/src/node-app-burger"),
PathBuf::from("/src/node/.claude/worktrees/feature/modules/node-app-burger"),
],
"NODE_APP_BURGER_DIR, then the main checkout's sibling, then modules/"
);
assert_eq!(
burger_dev_checkouts(monorepo, Some("".into()), None),
vec![PathBuf::from("/src/node/.claude/worktrees/feature/modules/node-app-burger")],
"an empty override and an unknown git common dir are both skipped"
);
}
#[test]
fn git_common_dir_resolves_a_linked_worktree_to_the_main_checkout() {
let tmp = tempfile::tempdir().unwrap();
let main = tmp.path().join("node");
std::fs::create_dir_all(&main).unwrap();
git(&main, &["init", "-q"]);
git(&main, &["commit", "-q", "--allow-empty", "-m", "init"]);
git(&main, &["worktree", "add", "-q", "../feature-worktree"]);
let worktree = tmp.path().join("feature-worktree");
let canonical = |path: PathBuf| std::fs::canonicalize(path).unwrap();
let main_common = canonical(git_common_dir(&main).expect("main checkout"));
let worktree_common = canonical(git_common_dir(&worktree).expect("linked worktree"));
assert_eq!(worktree_common, main_common, "a linked worktree shares the main .git");
assert_eq!(main_common, canonical(main.join(".git")));
assert_eq!(
burger_dev_checkouts(&worktree, None, Some(&worktree_common))[0],
canonical(tmp.path().to_path_buf()).join("node-app-burger"),
"the worktree resolves the same sibling clone as the main checkout"
);
assert_eq!(git_common_dir(&tmp.path().join("not-a-repo-dir-that-does-not-exist")), None);
}
#[test]
fn dev_burger_binary_prefers_the_first_built_checkout() {
let tmp = tempfile::tempdir().unwrap();
let sibling = burger_checkout(&tmp.path().join("node-app-burger"));
let modules = burger_checkout(&tmp.path().join("node/modules/node-app-burger"));
let modules_binary = built_binary(&modules.join("target"));
let empty_target = tempfile::tempdir().unwrap();
with_cargo_target_dir(Some(empty_target.path().to_str().unwrap()), || {
let checkouts = vec![sibling.clone(), modules.clone()];
assert_eq!(
dev_burger_binary(&checkouts),
Some(modules_binary.clone()),
"an unbuilt sibling clone falls through to the built modules/ checkout"
);
let sibling_binary = built_binary(&sibling.join("target"));
assert_eq!(
dev_burger_binary(&checkouts),
Some(sibling_binary),
"a built sibling clone wins over modules/"
);
});
}
#[test]
fn dev_burger_binary_follows_a_shared_cargo_target_dir() {
let tmp = tempfile::tempdir().unwrap();
let sibling = burger_checkout(&tmp.path().join("node-app-burger"));
let shared = tempfile::tempdir().unwrap();
let binary = built_binary(shared.path());
with_cargo_target_dir(Some(shared.path().to_str().unwrap()), || {
assert_eq!(
dev_burger_binary(std::slice::from_ref(&sibling)),
Some(binary.clone())
);
});
}
#[test]
fn dev_burger_binary_is_none_without_a_checkout_or_a_build() {
let tmp = tempfile::tempdir().unwrap();
let sibling = tmp.path().join("node-app-burger");
let empty_target = tempfile::tempdir().unwrap();
with_cargo_target_dir(Some(empty_target.path().to_str().unwrap()), || {
assert_eq!(
dev_burger_binary(std::slice::from_ref(&sibling)),
None,
"no checkout"
);
burger_checkout(&sibling);
assert_eq!(
dev_burger_binary(std::slice::from_ref(&sibling)),
None,
"checkout present but never built"
);
});
}
}