node-app-build 6.3.0

Mini app developer CLI: scaffold, validate, package node-app-* Debian packages
//! `node-app reload <name>` — rebuild ONE app by name and hot-sideload it
//! into the running `node-app dev` platform daemon(s), leaving the daemon
//! and every other app untouched.
//!
//! The name is resolved through the same chain platform-mode dev uses
//! (sources.lock pin → sibling checkout → cache → clone), so `reload lcd`
//! rebuilds whatever source `node-app deps` says `lcd` comes from. The app
//! is staged into each target instance's dev dir and loaded via the
//! daemon's `app.dev_load` IPC — the daemon unloads the old app (capability
//! providers unregistered), then loads the new artifact.
//!
//! Limits: `standalone` apps run as their own OS process owned by the dev
//! session's supervisor — they can't be swapped from outside; use the TUI or
//! `node-app restart`. `platform-runtime` deps have nothing to load at all.

use std::path::{Path, PathBuf};
use std::time::Instant;

use anyhow::{bail, Context, Result};

use super::dev::host::monorepo::monorepo_dev_dir;
use super::dev::sources_resolver;
use super::dev::{build_and_stage, copy_dir_recursive, ipc_call, load_manifest};

/// Only daemon-managed app types can be hot-swapped via `app.dev_load`.
fn ensure_reloadable(name: &str, app_type: &str) -> Result<()> {
    if app_type.eq_ignore_ascii_case("platform-runtime") {
        bail!("'{name}' is a platform-runtime packaging dep — nothing to reload");
    }
    if app_type.eq_ignore_ascii_case("standalone") {
        bail!(
            "'{name}' is a standalone app (own OS process, supervised by the dev session) — \
             it can't be hot-swapped from outside; use `node-app restart` or the dev TUI"
        );
    }
    Ok(())
}

pub fn run(project_path: &Path, names: &[String], instances: &[String]) -> Result<()> {
    let project_path = project_path
        .canonicalize()
        .with_context(|| format!("canonicalize {}", project_path.display()))?;
    if !project_path.join("infra/debian/platform-depends").is_file() {
        bail!(
            "{} is not the platform repo (no infra/debian/platform-depends) — run from the monorepo root or pass --path",
            project_path.display()
        );
    }
    if names.is_empty() {
        bail!("pass one or more app names (as in platform-depends, without the node-app- prefix)");
    }
    let instances: Vec<String> = if instances.is_empty() {
        vec!["alice".to_string()]
    } else {
        instances.to_vec()
    };

    // Locate every target instance's dev dir + IPC socket up front so a
    // stopped session fails fast, before any build work.
    let mut targets: Vec<(String, PathBuf, PathBuf)> = Vec::new(); // (instance, dev_dir, socket)
    for instance in &instances {
        let dev_dir = monorepo_dev_dir(&project_path, instance, None)?;
        let socket = dev_dir
            .parent()
            .map(|env| env.join("control.sock"))
            .ok_or_else(|| anyhow::anyhow!("dev dir {} has no parent", dev_dir.display()))?;
        if !socket.exists() {
            bail!(
                "no running dev session for instance '{instance}' ({} missing) — start `node-app dev` first",
                socket.display()
            );
        }
        targets.push((instance.clone(), dev_dir, socket));
    }

    // Dedupe names, preserving order.
    let mut seen = std::collections::HashSet::new();
    let names: Vec<String> = names
        .iter()
        .filter(|n| seen.insert((*n).clone()))
        .cloned()
        .collect();

    // Resolve each name through the standard chain (writes sources.lock).
    let sources = sources_resolver::resolve(&project_path, &names, &[], None)?;
    if sources.len() != names.len() {
        bail!("resolver returned {} source(s) for {} name(s)", sources.len(), names.len());
    }

    for (name, src) in names.iter().zip(&sources) {
        let manifest = load_manifest(src)
            .with_context(|| format!("read manifest for '{name}' at {}", src.display()))?;
        ensure_reloadable(name, &manifest.app_type)?;

        // Build ONCE into the first instance's dev dir, then mirror the
        // staged output byte-for-byte into the others (same strategy as
        // platform-mode pre-boot staging).
        let build_t0 = Instant::now();
        let (first_instance, first_dev_dir, _) = &targets[0];
        println!("→ building '{name}' from {}", src.display());
        build_and_stage(src, first_dev_dir, None)
            .with_context(|| format!("build dep '{name}' ({})", src.display()))?;
        let build_secs = build_t0.elapsed().as_secs_f32();

        let staged = first_dev_dir.join(&manifest.name);
        for (_, dev_dir, _) in &targets[1..] {
            let dst = dev_dir.join(&manifest.name);
            if dst.exists() {
                std::fs::remove_dir_all(&dst).ok();
            }
            copy_dir_recursive(&staged, &dst)
                .with_context(|| format!("mirror staged '{name}' into {}", dev_dir.display()))?;
        }

        // Hot-load into every instance.
        for (instance, dev_dir, socket) in &targets {
            let load_t0 = Instant::now();
            let dest = dev_dir.join(&manifest.name);
            ipc_call(
                socket,
                "app.dev_load",
                serde_json::json!({
                    "name": manifest.name,
                    "path": dest.to_string_lossy(),
                }),
            )
            .with_context(|| format!("app.dev_load '{name}' into '{instance}'"))?;
            let extra = if instance == first_instance {
                format!("build {build_secs:.1}s, ")
            } else {
                String::new()
            };
            println!(
                "✓ reloaded '{name}' into {instance} ({extra}dev_load {:.1}s)",
                load_t0.elapsed().as_secs_f32()
            );
        }
    }
    Ok(())
}

// ── Tests ────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn reload_rejects_standalone() {
        let err = ensure_reloadable("lcd", "standalone").unwrap_err();
        assert!(err.to_string().contains("standalone app"));
        assert!(err.to_string().contains("node-app restart"));
    }

    #[test]
    fn reload_rejects_platform_runtime() {
        let err = ensure_reloadable("bun-runtime", "platform-runtime").unwrap_err();
        assert!(err.to_string().contains("nothing to reload"));
    }

    #[test]
    fn reload_accepts_managed_types() {
        for t in ["bun", "native", "cdylib", "bun-fullstack"] {
            assert!(ensure_reloadable("x", t).is_ok(), "type {t} should be reloadable");
        }
    }
}