use std::io::BufRead;
use std::path::{Path, PathBuf};
use std::sync::atomic::Ordering;
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use super::host::{self, InstanceProfile};
use super::sources_resolver;
use super::{build_and_stage, ipc_call, load_manifest, DevArgs, SHUTDOWN_REQUESTED};
use crate::tui::{self, BuildScope, DevSignals, LogSource, LogTx, ServiceStatus};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlatformDependency {
pub key: String,
pub package: String,
}
pub fn run(platform_root: &Path, args: &DevArgs<'_>) -> Result<()> {
let depends_path = platform_root.join("infra/debian/platform-depends");
let dependencies = platform_dependencies(platform_root)
.with_context(|| format!("parse {}", depends_path.display()))?;
let dependency_keys: Vec<String> = dependencies.iter().map(|dep| dep.key.clone()).collect();
install_signal_handler();
let use_tui = !args.no_tui && !args.once && tui::is_tty();
let (log_tx_opt, signals_opt, tui_handle) = if use_tui {
let (tx, rx, signals) = tui::setup();
let _ = super::QUIT_FLAG.set(signals.quit_requested.clone());
let mut app_state = tui::state::AppState::new(
"node-platform".to_string(),
String::new(),
dependency_keys.clone(),
);
app_state.seed_app_list(&dependency_keys);
let node_names: Vec<String> = if args.instances.is_empty() {
vec!["alice".to_string()]
} else {
args.instances.clone()
};
if node_names.len() > 1 {
app_state.seed_node_list(&node_names);
}
let signals_clone = signals.clone();
let handle = std::thread::spawn(move || {
if let Err(e) = tui::run(rx, app_state, signals_clone) {
eprintln!("TUI error: {e}");
}
});
(Some(tx), Some(signals), Some(handle))
} else {
(None, None, None)
};
struct TuiGuard {
signals: Option<DevSignals>,
handle: Option<std::thread::JoinHandle<()>>,
}
impl Drop for TuiGuard {
fn drop(&mut self) {
if let Some(sigs) = &self.signals {
sigs.mark_shutdown_complete();
}
if let Some(h) = self.handle.take() {
let _ = h.join();
}
}
}
let _tui_guard = TuiGuard {
signals: signals_opt.clone(),
handle: tui_handle,
};
tui::sys_log(
log_tx_opt.as_ref(),
format!(
"→ platform mode (cwd = {}) reading infra/debian/platform-depends…",
platform_root.display()
),
);
if dependencies.is_empty() {
tui::sys_log(
log_tx_opt.as_ref(),
"→ no node-app-* entries in platform-depends; will boot server with no extra apps.",
);
} else {
tui::sys_log(
log_tx_opt.as_ref(),
format!(
"→ {} node-app dep(s) declared: {}",
dependencies.len(),
dependency_keys.join(", ")
),
);
}
let mut resolved = sources_resolver::resolve(
platform_root,
&dependencies,
&args.dep_paths,
log_tx_opt.as_ref(),
)?;
for path in &args.dep_paths {
let manifest = load_manifest(path)
.with_context(|| format!("load dependency manifest from {}", path.display()))?;
let key = dependencies
.iter()
.find(|dependency| {
path.file_name().and_then(|name| name.to_str())
== Some(dependency.package.as_str())
|| manifest.name == dependency.key
})
.map(|dependency| dependency.key.clone())
.unwrap_or_else(|| manifest.name.clone());
resolved.push(sources_resolver::ResolvedDependency {
key,
runtime_name: manifest.name,
source_path: path.clone(),
});
}
sources_resolver::ensure_unique_runtime_names(&resolved)?;
for dependency in &resolved {
tui::update_app_identity(
log_tx_opt.as_ref(),
&dependency.key,
&dependency.runtime_name,
);
}
let all_dep_paths: Vec<PathBuf> = resolved
.iter()
.map(|dependency| dependency.source_path.clone())
.collect();
let runtime_names: Vec<String> = resolved
.iter()
.map(|dependency| dependency.runtime_name.clone())
.collect();
for p in &all_dep_paths {
if let Ok(m) = load_manifest(p) {
tui::update_app_path(log_tx_opt.as_ref(), &m.name, p.clone());
}
}
let profiles: Vec<InstanceProfile> = if args.instances.is_empty() {
vec![InstanceProfile::alice()]
} else {
args.instances
.iter()
.map(|n| InstanceProfile::from_name(n))
.collect::<anyhow::Result<Vec<_>>>()?
};
tui::sys_log(
log_tx_opt.as_ref(),
format!(
"→ booting {} instance(s): {}",
profiles.len(),
profiles.iter().map(|p| p.name.as_str()).collect::<Vec<_>>().join(", ")
),
);
let mode = host::Mode::Monorepo {
path: platform_root.to_path_buf(),
};
let hosts: Vec<Box<dyn host::DaemonHost>> = profiles
.iter()
.map(|p| {
host::for_mode(
mode.clone(),
p.clone(),
if profiles.len() == 1 { args.socket_override } else { None },
if profiles.len() == 1 { args.dev_dir_override } else { None },
log_tx_opt.clone(),
args.client_node,
)
})
.collect();
if !all_dep_paths.is_empty() {
let pre_start_dev_dirs: Vec<PathBuf> = hosts
.iter()
.filter_map(|h| h.pre_start_dev_dir())
.collect();
if !pre_start_dev_dirs.is_empty() {
tui::sys_log(
log_tx_opt.as_ref(),
format!(
"→ staging {} dep(s) into {} instance dev-dir(s) (pre-boot)…",
all_dep_paths.len(),
pre_start_dev_dirs.len()
),
);
stage_with_status(&all_dep_paths, &pre_start_dev_dirs, log_tx_opt.as_ref())?;
stage_standalone_manifests(
&all_dep_paths,
&pre_start_dev_dirs,
log_tx_opt.as_ref(),
)?;
}
}
let mut handles: Vec<host::DaemonHandle> = Vec::with_capacity(hosts.len());
for host_impl in &hosts {
let handle = host_impl.ensure_running().context("start platform daemon")?;
tui::sys_log(
log_tx_opt.as_ref(),
format!("✓ platform up — {}", handle.banner),
);
handles.push(handle);
}
if !all_dep_paths.is_empty() {
let post_start_dirs: Vec<PathBuf> = hosts
.iter()
.zip(&handles)
.filter(|(h, _)| h.pre_start_dev_dir().is_none())
.map(|(_, handle)| handle.dev_dir.clone())
.collect();
if !post_start_dirs.is_empty() {
tui::sys_log(
log_tx_opt.as_ref(),
format!(
"→ staging {} dep(s) into {} instance(s) (post-boot)…",
all_dep_paths.len(),
post_start_dirs.len()
),
);
stage_with_status(&all_dep_paths, &post_start_dirs, log_tx_opt.as_ref())?;
stage_standalone_manifests(
&all_dep_paths,
&post_start_dirs,
log_tx_opt.as_ref(),
)?;
}
}
if !all_dep_paths.is_empty() {
tui::sys_log(
log_tx_opt.as_ref(),
format!(
"→ sideloading {} dep(s) into {} instance(s) via app.dev_load…",
all_dep_paths.len(),
handles.len()
),
);
for handle in &handles {
let mut daemon_down = false;
for dep_path in &all_dep_paths {
let dep_manifest = match load_manifest(dep_path) {
Ok(m) => m,
Err(e) => {
tui::sys_log(
log_tx_opt.as_ref(),
format!(
"⚠ skip dev_load — could not read manifest for {}: {:#}",
dep_path.display(),
e
),
);
continue;
}
};
if dep_manifest.app_type.eq_ignore_ascii_case("platform-runtime") {
continue;
}
if dep_manifest.app_type.eq_ignore_ascii_case("standalone") {
continue;
}
let dest = handle.dev_dir.join(&dep_manifest.name);
let label = if handle.name.is_empty() {
dep_manifest.name.clone()
} else {
format!("{} [{}]", dep_manifest.name, handle.name)
};
match ipc_call(
&handle.socket_path,
"app.dev_load",
serde_json::json!({
"name": dep_manifest.name,
"path": dest.to_string_lossy(),
}),
) {
Ok(_) => tui::sys_log(
log_tx_opt.as_ref(),
format!("✓ {} sideloaded", label),
),
Err(e) => {
let msg = format!("{:#}", e);
let connection_lost = msg.contains("Connection refused")
|| msg.contains("Broken pipe")
|| msg.contains("os error 61")
|| msg.contains("os error 32");
tui::sys_log(
log_tx_opt.as_ref(),
format!("✗ dev_load {}: {}", label, msg),
);
if connection_lost {
let _ = std::fs::remove_file(&handle.socket_path);
tui::sys_log(
log_tx_opt.as_ref(),
format!(
"✗ daemon IPC unreachable for instance '{}' — skipping the remaining {} dev_load(s). \
The daemon process died (check `{}/daemon.log` for the tail). \
Run `node-app dev` again; the orphaned socket has been removed and the next boot will recover.",
handle.name,
all_dep_paths.len(),
handle.dev_dir
.parent()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "<cache>".to_string()),
),
);
daemon_down = true;
break;
}
}
}
}
if daemon_down {
continue;
}
}
}
let mut spawned_standalones = spawn_standalones(
&all_dep_paths,
&handles,
&args.config,
log_tx_opt.as_ref(),
);
if args.once {
if args.agent {
tui::sys_log(
log_tx_opt.as_ref(),
"→ --agent + --once: running onboarding for all instances…",
);
super::agent::run_agent_setup(&handles, log_tx_opt.as_ref());
}
tui::sys_log(
log_tx_opt.as_ref(),
"✓ --once: platform booted and deps staged; shutting down.",
);
for s in spawned_standalones.iter_mut() {
let _ = s.child.kill();
let _ = s.child.wait();
}
for h in &hosts {
h.shutdown();
}
return Ok(());
}
for h in &hosts {
for name in &runtime_names {
h.tail_logs(name);
}
}
tui::sys_log(
log_tx_opt.as_ref(),
"→ platform running. Edit platform code in the monorepo and rerun to pick up changes. \
Ctrl-C to stop.",
);
loop {
if SHUTDOWN_REQUESTED.load(Ordering::SeqCst) {
break;
}
for h in &hosts {
let Some(req) = h.take_control_request() else {
continue;
};
match req.action.as_str() {
"restart" => {
let with_build = req.build.as_deref() == Some("system");
banner(
log_tx_opt.as_ref(),
format!(
"⟳ EXTERNAL RESTART — instance '{}'{}",
h.instance_name(),
if with_build { " (rebuild node-server)" } else { "" }
),
);
let outcome: Result<()> = (|| {
if with_build {
h.rebuild_daemon_binary()?;
}
h.restart()
})();
match outcome {
Ok(()) => {
h.write_control_result(req.ts, true, "restart complete");
banner(
log_tx_opt.as_ref(),
format!("✓ EXTERNAL RESTART COMPLETE — '{}'", h.instance_name()),
);
}
Err(e) => {
let msg = format!("{e:#}");
h.write_control_result(req.ts, false, &msg);
tui::sys_log(
log_tx_opt.as_ref(),
format!("✗ external restart '{}' failed: {msg}", h.instance_name()),
);
}
}
}
other => {
let msg = format!("unknown control action '{other}'");
h.write_control_result(req.ts, false, &msg);
tui::sys_log(log_tx_opt.as_ref(), format!("⚠ {msg}"));
}
}
}
if let Some(sigs) = &signals_opt {
if sigs.should_quit() {
break;
}
if sigs.take_restart() {
let started = Instant::now();
banner(log_tx_opt.as_ref(), "⟳ RESTART REQUESTED (r) — system daemon");
for h in &hosts {
if let Err(e) = h.restart() {
tui::sys_log(log_tx_opt.as_ref(), format!("✗ restart failed: {:#}", e));
}
}
banner(
log_tx_opt.as_ref(),
format!("✓ RESTART COMPLETE ({:.1}s)", started.elapsed().as_secs_f32()),
);
}
if let Some(scope) = sigs.take_build_scope() {
let started = Instant::now();
banner(
log_tx_opt.as_ref(),
format!("⟳ MANUAL REBUILD ({}) TRIGGERED", scope_label(scope)),
);
let do_apps = matches!(scope, BuildScope::Apps | BuildScope::All);
let do_system = matches!(scope, BuildScope::System | BuildScope::All);
let do_ui = matches!(scope, BuildScope::Ui | BuildScope::All);
if do_apps {
if all_dep_paths.is_empty() {
tui::sys_log(
log_tx_opt.as_ref(),
"→ apps: no node-app deps declared; nothing to rebuild.",
);
} else {
let dev_dirs: Vec<PathBuf> =
handles.iter().map(|h| h.dev_dir.clone()).collect();
tui::sys_log(
log_tx_opt.as_ref(),
format!("→ rebuilding {} app dep(s)…", all_dep_paths.len()),
);
if let Err(e) =
stage_with_status(&all_dep_paths, &dev_dirs, log_tx_opt.as_ref())
{
tui::sys_log(
log_tx_opt.as_ref(),
format!("✗ app rebuild failed: {:#}", e),
);
}
}
}
if do_system {
for h in &hosts {
if let Err(e) = h.restart() {
tui::sys_log(
log_tx_opt.as_ref(),
format!("✗ system rebuild/restart failed: {:#}", e),
);
}
}
}
if do_ui {
tui::sys_log(
log_tx_opt.as_ref(),
"→ ui: Vite HMR is active — no rebuild needed. \
Touch a source file to trigger a hot reload.",
);
tui::update_status(
log_tx_opt.as_ref(),
LogSource::UiServer,
ServiceStatus::Ready,
Some("HMR — no rebuild needed".into()),
);
}
banner(
log_tx_opt.as_ref(),
format!(
"✓ REBUILD COMPLETE ({:.1}s)",
started.elapsed().as_secs_f32()
),
);
}
}
std::thread::sleep(Duration::from_millis(200));
}
tui::sys_log(log_tx_opt.as_ref(), "→ shutting down…");
for s in spawned_standalones.iter_mut() {
tui::sys_log(
log_tx_opt.as_ref(),
format!("→ killing standalone {}", s.label),
);
let _ = s.child.kill();
let _ = s.child.wait();
}
for h in &hosts {
h.shutdown();
}
drop(log_tx_opt);
Ok(())
}
fn stage_with_status(
dep_paths: &[PathBuf],
dev_dirs: &[PathBuf],
log_tx: Option<&crate::tui::LogTx>,
) -> Result<()> {
if dev_dirs.is_empty() {
return Ok(());
}
for dep_path in dep_paths {
let manifest = load_manifest(dep_path).ok();
let app_name = manifest.as_ref().map(|m| m.name.clone());
if manifest
.as_ref()
.map(|m| m.app_type.eq_ignore_ascii_case("platform-runtime"))
.unwrap_or(false)
{
if let Some(ref n) = app_name {
tui::update_app_status(
log_tx,
n,
ServiceStatus::Loaded { reloads: 0 },
Some("platform-runtime (not staged)".to_string()),
);
}
continue;
}
if let Some(ref n) = app_name {
tui::update_app_status(log_tx, n, ServiceStatus::Building, None);
}
let first_dir = &dev_dirs[0];
if let Err(e) = build_and_stage(dep_path, first_dir, log_tx) {
let label = app_name
.clone()
.unwrap_or_else(|| dep_path.display().to_string());
let e = e.context(format!("build dep '{}' ({})", label, dep_path.display()));
if let Some(ref n) = app_name {
let short = e.to_string().chars().take(60).collect::<String>();
tui::update_app_status(log_tx, n, ServiceStatus::Failed(short), None);
}
return Err(e);
}
if let Some(name) = &app_name {
let src = first_dir.join(name);
for dst_root in &dev_dirs[1..] {
let dst = dst_root.join(name);
if dst.exists() {
std::fs::remove_dir_all(&dst).ok();
}
if let Err(e) = super::copy_dir_recursive(&src, &dst) {
let short = e.to_string().chars().take(60).collect::<String>();
tui::update_app_status(log_tx, name, ServiceStatus::Failed(short), None);
return Err(e);
}
}
}
if let Some(ref n) = app_name {
let detail = if dev_dirs.len() > 1 {
format!("staged ×{}", dev_dirs.len())
} else {
"staged".into()
};
tui::update_app_status(
log_tx,
n,
ServiceStatus::Loaded { reloads: 1 },
Some(detail),
);
}
}
Ok(())
}
struct SpawnedStandalone {
label: String,
child: std::process::Child,
}
fn stage_standalone_manifests(
dep_paths: &[PathBuf],
dev_dirs: &[PathBuf],
log_tx: Option<&LogTx>,
) -> Result<()> {
for dep_path in dep_paths {
let manifest_path = dep_path.join("manifest.json");
let raw = match std::fs::read_to_string(&manifest_path) {
Ok(s) => s,
Err(e) => {
tui::sys_log(
log_tx,
format!(
"⚠ standalone stage: read {} failed: {}",
manifest_path.display(),
e
),
);
continue;
}
};
let mut value: serde_json::Value = match serde_json::from_str(&raw) {
Ok(v) => v,
Err(e) => {
tui::sys_log(
log_tx,
format!(
"⚠ standalone stage: parse {} failed: {}",
manifest_path.display(),
e
),
);
continue;
}
};
let app_type = value
.get("app_type")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if !app_type.eq_ignore_ascii_case("standalone") {
continue;
}
let app_name = value
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if app_name.is_empty() {
continue;
}
let has_ui = value.get("has_ui").and_then(|v| v.as_bool()).unwrap_or(false);
let ui_relpath = value
.get("ui_path")
.and_then(|v| v.as_str())
.unwrap_or("dist")
.to_string();
for dev_dir in dev_dirs {
let env_dir = match dev_dir.parent() {
Some(p) => p.to_path_buf(),
None => {
tui::sys_log(
log_tx,
format!(
"⚠ {}: dev_dir {} has no parent — can't derive socket path",
app_name,
dev_dir.display()
),
);
continue;
}
};
let socket_path = env_dir.join(format!("{app_name}.sock"));
value["standalone"] = serde_json::json!({
"socket_path": socket_path.display().to_string()
});
let dest_dir = dev_dir.join(&app_name);
if let Err(e) = std::fs::create_dir_all(&dest_dir) {
tui::sys_log(
log_tx,
format!("✗ mkdir {}: {}", dest_dir.display(), e),
);
continue;
}
let dest_manifest = dest_dir.join("manifest.json");
let serialized = match serde_json::to_string_pretty(&value) {
Ok(s) => s,
Err(e) => {
tui::sys_log(
log_tx,
format!("✗ serialize manifest for {}: {}", app_name, e),
);
continue;
}
};
if let Err(e) = std::fs::write(&dest_manifest, serialized) {
tui::sys_log(
log_tx,
format!("✗ write {}: {}", dest_manifest.display(), e),
);
continue;
}
if has_ui {
let src_ui = dep_path.join(&ui_relpath);
if src_ui.exists() {
let dst_ui = dest_dir.join(&ui_relpath);
if dst_ui.exists() {
let _ = std::fs::remove_dir_all(&dst_ui);
}
if let Err(e) = super::copy_dir_recursive(&src_ui, &dst_ui) {
tui::sys_log(
log_tx,
format!("✗ copy UI {} → {}: {}", src_ui.display(), dst_ui.display(), e),
);
}
}
}
}
}
Ok(())
}
fn spawn_standalones(
dep_paths: &[PathBuf],
handles: &[host::DaemonHandle],
config: &[(String, String)],
log_tx: Option<&LogTx>,
) -> Vec<SpawnedStandalone> {
let mut spawned = Vec::new();
for dep_path in dep_paths {
let manifest_path = dep_path.join("manifest.json");
let raw = match std::fs::read_to_string(&manifest_path) {
Ok(s) => s,
Err(_) => continue,
};
let value: serde_json::Value = match serde_json::from_str(&raw) {
Ok(v) => v,
Err(_) => continue,
};
let app_type = value
.get("app_type")
.and_then(|v| v.as_str())
.unwrap_or("");
if !app_type.eq_ignore_ascii_case("standalone") {
continue;
}
let app_name = value
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if app_name.is_empty() {
continue;
}
let is_rust = dep_path.join("Cargo.toml").exists();
let bin_name = format!("node-app-{}", app_name);
let rust_bin = dep_path.join("target").join("debug").join(&bin_name);
for handle in handles {
let env_dir = match handle.dev_dir.parent() {
Some(p) => p.to_path_buf(),
None => continue,
};
let socket_path = env_dir.join(format!("{app_name}.sock"));
let _ = std::fs::remove_file(&socket_path);
let staged_manifest = handle.dev_dir.join(&app_name).join("manifest.json");
let mut cmd = if is_rust {
let mut c = std::process::Command::new(&rust_bin);
c.current_dir(dep_path);
c
} else {
let mut c = std::process::Command::new("bun");
c.args(["run", "src/index.ts"]);
c.current_dir(dep_path);
c
};
cmd.env("NODE_APP_SOCKET", &socket_path);
cmd.env("NODE_IPC_SOCKET", &handle.socket_path);
cmd.env("NODE_APP_MANIFEST_PATH", &staged_manifest);
cmd.env("NODE_ALLOW_DEV_SOCKET_PATH", "1");
let state_dir = env_dir.join("app-state").join(&app_name);
let _ = std::fs::create_dir_all(&state_dir);
cmd.env("NODE_APP_STATE_DIR", &state_dir);
let http_port = alloc_free_port();
if let Some(port) = http_port {
cmd.env("NODE_APP_HTTP_PORT", port.to_string());
}
for (k, v) in config {
cmd.env(k, v);
}
cmd.stdin(std::process::Stdio::null());
if log_tx.is_some() {
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
} else {
cmd.stdout(std::process::Stdio::inherit());
cmd.stderr(std::process::Stdio::inherit());
}
let label = format!("{} [{}]", app_name, handle.name);
match cmd.spawn() {
Ok(mut child) => {
if let Some(tx) = log_tx.cloned() {
let prefix = format!("[{}][{}] ", handle.name, app_name);
if let Some(stdout) = child.stdout.take() {
tail_to_tui(stdout, prefix.clone(), tx.clone());
}
if let Some(stderr) = child.stderr.take() {
tail_to_tui(stderr, prefix, tx);
}
}
let http_note = http_port
.map(|p| format!(", http=:{p}"))
.unwrap_or_default();
tui::sys_log(
log_tx,
format!(
"✓ {} spawned (pid {}, socket={}{})",
label,
child.id(),
socket_path.display(),
http_note
),
);
spawned.push(SpawnedStandalone { label, child });
}
Err(e) => {
let hint = if is_rust && !rust_bin.exists() {
format!(
" — binary not found at {}; was the dep built?",
rust_bin.display()
)
} else {
String::new()
};
tui::sys_log(
log_tx,
format!("✗ spawn {}: {}{}", label, e, hint),
);
}
}
}
}
spawned
}
fn alloc_free_port() -> Option<u16> {
use std::net::{Ipv4Addr, SocketAddrV4, TcpListener};
let listener = TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)).ok()?;
let port = listener.local_addr().ok()?.port();
drop(listener);
Some(port)
}
fn tail_to_tui<R: std::io::Read + Send + 'static>(reader: R, prefix: String, tx: LogTx) {
std::thread::spawn(move || {
for line in std::io::BufReader::new(reader).lines().map_while(Result::ok) {
let _ = tx.send(crate::tui::TuiEvent::Log(crate::tui::LogEntry {
source: LogSource::App,
line: format!("{prefix}{line}"),
}));
}
});
}
pub fn parse_platform_depends(path: &Path) -> Result<Vec<PlatformDependency>> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("read {}", path.display()))?;
let mut out = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let pkg = line
.split_whitespace()
.next()
.unwrap_or("")
.trim();
if let Some(rest) = pkg.strip_prefix("node-app-") {
if !rest.is_empty() {
out.push(PlatformDependency {
key: rest.to_string(),
package: pkg.to_string(),
});
}
}
}
Ok(out)
}
fn platform_dependencies(platform_root: &Path) -> Result<Vec<PlatformDependency>> {
parse_platform_depends(&platform_root.join("infra/debian/platform-depends"))
}
#[cfg(unix)]
fn install_signal_handler() {
use std::sync::atomic::AtomicBool;
static INSTALLED: AtomicBool = AtomicBool::new(false);
if INSTALLED.swap(true, Ordering::SeqCst) {
return;
}
unsafe {
libc::signal(
libc::SIGINT,
super::handle_shutdown_signal as *const () as libc::sighandler_t,
);
libc::signal(
libc::SIGTERM,
super::handle_shutdown_signal as *const () as libc::sighandler_t,
);
}
}
#[cfg(not(unix))]
fn install_signal_handler() {}
fn scope_label(scope: BuildScope) -> &'static str {
match scope {
BuildScope::All => "all",
BuildScope::System => "system",
BuildScope::Apps => "apps",
BuildScope::Ui => "ui",
}
}
fn banner(log_tx: Option<&crate::tui::LogTx>, label: impl Into<String>) {
const RULE: &str =
"════════════════════════════════════════════════════════════════════";
tui::sys_log(log_tx, "");
tui::sys_log(log_tx, RULE);
tui::sys_log(log_tx, format!(" {}", label.into()));
tui::sys_log(log_tx, RULE);
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn dependency(key: &str) -> PlatformDependency {
PlatformDependency {
key: key.to_string(),
package: format!("node-app-{key}"),
}
}
#[test]
fn platform_dependency_selection_is_shell_independent() {
let tmp = TempDir::new().unwrap();
let infra = tmp.path().join("infra/debian");
fs::create_dir_all(&infra).unwrap();
fs::write(
infra.join("platform-depends"),
"node-app-core-storage\nnode-app-stage-home\n",
)
.unwrap();
assert_eq!(
platform_dependencies(tmp.path()).unwrap(),
vec![dependency("core-storage"), dependency("stage-home")]
);
}
#[test]
fn parse_strips_prefix_and_skips_non_app_lines() {
let tmp = TempDir::new().unwrap();
let p = tmp.path().join("platform-depends");
fs::write(
&p,
"# header comment\n\
jq\n\
curl\n\
\n\
# Built-in node apps\n\
node-app-esp32-bridge\n\
node-app-discovery\n\
# trailing comment\n",
)
.unwrap();
let dependencies = parse_platform_depends(&p).unwrap();
assert_eq!(
dependencies
.iter()
.map(|dep| dep.key.as_str())
.collect::<Vec<_>>(),
vec!["esp32-bridge", "discovery"]
);
}
#[test]
fn parse_handles_version_constraints() {
let tmp = TempDir::new().unwrap();
let p = tmp.path().join("platform-depends");
fs::write(&p, "node-app-foo (>= 1.2.3)\n").unwrap();
assert_eq!(parse_platform_depends(&p).unwrap()[0].key, "foo");
}
#[test]
fn parse_preserves_package_identity_for_stage_apps() {
let tmp = TempDir::new().unwrap();
let p = tmp.path().join("platform-depends");
fs::write(&p, "node-app-stage-home\n").unwrap();
assert_eq!(
parse_platform_depends(&p).unwrap(),
vec![PlatformDependency {
key: "stage-home".to_string(),
package: "node-app-stage-home".to_string(),
}]
);
}
#[test]
fn parse_skips_empty_node_app_prefix() {
let tmp = TempDir::new().unwrap();
let p = tmp.path().join("platform-depends");
fs::write(&p, "node-app-\nnode-app-real\n").unwrap();
assert_eq!(parse_platform_depends(&p).unwrap()[0].key, "real");
}
#[test]
fn parse_missing_file_errors() {
let tmp = TempDir::new().unwrap();
let p = tmp.path().join("nope");
assert!(parse_platform_depends(&p).is_err());
}
}