use anyhow::{anyhow, bail, Context, Result};
#[cfg(unix)]
use libc;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::collections::VecDeque;
use std::sync::{mpsc, Arc, Mutex, OnceLock};
use std::time::{Duration, Instant};
use crate::commands::infra::{self as infra_cmd, InfraContext};
use crate::commands::package::{stage_ui_bundle, write_staged_manifest};
use crate::commands::ui_bundle;
use crate::tui::{
self, DevSignals, InfraChannelEntry, InfraGraphData, InfraLogLine, InfraLogService,
InfraServiceHealth, InfraViewState, LogSource, LogTx, ServiceStatus, TuiEvent,
};
use host::{DaemonHandle, InstanceProfile};
static SHUTDOWN_REQUESTED: AtomicBool = AtomicBool::new(false);
static QUIT_FLAG: OnceLock<Arc<AtomicBool>> = OnceLock::new();
pub(crate) fn is_cancelled() -> bool {
SHUTDOWN_REQUESTED.load(Ordering::SeqCst)
|| QUIT_FLAG.get().is_some_and(|f| f.load(Ordering::SeqCst))
}
#[cfg(unix)]
extern "C" fn handle_shutdown_signal(_: libc::c_int) {
SHUTDOWN_REQUESTED.store(true, Ordering::SeqCst);
}
pub fn shutdown_requested() -> bool {
SHUTDOWN_REQUESTED.load(Ordering::SeqCst)
}
pub fn install_shutdown_handler() {
#[cfg(unix)]
{
static INSTALLED: AtomicBool = AtomicBool::new(false);
if INSTALLED.swap(true, Ordering::SeqCst) {
return;
}
unsafe {
libc::signal(
libc::SIGINT,
handle_shutdown_signal as *const () as libc::sighandler_t,
);
libc::signal(
libc::SIGTERM,
handle_shutdown_signal as *const () as libc::sighandler_t,
);
}
}
}
pub mod agent;
pub mod apps_dir;
pub mod autodetect;
pub mod host;
pub mod operation_mode;
pub mod platform;
pub mod sources_resolver;
use host::Mode;
const WATCH_DEBOUNCE: Duration = Duration::from_millis(500);
const IPC_TIMEOUT: Duration = Duration::from_secs(60);
pub struct DevArgs<'a> {
pub project_path: &'a Path,
pub daemon: Option<&'a str>,
pub monorepo_path: Option<&'a Path>,
pub socket_override: Option<&'a Path>,
pub dev_dir_override: Option<&'a Path>,
pub once: bool,
pub dep_paths: Vec<PathBuf>,
pub no_tui: bool,
pub config: Vec<(String, String)>,
pub instances: Vec<String>,
pub agent: bool,
pub client_node: bool,
pub operation_mode: bool,
pub no_sideload_native_builtins: bool,
}
fn validate_operation_mode_args(args: &DevArgs<'_>) -> Result<()> {
if args.operation_mode && !args.client_node {
bail!("--operation-mode requires --client-node");
}
if args.operation_mode && args.instances.len() > 1 {
bail!(
"--operation-mode supports one direct-dev instance; select alice or bob with --instances"
);
}
Ok(())
}
fn load_operation_mode_session(handle: &DaemonHandle) -> Result<agent::session::AgentSession> {
let instance = agent::instance_label(handle);
agent::session::AgentSession::load(&handle.dev_dir, instance)
.with_context(|| format!("load owner session for operation-mode instance '{instance}'"))?
.ok_or_else(|| {
anyhow!(
"operation mode requires a valid owner session for instance '{instance}', but \
agent bootstrap did not produce one"
)
})
}
pub fn run(args: DevArgs<'_>) -> Result<()> {
validate_operation_mode_args(&args)?;
let project_path = args.project_path.canonicalize().with_context(|| {
format!("canonicalize project path '{}'", args.project_path.display())
})?;
let has_platform_depends = project_path.join("infra/debian/platform-depends").is_file();
let has_manifest = project_path.join("manifest.json").is_file();
if has_platform_depends && has_manifest {
bail!(
"{} contains both manifest.json and infra/debian/platform-depends — \
ambiguous. cd into the app or platform subdirectory, or pass --path.",
project_path.display()
);
}
if has_platform_depends {
return platform::run(&project_path, &args);
}
let manifest = load_manifest(&project_path)?;
if manifest.app_type.eq_ignore_ascii_case("platform-runtime") {
eprintln!(
"→ '{}' (v{}) has app_type=platform-runtime — packaging-only target, \
not loaded by the node app loader. Use `make build` to produce the .deb.",
manifest.name, manifest.version
);
return Ok(());
}
let app_kind = AppKindDetected::detect(&project_path, &manifest)?;
if let AppKindDetected::Standalone(ref runtime) = app_kind {
return run_standalone_dev(
&project_path,
&manifest,
runtime,
args.once,
args.no_tui,
&args.config,
);
}
let dev_config = load_dev_config(&project_path, &args.config);
let infra_ctx = InfraContext::try_resolve_from(
Some(&project_path),
args.monorepo_path,
);
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 _ = QUIT_FLAG.set(signals.quit_requested.clone());
let tui_instance_names: Vec<String> = if args.instances.is_empty() {
vec!["alice".to_string()]
} else {
args.instances.clone()
};
let mut app_state = tui::state::AppState::new(
manifest.name.clone(),
manifest.version.clone(),
tui_instance_names,
);
if let Some(ref ctx) = infra_ctx {
let rgs_url = ctx.rgs_url();
app_state.infra = Some(InfraViewState::new(
rgs_url,
"mainnet".to_string(),
vec![],
false,
));
start_infra_threads(tx.clone(), ctx.clone());
}
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,
};
let mode = resolve_mode(args.daemon, args.monorepo_path, args.socket_override)?;
if args.client_node && !matches!(mode, Mode::Monorepo { .. }) {
bail!(
"--client-node is only supported with --daemon monorepo (got: {}); \
it drives env overrides `cargo run` reads directly — other daemon-host \
modes don't have an equivalent PWA-vs-legacy-UI toggle yet.",
mode.label()
);
}
let mut 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<_>>>()?
};
if profiles.len() > 1 && !matches!(mode, Mode::Monorepo { .. }) {
tui::sys_log(
log_tx_opt.as_ref(),
"⚠ --instances with multiple values only supported with --daemon monorepo; running alice only",
);
profiles.truncate(1);
}
tui::sys_log(
log_tx_opt.as_ref(),
format!(
"→ daemon-host mode: {} instances: {}",
mode.label(),
profiles.iter().map(|p| p.name.as_str()).collect::<Vec<_>>().join(", ")
),
);
let hosts: Vec<Box<dyn host::DaemonHost>> = profiles
.iter()
.map(|p| {
host::for_mode(
mode.clone(),
p.clone(),
args.socket_override,
args.dev_dir_override,
log_tx_opt.clone(),
args.client_node,
None, )
})
.collect();
let (handles, prestaged) = stage_and_boot(
&project_path,
&manifest,
&app_kind,
&args.dep_paths,
&hosts,
log_tx_opt.as_ref(),
)?;
#[cfg(unix)]
unsafe {
libc::signal(libc::SIGINT, handle_shutdown_signal as *const () as libc::sighandler_t);
libc::signal(libc::SIGTERM, handle_shutdown_signal as *const () as libc::sighandler_t);
}
for handle in &handles {
tui::sys_log(
log_tx_opt.as_ref(),
format!(
"→ {} app '{}' (v{})\n project = {}\n {}",
app_kind.label(),
manifest.name,
manifest.version,
project_path.display(),
handle.banner,
),
);
}
if !args.dep_paths.is_empty() {
if hosts[0].pre_start_dev_dir().is_none() {
tui::sys_log(
log_tx_opt.as_ref(),
format!(
"→ staging {} dep(s) into dev-dir (post-boot)…",
args.dep_paths.len()
),
);
for dep_path in &args.dep_paths {
stage_dep_after_boot(dep_path, &handles[0], log_tx_opt.as_ref())?;
}
} else {
for dep_path in &args.dep_paths {
let dep_manifest = load_manifest(dep_path)?;
if dep_manifest.ui_only {
publish_staged_ui_only(dep_path, &dep_manifest.name, &handles[..1], log_tx_opt.as_ref());
}
}
}
}
if manifest.ui_only {
publish_ui_only(
&project_path,
&manifest,
&handles,
&prestaged,
log_tx_opt.as_ref(),
0,
Instant::now(),
)
.inspect_err(|_| {
for host_impl in &hosts { host_impl.shutdown(); }
})?;
} else {
let build_result = build_app(
&project_path,
&manifest,
&app_kind,
log_tx_opt.as_ref(),
);
if let Err(e) = build_result {
for host_impl in &hosts { host_impl.shutdown(); }
return Err(e);
}
for handle in &handles {
let sl_result = sideload_to_handle(
&project_path,
&manifest,
&app_kind,
handle,
log_tx_opt.as_ref(),
0,
&dev_config,
);
if let Err(e) = sl_result {
for host_impl in &hosts { host_impl.shutdown(); }
return Err(e);
}
}
}
let needs_agent_session = args.agent || args.operation_mode;
if needs_agent_session {
let _ = agent::run_agent_setup(&handles, log_tx_opt.as_ref(), false);
}
if args.once {
tui::sys_log(
log_tx_opt.as_ref(),
if manifest.ui_only {
"✓ --once mode: built + staged; exiting without watcher."
} else {
"✓ --once mode: built + sideloaded; exiting without watcher."
},
);
for host_impl in &hosts { host_impl.shutdown(); }
return Ok(());
}
for (host_impl, _handle) in hosts.iter().zip(handles.iter()) {
host_impl.tail_logs(&manifest.name);
}
tui::sys_log(
log_tx_opt.as_ref(),
if manifest.ui_only {
format!(
"✓ Initial stage complete. Watching {}/ui/ for changes (Ctrl-C to stop)…",
project_path.display()
)
} else {
format!(
"✓ Initial sideload complete. Watching {}/src/ for changes (Ctrl-C to stop)…",
project_path.display()
)
},
);
let _operation_mode_thread = if args.operation_mode {
let session = load_operation_mode_session(&handles[0])?;
Some(operation_mode::prepare(session)?.spawn(log_tx_opt.clone()))
} else {
None
};
let watch_result = watch_loop(
&project_path,
&manifest,
&app_kind,
&handles,
log_tx_opt.as_ref(),
signals_opt.as_ref(),
&hosts,
&dev_config,
);
tui::sys_log(log_tx_opt.as_ref(), "→ shutting down…");
for host_impl in &hosts { host_impl.shutdown(); }
drop(log_tx_opt);
watch_result
}
fn run_standalone_dev(
project_path: &Path,
manifest: &Manifest,
runtime: &StandaloneRuntime,
once: bool,
no_tui: bool,
config: &[(String, String)],
) -> Result<()> {
let use_tui = !no_tui && !once && tui::is_tty();
let (log_tx_opt, signals_opt, tui_handle) = if use_tui {
let (tx, rx, signals) = tui::setup();
let app_state = tui::state::AppState::new(
manifest.name.clone(),
manifest.version.clone(),
vec![],
);
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,
};
#[cfg(unix)]
unsafe {
libc::signal(libc::SIGINT, handle_shutdown_signal as *const () as libc::sighandler_t);
libc::signal(libc::SIGTERM, handle_shutdown_signal as *const () as libc::sighandler_t);
}
tui::sys_log(
log_tx_opt.as_ref(),
format!(
"→ standalone ({}) app '{}' (v{})\n project = {}",
match runtime {
StandaloneRuntime::Rust => "Rust",
StandaloneRuntime::Bun => "Bun",
},
manifest.name,
manifest.version,
project_path.display(),
),
);
standalone_build(project_path, runtime, log_tx_opt.as_ref())?;
let mut child = standalone_spawn(project_path, manifest, runtime, config)?;
tui::sys_log(
log_tx_opt.as_ref(),
format!("✓ '{}' started (pid {})", manifest.name, child.id()),
);
if once {
let _ = child.wait();
return Ok(());
}
tui::sys_log(
log_tx_opt.as_ref(),
format!(
"✓ Watching {}/src/ for changes (Ctrl-C to stop)…",
project_path.display()
),
);
let (watcher_tx, watcher_rx) = mpsc::channel();
let mut watcher =
notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
if let Ok(event) = res {
use notify::EventKind::*;
if matches!(
event.kind,
Create(_) | Modify(_) | Remove(_)
) {
let _ = watcher_tx.send(());
}
}
})
.context("create file watcher")?;
use notify::Watcher;
let src_dir = project_path.join("src");
if src_dir.is_dir() {
watcher
.watch(&src_dir, notify::RecursiveMode::Recursive)
.ok();
}
let mut last_event = Instant::now();
loop {
if SHUTDOWN_REQUESTED.load(Ordering::SeqCst) {
break;
}
let mut force_rebuild = false;
if let Some(sigs) = &signals_opt {
if sigs.should_quit() {
break;
}
let restart = sigs.take_restart();
let scope = sigs.take_build_scope();
if restart || scope.is_some() {
force_rebuild = true;
standalone_banner(
log_tx_opt.as_ref(),
"⟳ MANUAL REBUILD TRIGGERED",
);
}
}
let got_event = watcher_rx
.recv_timeout(Duration::from_millis(200))
.is_ok();
if got_event {
last_event = Instant::now();
} else if force_rebuild
|| (last_event.elapsed() > WATCH_DEBOUNCE
&& last_event.elapsed() < Duration::from_secs(1))
{
let rebuild_started = Instant::now();
if !force_rebuild {
tui::sys_log(log_tx_opt.as_ref(), "→ change detected, rebuilding…");
}
let _ = child.kill();
let _ = child.wait();
tui::update_status(
log_tx_opt.as_ref(),
LogSource::Build,
ServiceStatus::Building,
None,
);
match standalone_build(project_path, runtime, log_tx_opt.as_ref()) {
Ok(()) => {
match standalone_spawn(project_path, manifest, runtime, config) {
Ok(new_child) => {
child = new_child;
if force_rebuild {
standalone_banner(
log_tx_opt.as_ref(),
format!(
"✓ REBUILD COMPLETE ({:.1}s) — pid {}",
rebuild_started.elapsed().as_secs_f32(),
child.id()
),
);
} else {
tui::sys_log(
log_tx_opt.as_ref(),
format!("✓ restarted '{}' (pid {})", manifest.name, child.id()),
);
}
tui::update_status(
log_tx_opt.as_ref(),
LogSource::Build,
ServiceStatus::Watching,
None,
);
}
Err(e) => {
tui::sys_log(
log_tx_opt.as_ref(),
format!("✗ spawn failed: {}", e),
);
}
}
}
Err(e) => {
tui::sys_log(
log_tx_opt.as_ref(),
format!("✗ build failed: {}", e),
);
tui::update_status(
log_tx_opt.as_ref(),
LogSource::Build,
ServiceStatus::Failed("build error".into()),
None,
);
}
}
last_event = Instant::now() - WATCH_DEBOUNCE - Duration::from_secs(1);
}
}
let _ = child.kill();
let _ = child.wait();
tui::sys_log(log_tx_opt.as_ref(), "→ shutting down standalone process…");
drop(log_tx_opt);
Ok(())
}
fn standalone_banner(log_tx: Option<&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);
}
fn standalone_build(
project_path: &Path,
runtime: &StandaloneRuntime,
log_tx: Option<&LogTx>,
) -> Result<()> {
tui::update_status(log_tx, LogSource::Build, ServiceStatus::Building, None);
let result = match runtime {
StandaloneRuntime::Rust => run_in_with_sink(
project_path,
"cargo",
&["build"],
"cargo build",
log_tx,
LogSource::Build,
),
StandaloneRuntime::Bun => run_in_with_sink(
project_path,
"bun",
&["install"],
"bun install",
log_tx,
LogSource::Build,
),
};
if result.is_ok() {
tui::update_status(log_tx, LogSource::Build, ServiceStatus::Watching, None);
}
result
}
fn bun_backend_build_args(project_path: &Path) -> Result<Vec<String>> {
let entry = crate::commands::build::resolve_bun_entry_rel(project_path)?.to_string_lossy().into_owned();
let mut args = vec![
"build".to_string(),
entry,
"--outdir".to_string(),
"dist".to_string(),
"--target=bun".to_string(),
];
for ext in crate::blueprint::CURRENT.shared_externals {
args.push("--external".to_string());
args.push((*ext).to_string());
}
Ok(args)
}
fn standalone_spawn(
project_path: &Path,
manifest: &Manifest,
runtime: &StandaloneRuntime,
config: &[(String, String)],
) -> Result<std::process::Child> {
let mut cmd = match runtime {
StandaloneRuntime::Rust => {
let bin_name = format!("node-app-{}", manifest.name);
let bin_path = project_path.join("target").join("debug").join(&bin_name);
let mut c = Command::new(&bin_path);
c.current_dir(project_path);
c
}
StandaloneRuntime::Bun => {
let entry = crate::commands::build::resolve_bun_entry_rel(project_path)?;
let mut c = Command::new("bun");
c.arg("run").arg(&entry);
c.current_dir(project_path);
c
}
};
let mut explicit_http_port = false;
for (k, v) in config {
if k == "NODE_APP_HTTP_PORT" {
explicit_http_port = true;
}
cmd.env(k, v);
}
if !explicit_http_port {
if let Some(preferred) = manifest.tcp.as_ref().and_then(|t| t.preferred_port) {
let port = resolve_dev_port(preferred, &manifest.name);
cmd.env("NODE_APP_HTTP_PORT", port.to_string());
}
}
cmd.spawn().with_context(|| {
format!(
"spawn standalone '{}' — ensure the binary is built",
manifest.name
)
})
}
fn resolve_dev_port(preferred: u16, app_name: &str) -> u16 {
use std::net::{Ipv4Addr, SocketAddrV4, TcpListener};
let preferred_addr = SocketAddrV4::new(Ipv4Addr::LOCALHOST, preferred);
match TcpListener::bind(preferred_addr) {
Ok(l) => {
drop(l);
preferred
}
Err(_) => {
match TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)) {
Ok(l) => {
let actual = l.local_addr().map(|a| a.port()).unwrap_or(preferred);
drop(l);
eprintln!(
"warning: tcp.preferred_port {preferred} taken on this host; \
dev server for '{app_name}' bound to {actual} instead. \
To override, pass --config NODE_APP_HTTP_PORT=<N>."
);
actual
}
Err(_) => {
eprintln!(
"warning: could not reserve any TCP port; \
dev server for '{app_name}' will try preferred ({preferred}) — \
expect bind failure."
);
preferred
}
}
}
}
}
pub(crate) fn build_and_stage(dep_path: &Path, dev_dir: &Path, log_tx: Option<&LogTx>) -> Result<()> {
let dep_path = dep_path
.canonicalize()
.with_context(|| format!("canonicalize dep path '{}'", dep_path.display()))?;
let dep_manifest = load_manifest(&dep_path)?;
if dep_manifest.app_type.eq_ignore_ascii_case("platform-runtime") {
tui::sys_log(
log_tx,
format!(
"→ dep '{}' (platform-runtime): skipped — packaging-only, not staged",
dep_manifest.name
),
);
return Ok(());
}
let dep_kind = AppKindDetected::detect(&dep_path, &dep_manifest)?;
tui::sys_log(
log_tx,
format!(
"→ dep '{}' ({}): building…",
dep_manifest.name,
dep_kind.label()
),
);
dep_kind.build_with_manifest(&dep_path, Some(&dep_manifest), log_tx)?;
if dep_manifest.ui_only {
stage_into_dev_dir(&dep_path, &dep_manifest, &dep_kind, dev_dir)?;
} else {
let dest = dev_dir.join(&dep_manifest.name);
std::fs::create_dir_all(&dest)
.with_context(|| format!("mkdir -p {}", dest.display()))?;
dep_kind.copy_artifacts(&dep_path, &dep_manifest, &dest)?;
}
tui::sys_log(log_tx, format!("✓ dep '{}' staged", dep_manifest.name));
Ok(())
}
fn stage_into_dev_dir(
project_path: &Path,
manifest: &Manifest,
app_kind: &AppKindDetected,
dev_dir: &Path,
) -> Result<PathBuf> {
let dest = dev_dir.join(&manifest.name);
let temp_dest = temp_app_dir(&dest, "full");
if temp_dest.exists() {
std::fs::remove_dir_all(&temp_dest).ok();
}
std::fs::create_dir_all(&temp_dest)
.with_context(|| format!("mkdir -p {}", temp_dest.display()))?;
let staged = app_kind
.copy_artifacts(project_path, manifest, &temp_dest)
.and_then(|()| replace_dir_atomically(&temp_dest, &dest));
if let Err(error) = staged {
std::fs::remove_dir_all(&temp_dest).ok();
return Err(error);
}
Ok(dest)
}
fn resolve_mode(
daemon: Option<&str>,
monorepo_path: Option<&Path>,
socket_override: Option<&Path>,
) -> Result<Mode> {
if let Some(d) = daemon {
return match d {
"deb" => Ok(Mode::Deb),
"docker" => Ok(Mode::Docker),
"clone" => Ok(Mode::Clone),
"monorepo" => {
let path = monorepo_path.ok_or_else(|| {
anyhow!("--daemon monorepo requires --monorepo-path PATH")
})?;
Ok(Mode::Monorepo {
path: path.to_path_buf(),
})
}
other => Err(anyhow!(
"unknown --daemon value '{}'. Allowed: deb | docker | clone | monorepo",
other
)),
};
}
if let Some(p) = socket_override {
return Ok(Mode::Remote {
socket_path: p.to_path_buf(),
});
}
autodetect::detect()
}
fn load_dev_config(
project_path: &Path,
cli_overrides: &[(String, String)],
) -> std::collections::HashMap<String, String> {
let mut map = std::collections::HashMap::new();
if let Ok(text) = std::fs::read_to_string(project_path.join("node-app.toml")) {
if let Ok(doc) = text.parse::<toml::Table>() {
if let Some(toml::Value::Table(cfg)) = doc.get("config") {
for (k, v) in cfg {
if let toml::Value::String(s) = v {
let resolved = resolve_config_path(s, project_path);
map.insert(k.clone(), resolved);
}
}
}
}
}
for (k, v) in cli_overrides {
map.insert(k.clone(), v.clone());
}
map
}
fn resolve_config_path(value: &str, base: &Path) -> String {
let p = std::path::Path::new(value);
if p.is_relative() && (value.starts_with('.') || value.contains('/')) {
if let Ok(abs) = base.join(p).canonicalize() {
return abs.to_string_lossy().into_owned();
}
return base.join(p).to_string_lossy().into_owned();
}
value.to_string()
}
fn build_app(
project_path: &Path,
manifest: &Manifest,
app_kind: &AppKindDetected,
log_tx: Option<&LogTx>,
) -> Result<()> {
tui::sys_log(log_tx, format!("→ building ({})…", app_kind.build_label()));
tui::update_status(
log_tx,
LogSource::Build,
ServiceStatus::Building,
Some(app_kind.build_label().to_string()),
);
tui::update_status(log_tx, LogSource::App, ServiceStatus::Changed, None);
app_kind.build_with_manifest(project_path, Some(manifest), log_tx)?;
tui::update_status(log_tx, LogSource::Build, ServiceStatus::Ready, None);
Ok(())
}
fn sideload_to_handle(
project_path: &Path,
manifest: &Manifest,
app_kind: &AppKindDetected,
handle: &DaemonHandle,
log_tx: Option<&LogTx>,
reload_count: usize,
dev_config: &std::collections::HashMap<String, String>,
) -> Result<()> {
let started = Instant::now();
let dest = stage_into_dev_dir(project_path, manifest, app_kind, &handle.dev_dir)?;
tui::sys_log(log_tx, "→ sideloading via app.dev_load…");
let mut params = serde_json::json!({
"name": manifest.name,
"path": dest.to_string_lossy(),
});
if !dev_config.is_empty() {
params["config"] = serde_json::to_value(dev_config).unwrap_or_default();
}
let result = ipc_call(&handle.socket_path, "app.dev_load", params)?;
let elapsed_ms = started.elapsed().as_millis();
let label = if handle.name.is_empty() {
manifest.name.clone()
} else {
format!("{} [{}]", manifest.name, handle.name)
};
tui::sys_log(
log_tx,
format!("✓ {} reloaded ({}ms) — daemon: {}", label, elapsed_ms, result),
);
tui::update_status(
log_tx,
LogSource::App,
ServiceStatus::Loaded { reloads: reload_count + 1 },
Some(format!("last reload: {elapsed_ms}ms")),
);
Ok(())
}
fn stage_and_boot(
project_path: &Path,
manifest: &Manifest,
app_kind: &AppKindDetected,
dep_paths: &[PathBuf],
hosts: &[Box<dyn host::DaemonHost>],
log_tx: Option<&LogTx>,
) -> Result<(Vec<DaemonHandle>, Vec<bool>)> {
let apps_root = hosts.first().and_then(|host| host.apps_dir());
if !dep_paths.is_empty() {
if let Some(early_dev_dir) = hosts[0].pre_start_dev_dir() {
tui::sys_log(
log_tx,
format!("→ staging {} dep(s) into dev-dir (pre-boot)…", dep_paths.len()),
);
for dep_path in dep_paths {
build_and_stage(dep_path, &early_dev_dir, log_tx)?;
let dep_manifest = load_manifest(dep_path)?;
if let (true, Some(apps_root)) = (dep_manifest.ui_only, apps_root.as_deref()) {
let staged = [early_dev_dir.join(&dep_manifest.name)];
let _ = apps_dir::link_staged_copy(apps_root, &dep_manifest.name, &staged, dep_path);
}
}
}
}
let mut prestaged = vec![false; hosts.len()];
if manifest.ui_only {
build_app(project_path, manifest, app_kind, log_tx)?;
let mut staged = Vec::new();
for (host_impl, prestaged) in hosts.iter().zip(prestaged.iter_mut()) {
if let Some(dev_dir) = host_impl.pre_start_dev_dir() {
staged.push(stage_into_dev_dir(project_path, manifest, app_kind, &dev_dir)?);
*prestaged = true;
}
}
if let (false, Some(apps_root)) = (staged.is_empty(), apps_root.as_deref()) {
let _ = apps_dir::link_staged_copy(apps_root, &manifest.name, &staged, project_path);
}
}
let mut handles = Vec::with_capacity(hosts.len());
for host_impl in hosts {
handles.push(host_impl.ensure_running().context("bring up daemon-host")?);
}
Ok((handles, prestaged))
}
fn stage_dep_after_boot(dep_path: &Path, handle: &DaemonHandle, log_tx: Option<&LogTx>) -> Result<()> {
build_and_stage(dep_path, &handle.dev_dir, log_tx)?;
let dep_manifest = load_manifest(dep_path)?;
if dep_manifest.ui_only {
publish_staged_ui_only(dep_path, &dep_manifest.name, std::slice::from_ref(handle), log_tx);
return Ok(());
}
let dest = handle.dev_dir.join(&dep_manifest.name);
ipc_call(
&handle.socket_path,
"app.dev_load",
serde_json::json!({
"name": dep_manifest.name,
"path": dest.to_string_lossy(),
}),
)?;
tui::sys_log(log_tx, format!("✓ dep '{}' sideloaded", dep_manifest.name));
Ok(())
}
fn publish_ui_only(
project_path: &Path,
manifest: &Manifest,
handles: &[DaemonHandle],
staged_already: &[bool],
log_tx: Option<&LogTx>,
reload_count: usize,
started: Instant,
) -> Result<()> {
for (index, handle) in handles.iter().enumerate() {
if !staged_already.get(index).copied().unwrap_or(false) {
stage_into_dev_dir(project_path, manifest, &AppKindDetected::UiOnly, &handle.dev_dir)?;
}
}
publish_staged_ui_only(project_path, &manifest.name, handles, log_tx);
let elapsed_ms = started.elapsed().as_millis();
tui::update_status(
log_tx,
LogSource::App,
ServiceStatus::Loaded { reloads: reload_count + 1 },
Some(format!("UI-only, staged in {elapsed_ms}ms")),
);
Ok(())
}
fn publish_staged_ui_only(source: &Path, name: &str, handles: &[DaemonHandle], log_tx: Option<&LogTx>) {
let Some(first) = handles.first() else { return };
let staged: Vec<PathBuf> = handles.iter().map(|handle| handle.dev_dir.join(name)).collect();
let entry = first
.builtin_apps_dir
.as_deref()
.map(|apps_root| apps_dir::link_staged_copy(apps_root, name, &staged, source));
tui::sys_log(
log_tx,
apps_dir::describe(name, &staged[0], entry.as_ref(), &first.socket_path),
);
}
fn rebuild_ui_only(
project_path: &Path,
manifest: &Manifest,
handles: &[DaemonHandle],
ui_sources_only: bool,
log_tx: Option<&LogTx>,
reload_count: usize,
) -> Result<()> {
let started = Instant::now();
if ui_sources_only {
build_ui_only(project_path, manifest, log_tx)?;
} else {
build_app(project_path, manifest, &AppKindDetected::UiOnly, log_tx)?;
}
publish_ui_only(project_path, manifest, handles, &[], log_tx, reload_count, started)
}
fn ui_staging_manifest(manifest: &Manifest, manifest_json: &Value) -> Value {
let mut staging = manifest_json.clone();
if manifest.ui_only {
if let Some(object) = staging.as_object_mut() {
object.insert("has_ui".into(), Value::Bool(true));
}
}
staging
}
fn validate_staged_ui_only(staged_root: &Path, name: &str) -> Result<()> {
let path = staged_root.join("manifest.json");
let raw = std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
node_app_manifest::AppManifest::from_json(&raw)
.map(|_| ())
.map_err(|reason| anyhow!("UI-only app '{name}' cannot be loaded: {reason}"))
}
fn build_ui_only(
project_path: &Path,
manifest: &Manifest,
log_tx: Option<&LogTx>,
) -> Result<PathBuf> {
tui::sys_log(log_tx, "→ building UI…");
tui::update_status(log_tx, LogSource::Build, ServiceStatus::Building, Some("bun build (ui)".into()));
let ui_dir = manifest
.ui_path
.as_deref()
.and_then(|p| p.strip_suffix("/dist"))
.map(|p| project_path.join(p))
.unwrap_or_else(|| project_path.join("ui"));
if ui_dir.join("package.json").exists() {
let pkg_mgr = if ui_dir.join("package-lock.json").exists() { "npm" } else { "bun" };
run_in_with_sink(&ui_dir, pkg_mgr, &["run", "build"], "ui build", log_tx, LogSource::Build)?;
}
tui::update_status(log_tx, LogSource::Build, ServiceStatus::Ready, None);
Ok(ui_dir)
}
#[allow(clippy::too_many_arguments)]
fn sideload_ui_to_handle(
project_path: &Path,
manifest: &Manifest,
ui_dir: &Path,
handle: &DaemonHandle,
log_tx: Option<&LogTx>,
reload_count: usize,
dev_config: &std::collections::HashMap<String, String>,
started: Instant,
) -> Result<()> {
let dest = handle.dev_dir.join(&manifest.name);
let temp_dest = temp_app_dir(&dest, "ui");
if temp_dest.exists() {
std::fs::remove_dir_all(&temp_dest).ok();
}
if dest.exists() {
copy_dir_recursive(&dest, &temp_dest)
.with_context(|| format!("copy {} → {}", dest.display(), temp_dest.display()))?;
} else {
std::fs::create_dir_all(&temp_dest)
.with_context(|| format!("mkdir -p {}", temp_dest.display()))?;
}
let manifest_json = load_manifest_json(project_path)?;
let ui_path_str = manifest_json
.get("ui_path")
.and_then(|value| value.as_str())
.unwrap_or("ui/dist");
let ui_dst = temp_dest.join(ui_path_str);
if ui_dst.exists() {
std::fs::remove_dir_all(&ui_dst).ok();
}
let _ = ui_dir; stage_ui_bundle(project_path, &temp_dest, &manifest_json)?;
write_staged_manifest(&project_path.join("manifest.json"), &temp_dest)?;
replace_dir_atomically(&temp_dest, &dest)?;
tui::sys_log(log_tx, "→ sideloading via app.dev_load…");
let mut params = serde_json::json!({ "name": manifest.name, "path": dest.to_string_lossy() });
if !dev_config.is_empty() {
params["config"] = serde_json::to_value(dev_config).unwrap_or_default();
}
let result = ipc_call(&handle.socket_path, "app.dev_load", params)?;
let elapsed_ms = started.elapsed().as_millis();
tui::sys_log(log_tx, format!("✓ UI reloaded ({}ms) — daemon: {}", elapsed_ms, result));
tui::update_status(log_tx, LogSource::App, ServiceStatus::Loaded { reloads: reload_count + 1 },
Some(format!("last UI reload: {elapsed_ms}ms")));
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn watch_loop(
project_path: &Path,
manifest: &Manifest,
app_kind: &AppKindDetected,
handles: &[DaemonHandle],
log_tx: Option<&LogTx>,
signals: Option<&DevSignals>,
hosts: &[Box<dyn host::DaemonHost>],
dev_config: &std::collections::HashMap<String, String>,
) -> Result<()> {
use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher as _};
let (tx, rx) = mpsc::channel::<notify::Result<Event>>();
let mut watcher: RecommendedWatcher = notify::recommended_watcher(move |res| {
let _ = tx.send(res);
})
.context("create notify watcher")?;
let src_dir = project_path.join("src");
if src_dir.exists() {
watcher
.watch(&src_dir, RecursiveMode::Recursive)
.with_context(|| format!("watch {}", src_dir.display()))?;
}
if manifest.has_ui {
let ui_dir = manifest
.ui_path
.as_deref()
.and_then(|p| p.strip_suffix("/dist"))
.map(|p| project_path.join(p))
.unwrap_or_else(|| project_path.join("ui"));
let ui_src = ui_dir.join("src");
let watch_target = if ui_src.exists() { ui_src } else { ui_dir };
if watch_target.exists() {
let _ = watcher.watch(&watch_target, RecursiveMode::Recursive);
}
}
let manifest_path = project_path.join("manifest.json");
if manifest_path.exists() {
let _ = watcher.watch(&manifest_path, RecursiveMode::NonRecursive);
}
tui::update_status(
log_tx,
LogSource::App,
ServiceStatus::Watching,
Some("watching src/ for changes".into()),
);
let ui_src_dir: Option<PathBuf> = if manifest.has_ui {
let ui_dir = manifest
.ui_path
.as_deref()
.and_then(|p| p.strip_suffix("/dist"))
.map(|p| project_path.join(p))
.unwrap_or_else(|| project_path.join("ui"));
let candidate = ui_dir.join("src");
if candidate.exists() { Some(candidate) } else { Some(ui_dir) }
} else {
None
};
let ui_out_dirs: Vec<PathBuf> = if manifest.has_ui {
vec![
project_path.join(manifest.ui_path.as_deref().unwrap_or("ui/dist")),
project_path.join("ui/dist"),
]
} else {
Vec::new()
};
let mut manifest_after_build = std::fs::read(&manifest_path).ok();
let mut last_event = Instant::now();
let mut pending = false;
let mut pending_ui_only = false; let mut reload_count = 0usize;
loop {
if SHUTDOWN_REQUESTED.load(Ordering::SeqCst) {
return Ok(());
}
if let Some(sigs) = signals {
if sigs.should_quit() {
return Ok(());
}
if sigs.take_restart() {
tui::sys_log(log_tx, "→ restart requested…");
for host_impl in hosts {
if let Err(e) = host_impl.restart() {
tui::sys_log(log_tx, format!("✗ restart failed: {:#}", e));
}
}
}
if let Some(_scope) = sigs.take_build_scope() {
tui::sys_log(log_tx, "→ manual build triggered…");
pending = false;
pending_ui_only = false;
if manifest.ui_only {
match rebuild_ui_only(project_path, manifest, handles, false, log_tx, reload_count) {
Ok(()) => reload_count += 1,
Err(e) => tui::sys_log(log_tx, format!("✗ UI-only rebuild failed: {:#}", e)),
}
} else {
match build_app(project_path, manifest, app_kind, log_tx) {
Ok(()) => {
for handle in handles {
match sideload_to_handle(project_path, manifest, app_kind, handle, log_tx, reload_count, dev_config) {
Ok(()) => {}
Err(e) => tui::sys_log(log_tx, format!("✗ sideload failed: {:#}", e)),
}
}
reload_count += 1;
}
Err(e) => tui::sys_log(log_tx, format!("✗ build failed: {:#}", e)),
}
}
manifest_after_build = std::fs::read(&manifest_path).ok();
}
}
match rx.recv_timeout(WATCH_DEBOUNCE) {
Ok(Ok(event)) => {
if !is_rebuild_trigger(&event)
|| is_build_echo(
&event,
&manifest_path,
manifest_after_build.as_deref(),
&ui_out_dirs,
)
{
continue;
}
let is_ui_change = ui_src_dir.as_ref().is_some_and(|ui| {
event.paths.iter().all(|p| p.starts_with(ui))
});
last_event = Instant::now();
if pending {
if !is_ui_change { pending_ui_only = false; }
} else {
pending = true;
pending_ui_only = is_ui_change;
}
tui::update_status(
log_tx,
LogSource::App,
ServiceStatus::Changed,
event.paths.first().and_then(|p| p.file_name()).map(|n| n.to_string_lossy().into_owned()),
);
}
Ok(Err(e)) => tui::sys_log(log_tx, format!("watch error: {}", e)),
Err(mpsc::RecvTimeoutError::Timeout) => {
if pending && last_event.elapsed() >= WATCH_DEBOUNCE {
let ui_only = pending_ui_only && manifest.has_ui;
pending = false;
pending_ui_only = false;
if manifest.ui_only {
tui::sys_log(log_tx, "→ change detected, rebuilding the UI…");
match rebuild_ui_only(project_path, manifest, handles, ui_only, log_tx, reload_count) {
Ok(()) => reload_count += 1,
Err(e) => tui::sys_log(log_tx, format!("✗ UI-only rebuild failed: {:#}", e)),
}
} else if ui_only {
tui::sys_log(log_tx, "→ UI change detected, rebuilding frontend…");
let started = Instant::now();
match build_ui_only(project_path, manifest, log_tx) {
Ok(ui_dir) => {
for handle in handles {
match sideload_ui_to_handle(project_path, manifest, &ui_dir, handle, log_tx, reload_count, dev_config, started) {
Ok(()) => {}
Err(e) => tui::sys_log(log_tx, format!("✗ UI sideload failed: {:#}", e)),
}
}
reload_count += 1;
}
Err(e) => tui::sys_log(log_tx, format!("✗ UI build failed: {:#}", e)),
}
} else {
tui::sys_log(log_tx, "→ change detected, rebuilding…");
match build_app(project_path, manifest, app_kind, log_tx) {
Ok(()) => {
for handle in handles {
match sideload_to_handle(project_path, manifest, app_kind, handle, log_tx, reload_count, dev_config) {
Ok(()) => {}
Err(e) => tui::sys_log(log_tx, format!("✗ sideload failed: {:#}", e)),
}
}
reload_count += 1;
}
Err(e) => tui::sys_log(log_tx, format!("✗ cycle failed: {:#}", e)),
}
}
manifest_after_build = std::fs::read(&manifest_path).ok();
}
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
bail!("watcher channel closed unexpectedly");
}
}
}
}
fn is_build_echo(
event: ¬ify::Event,
manifest_path: &Path,
manifest_after_build: Option<&[u8]>,
ui_out_dirs: &[PathBuf],
) -> bool {
event.paths.iter().all(|path| {
let unchanged_manifest = path == manifest_path
&& manifest_after_build
.is_some_and(|built| std::fs::read(path).is_ok_and(|now| now == built));
unchanged_manifest || ui_out_dirs.iter().any(|out| path.starts_with(out))
})
}
fn is_rebuild_trigger(event: ¬ify::Event) -> bool {
use notify::EventKind;
matches!(
event.kind,
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
)
}
#[derive(Debug, Deserialize)]
pub(crate) struct Manifest {
pub(crate) name: String,
pub(crate) version: String,
#[serde(default)]
pub(crate) app_type: String,
#[serde(skip)]
pub(crate) ui_only: bool,
#[serde(default)]
entrypoint: Option<String>,
#[serde(default)]
has_ui: bool,
#[serde(default)]
ui_path: Option<String>,
#[serde(default)]
tcp: Option<TcpManifestSection>,
#[serde(default)]
provides: serde_json::Map<String, Value>,
#[serde(default)]
capabilities: ManifestCapabilitiesSection,
}
#[derive(Debug, Deserialize, Default)]
struct ManifestCapabilitiesSection {
#[serde(default)]
provides: Vec<String>,
}
impl Manifest {
pub(crate) fn declared_provides(&self) -> Vec<String> {
let mut names: Vec<String> = self
.provides
.keys()
.cloned()
.chain(self.capabilities.provides.iter().cloned())
.collect();
names.sort();
names.dedup();
names
}
}
#[derive(Debug, Deserialize, Default)]
struct TcpManifestSection {
#[serde(default)]
preferred_port: Option<u16>,
#[allow(dead_code)] #[serde(default)]
direct_bind: Option<bool>,
}
pub(crate) fn load_manifest(project_path: &Path) -> Result<Manifest> {
let path = project_path.join("manifest.json");
let raw = load_manifest_json(project_path)?;
let ui_only = node_app_manifest::declares_ui_only(&raw);
let mut manifest: Manifest =
serde_json::from_value(raw).with_context(|| format!("parse {}", path.display()))?;
if manifest.name.is_empty() {
bail!("manifest.name is empty");
}
if manifest.version.is_empty() {
bail!("manifest.version is empty");
}
if ui_only {
manifest.ui_only = true;
manifest.app_type = "ui-only".to_string();
manifest.has_ui = true;
} else if manifest.app_type.is_empty() {
bail!(
"{} has no app_type; only a UI-only app (a ui block and no entrypoint) may omit it",
path.display()
);
}
Ok(manifest)
}
fn load_manifest_json(project_path: &Path) -> Result<Value> {
let path = project_path.join("manifest.json");
let content = std::fs::read_to_string(&path)
.with_context(|| format!("read {}", path.display()))?;
serde_json::from_str(&content).with_context(|| format!("parse {}", path.display()))
}
fn temp_app_dir(dest: &Path, label: &str) -> PathBuf {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let file_name = dest
.file_name()
.map(|value| value.to_string_lossy().into_owned())
.unwrap_or_else(|| "node-app".to_string());
dest.parent()
.unwrap_or_else(|| Path::new("."))
.join(format!(".{file_name}.{label}.{nonce}.tmp"))
}
fn replace_dir_atomically(staged: &Path, dest: &Path) -> Result<()> {
replace_dir_atomically_with(staged, dest, |from, to| std::fs::rename(from, to))
}
fn replace_dir_atomically_with<F>(staged: &Path, dest: &Path, mut rename: F) -> Result<()>
where
F: FnMut(&Path, &Path) -> std::io::Result<()>,
{
let backup = temp_app_dir(dest, "backup");
if dest.exists() {
rename(dest, &backup)
.with_context(|| format!("rename {} → {}", dest.display(), backup.display()))?;
}
match rename(staged, dest) {
Ok(()) => {
if backup.exists() {
std::fs::remove_dir_all(&backup).ok();
}
Ok(())
}
Err(error) => {
if backup.exists() {
let _ = std::fs::rename(&backup, dest);
}
Err(error).with_context(|| format!("rename {} → {}", staged.display(), dest.display()))
}
}
}
#[derive(Debug, Clone, Copy)]
enum StandaloneRuntime {
Rust,
Bun,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CdylibCrateLayout {
Root,
RustSubdir,
}
impl CdylibCrateLayout {
const CANDIDATES: [(&'static str, CdylibCrateLayout); 2] = [
("Cargo.toml", CdylibCrateLayout::Root),
("rust/Cargo.toml", CdylibCrateLayout::RustSubdir),
];
fn detect(project_path: &Path) -> Result<Self> {
for (rel, layout) in Self::CANDIDATES {
if project_path.join(rel).is_file() {
return Ok(layout);
}
}
let searched = Self::CANDIDATES
.iter()
.map(|(rel, _)| project_path.join(rel).display().to_string())
.collect::<Vec<_>>()
.join(", ");
bail!(
"native app at {} has no Cargo manifest — looked for {searched}. \
A native (cdylib) dep must keep its crate either at the repo root or under rust/ \
(the layout node-app-observability uses); there is no monorepo workspace to fall back on \
when the dep is resolved from a sibling checkout or the sources.lock cache.",
project_path.display()
)
}
fn cargo_build_args(self) -> &'static [&'static str] {
match self {
CdylibCrateLayout::Root => &["build", "--release", "--lib"],
CdylibCrateLayout::RustSubdir => &[
"build",
"--release",
"--lib",
"--manifest-path",
"rust/Cargo.toml",
"--target-dir",
"target",
],
}
}
fn build_label(self) -> &'static str {
match self {
CdylibCrateLayout::Root => "cargo build --release",
CdylibCrateLayout::RustSubdir => {
"cargo build --release --manifest-path rust/Cargo.toml --target-dir target"
}
}
}
fn release_dirs(self, project_path: &Path) -> Vec<PathBuf> {
let mut dirs = vec![project_path.join("target").join("release")];
if self == CdylibCrateLayout::RustSubdir {
dirs.push(project_path.join("rust").join("target").join("release"));
}
dirs
}
}
enum AppKindDetected {
Cdylib(CdylibCrateLayout),
Bun,
Burger,
Standalone(StandaloneRuntime),
UiOnly,
}
impl AppKindDetected {
fn detect(project_path: &Path, manifest: &Manifest) -> Result<Self> {
if manifest.ui_only {
return Ok(AppKindDetected::UiOnly);
}
match manifest.app_type.to_ascii_lowercase().as_str() {
"native" | "cdylib" => Ok(AppKindDetected::Cdylib(CdylibCrateLayout::detect(
project_path,
)?)),
"bun" => Ok(AppKindDetected::Bun),
"burger" => Ok(AppKindDetected::Burger),
"standalone" => {
if project_path.join("Cargo.toml").exists() {
Ok(AppKindDetected::Standalone(StandaloneRuntime::Rust))
} else {
Ok(AppKindDetected::Standalone(StandaloneRuntime::Bun))
}
}
other => bail!(
"unsupported manifest.app_type '{}' (expected 'native' | 'cdylib' | 'bun' | 'burger' | 'standalone' | 'ui-only'); \
project={}",
other,
project_path.display()
),
}
}
fn label(&self) -> &'static str {
match self {
AppKindDetected::Cdylib(CdylibCrateLayout::Root) => "cdylib (Rust)",
AppKindDetected::Cdylib(CdylibCrateLayout::RustSubdir) => "cdylib (Rust, crate in rust/)",
AppKindDetected::Bun => "Bun",
AppKindDetected::Burger => "Burger",
AppKindDetected::Standalone(StandaloneRuntime::Rust) => "standalone (Rust)",
AppKindDetected::Standalone(StandaloneRuntime::Bun) => "standalone (Bun)",
AppKindDetected::UiOnly => "UI-only",
}
}
fn build_label(&self) -> &'static str {
match self {
AppKindDetected::Cdylib(layout) => layout.build_label(),
AppKindDetected::Bun => "bun build src/index.{ts,js} --outdir dist --target=bun",
AppKindDetected::Burger => "burger-build build src/index.ts --outdir dist",
AppKindDetected::Standalone(StandaloneRuntime::Rust) => "cargo build",
AppKindDetected::Standalone(StandaloneRuntime::Bun) => "bun install",
AppKindDetected::UiOnly => "ui build only (bun run build in the UI project)",
}
}
fn build_with_manifest(
&self,
project_path: &Path,
manifest: Option<&Manifest>,
log_tx: Option<&LogTx>,
) -> Result<()> {
match self {
AppKindDetected::UiOnly => build_ui_bundle(project_path, manifest, log_tx),
AppKindDetected::Standalone(StandaloneRuntime::Rust) => {
build_ui_bundle(project_path, manifest, log_tx)?;
run_in_with_sink(
project_path,
"cargo",
&["build"],
"cargo build",
log_tx,
LogSource::Build,
)
}
AppKindDetected::Standalone(StandaloneRuntime::Bun) => {
build_ui_bundle(project_path, manifest, log_tx)?;
run_in_with_sink(
project_path,
"bun",
&["install"],
"bun install",
log_tx,
LogSource::Build,
)
}
AppKindDetected::Cdylib(layout) => {
build_ui_bundle(project_path, manifest, log_tx)?;
run_in_with_sink(
project_path,
"cargo",
layout.cargo_build_args(),
"cargo build",
log_tx,
LogSource::Build,
)
}
AppKindDetected::Burger => {
run_in_with_sink(
project_path,
"bun",
&["install"],
"bun install",
log_tx,
LogSource::Build,
)?;
build_ui_bundle(project_path, manifest, log_tx)?;
let args = crate::commands::build::burger_bundle_args(
&crate::commands::build::burger_build_cli(project_path)?,
);
let args: Vec<&str> = args.iter().map(String::as_str).collect();
run_in_with_sink(
project_path,
"bun",
&args,
"burger-build",
log_tx,
LogSource::Build,
)
}
AppKindDetected::Bun => {
let _ = run_in_with_sink(
project_path,
"bun",
&["install"],
"bun install",
log_tx,
LogSource::Build,
);
build_ui_bundle(project_path, manifest, log_tx)?;
let bun_args = bun_backend_build_args(project_path)?;
let bun_args: Vec<&str> = bun_args.iter().map(String::as_str).collect();
run_in_with_sink(
project_path,
"bun",
&bun_args,
"bun build",
log_tx,
LogSource::Build,
)
}
}
}
fn copy_artifacts(&self, project_path: &Path, manifest: &Manifest, dest: &Path) -> Result<()> {
let manifest_src = project_path.join("manifest.json");
let manifest_json = load_manifest_json(project_path)?;
match self {
AppKindDetected::Cdylib(layout) => {
let so = find_cdylib_artifact_in(&layout.release_dirs(project_path))?;
let dst = dest.join("app.so");
std::fs::copy(&so, &dst)
.with_context(|| format!("copy {} → {}", so.display(), dst.display()))?;
if manifest.has_ui {
stage_ui_bundle(project_path, dest, &manifest_json)?;
}
}
AppKindDetected::Bun => {
let dist_src = project_path.join("dist");
let dist_dst = dest.join("dist");
if dist_dst.exists() {
std::fs::remove_dir_all(&dist_dst).ok();
}
if dist_src.exists() {
copy_dir_recursive(&dist_src, &dist_dst).with_context(|| {
format!("copy {} → {}", dist_src.display(), dist_dst.display())
})?;
}
let nm_src = project_path.join("node_modules");
if nm_src.exists() {
let nm_dst = dest.join("node_modules");
if nm_dst.exists() {
std::fs::remove_dir_all(&nm_dst).ok();
}
copy_dir_recursive(&nm_src, &nm_dst).with_context(|| {
format!("copy {} → {}", nm_src.display(), nm_dst.display())
})?;
}
let is_unbundled = manifest
.entrypoint
.as_deref()
.map(|ep| ep.starts_with("src/"))
.unwrap_or(false);
let mut subdirs: Vec<&str> = vec!["assets", "migrations"];
if is_unbundled {
subdirs.push("src");
}
for sub in subdirs {
let sub_src = project_path.join(sub);
if sub_src.exists() {
let sub_dst = dest.join(sub);
if sub_dst.exists() {
std::fs::remove_dir_all(&sub_dst).ok();
}
copy_dir_recursive(&sub_src, &sub_dst).with_context(|| {
format!("copy {} → {}", sub_src.display(), sub_dst.display())
})?;
}
}
if manifest.has_ui {
stage_ui_bundle(project_path, dest, &manifest_json)?;
}
}
AppKindDetected::Burger => {
let dist_src = project_path.join("dist");
let dist_dst = dest.join("dist");
if dist_dst.exists() {
std::fs::remove_dir_all(&dist_dst).ok();
}
copy_dir_recursive(&dist_src, &dist_dst).with_context(|| {
format!("copy {} → {}", dist_src.display(), dist_dst.display())
})?;
if manifest.has_ui {
stage_ui_bundle(project_path, dest, &manifest_json)?;
}
}
AppKindDetected::Standalone(_) => {}
AppKindDetected::UiOnly => {
stage_ui_bundle(project_path, dest, &ui_staging_manifest(manifest, &manifest_json))?;
}
}
if !matches!(self, AppKindDetected::Standalone(_)) {
write_staged_manifest(&manifest_src, dest)?;
}
if matches!(self, AppKindDetected::UiOnly) {
validate_staged_ui_only(dest, &manifest.name)?;
}
Ok(())
}
}
fn find_cdylib_artifact_in(release_dirs: &[PathBuf]) -> Result<PathBuf> {
let mut best: Option<(PathBuf, std::time::SystemTime)> = None;
for dir in release_dirs {
if let Some(candidate) = find_cdylib_artifact(dir) {
match &best {
Some((_, prev)) if *prev >= candidate.1 => {}
_ => best = Some(candidate),
}
}
}
best.map(|(p, _)| p).ok_or_else(|| {
anyhow!(
"no lib*.so / lib*.dylib found — searched {}",
release_dirs
.iter()
.map(|d| d.display().to_string())
.collect::<Vec<_>>()
.join(", ")
)
})
}
fn find_cdylib_artifact(release_dir: &Path) -> Option<(PathBuf, std::time::SystemTime)> {
let mut best: Option<(PathBuf, std::time::SystemTime)> = None;
let entries = std::fs::read_dir(release_dir).ok()?;
for entry in entries.flatten() {
let path = entry.path();
let fname = match path.file_name().and_then(|s| s.to_str()) {
Some(n) => n.to_string(),
None => continue,
};
let is_lib =
fname.starts_with("lib") && (fname.ends_with(".so") || fname.ends_with(".dylib"));
if !is_lib {
continue;
}
let mtime = entry
.metadata()
.ok()
.and_then(|m| m.modified().ok())
.unwrap_or(std::time::UNIX_EPOCH);
match &best {
Some((_, prev)) if *prev >= mtime => {}
_ => best = Some((path, mtime)),
}
}
best
}
pub(crate) fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
std::fs::create_dir_all(dst).with_context(|| format!("mkdir -p {}", dst.display()))?;
for entry in
std::fs::read_dir(src).with_context(|| format!("read_dir {}", src.display()))?
{
let entry = entry?;
let from = entry.path();
let to = dst.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_dir_recursive(&from, &to)?;
} else {
std::fs::copy(&from, &to)
.with_context(|| format!("copy {} → {}", from.display(), to.display()))?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
use std::io;
use std::rc::Rc;
fn bun_project(files: &[&str]) -> tempfile::TempDir {
let project = tempfile::tempdir().unwrap();
std::fs::create_dir_all(project.path().join("src")).unwrap();
for f in files {
std::fs::write(project.path().join(f), "").unwrap();
}
project
}
#[test]
fn bun_build_takes_the_javascript_entry_when_there_is_no_typescript_one() {
let project = bun_project(&["src/index.js"]);
let args = bun_backend_build_args(project.path()).unwrap();
assert_eq!(&args[..5], ["build", "src/index.js", "--outdir", "dist", "--target=bun"]);
assert!(args.iter().any(|a| a == "--external"), "shared externals stay external");
}
#[test]
fn bun_build_keeps_the_typescript_entry_first() {
let project = bun_project(&["src/index.ts", "src/index.js"]);
let args = bun_backend_build_args(project.path()).unwrap();
assert_eq!(args[1], "src/index.ts");
}
#[test]
fn bun_build_without_any_entry_is_an_error_not_a_guess() {
let project = bun_project(&[]);
assert!(bun_backend_build_args(project.path()).is_err());
}
fn native_manifest() -> Manifest {
serde_json::from_value(serde_json::json!({
"name": "observability",
"version": "0.3.0",
"app_type": "native",
}))
.unwrap()
}
fn write_files(root: &Path, files: &[(&str, &str)]) {
for (path, contents) in files {
let full = root.join(path);
std::fs::create_dir_all(full.parent().unwrap()).unwrap();
std::fs::write(full, contents).unwrap();
}
}
fn sha256_hex(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
format!("{:x}", Sha256::digest(bytes))
}
fn widget_manifest() -> Value {
serde_json::from_str(crate::manifest::UI_ONLY_WIDGET_MANIFEST).unwrap()
}
fn ui_only_project(root: &Path, manifest: &Value) {
std::fs::create_dir_all(root).unwrap();
std::fs::write(root.join("manifest.json"), manifest.to_string()).unwrap();
write_files(root, &[("ui/dist/main.js", "export function mount() {}\n")]);
}
fn entries(dir: &Path) -> Vec<String> {
let mut names: Vec<String> = std::fs::read_dir(dir)
.map(|it| it.flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect())
.unwrap_or_default();
names.sort();
names
}
fn staged_integrity(staged: &Path) -> serde_json::Map<String, Value> {
let manifest: Value =
serde_json::from_str(&std::fs::read_to_string(staged.join("manifest.json")).unwrap())
.unwrap();
manifest["ui"]["integrity"].as_object().unwrap().clone()
}
#[test]
fn derived_and_explicit_ui_only_manifests_load_as_ui_only() {
let project = tempfile::tempdir().unwrap();
let path = project.path();
let mut explicit_widget = widget_manifest();
explicit_widget["app_type"] = serde_json::json!("ui-only");
let mut derived_without_has_ui = widget_manifest();
derived_without_has_ui.as_object_mut().unwrap().remove("has_ui");
let stage: Value = serde_json::from_str(crate::manifest::UI_ONLY_MANIFEST).unwrap();
let mut explicit_stage = stage.clone();
explicit_stage["app_type"] = serde_json::json!("ui-only");
for manifest in [widget_manifest(), explicit_widget, derived_without_has_ui, stage, explicit_stage] {
std::fs::write(path.join("manifest.json"), manifest.to_string()).unwrap();
let loaded = load_manifest(path).unwrap();
assert!(loaded.ui_only, "{manifest}");
assert_eq!(loaded.app_type, "ui-only");
assert!(loaded.has_ui, "the ui block implies has_ui");
let kind = AppKindDetected::detect(path, &loaded).unwrap();
assert!(matches!(kind, AppKindDetected::UiOnly));
assert_eq!(kind.label(), "UI-only");
}
}
#[test]
fn a_widget_with_an_explicit_backend_type_is_not_ui_only() {
let project = tempfile::tempdir().unwrap();
let mut bun_widget = widget_manifest();
bun_widget["app_type"] = serde_json::json!("bun");
bun_widget["entrypoint"] = serde_json::json!("dist/index.js");
std::fs::write(project.path().join("manifest.json"), bun_widget.to_string()).unwrap();
let loaded = load_manifest(project.path()).unwrap();
assert!(!loaded.ui_only);
assert!(matches!(
AppKindDetected::detect(project.path(), &loaded).unwrap(),
AppKindDetected::Bun
));
std::fs::write(
project.path().join("manifest.json"),
r#"{"name":"demo","version":"1.0.0","entrypoint":"dist/index.js"}"#,
)
.unwrap();
let err = load_manifest(project.path()).unwrap_err();
assert!(err.to_string().contains("no app_type"), "{err}");
}
#[test]
fn a_ui_only_widget_stages_its_bundle_and_a_stamped_manifest_only() {
let project = tempfile::tempdir().unwrap();
let mut manifest = widget_manifest();
manifest.as_object_mut().unwrap().remove("has_ui");
ui_only_project(project.path(), &manifest);
write_files(
project.path(),
&[
("src/index.ts", "export {};\n"),
("dist/index.js", "export {};\n"),
("node_modules/x/package.json", "{}"),
("migrations/0001.sql", "select 1;"),
],
);
let dev_dir = tempfile::tempdir().unwrap();
build_and_stage(project.path(), dev_dir.path(), None).unwrap();
assert_eq!(entries(dev_dir.path()), vec!["agent-chat"], "no temp dir left behind");
let staged = dev_dir.path().join("agent-chat");
assert_eq!(entries(&staged), vec!["manifest.json", "ui"]);
assert_eq!(entries(&staged.join("ui/dist")), vec!["main.js"]);
let integrity = staged_integrity(&staged);
assert_eq!(integrity.len(), 1, "the fixture's placeholder digest is replaced");
assert_eq!(
integrity["ui/dist/main.js"],
sha256_hex(b"export function mount() {}\n")
);
}
#[test]
fn a_ui_only_stage_stamps_its_icon_and_restages_in_place() {
let project = tempfile::tempdir().unwrap();
let mut manifest: Value = serde_json::from_str(crate::manifest::UI_ONLY_MANIFEST).unwrap();
manifest["app_type"] = serde_json::json!("ui-only");
ui_only_project(project.path(), &manifest);
write_files(project.path(), &[("ui/dist/icon.svg", "<svg/>")]);
let dev_dir = tempfile::tempdir().unwrap();
build_and_stage(project.path(), dev_dir.path(), None).unwrap();
let staged = dev_dir.path().join("burger-runtime");
let integrity = staged_integrity(&staged);
assert_eq!(integrity["ui/dist/icon.svg"], sha256_hex(b"<svg/>"));
write_files(project.path(), &[("ui/dist/main.js", "export function mount() { 2 }\n")]);
build_and_stage(project.path(), dev_dir.path(), None).unwrap();
assert_eq!(
staged_integrity(&staged)["ui/dist/main.js"],
sha256_hex(b"export function mount() { 2 }\n")
);
assert_eq!(entries(dev_dir.path()), vec!["burger-runtime"]);
}
#[test]
fn a_ui_only_app_nested_in_another_apps_repo_stages_under_its_own_name() {
let repo = tempfile::tempdir().unwrap();
write_files(
repo.path(),
&[(
"manifest.json",
r#"{"name":"agent","version":"1.0.0","app_type":"bun","entrypoint":"dist/index.js"}"#,
)],
);
let widget = repo.path().join("chat-widget");
ui_only_project(&widget, &widget_manifest());
let dev_dir = tempfile::tempdir().unwrap();
build_and_stage(&widget, dev_dir.path(), None).unwrap();
assert_eq!(entries(dev_dir.path()), vec!["agent-chat"]);
}
#[test]
fn a_ui_only_app_that_declares_a_process_is_refused_and_the_staged_copy_kept() {
let project = tempfile::tempdir().unwrap();
ui_only_project(project.path(), &widget_manifest());
let dev_dir = tempfile::tempdir().unwrap();
build_and_stage(project.path(), dev_dir.path(), None).unwrap();
let staged_manifest = dev_dir.path().join("agent-chat/manifest.json");
let good = std::fs::read(&staged_manifest).unwrap();
let mut with_process = widget_manifest();
with_process["app_type"] = serde_json::json!("ui-only");
with_process["entrypoint"] = serde_json::json!("dist/index.js");
std::fs::write(project.path().join("manifest.json"), with_process.to_string()).unwrap();
let err = build_and_stage(project.path(), dev_dir.path(), None).unwrap_err();
let message = format!("{err:#}");
assert!(message.contains("UI-only app 'agent-chat' cannot be loaded"), "{message}");
assert!(message.contains("entrypoint"), "{message}");
assert_eq!(std::fs::read(&staged_manifest).unwrap(), good);
assert_eq!(entries(dev_dir.path()), vec!["agent-chat"], "no temp dir left behind");
}
#[test]
fn a_ui_only_app_without_a_bundle_says_where_it_looked() {
let project = tempfile::tempdir().unwrap();
std::fs::write(
project.path().join("manifest.json"),
crate::manifest::UI_ONLY_WIDGET_MANIFEST,
)
.unwrap();
let dev_dir = tempfile::tempdir().unwrap();
let err = build_and_stage(project.path(), dev_dir.path(), None).unwrap_err();
assert!(format!("{err:#}").contains("UI build produced no bundle"), "{err:#}");
assert!(entries(dev_dir.path()).is_empty());
}
fn handle_without_daemon(lane: &Path, apps_dir: Option<PathBuf>) -> DaemonHandle {
DaemonHandle {
name: String::new(),
socket_path: lane.join("absent.sock"),
dev_dir: lane.join("dev-apps"),
banner: String::new(),
builtin_apps_dir: apps_dir,
api_base_url: None,
}
}
#[test]
fn a_ui_only_app_is_staged_and_linked_without_any_ipc() {
let project = tempfile::tempdir().unwrap();
ui_only_project(project.path(), &widget_manifest());
let manifest = load_manifest(project.path()).unwrap();
let lane = tempfile::tempdir().unwrap();
let modules = lane.path().join("modules");
std::fs::create_dir_all(&modules).unwrap();
let handle = handle_without_daemon(lane.path(), Some(modules.clone()));
publish_ui_only(project.path(), &manifest, std::slice::from_ref(&handle), &[], None, 0, Instant::now())
.unwrap();
rebuild_ui_only(project.path(), &manifest, std::slice::from_ref(&handle), true, None, 1).unwrap();
let staged = handle.dev_dir.join("agent-chat");
assert!(staged.join("ui/dist/main.js").is_file());
assert_eq!(
modules.join("agent-chat").canonicalize().unwrap(),
staged.canonicalize().unwrap(),
"the host reads the staged copy through its apps directory"
);
}
#[test]
fn the_watcher_refuses_a_manifest_the_validator_refuses_and_keeps_the_staged_copy() {
let project = tempfile::tempdir().unwrap();
ui_only_project(project.path(), &widget_manifest());
let manifest = load_manifest(project.path()).unwrap();
let lane = tempfile::tempdir().unwrap();
let handle = handle_without_daemon(lane.path(), None);
rebuild_ui_only(project.path(), &manifest, std::slice::from_ref(&handle), true, None, 0).unwrap();
let staged_manifest = handle.dev_dir.join("agent-chat/manifest.json");
let good = std::fs::read(&staged_manifest).unwrap();
let mut with_process = widget_manifest();
with_process["app_type"] = serde_json::json!("ui-only");
with_process["entrypoint"] = serde_json::json!("dist/index.js");
std::fs::write(project.path().join("manifest.json"), with_process.to_string()).unwrap();
let err = rebuild_ui_only(project.path(), &manifest, std::slice::from_ref(&handle), true, None, 1)
.unwrap_err();
assert!(format!("{err:#}").contains("cannot be loaded"), "{err:#}");
assert_eq!(std::fs::read(&staged_manifest).unwrap(), good);
}
#[test]
fn a_ui_only_dep_staged_after_boot_is_linked_not_sideloaded() {
let dep = tempfile::tempdir().unwrap();
ui_only_project(dep.path(), &widget_manifest());
let lane = tempfile::tempdir().unwrap();
let modules = lane.path().join("modules");
std::fs::create_dir_all(&modules).unwrap();
let handle = handle_without_daemon(lane.path(), Some(modules.clone()));
stage_dep_after_boot(dep.path(), &handle, None).unwrap();
assert!(modules.join("agent-chat/ui/dist/main.js").is_file());
}
struct BootProbe {
lane: PathBuf,
seen_at_boot: std::sync::Arc<Mutex<Vec<String>>>,
}
impl host::DaemonHost for BootProbe {
fn ensure_running(&self) -> Result<DaemonHandle> {
let modules = self.lane.join("modules");
*self.seen_at_boot.lock().unwrap() = entries(&modules)
.into_iter()
.filter(|name| modules.join(name).join("ui/dist/main.js").is_file())
.collect();
let handle = handle_without_daemon(&self.lane, Some(modules));
std::fs::write(&handle.socket_path, "")?;
Ok(handle)
}
fn pre_start_dev_dir(&self) -> Option<PathBuf> {
Some(self.lane.join("dev-apps"))
}
fn apps_dir(&self) -> Option<PathBuf> {
Some(self.lane.join("modules"))
}
}
#[test]
fn a_ui_only_app_and_ui_only_deps_are_where_the_host_reads_apps_before_it_boots() {
let project = tempfile::tempdir().unwrap();
let stage: Value = serde_json::from_str(crate::manifest::UI_ONLY_MANIFEST).unwrap();
ui_only_project(project.path(), &stage);
write_files(project.path(), &[("ui/dist/icon.svg", "<svg/>")]);
let manifest = load_manifest(project.path()).unwrap();
let kind = AppKindDetected::detect(project.path(), &manifest).unwrap();
let dep = tempfile::tempdir().unwrap();
ui_only_project(dep.path(), &widget_manifest());
let lane = tempfile::tempdir().unwrap();
std::fs::create_dir_all(lane.path().join("modules")).unwrap();
let seen_at_boot = std::sync::Arc::new(Mutex::new(Vec::new()));
let hosts: Vec<Box<dyn host::DaemonHost>> = vec![Box::new(BootProbe {
lane: lane.path().to_path_buf(),
seen_at_boot: seen_at_boot.clone(),
})];
let (handles, prestaged) =
stage_and_boot(project.path(), &manifest, &kind, &[dep.path().to_path_buf()], &hosts, None)
.unwrap();
assert_eq!(*seen_at_boot.lock().unwrap(), vec!["agent-chat", "burger-runtime"]);
assert_eq!(prestaged, vec![true]);
let line = apps_dir::describe(
"burger-runtime",
&handles[0].dev_dir.join("burger-runtime"),
Some(&apps_dir::link_staged_copy(
&lane.path().join("modules"),
"burger-runtime",
&[handles[0].dev_dir.join("burger-runtime")],
project.path(),
)),
&handles[0].socket_path,
);
assert!(line.contains("the host serves it"), "{line}");
}
#[test]
fn the_watcher_ignores_writes_the_ui_build_makes() {
let project = tempfile::tempdir().unwrap();
let manifest_path = project.path().join("manifest.json");
std::fs::write(&manifest_path, "{\"stamped\":1}").unwrap();
let after_build = std::fs::read(&manifest_path).unwrap();
let out = vec![project.path().join("ui/dist")];
let event = |path: PathBuf| {
notify::Event::new(notify::EventKind::Modify(notify::event::ModifyKind::Any)).add_path(path)
};
assert!(is_build_echo(&event(manifest_path.clone()), &manifest_path, Some(&after_build), &out));
assert!(is_build_echo(&event(project.path().join("ui/dist/main.js")), &manifest_path, Some(&after_build), &out));
std::fs::write(&manifest_path, "{\"stamped\":2}").unwrap();
assert!(!is_build_echo(&event(manifest_path.clone()), &manifest_path, Some(&after_build), &out));
assert!(!is_build_echo(&event(project.path().join("ui/src/main.js")), &manifest_path, Some(&after_build), &out));
}
#[test]
fn burger_apps_stage_only_the_bundle_and_manifest() {
let project = tempfile::tempdir().unwrap();
let manifest_json = serde_json::json!({
"name": "hello-burger",
"version": "0.1.0",
"app_type": "burger",
"entrypoint": "dist/index.js",
});
std::fs::write(
project.path().join("manifest.json"),
manifest_json.to_string(),
)
.unwrap();
for (path, contents) in [
("dist/index.js", "export {};\n"),
("node_modules/@econ-v1/app-sdk/package.json", "{}"),
("migrations/0001_init.sql", "CREATE TABLE t (a);"),
] {
let full = project.path().join(path);
std::fs::create_dir_all(full.parent().unwrap()).unwrap();
std::fs::write(full, contents).unwrap();
}
let manifest: Manifest = serde_json::from_value(manifest_json).unwrap();
let kind = AppKindDetected::detect(project.path(), &manifest).unwrap();
assert!(matches!(kind, AppKindDetected::Burger));
assert_eq!(kind.label(), "Burger");
assert_eq!(
kind.build_label(),
"burger-build build src/index.ts --outdir dist"
);
let dest = tempfile::tempdir().unwrap();
kind.copy_artifacts(project.path(), &manifest, dest.path())
.unwrap();
assert_eq!(
std::fs::read_to_string(dest.path().join("dist/index.js")).unwrap(),
"export {};\n"
);
assert!(dest.path().join("manifest.json").is_file());
assert!(!dest.path().join("node_modules").exists());
assert!(!dest.path().join("migrations").exists());
}
#[test]
fn cdylib_layout_prefers_a_root_cargo_manifest() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(temp.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap();
std::fs::create_dir_all(temp.path().join("rust")).unwrap();
std::fs::write(temp.path().join("rust/Cargo.toml"), "[package]\nname = \"y\"\n").unwrap();
assert_eq!(CdylibCrateLayout::detect(temp.path()).unwrap(), CdylibCrateLayout::Root);
assert_eq!(
CdylibCrateLayout::Root.cargo_build_args(),
&["build", "--release", "--lib"]
);
assert_eq!(
CdylibCrateLayout::Root.release_dirs(temp.path()),
vec![temp.path().join("target/release")]
);
}
#[test]
fn cdylib_layout_detects_a_crate_under_rust_subdir() {
let temp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(temp.path().join("rust")).unwrap();
std::fs::write(temp.path().join("rust/Cargo.toml"), "[package]\nname = \"y\"\n").unwrap();
let layout = CdylibCrateLayout::detect(temp.path()).unwrap();
assert_eq!(layout, CdylibCrateLayout::RustSubdir);
assert_eq!(
layout.cargo_build_args(),
&[
"build",
"--release",
"--lib",
"--manifest-path",
"rust/Cargo.toml",
"--target-dir",
"target",
]
);
assert_eq!(
layout.release_dirs(temp.path()),
vec![
temp.path().join("target/release"),
temp.path().join("rust/target/release"),
]
);
assert!(matches!(
AppKindDetected::detect(temp.path(), &native_manifest()).unwrap(),
AppKindDetected::Cdylib(CdylibCrateLayout::RustSubdir)
));
}
#[test]
fn cdylib_layout_without_any_cargo_manifest_names_every_searched_path() {
let temp = tempfile::tempdir().unwrap();
let err = AppKindDetected::detect(temp.path(), &native_manifest())
.err()
.map(|e| format!("{e:#}"))
.expect("no manifest must be an error, not a raw cargo failure");
assert!(err.contains(&temp.path().join("Cargo.toml").display().to_string()), "{err}");
assert!(err.contains(&temp.path().join("rust/Cargo.toml").display().to_string()), "{err}");
assert!(err.contains("no Cargo manifest"), "{err}");
}
#[test]
fn cdylib_locator_accepts_a_manual_rust_target_release_build() {
let temp = tempfile::tempdir().unwrap();
let nested = temp.path().join("rust/target/release");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(nested.join("libnode_app_observability.dylib"), b"x").unwrap();
let dirs = CdylibCrateLayout::RustSubdir.release_dirs(temp.path());
assert_eq!(
find_cdylib_artifact_in(&dirs).unwrap(),
nested.join("libnode_app_observability.dylib")
);
let empty = tempfile::tempdir().unwrap();
let err = find_cdylib_artifact_in(&CdylibCrateLayout::RustSubdir.release_dirs(empty.path()))
.err()
.map(|e| format!("{e:#}"))
.unwrap();
assert!(err.contains(&empty.path().join("target/release").display().to_string()), "{err}");
assert!(err.contains(&empty.path().join("rust/target/release").display().to_string()), "{err}");
}
#[test]
fn atomic_replace_swaps_dirs_and_removes_backup() {
let temp = tempfile::tempdir().unwrap();
let dest = temp.path().join("dest");
let staged = temp.path().join("staged");
std::fs::create_dir_all(&dest).unwrap();
std::fs::create_dir_all(&staged).unwrap();
std::fs::write(dest.join("state.txt"), "old").unwrap();
std::fs::write(staged.join("state.txt"), "new").unwrap();
replace_dir_atomically(&staged, &dest).unwrap();
assert_eq!(std::fs::read_to_string(dest.join("state.txt")).unwrap(), "new");
assert!(!staged.exists(), "staged directory should be moved into place");
let backups = std::fs::read_dir(temp.path())
.unwrap()
.filter_map(|entry| entry.ok())
.filter(|entry| entry.file_name().to_string_lossy().contains(".dest.backup."))
.count();
assert_eq!(backups, 0, "backup directory should be cleaned up");
}
#[test]
fn atomic_replace_restores_previous_dest_on_rename_failure() {
let temp = tempfile::tempdir().unwrap();
let dest = temp.path().join("dest");
let staged = temp.path().join("staged");
std::fs::create_dir_all(&dest).unwrap();
std::fs::create_dir_all(&staged).unwrap();
std::fs::write(dest.join("state.txt"), "old").unwrap();
std::fs::write(staged.join("state.txt"), "new").unwrap();
let calls = Rc::new(Cell::new(0usize));
let calls_for_closure = Rc::clone(&calls);
let error = replace_dir_atomically_with(&staged, &dest, move |from, to| {
let call = calls_for_closure.get();
calls_for_closure.set(call + 1);
if call == 1 {
return Err(io::Error::other("forced rename failure"));
}
std::fs::rename(from, to)
})
.unwrap_err();
assert!(!error.to_string().is_empty());
assert_eq!(std::fs::read_to_string(dest.join("state.txt")).unwrap(), "old");
assert_eq!(std::fs::read_to_string(staged.join("state.txt")).unwrap(), "new");
assert_eq!(calls.get(), 2);
}
fn op_mode_args(
client_node: bool,
operation_mode: bool,
instances: Vec<String>,
) -> DevArgs<'static> {
DevArgs {
project_path: Path::new("."),
daemon: None,
monorepo_path: None,
socket_override: None,
dev_dir_override: None,
once: false,
dep_paths: Vec::new(),
no_tui: true,
config: Vec::new(),
instances,
agent: false,
client_node,
operation_mode,
no_sideload_native_builtins: false,
}
}
#[test]
fn validate_operation_mode_args_rejects_operation_mode_without_client_node() {
let args = op_mode_args(false, true, vec![]);
let err = validate_operation_mode_args(&args).unwrap_err();
assert!(
err.to_string().contains("requires --client-node"),
"unexpected error: {err}"
);
}
#[test]
fn validate_operation_mode_args_rejects_multiple_instances() {
let args = op_mode_args(true, true, vec!["alice".into(), "bob".into()]);
let err = validate_operation_mode_args(&args).unwrap_err();
assert!(
err.to_string().contains("one direct-dev instance"),
"unexpected error: {err}"
);
}
#[test]
fn validate_operation_mode_args_accepts_default_single_instance() {
validate_operation_mode_args(&op_mode_args(true, true, vec![])).unwrap();
}
#[test]
fn validate_operation_mode_args_accepts_alice() {
validate_operation_mode_args(&op_mode_args(true, true, vec!["alice".into()])).unwrap();
}
#[test]
fn validate_operation_mode_args_accepts_bob() {
validate_operation_mode_args(&op_mode_args(true, true, vec!["bob".into()])).unwrap();
}
#[test]
fn validate_operation_mode_args_ignores_multi_instance_when_disabled() {
validate_operation_mode_args(&op_mode_args(
false,
false,
vec!["alice".into(), "bob".into()],
))
.unwrap();
}
}
const OUTPUT_TAIL_LINES: usize = 60;
pub(crate) fn push_tail(tail: &Mutex<VecDeque<String>>, line: &str) {
let mut t = tail.lock().unwrap();
if t.len() == OUTPUT_TAIL_LINES {
t.pop_front();
}
t.push_back(line.to_string());
}
pub(crate) fn format_tail(tail: &Mutex<VecDeque<String>>) -> String {
let t = tail.lock().unwrap();
if t.is_empty() {
return String::new();
}
let mut s = format!("; last {} line(s) of output:\n", t.len());
for line in t.iter() {
s.push_str(" ");
s.push_str(line);
s.push('\n');
}
s.pop(); s
}
fn build_ui_bundle(
project_path: &Path,
manifest: Option<&Manifest>,
log_tx: Option<&LogTx>,
) -> Result<()> {
let Some(m) = manifest else { return Ok(()) };
if !m.has_ui {
return Ok(());
}
let manifest_json = load_manifest_json(project_path)?;
let ui_dir = ui_bundle::project_dir(project_path, &manifest_json);
if !ui_dir.join("package.json").exists() {
return Ok(());
}
let pkg_mgr = if ui_dir.join("package-lock.json").exists() { "npm" } else { "bun" };
run_in_with_sink(
&ui_dir,
pkg_mgr,
&["install"],
&format!("{pkg_mgr} install (ui)"),
log_tx,
LogSource::Build,
)?;
run_in_with_sink(
&ui_dir,
pkg_mgr,
&["run", "build"],
&format!("{pkg_mgr} run build (ui)"),
log_tx,
LogSource::Build,
)
}
fn run_in_with_sink(
dest: &Path,
cmd: &str,
args: &[&str],
label: &str,
log_tx: Option<&LogTx>,
log_source: LogSource,
) -> Result<()> {
if let Some(tx) = log_tx {
let mut command = Command::new(cmd);
command
.current_dir(dest)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = command
.spawn()
.with_context(|| format!("invoke `{}`", label))?;
let stdout = child.stdout.take().unwrap();
let stderr = child.stderr.take().unwrap();
let tail = Arc::new(Mutex::new(VecDeque::<String>::new()));
let tx1 = tx.clone();
let tail1 = tail.clone();
let t1 = std::thread::spawn(move || {
for line in BufReader::new(stdout).lines().map_while(Result::ok) {
push_tail(&tail1, &line);
let _ = tx1.send(TuiEvent::Log(crate::tui::LogEntry {
source: log_source,
line,
}));
}
});
let tx2 = tx.clone();
let tail2 = tail.clone();
let t2 = std::thread::spawn(move || {
for line in BufReader::new(stderr).lines().map_while(Result::ok) {
push_tail(&tail2, &line);
let _ = tx2.send(TuiEvent::Log(crate::tui::LogEntry {
source: log_source,
line,
}));
}
});
let status = loop {
match child.try_wait().with_context(|| format!("`{}` try_wait", label))? {
Some(s) => break s,
None => {
if is_cancelled() {
let _ = child.kill();
let _ = child.wait();
t1.join().ok();
t2.join().ok();
bail!("build cancelled");
}
std::thread::sleep(Duration::from_millis(50));
}
}
};
t1.join().ok();
t2.join().ok();
if !status.success() {
bail!(
"`{}` exited {}{}",
label,
status.code().unwrap_or(-1),
format_tail(&tail)
);
}
} else {
let mut child = Command::new(cmd)
.current_dir(dest)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.spawn()
.with_context(|| format!("invoke `{}`", label))?;
let status = loop {
match child.try_wait().with_context(|| format!("`{}` try_wait", label))? {
Some(s) => break s,
None => {
if SHUTDOWN_REQUESTED.load(Ordering::SeqCst) {
let _ = child.kill();
let _ = child.wait();
bail!("build cancelled");
}
std::thread::sleep(Duration::from_millis(50));
}
}
};
if !status.success() {
bail!("`{}` exited {}", label, status.code().unwrap_or(-1));
}
}
Ok(())
}
#[derive(Serialize)]
struct IpcRequest {
jsonrpc: &'static str,
id: u64,
method: String,
params: Value,
}
#[derive(Deserialize, Debug)]
struct IpcResponse {
#[allow(dead_code)]
jsonrpc: String,
#[allow(dead_code)]
id: Option<Value>,
result: Option<Value>,
error: Option<IpcRpcError>,
}
#[derive(Deserialize, Debug)]
struct IpcRpcError {
code: i32,
message: String,
}
pub(crate) fn ipc_call(socket_path: &Path, method: &str, params: Value) -> Result<Value> {
let stream = UnixStream::connect(socket_path).with_context(|| {
format!(
"connect to daemon IPC socket at '{}'.\n\
Is the node daemon running? Is your user in the 'node' group? \
Override with --socket if you used a custom path.",
socket_path.display()
)
})?;
stream
.set_read_timeout(Some(IPC_TIMEOUT))
.context("set read timeout")?;
stream
.set_write_timeout(Some(IPC_TIMEOUT))
.context("set write timeout")?;
let req = IpcRequest {
jsonrpc: "2.0",
id: 1,
method: method.to_string(),
params,
};
let line = serde_json::to_string(&req).context("serialize request")?;
let mut stream = stream;
writeln!(stream, "{}", line).context("write request")?;
stream.flush().ok();
let reader = BufReader::new(stream);
let response_line = reader
.lines()
.next()
.ok_or_else(|| anyhow!("daemon closed connection without responding"))?
.context("read response")?;
let response: IpcResponse =
serde_json::from_str(&response_line).context("parse JSON-RPC response")?;
if let Some(err) = response.error {
bail!("daemon RPC error {}: {}", err.code, err.message);
}
response
.result
.ok_or_else(|| anyhow!("response missing both 'result' and 'error'"))
}
fn convert_health_status(
s: infra_cmd::health::InfraHealthStatus,
) -> tui::InfraHealthStatus {
match s {
infra_cmd::health::InfraHealthStatus::Healthy => tui::InfraHealthStatus::Healthy,
infra_cmd::health::InfraHealthStatus::Running => tui::InfraHealthStatus::Running,
infra_cmd::health::InfraHealthStatus::Degraded => tui::InfraHealthStatus::Degraded,
infra_cmd::health::InfraHealthStatus::Down => tui::InfraHealthStatus::Down,
infra_cmd::health::InfraHealthStatus::External => tui::InfraHealthStatus::External,
}
}
fn start_infra_threads(tx: LogTx, ctx: InfraContext) {
let tx_p = tx.clone();
let ctx_p = ctx.clone();
std::thread::spawn(move || {
loop {
let services: Vec<InfraServiceHealth> =
infra_cmd::health::check_all(&ctx_p)
.into_iter()
.map(|h| InfraServiceHealth {
name: h.name,
status: convert_health_status(h.status),
detail: h.detail,
})
.collect();
let _ = tx_p.send(TuiEvent::InfraHealth(services));
if let Ok(db) = infra_cmd::db::query_summary(&ctx_p) {
let raw_channels = infra_cmd::db::query_channel_list(&ctx_p, 200);
let channels: Vec<InfraChannelEntry> = raw_channels
.into_iter()
.map(|(scid, node1)| InfraChannelEntry { scid, node1 })
.collect();
let _ = tx_p.send(TuiEvent::InfraGraph(InfraGraphData {
node_count: db.node_announcements,
channel_count: db.channel_announcements,
channels,
}));
let _ = tx_p.send(TuiEvent::InfraDb(tui::state::InfraDbSummary {
node_announcements: db.node_announcements,
channel_announcements: db.channel_announcements,
channel_updates: db.channel_updates,
config_rows: db.config_rows,
}));
}
if let Ok(s) = infra_cmd::snapshot::fetch_snapshot_summary(&ctx_p, None) {
let _ = tx_p.send(TuiEvent::InfraSnapshot(tui::state::InfraSnapshotInfo {
version: s.version,
chain_hash: s.chain_hash,
timestamp: s.timestamp,
timestamp_str: s.timestamp_str,
node_count: s.node_count,
channel_count: s.channel_count,
update_count: s.update_count,
}));
}
std::thread::sleep(Duration::from_secs(10));
}
});
for (container, service) in [
(infra_cmd::ops::rgs_container_name(&ctx), InfraLogService::Rgs),
(
infra_cmd::ops::postgres_container_name(&ctx),
InfraLogService::Postgres,
),
] {
let tx_l = tx.clone();
std::thread::spawn(move || {
let Ok(mut child) = std::process::Command::new("docker")
.args(["logs", "-f", "--tail=100", &container])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
else {
return;
};
let tx2 = tx_l.clone();
let svc2 = service;
if let Some(stdout) = child.stdout.take() {
std::thread::spawn(move || {
use std::io::BufRead;
for line in BufReader::new(stdout).lines().map_while(Result::ok) {
let _ = tx2.send(TuiEvent::InfraLog(InfraLogLine {
line,
service: svc2,
}));
}
});
}
if let Some(stderr) = child.stderr.take() {
use std::io::BufRead;
for line in BufReader::new(stderr).lines().map_while(Result::ok) {
let _ = tx_l.send(TuiEvent::InfraLog(InfraLogLine {
line,
service,
}));
}
}
});
}
}
#[cfg(test)]
mod output_tail_tests {
use super::*;
#[test]
fn empty_tail_renders_nothing() {
let tail = Mutex::new(VecDeque::new());
assert_eq!(format_tail(&tail), "");
}
#[test]
fn tail_keeps_last_n_lines() {
let tail = Mutex::new(VecDeque::new());
for i in 0..OUTPUT_TAIL_LINES + 5 {
push_tail(&tail, &format!("line {i}"));
}
let t = tail.lock().unwrap();
assert_eq!(t.len(), OUTPUT_TAIL_LINES);
assert_eq!(t.front().unwrap(), "line 5");
assert_eq!(t.back().unwrap(), &format!("line {}", OUTPUT_TAIL_LINES + 4));
}
#[test]
fn failed_build_error_carries_output_tail() {
let (tx, rx) = std::sync::mpsc::sync_channel(64);
let err = run_in_with_sink(
Path::new("."),
"sh",
&["-c", "echo compiling; echo 'error[E0308]: boom' >&2; exit 3"],
"sh -c test",
Some(&tx),
LogSource::Build,
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("exited 3"), "got: {msg}");
assert!(msg.contains("error[E0308]: boom"), "got: {msg}");
drop(tx);
let lines: Vec<String> = rx
.iter()
.filter_map(|ev| match ev {
TuiEvent::Log(entry) => Some(entry.line),
_ => None,
})
.collect();
assert!(lines.iter().any(|l| l == "compiling"));
assert!(lines.iter().any(|l| l.contains("boom")));
}
#[test]
fn format_tail_prefixes_and_counts() {
let tail = Mutex::new(VecDeque::new());
push_tail(&tail, "error[E0308]: mismatched types");
push_tail(&tail, " --> src/lib.rs:1:1");
let s = format_tail(&tail);
assert!(s.starts_with("; last 2 line(s) of output:\n"));
assert!(s.contains(" error[E0308]: mismatched types\n"));
assert!(s.ends_with(" --> src/lib.rs:1:1"));
}
}