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::sync::{mpsc, Arc, OnceLock};
use std::time::{Duration, Instant};
use crate::tui::{self, DevSignals, 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 mod autodetect;
pub mod host;
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 fn run(args: DevArgs<'_>) -> Result<()> {
let project_path = args.project_path.canonicalize().with_context(|| {
format!("canonicalize project path '{}'", args.project_path.display())
})?;
let manifest = load_manifest(&project_path)?;
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 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 app_state = tui::state::AppState::new(
manifest.name.clone(),
manifest.version.clone(),
tui_instance_names,
);
let signals_clone = signals.clone();
let handle = std::thread::spawn(move || {
if let Err(e) = tui::run(rx, app_state, signals_clone) {
eprintln!("TUI error: {e}");
}
});
(Some(tx), Some(signals), Some(handle))
} else {
(None, None, None)
};
struct TuiGuard {
signals: Option<DevSignals>,
handle: Option<std::thread::JoinHandle<()>>,
}
impl Drop for TuiGuard {
fn drop(&mut self) {
if let Some(sigs) = &self.signals {
sigs.mark_shutdown_complete();
}
if let Some(h) = self.handle.take() {
let _ = h.join();
}
}
}
let _tui_guard = TuiGuard {
signals: signals_opt.clone(),
handle: tui_handle,
};
let mode = resolve_mode(args.daemon, args.monorepo_path, args.socket_override)?;
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(),
)
})
.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.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;
}
if let Some(sigs) = &signals_opt {
if sigs.should_quit() {
break;
}
}
let got_event = watcher_rx
.recv_timeout(Duration::from_millis(200))
.is_ok();
if got_event {
last_event = Instant::now();
} else if last_event.elapsed() > WATCH_DEBOUNCE && last_event.elapsed() < Duration::from_secs(1) {
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;
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_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
}
};
for (k, v) in config {
cmd.env(k, v);
}
cmd.spawn().with_context(|| {
format!(
"spawn standalone '{}' — ensure the binary is built",
manifest.name
)
})
}
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)?;
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);
std::fs::create_dir_all(&dest)
.with_context(|| format!("mkdir -p {}", dest.display()))?;
app_kind.copy_artifacts(project_path, manifest, &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)
}
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);
std::fs::create_dir_all(&dest)
.with_context(|| format!("mkdir -p {}", dest.display()))?;
let manifest_src = project_path.join("manifest.json");
std::fs::copy(&manifest_src, dest.join("manifest.json"))
.with_context(|| format!("copy manifest → {}", dest.display()))?;
let ui_dist_src = ui_dir.join("dist");
if ui_dist_src.exists() {
let ui_path_str = manifest.ui_path.as_deref().unwrap_or("dist");
let ui_dst = dest.join(ui_path_str);
if ui_dst.exists() { std::fs::remove_dir_all(&ui_dst).ok(); }
copy_dir_recursive(&ui_dist_src, &ui_dst)
.with_context(|| format!("copy ui dist → {}", ui_dst.display()))?;
}
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(())
}
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 sigs.take_build_now() {
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)]
struct Manifest {
name: String,
version: String,
app_type: String,
#[serde(default)]
has_ui: bool,
#[serde(default)]
ui_path: Option<String>,
}
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)
}
#[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) => run_in_with_sink(
project_path,
"cargo",
&["build"],
"cargo build",
log_tx,
LogSource::Build,
),
AppKindDetected::Standalone(StandaloneRuntime::Bun) => run_in_with_sink(
project_path,
"bun",
&["install"],
"bun install",
log_tx,
LogSource::Build,
),
AppKindDetected::Cdylib => {
if let Some(m) = manifest {
if m.has_ui {
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() {
let pkg_mgr =
if ui_dir.join("package-lock.json").exists() { "npm" } else { "bun" };
let _ = run_in_with_sink(
&ui_dir,
pkg_mgr,
&["install"],
&format!("{pkg_mgr} install (ui)"),
log_tx,
LogSource::Build,
);
let _ = run_in_with_sink(
&ui_dir,
pkg_mgr,
&["run", "build"],
&format!("{pkg_mgr} run build (ui)"),
log_tx,
LogSource::Build,
);
}
}
}
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,
);
if let Some(m) = manifest {
if m.has_ui {
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() {
let pkg_mgr =
if ui_dir.join("package-lock.json").exists() { "npm" } else { "bun" };
let _ = run_in_with_sink(
&ui_dir,
pkg_mgr,
&["install"],
&format!("{pkg_mgr} install (ui)"),
log_tx,
LogSource::Build,
);
let _ = run_in_with_sink(
&ui_dir,
pkg_mgr,
&["run", "build"],
&format!("{pkg_mgr} run build (ui)"),
log_tx,
LogSource::Build,
);
}
}
}
run_in_with_sink(
project_path,
"bun",
&["build", "src/index.ts", "--outdir", "dist", "--target=bun"],
"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_dst = dest.join("manifest.json");
std::fs::copy(&manifest_src, &manifest_dst).with_context(|| {
format!(
"copy {} → {}",
manifest_src.display(),
manifest_dst.display()
)
})?;
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 {
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_dist_src = ui_dir.join("dist");
if ui_dist_src.exists() {
let ui_path_str = manifest.ui_path.as_deref().unwrap_or("dist");
let ui_dst = dest.join(ui_path_str);
if ui_dst.exists() {
std::fs::remove_dir_all(&ui_dst).ok();
}
copy_dir_recursive(&ui_dist_src, &ui_dst).with_context(|| {
format!("copy ui dist → {}", ui_dst.display())
})?;
}
}
}
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();
}
copy_dir_recursive(&dist_src, &dist_dst).with_context(|| {
format!("copy {} → {}", dist_src.display(), dist_dst.display())
})?;
if manifest.has_ui {
let ui_path_str = manifest.ui_path.as_deref().unwrap_or("ui");
let ui_src = project_path.join(ui_path_str);
if ui_src.exists() {
let ui_dst = dest.join(ui_path_str);
if ui_dst.exists() {
std::fs::remove_dir_all(&ui_dst).ok();
}
copy_dir_recursive(&ui_src, &ui_dst).ok();
}
}
}
AppKindDetected::Standalone(_) => {}
}
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()))
}
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(())
}
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 tx1 = tx.clone();
let t1 = std::thread::spawn(move || {
for line in BufReader::new(stdout).lines().map_while(Result::ok) {
let _ = tx1.send(TuiEvent::Log(crate::tui::LogEntry {
source: log_source,
line,
}));
}
});
let tx2 = tx.clone();
let t2 = std::thread::spawn(move || {
for line in BufReader::new(stderr).lines().map_while(Result::ok) {
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));
}
} 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,
}
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'"))
}