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;
use std::time::{Duration, Instant};
use crate::tui::{self, DevSignals, LogSource, LogTx, ServiceStatus, TuiEvent};
static SHUTDOWN_REQUESTED: AtomicBool = AtomicBool::new(false);
#[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: 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 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 app_state = tui::state::AppState::new(
manifest.name.clone(),
manifest.version.clone(),
String::new(),
);
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, args.socket_override)?;
tui::sys_log(
log_tx_opt.as_ref(),
format!("→ daemon-host mode: {}", mode.label()),
);
let host_impl = host::for_mode(
mode,
args.socket_override,
args.dev_dir_override,
log_tx_opt.clone(),
);
if !args.dep_paths.is_empty() {
if let Some(early_dev_dir) = host_impl.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 handle = host_impl
.ensure_running()
.context("bring up daemon-host")?;
#[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!(
"→ {} app '{}' (v{})\n project = {}\n {}",
app_kind.label(),
manifest.name,
manifest.version,
project_path.display(),
handle.banner,
),
);
if !args.dep_paths.is_empty() && host_impl.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 {
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 result = cycle(
&project_path,
&manifest,
&app_kind,
&handle.dev_dir,
&handle.socket_path,
log_tx_opt.as_ref(),
0,
&dev_config,
);
if let Err(e) = result {
host_impl.shutdown();
return Err(e);
}
if args.once {
tui::sys_log(
log_tx_opt.as_ref(),
"✓ --once mode: built + sideloaded; exiting without watcher.",
);
host_impl.shutdown();
return Ok(());
}
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,
&handle.dev_dir,
&handle.socket_path,
log_tx_opt.as_ref(),
signals_opt.as_ref(),
host_impl.as_ref(),
&dev_config,
);
tui::sys_log(log_tx_opt.as_ref(), "→ shutting down…");
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(),
String::new(),
);
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: Option<&Path>,
socket_override: Option<&Path>,
) -> Result<Mode> {
if let Some(path) = monorepo {
return Ok(Mode::Monorepo {
path: path.to_path_buf(),
});
}
if let Some(d) = daemon {
return match d {
"deb" => Ok(Mode::Deb),
"docker" => Ok(Mode::Docker),
"clone" => Ok(Mode::Clone),
other if other.starts_with('/') || other.starts_with('~') => Ok(Mode::Remote {
socket_path: PathBuf::from(other),
}),
other => Err(anyhow!(
"unknown --daemon value '{}'.\n\
Allowed: 'deb' | 'docker' | 'clone' | <absolute-socket-path>\n\
Or use --monorepo PATH for a local checkout.",
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 cycle_ui(
project_path: &Path,
manifest: &Manifest,
dev_dir: &Path,
socket_path: &Path,
log_tx: Option<&LogTx>,
reload_count: usize,
dev_config: &std::collections::HashMap<String, String>,
) -> Result<()> {
let started = Instant::now();
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);
let dest = 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(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 cycle(
project_path: &Path,
manifest: &Manifest,
app_kind: &AppKindDetected,
dev_dir: &Path,
socket_path: &Path,
log_tx: Option<&LogTx>,
reload_count: usize,
dev_config: &std::collections::HashMap<String, String>,
) -> Result<()> {
let started = Instant::now();
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);
let dest = 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(socket_path, "app.dev_load", params)?;
let elapsed_ms = started.elapsed().as_millis();
tui::sys_log(
log_tx,
format!(
"✓ {} reloaded ({}ms) — daemon: {}",
manifest.name, elapsed_ms, result
),
);
tui::update_status(
log_tx,
LogSource::App,
ServiceStatus::Loaded {
reloads: reload_count + 1,
},
Some(format!("last reload: {elapsed_ms}ms")),
);
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn watch_loop(
project_path: &Path,
manifest: &Manifest,
app_kind: &AppKindDetected,
dev_dir: &Path,
socket_path: &Path,
log_tx: Option<&LogTx>,
signals: Option<&DevSignals>,
host_impl: &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…");
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 cycle(project_path, manifest, app_kind, dev_dir, socket_path, log_tx, reload_count, dev_config) {
Ok(()) => 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…");
match cycle_ui(project_path, manifest, dev_dir, socket_path, log_tx, reload_count, dev_config) {
Ok(()) => reload_count += 1,
Err(e) => tui::sys_log(log_tx, format!("✗ UI cycle failed: {:#}", e)),
}
} else {
tui::sys_log(log_tx, "→ change detected, rebuilding…");
match cycle(project_path, manifest, app_kind, dev_dir, socket_path, log_tx, reload_count, dev_config) {
Ok(()) => 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 => 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()))?;
}
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 = child.wait().with_context(|| format!("`{}` wait", label))?;
t1.join().ok();
t2.join().ok();
if !status.success() {
bail!("`{}` exited {}", label, status.code().unwrap_or(-1));
}
} else {
let status = Command::new(cmd)
.current_dir(dest)
.args(args)
.status()
.with_context(|| format!("invoke `{}`", label))?;
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'"))
}