use std::io::BufRead;
use std::path::{Path, PathBuf};
use std::sync::atomic::Ordering;
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use serde::Deserialize;
use super::host::{self, InstanceProfile};
use super::sources_resolver;
use super::{build_and_stage, ipc_call, load_manifest, DevArgs, Manifest, 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,
None, )
})
.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(),
)?;
}
}
let mut spawned_standalones = spawn_standalones(
&all_dep_paths,
&handles,
&args.config,
log_tx_opt.as_ref(),
);
let mut sideload_failures: Vec<SideloadFailure> = Vec::new();
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)
};
let is_native = is_native_app_type(&dep_manifest.app_type);
let loaded = if is_native {
match query_loaded_app(&handle.socket_path, &dep_manifest.name) {
Ok(loaded) => loaded,
Err(e) => {
tui::sys_log(
log_tx_opt.as_ref(),
format!(
"⚠ {label}: could not query app.status before dev_load ({:#}) — attempting dev_load anyway",
e
),
);
None
}
}
} else {
None
};
let decision = sideload_decision(
&dep_manifest,
loaded.as_ref(),
|| checkout_match(dep_path, handle.builtin_apps_dir.as_deref(), &dep_manifest.name),
args.no_sideload_native_builtins,
);
match decision {
SideloadDecision::Skip(reason) => {
tui::sys_log(
log_tx_opt.as_ref(),
format!("↷ {label} left as loaded — {reason}"),
);
continue;
}
SideloadDecision::RestartRequired(reason) => {
tui::sys_log(
log_tx_opt.as_ref(),
format!("✗ {label} NOT loaded — {reason}"),
);
sideload_failures.push(SideloadFailure {
label,
instance: handle.name.clone(),
env_dir: handle.dev_dir.parent().map(Path::to_path_buf),
reason,
restart_class: true,
});
continue;
}
SideloadDecision::Load(Some(note)) => {
tui::sys_log(log_tx_opt.as_ref(), format!("→ {label}: {note}"));
}
SideloadDecision::Load(None) => {}
}
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),
);
sideload_failures.push(SideloadFailure {
label,
instance: handle.name.clone(),
env_dir: handle.dev_dir.parent().map(Path::to_path_buf),
restart_class: is_restart_class_error(&msg, is_native),
reason: 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;
}
}
}
report_sideload_failures(&sideload_failures, log_tx_opt.as_ref());
if args.once {
let needs_agent_session = args.agent || args.operation_mode;
if needs_agent_session {
tui::sys_log(
log_tx_opt.as_ref(),
"→ --agent/--operation-mode + --once: running onboarding for all instances…",
);
let _ = super::agent::run_agent_setup(&handles, log_tx_opt.as_ref(), false);
}
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);
}
}
if sideload_failures.is_empty() {
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.",
);
} else {
tui::sys_log(
log_tx_opt.as_ref(),
format!(
"⚠ platform running WITH {} app(s) NOT loaded (see the ✗ sideload summary above). \
Edit platform code in the monorepo and rerun to pick up changes. Ctrl-C to stop.",
sideload_failures.len()
),
);
}
let needs_agent_session = args.agent || args.operation_mode;
if needs_agent_session {
let _ = super::agent::run_agent_setup(&handles, log_tx_opt.as_ref(), false);
}
let _operation_mode_thread = if args.operation_mode {
let session = super::load_operation_mode_session(&handles[0])?;
Some(super::operation_mode::prepare(session)?.spawn(log_tx_opt.clone()))
} else {
None
};
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(())
}
pub(crate) struct SpawnedStandalone {
pub(crate) label: String,
pub(crate) child: std::process::Child,
}
pub(crate) 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(())
}
pub(crate) 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);
if app_name == "wifi" {
cmd.env("NODE_APP_WIFI_DATA_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",
}
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub(crate) struct LoadedApp {
#[serde(default)]
pub(crate) version: String,
#[serde(default)]
pub(crate) status: String,
#[serde(default)]
pub(crate) sandbox: String,
#[serde(default)]
pub(crate) source: String,
#[serde(default)]
pub(crate) registered_capabilities: Option<Vec<String>>,
}
fn query_loaded_app(socket_path: &Path, name: &str) -> Result<Option<LoadedApp>> {
match ipc_call(socket_path, "app.status", serde_json::json!({ "name": name })) {
Ok(value) => {
let loaded: LoadedApp = serde_json::from_value(value)
.with_context(|| format!("parse app.status reply for '{name}'"))?;
Ok(Some(loaded))
}
Err(e) => {
let msg = format!("{e:#}");
if msg.contains("-32001") || msg.contains("not found") {
Ok(None)
} else {
Err(e)
}
}
}
}
fn is_native_app_type(app_type: &str) -> bool {
app_type.eq_ignore_ascii_case("native") || app_type.eq_ignore_ascii_case("cdylib")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum CheckoutMatch {
SamePath,
SameRev(String),
Unknown,
Different { dep_rev: String, builtin_rev: String },
}
pub(crate) fn builtin_checkout_for(builtin_apps_dir: &Path, name: &str) -> Option<PathBuf> {
for candidate in [
builtin_apps_dir.join(name),
builtin_apps_dir.join(format!("node-app-{name}")),
] {
if sources_resolver::is_node_app_dir(&candidate)
&& sources_resolver::read_manifest_name(&candidate).ok().as_deref() == Some(name)
{
return Some(candidate);
}
}
let entries = std::fs::read_dir(builtin_apps_dir).ok()?;
entries
.flatten()
.map(|entry| entry.path())
.filter(|path| sources_resolver::is_node_app_dir(path))
.find(|path| sources_resolver::read_manifest_name(path).ok().as_deref() == Some(name))
}
pub(crate) fn checkout_match(
dep_path: &Path,
builtin_apps_dir: Option<&Path>,
name: &str,
) -> CheckoutMatch {
let Some(builtin_dir) = builtin_apps_dir else {
return CheckoutMatch::Unknown;
};
let Some(builtin_checkout) = builtin_checkout_for(builtin_dir, name) else {
return CheckoutMatch::Unknown;
};
if let (Ok(a), Ok(b)) = (dep_path.canonicalize(), builtin_checkout.canonicalize()) {
if a == b {
return CheckoutMatch::SamePath;
}
}
match (
sources_resolver::git_rev_at(dep_path),
sources_resolver::git_rev_at(&builtin_checkout),
) {
(Some(dep_rev), Some(builtin_rev)) if dep_rev == builtin_rev => CheckoutMatch::SameRev(dep_rev),
(Some(dep_rev), Some(builtin_rev)) => CheckoutMatch::Different { dep_rev, builtin_rev },
_ => CheckoutMatch::Unknown,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum SideloadDecision {
Load(Option<String>),
Skip(String),
RestartRequired(String),
}
fn is_resident_status(status: &str) -> bool {
matches!(status, "active" | "lazy" | "degraded")
}
fn short_rev(rev: &str) -> &str {
&rev[..rev.len().min(12)]
}
fn unregistered_provides(dep: &Manifest, loaded: &LoadedApp) -> Option<Vec<String>> {
let registered = loaded.registered_capabilities.as_ref()?;
Some(
dep.declared_provides()
.into_iter()
.filter(|name| !registered.iter().any(|r| r == name))
.collect(),
)
}
pub(crate) fn sideload_decision(
dep: &Manifest,
loaded: Option<&LoadedApp>,
checkout: impl FnOnce() -> CheckoutMatch,
skip_every_loaded_native: bool,
) -> SideloadDecision {
if !is_native_app_type(&dep.app_type) {
return SideloadDecision::Load(None);
}
let Some(loaded) = loaded else {
return SideloadDecision::Load(None);
};
if loaded.status == "requires_restart" {
return SideloadDecision::RestartRequired(format!(
"the daemon already flags v{} as requires_restart (a native app was unloaded earlier in this daemon's life); dev_load would fail the same way",
loaded.version
));
}
if !loaded.sandbox.is_empty() && loaded.sandbox != "in_process" {
return SideloadDecision::Load(None);
}
if !is_resident_status(&loaded.status) {
return SideloadDecision::Load(None);
}
let origin = if loaded.source == "bundled" { "builtin" } else { "in-process app" };
if skip_every_loaded_native {
return SideloadDecision::Skip(format!(
"--no-sideload-native-builtins: daemon already has {origin} v{} loaded ({})",
loaded.version, loaded.status
));
}
if loaded.version != dep.version {
return SideloadDecision::Load(Some(format!(
"daemon has {origin} v{} loaded ({}) but the staged dep is v{} — attempting dev_load; \
a loaded native app cannot be hot-swapped, so expect to restart the daemon if this fails",
loaded.version, loaded.status, dep.version
)));
}
match unregistered_provides(dep, loaded) {
None => {
return SideloadDecision::Load(Some(format!(
"daemon has {origin} v{} loaded ({}) but does not report its registered capabilities — \
calling dev_load so its same-manifest no-op path re-registers provides",
loaded.version, loaded.status
)));
}
Some(missing) if !missing.is_empty() => {
let registered = loaded
.registered_capabilities
.as_ref()
.map(Vec::len)
.unwrap_or(0);
return SideloadDecision::Load(Some(format!(
"daemon has {origin} v{} loaded ({}) with {registered} capabilit{} registered but {} declared \
provide(s) missing ({}) — calling dev_load so its same-manifest no-op path re-registers them",
loaded.version,
loaded.status,
if registered == 1 { "y" } else { "ies" },
missing.len(),
missing.join(", ")
)));
}
Some(_) => {}
}
match checkout() {
CheckoutMatch::SamePath => SideloadDecision::Skip(format!(
"daemon already loaded {origin} v{} ({}) from this same checkout; a loaded native app cannot be hot-swapped",
loaded.version, loaded.status
)),
CheckoutMatch::SameRev(rev) => SideloadDecision::Skip(format!(
"daemon already loaded {origin} v{} ({}) at the same git rev {}; a loaded native app cannot be hot-swapped",
loaded.version,
loaded.status,
short_rev(&rev)
)),
CheckoutMatch::Unknown => SideloadDecision::Skip(format!(
"daemon already loaded {origin} v{} ({}) — same version (checkout identity not verifiable); a loaded native app cannot be hot-swapped",
loaded.version, loaded.status
)),
CheckoutMatch::Different { dep_rev, builtin_rev } => SideloadDecision::Load(Some(format!(
"daemon has {origin} v{} loaded from a different checkout (git {} vs staged {}) — attempting dev_load; \
a loaded native app cannot be hot-swapped, so expect to restart the daemon if this fails",
loaded.version,
short_rev(&builtin_rev),
short_rev(&dep_rev)
))),
}
}
#[derive(Debug, Clone)]
pub(crate) struct SideloadFailure {
pub(crate) label: String,
pub(crate) instance: String,
pub(crate) env_dir: Option<PathBuf>,
pub(crate) reason: String,
pub(crate) restart_class: bool,
}
pub(crate) fn is_restart_class_error(message: &str, native: bool) -> bool {
let m = message.to_ascii_lowercase();
m.contains("requires a node restart")
|| m.contains("requires_restart")
|| (native && (m.contains("timed out") || m.contains("timeout")))
}
fn report_sideload_failures(failures: &[SideloadFailure], log_tx: Option<&LogTx>) {
if failures.is_empty() {
return;
}
tui::sys_log(
log_tx,
format!(
"✗ SIDELOAD SUMMARY: {} app(s) failed to sideload and are NOT loaded:",
failures.len()
),
);
for failure in failures {
tui::sys_log(log_tx, format!(" ✗ {}: {}", failure.label, failure.reason));
}
if !failures.iter().any(|f| f.restart_class) {
return;
}
tui::sys_log(
log_tx,
" ↳ a loaded native (cdylib) app cannot be hot-swapped in-process: the daemon unloaded it \
and cannot load a native app again until it restarts. Restart the daemon, or stop and rerun `node-app dev`:",
);
let mut seen = std::collections::BTreeSet::new();
for failure in failures.iter().filter(|f| f.restart_class) {
if !seen.insert(failure.instance.clone()) {
continue;
}
let hint = match (&failure.instance, &failure.env_dir) {
(instance, Some(env_dir)) if !instance.is_empty() => format!(
" instance '{instance}': `node-app restart -i {instance}` — or write {{\"action\":\"restart\"}} to {}/control/request.json",
env_dir.display()
),
(instance, Some(env_dir)) => format!(
" write {{\"action\":\"restart\"}} to {}/control/request.json (instance '{instance}')",
env_dir.display()
),
(instance, None) => format!(
" restart the daemon serving instance '{instance}' by hand (this host has no control dir)"
),
};
tui::sys_log(log_tx, hint);
}
}
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());
}
fn manifest(name: &str, app_type: &str, version: &str) -> Manifest {
serde_json::from_value(serde_json::json!({
"name": name,
"version": version,
"app_type": app_type,
}))
.unwrap()
}
fn loaded(version: &str, status: &str, sandbox: &str, source: &str) -> LoadedApp {
LoadedApp {
version: version.to_string(),
status: status.to_string(),
sandbox: sandbox.to_string(),
source: source.to_string(),
registered_capabilities: Some(vec![
"core.did.devices.list".to_string(),
"core.did.sign".to_string(),
]),
}
}
fn manifest_providing(name: &str, version: &str, provides: &[&str]) -> Manifest {
let provides: serde_json::Map<String, serde_json::Value> = provides
.iter()
.map(|p| (p.to_string(), serde_json::json!({ "description": p })))
.collect();
serde_json::from_value(serde_json::json!({
"name": name,
"version": version,
"app_type": "native",
"provides": provides,
}))
.unwrap()
}
#[test]
fn loaded_native_with_zero_registered_capabilities_is_still_sideloaded() {
let dep = manifest_providing("did", "0.5.0", &["core.did.devices.list", "core.did.sign"]);
let mut auto_started = loaded("0.5.0", "active", "in_process", "apt");
auto_started.registered_capabilities = Some(Vec::new());
match sideload_decision(&dep, Some(&auto_started), never_checked, false) {
SideloadDecision::Load(Some(note)) => {
assert!(note.contains("0 capabilities registered"), "{note}");
assert!(note.contains("core.did.devices.list"), "{note}");
assert!(note.contains("re-registers"), "{note}");
}
other => panic!("expected Load(Some), got {other:?}"),
}
}
#[test]
fn partially_registered_provides_still_sideload() {
let dep = manifest_providing("did", "0.5.0", &["core.did.devices.list", "core.did.sign"]);
let mut partial = loaded("0.5.0", "active", "in_process", "bundled");
partial.registered_capabilities = Some(vec!["core.did.sign".to_string()]);
match sideload_decision(&dep, Some(&partial), never_checked, false) {
SideloadDecision::Load(Some(note)) => {
assert!(note.contains("1 capability registered"), "{note}");
assert!(note.contains("core.did.devices.list") && !note.contains("core.did.sign)"), "{note}");
}
other => panic!("expected Load(Some), got {other:?}"),
}
}
#[test]
fn unknown_registration_state_is_still_sideloaded() {
let dep = manifest_providing("did", "0.5.0", &["core.did.devices.list"]);
let mut old_daemon = loaded("0.5.0", "active", "in_process", "bundled");
old_daemon.registered_capabilities = None;
match sideload_decision(&dep, Some(&old_daemon), never_checked, false) {
SideloadDecision::Load(Some(note)) => {
assert!(note.contains("does not report its registered capabilities"), "{note}")
}
other => panic!("expected Load(Some), got {other:?}"),
}
}
#[test]
fn fully_registered_provides_allow_the_skip() {
let dep = manifest_providing("did", "0.5.0", &["core.did.devices.list", "core.did.sign"]);
let resident = loaded("0.5.0", "active", "in_process", "bundled");
assert!(matches!(
sideload_decision(&dep, Some(&resident), || CheckoutMatch::SamePath, false),
SideloadDecision::Skip(_)
));
let v2: Manifest = serde_json::from_value(serde_json::json!({
"name": "did",
"version": "0.5.0",
"app_type": "native",
"capabilities": { "provides": ["core.did.sign"] },
}))
.unwrap();
assert_eq!(v2.declared_provides(), vec!["core.did.sign".to_string()]);
assert!(matches!(
sideload_decision(&v2, Some(&resident), || CheckoutMatch::SamePath, false),
SideloadDecision::Skip(_)
));
let silent = manifest_providing("terminal", "0.5.0", &[]);
let mut nothing = loaded("0.5.0", "active", "in_process", "bundled");
nothing.registered_capabilities = Some(Vec::new());
assert!(matches!(
sideload_decision(&silent, Some(¬hing), || CheckoutMatch::SamePath, false),
SideloadDecision::Skip(_)
));
}
#[test]
fn loaded_app_distinguishes_absent_from_empty_registration() {
let absent: LoadedApp = serde_json::from_value(serde_json::json!({
"version": "1.0.0", "status": "active", "sandbox": "in_process", "source": "bundled",
}))
.unwrap();
assert_eq!(absent.registered_capabilities, None);
let empty: LoadedApp = serde_json::from_value(serde_json::json!({
"version": "1.0.0", "status": "active", "sandbox": "in_process", "source": "bundled",
"registered_capabilities": [],
}))
.unwrap();
assert_eq!(empty.registered_capabilities, Some(Vec::new()));
}
fn never_checked() -> CheckoutMatch {
panic!("checkout identity must not be consulted on this path")
}
#[test]
fn loaded_native_builtin_from_the_same_checkout_is_skipped() {
let dep = manifest("ldk-node", "native", "1.2.3");
let resident = loaded("1.2.3", "active", "in_process", "bundled");
for (checkout, needle) in [
(CheckoutMatch::SamePath, "same checkout"),
(CheckoutMatch::SameRev("38d9ce5c34da2f7799c4df27a6badac5a7787115".into()), "38d9ce5c34da"),
(CheckoutMatch::Unknown, "not verifiable"),
] {
match sideload_decision(&dep, Some(&resident), || checkout.clone(), false) {
SideloadDecision::Skip(reason) => {
assert!(reason.contains(needle), "{reason}");
assert!(reason.contains("builtin v1.2.3"), "{reason}");
}
other => panic!("expected Skip, got {other:?}"),
}
}
}
#[test]
fn lazy_native_builtins_count_as_resident() {
let dep = manifest("terminal", "native", "0.4.0");
let resident = loaded("0.4.0", "lazy", "in_process", "bundled");
assert!(matches!(
sideload_decision(&dep, Some(&resident), || CheckoutMatch::SamePath, false),
SideloadDecision::Skip(_)
));
}
#[test]
fn a_different_version_or_checkout_is_still_attempted_with_a_warning() {
let dep = manifest("ldk-node", "native", "1.3.0");
let resident = loaded("1.2.3", "active", "in_process", "bundled");
match sideload_decision(&dep, Some(&resident), never_checked, false) {
SideloadDecision::Load(Some(note)) => {
assert!(note.contains("v1.2.3") && note.contains("v1.3.0"), "{note}")
}
other => panic!("expected Load(Some), got {other:?}"),
}
let dep = manifest("ldk-node", "native", "1.2.3");
let different = CheckoutMatch::Different {
dep_rev: "aaaaaaaaaaaaaaaa".into(),
builtin_rev: "bbbbbbbbbbbbbbbb".into(),
};
match sideload_decision(&dep, Some(&resident), || different.clone(), false) {
SideloadDecision::Load(Some(note)) => {
assert!(note.contains("bbbbbbbbbbbb") && note.contains("aaaaaaaaaaaa"), "{note}")
}
other => panic!("expected Load(Some), got {other:?}"),
}
}
#[test]
fn bun_apps_and_unknown_apps_keep_the_existing_hot_reload_path() {
let bun = manifest("contest", "bun", "2.0.0");
let resident = loaded("2.0.0", "active", "subprocess", "apt");
assert_eq!(
sideload_decision(&bun, Some(&resident), never_checked, false),
SideloadDecision::Load(None)
);
assert_eq!(
sideload_decision(&bun, Some(&resident), never_checked, true),
SideloadDecision::Load(None)
);
let native = manifest("esp32-bridge", "native", "0.1.0");
assert_eq!(
sideload_decision(&native, None, never_checked, false),
SideloadDecision::Load(None)
);
let as_subprocess = loaded("0.1.0", "active", "subprocess", "apt");
assert_eq!(
sideload_decision(&native, Some(&as_subprocess), never_checked, false),
SideloadDecision::Load(None)
);
for status in ["stopped", "error", "installed", "awaiting_approval"] {
let gone = loaded("0.1.0", status, "in_process", "bundled");
assert_eq!(
sideload_decision(&native, Some(&gone), never_checked, false),
SideloadDecision::Load(None),
"{status}"
);
}
}
#[test]
fn an_app_already_flagged_requires_restart_is_reported_not_retried() {
let dep = manifest("notifications", "native", "0.9.0");
let flagged = loaded("0.9.0", "requires_restart", "in_process", "bundled");
assert!(matches!(
sideload_decision(&dep, Some(&flagged), never_checked, false),
SideloadDecision::RestartRequired(_)
));
}
#[test]
fn the_wide_flag_skips_every_loaded_native_regardless_of_version() {
let dep = manifest("ldk-node", "native", "9.9.9");
let resident = loaded("1.2.3", "active", "in_process", "bundled");
match sideload_decision(&dep, Some(&resident), never_checked, true) {
SideloadDecision::Skip(reason) => assert!(reason.contains("--no-sideload-native-builtins")),
other => panic!("expected Skip, got {other:?}"),
}
}
#[test]
fn restart_class_errors_are_recognised() {
assert!(is_restart_class_error(
"daemon RPC error -32603: Native app 'notifications' requires a node restart before another native app can load",
true
));
assert!(is_restart_class_error(
"daemon RPC error -32603: native app 'ldk-node' requires a node restart before reload",
true
));
assert!(is_restart_class_error("unload of 'ldk-node' timed out after 5s", true));
assert!(!is_restart_class_error("unload of 'contest' timed out after 5s", false));
assert!(!is_restart_class_error(
"daemon RPC error -32602: tier validation refused dev_load of 'x'",
true
));
}
#[test]
fn builtin_checkout_lookup_handles_both_directory_conventions() {
let tmp = TempDir::new().unwrap();
let modules = tmp.path().join("modules");
for (dir, name) in [
("ldk-node", "ldk-node"),
("node-app-notifications", "notifications"),
("some-other-dir", "terminal"),
] {
let d = modules.join(dir);
fs::create_dir_all(&d).unwrap();
fs::write(
d.join("manifest.json"),
serde_json::json!({ "name": name, "version": "1.0.0", "app_type": "native" }).to_string(),
)
.unwrap();
}
let decoy = modules.join("observability");
fs::create_dir_all(&decoy).unwrap();
fs::write(
decoy.join("manifest.json"),
serde_json::json!({ "name": "not-observability", "version": "1.0.0", "app_type": "native" }).to_string(),
)
.unwrap();
assert_eq!(builtin_checkout_for(&modules, "ldk-node"), Some(modules.join("ldk-node")));
assert_eq!(
builtin_checkout_for(&modules, "notifications"),
Some(modules.join("node-app-notifications"))
);
assert_eq!(builtin_checkout_for(&modules, "terminal"), Some(modules.join("some-other-dir")));
assert_eq!(builtin_checkout_for(&modules, "observability"), None);
assert_eq!(builtin_checkout_for(&modules, "missing"), None);
}
#[test]
fn checkout_match_is_same_path_for_the_builtin_dir_itself_and_unknown_without_one() {
let tmp = TempDir::new().unwrap();
let modules = tmp.path().join("modules");
let ldk = modules.join("ldk-node");
fs::create_dir_all(&ldk).unwrap();
fs::write(
ldk.join("manifest.json"),
serde_json::json!({ "name": "ldk-node", "version": "1.0.0", "app_type": "native" }).to_string(),
)
.unwrap();
assert_eq!(checkout_match(&ldk, Some(&modules), "ldk-node"), CheckoutMatch::SamePath);
assert_eq!(checkout_match(&ldk, None, "ldk-node"), CheckoutMatch::Unknown);
let elsewhere = tmp.path().join("elsewhere");
fs::create_dir_all(&elsewhere).unwrap();
assert_eq!(checkout_match(&elsewhere, Some(&modules), "ldk-node"), CheckoutMatch::Unknown);
}
}