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::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 autodetect;
pub mod host;
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 fn run(args: DevArgs<'_>) -> Result<()> {
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,
)
})
.collect();
if !args.dep_paths.is_empty() {
if let Some(early_dev_dir) = hosts[0].pre_start_dev_dir() {
tui::sys_log(
log_tx_opt.as_ref(),
format!(
"→ staging {} dep(s) into dev-dir (pre-boot)…",
args.dep_paths.len()
),
);
for dep_path in &args.dep_paths {
build_and_stage(dep_path, &early_dev_dir, log_tx_opt.as_ref())?;
}
}
}
let mut handles: Vec<DaemonHandle> = Vec::with_capacity(hosts.len());
for host_impl in &hosts {
let handle = host_impl
.ensure_running()
.context("bring up daemon-host")?;
handles.push(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);
}
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() && hosts[0].pre_start_dev_dir().is_none() {
let handle = &handles[0];
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 {
build_and_stage(dep_path, &handle.dev_dir, log_tx_opt.as_ref())?;
let dep_manifest = load_manifest(dep_path)?;
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_opt.as_ref(),
format!("✓ dep '{}' sideloaded", dep_manifest.name),
);
}
}
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);
}
}
if args.agent {
agent::run_agent_setup(&handles, log_tx_opt.as_ref());
}
if args.once {
tui::sys_log(
log_tx_opt.as_ref(),
"✓ --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(),
format!(
"✓ Initial sideload complete. Watching {}/src/ for changes (Ctrl-C to stop)…",
project_path.display()
),
);
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 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 mut c = Command::new("bun");
c.args(["run", "src/index.ts"]);
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)?;
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 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 = handle.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()))?;
app_kind.copy_artifacts(project_path, manifest, &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();
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 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 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;
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)),
}
}
}
match rx.recv_timeout(WATCH_DEBOUNCE) {
Ok(Ok(event)) => {
if !is_rebuild_trigger(&event) {
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 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)),
}
}
}
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
bail!("watcher channel closed unexpectedly");
}
}
}
}
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,
version: String,
pub(crate) app_type: String,
#[serde(default)]
entrypoint: Option<String>,
#[serde(default)]
has_ui: bool,
#[serde(default)]
ui_path: Option<String>,
#[serde(default)]
tcp: Option<TcpManifestSection>,
}
#[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 content = std::fs::read_to_string(&path)
.with_context(|| format!("read {}", path.display()))?;
let manifest: Manifest =
serde_json::from_str(&content).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");
}
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,
}
enum AppKindDetected {
Cdylib,
Bun,
Standalone(StandaloneRuntime),
}
impl AppKindDetected {
fn detect(project_path: &Path, manifest: &Manifest) -> Result<Self> {
match manifest.app_type.to_ascii_lowercase().as_str() {
"native" | "cdylib" => Ok(AppKindDetected::Cdylib),
"bun" => Ok(AppKindDetected::Bun),
"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' | 'standalone'); \
project={}",
other,
project_path.display()
),
}
}
fn label(&self) -> &'static str {
match self {
AppKindDetected::Cdylib => "cdylib (Rust)",
AppKindDetected::Bun => "Bun",
AppKindDetected::Standalone(StandaloneRuntime::Rust) => "standalone (Rust)",
AppKindDetected::Standalone(StandaloneRuntime::Bun) => "standalone (Bun)",
}
}
fn build_label(&self) -> &'static str {
match self {
AppKindDetected::Cdylib => "cargo build --release",
AppKindDetected::Bun => "bun build src/index.ts --outdir dist --target=bun",
AppKindDetected::Standalone(StandaloneRuntime::Rust) => "cargo build",
AppKindDetected::Standalone(StandaloneRuntime::Bun) => "bun install",
}
}
fn build_with_manifest(
&self,
project_path: &Path,
manifest: Option<&Manifest>,
log_tx: Option<&LogTx>,
) -> Result<()> {
match self {
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 => {
build_ui_bundle(project_path, manifest, log_tx)?;
run_in_with_sink(
project_path,
"cargo",
&["build", "--release", "--lib"],
"cargo 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 mut bun_args: Vec<&str> =
vec!["build", "src/index.ts", "--outdir", "dist", "--target=bun"];
for ext in crate::blueprint::CURRENT.shared_externals {
bun_args.push("--external");
bun_args.push(*ext);
}
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 => {
let release_dir = project_path.join("target").join("release");
let so = find_cdylib_artifact(&release_dir).with_context(|| {
format!("locate cdylib artifact under {}", release_dir.display())
})?;
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);
if is_unbundled {
for sub in ["src", "assets", "migrations"] {
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::Standalone(_) => {}
}
if !matches!(self, AppKindDetected::Standalone(_)) {
write_staged_manifest(&manifest_src, dest)?;
}
Ok(())
}
}
fn find_cdylib_artifact(release_dir: &Path) -> Result<PathBuf> {
let mut best: Option<(PathBuf, std::time::SystemTime)> = None;
let entries = std::fs::read_dir(release_dir)
.with_context(|| format!("read_dir {}", release_dir.display()))?;
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.map(|(p, _)| p)
.ok_or_else(|| anyhow!("no lib*.so / lib*.dylib found in {}", release_dir.display()))
}
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;
#[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::new(io::ErrorKind::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);
}
}
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 ui_dir = m
.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() {
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"));
}
}