node-app-build 6.5.0

Mini app developer CLI: scaffold, validate, package node-app-* Debian packages
//! `node-app build` — bundle a bun app's source into `dist/index.js`, or
//! cross-compile a native cdylib and stage it under `target/node-app/<arch>/`.
//!
//! Phase 3 of econ-v1/node#868. Replaces per-template `bun build src/index.ts
//! --target=bun --outdir=dist` invocations baked into each scaffold. Bun apps
//! build here; native (cdylib) apps now also supported via `build_native`.
//!
//! Steps (bun):
//!   1. Read `manifest.json`, confirm app_type=bun.
//!   2. Verify `package.json` exists.
//!   3. Run `bun install --silent`.
//!   4. Run `bun build <entry> --target=bun --outdir=dist
//!      --external <each blueprint.shared_externals>`.
//!   5. Verify `dist/index.js` exists; report size.
//!
//! Steps (native):
//!   1. Read `manifest.json`, confirm app_type=native.
//!   2. Resolve target arch (--arch flag or host arch).
//!   3. Run `cargo`/`cross build --release --target <triple>`.
//!   4. Stage the `.so` under `target/node-app/<arch>/<entrypoint>`.
//!
//! Entry resolution: `src/index.ts`, `src/index.js`, `index.ts`, `index.js`
//! — same priority as the legacy `infra/scripts/build-deb-bun-app.sh`.

use anyhow::{bail, Context, Result};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Instant;

use crate::blueprint::CURRENT;
use crate::commands::crossbuild::{self, Arch};
use crate::commands::ui_bundle;

fn read_manifest(path: &Path) -> Result<serde_json::Value> {
    let manifest_path = path.join("manifest.json");
    let raw = std::fs::read_to_string(&manifest_path)
        .with_context(|| format!("manifest.json not found at {}", manifest_path.display()))?;
    serde_json::from_str(&raw)
        .with_context(|| format!("manifest.json is not valid JSON: {}", manifest_path.display()))
}

fn manifest_str<'a>(m: &'a serde_json::Value, key: &str) -> Option<&'a str> {
    m.get(key).and_then(|v| v.as_str())
}

/// Resolve the build target arch: explicit flag wins; otherwise the host arch.
/// Rejects `all` (that is a bun-only deb arch, not a build target).
fn resolve_build_arch(arch: Option<String>) -> Result<Arch> {
    match arch.as_deref() {
        Some("all") => bail!("native/standalone builds need a concrete --arch (arm64 or amd64), not 'all'"),
        Some(s) => Arch::from_deb_arch(s),
        None => Arch::host().ok_or_else(|| {
            anyhow::anyhow!(
                "could not infer host arch ({}); pass --arch arm64|amd64",
                std::env::consts::ARCH
            )
        }),
    }
}

/// Read the Cargo `[lib] name` (falling back to the package name) and return it
/// with hyphens mapped to underscores — the form cargo uses in `lib<name>.so`.
fn crate_lib_name(project: &Path) -> Result<String> {
    #[derive(serde::Deserialize)]
    struct Cargo {
        package: Pkg,
        lib: Option<Lib>,
    }
    #[derive(serde::Deserialize)]
    struct Pkg {
        name: String,
    }
    #[derive(serde::Deserialize)]
    struct Lib {
        name: Option<String>,
    }
    let raw = std::fs::read_to_string(project.join("Cargo.toml"))
        .with_context(|| "Cargo.toml not found")?;
    let cargo: Cargo = toml::from_str(&raw).with_context(|| "Cargo.toml is not valid TOML")?;
    let name = cargo
        .lib
        .and_then(|l| l.name)
        .unwrap_or(cargo.package.name);
    Ok(name.replace('-', "_"))
}

fn run_command(prog: &str, args: &[String], cwd: &Path) -> Result<()> {
    let status = std::process::Command::new(prog)
        .args(args)
        .current_dir(cwd)
        .status()
        .with_context(|| format!("failed to spawn `{}` — is it on PATH?", prog))?;
    if !status.success() {
        bail!("`{}` exited with status {}", prog, status);
    }
    Ok(())
}

/// Ensure the `cross` tool and a running Docker daemon are available when the
/// requested arch differs from the host arch. Gives a clear install hint rather
/// than letting the subsequent `cross build` command produce an opaque error.
fn preflight_cross(arch: Arch) -> Result<()> {
    if crossbuild::needs_cross(arch) {
        let ok = std::process::Command::new("cross")
            .arg("--version")
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null())
            .status()
            .map(|s| s.success())
            .unwrap_or(false);
        if !ok {
            bail!(
                "cross-compiling to {} requires the `cross` tool and a running Docker daemon. \
                 Install with `cargo install cross` and start Docker, or build on a {} host.",
                arch.deb_arch(),
                arch.deb_arch()
            );
        }
    }
    Ok(())
}

/// Build a native cdylib for `arch` and stage the `.so` under the manifest's
/// `entrypoint` filename in `target/node-app/<arch>/`.
fn build_native(path: &Path, manifest: &serde_json::Value, arch: Arch) -> Result<()> {
    preflight_cross(arch)?;
    let entrypoint = manifest_str(manifest, "entrypoint")
        .ok_or_else(|| anyhow::anyhow!("manifest.entrypoint missing (expected e.g. lib<name>.so)"))?;
    let lib_name = crate_lib_name(path)?;
    let use_cross = crossbuild::needs_cross(arch);

    let (prog, args) = crossbuild::cargo_cdylib_command(arch, use_cross);
    println!("{} {}", prog, args.join(" "));
    run_command(&prog, &args, path)?;

    let built = crossbuild::cdylib_output_path(path, arch, &lib_name);
    if !built.exists() {
        bail!("expected cdylib not found at {}", built.display());
    }
    let dest_dir = crossbuild::staged_artifact_dir(path, arch);
    std::fs::create_dir_all(&dest_dir)
        .with_context(|| format!("mkdir -p {}", dest_dir.display()))?;
    let dest = dest_dir.join(entrypoint);
    std::fs::copy(&built, &dest)
        .with_context(|| format!("stage {} -> {}", built.display(), dest.display()))?;
    println!("✓ staged {} ({})", dest.display(), arch.deb_arch());
    Ok(())
}

/// Standalone apps with a `Cargo.toml` build as Rust; otherwise as Bun.
fn is_rust_standalone(path: &Path) -> bool {
    path.join("Cargo.toml").exists()
}

/// The systemd `ExecStart` binary name for a standalone app.
fn standalone_bin_name(name: &str) -> String {
    format!("node-app-{name}")
}

/// Bun entry resolution — same priority as the bun build path.
fn resolve_bun_entry(path: &Path) -> Result<PathBuf> {
    ["src/index.ts", "src/index.js", "index.ts", "index.js"]
        .iter()
        .map(|p| path.join(p))
        .find(|p| p.exists())
        .ok_or_else(|| anyhow::anyhow!("no entry point found (tried src/index.{{ts,js}}, index.{{ts,js}})"))
}

fn build_standalone(path: &Path, manifest: &serde_json::Value, arch: Arch) -> Result<()> {
    preflight_cross(arch)?;
    let name = manifest_str(manifest, "name")
        .ok_or_else(|| anyhow::anyhow!("manifest.name missing"))?;
    let bin = standalone_bin_name(name);
    let dest_dir = crossbuild::staged_artifact_dir(path, arch);
    std::fs::create_dir_all(&dest_dir)
        .with_context(|| format!("mkdir -p {}", dest_dir.display()))?;
    let dest = dest_dir.join(&bin);

    if is_rust_standalone(path) {
        let use_cross = crossbuild::needs_cross(arch);
        let (prog, args) = crossbuild::cargo_bin_command(arch, use_cross, &bin);
        println!("{} {}", prog, args.join(" "));
        run_command(&prog, &args, path)?;
        let built = crossbuild::bin_output_path(path, arch, &bin);
        if !built.exists() {
            bail!("expected binary not found at {}", built.display());
        }
        std::fs::copy(&built, &dest)
            .with_context(|| format!("stage {} -> {}", built.display(), dest.display()))?;
    } else {
        let entry = resolve_bun_entry(path)?;
        let entry_str = entry.to_string_lossy().to_string();
        let dest_str = dest.to_string_lossy().to_string();
        let (prog, args) = crossbuild::bun_compile_command(arch, &entry_str, &dest_str);
        println!("{} {}", prog, args.join(" "));
        run_command(&prog, &args, path)?;
    }
    if !dest.exists() {
        bail!("standalone build produced no binary at {}", dest.display());
    }
    println!("✓ staged {} ({})", dest.display(), arch.deb_arch());
    Ok(())
}

pub(crate) fn build_ui(path: &Path, manifest: &serde_json::Value) -> Result<()> {
    let ui_dir = ui_bundle::project_dir(path, manifest);
    if !ui_dir.exists() {
        bail!("has_ui=true but no ui/ directory at {}", ui_dir.display());
    }
    run_command("bun", &["install".into()], &ui_dir)?;
    run_command("bun", &["run".into(), "build".into()], &ui_dir)?;
    let ui_output = ui_bundle::source_dir(path, manifest)?;
    if let Some(entry) = manifest
        .get("ui")
        .and_then(|ui| ui.get("entry"))
        .and_then(|value| value.as_str())
    {
        let entry_path = path.join(entry);
        if !entry_path.is_file() {
            bail!("UI build did not produce the stage entry {}", entry_path.display());
        }
    }
    println!("✓ built {}", ui_output.display());
    Ok(())
}

pub fn run(path: PathBuf, arch: Option<String>) -> Result<()> {
    // Build commands change into project subdirectories. Resolve the project
    // once up front so Bun/Cargo arguments never become relative to a second
    // working directory (for example `--path examples/my-app`).
    let path = path
        .canonicalize()
        .with_context(|| format!("could not canonicalize project path '{}'", path.display()))?;

    // 1. Read manifest.json + determine app_type.
    let manifest = read_manifest(&path)?;
    let app_type = manifest_str(&manifest, "app_type").unwrap_or("");
    match app_type {
        "bun" => {
            // Bun apps are arch-agnostic; reject a concrete --arch.
            if let Some(ref a) = arch {
                if a != "all" {
                    bail!("bun apps are arch-agnostic; --arch must be omitted or 'all'");
                }
            }
        }
        "native" => {
            let arch = resolve_build_arch(arch)?;
            build_native(&path, &manifest, arch)?;
            if manifest.get("has_ui").and_then(|v| v.as_bool()).unwrap_or(false) {
                build_ui(&path, &manifest)?;
            }
            return Ok(());
        }
        "standalone" => {
            let target = resolve_build_arch(arch)?;
            build_standalone(&path, &manifest, target)?;
            // standalone apps serve their own UI; package.rs does not stage a UI
            // bundle for them, so calling build_ui here would be wasted work that
            // can hard-fail the build on apps without a ui/ directory.
            return Ok(());
        }
        other => bail!("unrecognized app_type in manifest: {:?}", other),
    }

    // Bun path continues below.

    // 2. Verify package.json.
    let pkg_path = path.join("package.json");
    if !pkg_path.exists() {
        bail!("package.json not found at {}", pkg_path.display());
    }

    // Entry resolution.
    let entry = ["src/index.ts", "src/index.js", "index.ts", "index.js"]
        .iter()
        .map(|p| path.join(p))
        .find(|p| p.exists())
        .ok_or_else(|| {
            anyhow::anyhow!("no entry point found (tried src/index.{{ts,js}}, index.{{ts,js}})")
        })?;

    // 3. bun install --silent.
    println!("→ bun install --silent");
    let t = Instant::now();
    let status = Command::new("bun")
        .arg("install")
        .arg("--silent")
        .current_dir(&path)
        .status()
        .with_context(|| "failed to run `bun install` — is bun on PATH?")?;
    if !status.success() {
        bail!("`bun install` failed");
    }
    println!("✓ bun install ({:.1}s)", t.elapsed().as_secs_f32());

    // 4. bun build with shared externals.
    let mut bun_build = Command::new("bun");
    bun_build
        .arg("build")
        .arg(&entry)
        .args(["--target=bun", "--outdir=dist"])
        .current_dir(&path);
    for ext in CURRENT.shared_externals {
        bun_build.arg("--external").arg(ext);
    }

    let t = Instant::now();
    let status = bun_build
        .status()
        .with_context(|| "failed to run `bun build`")?;
    if !status.success() {
        bail!("`bun build` failed");
    }

    // 5. Verify output.
    let dist_index = path.join("dist/index.js");
    if !dist_index.exists() {
        bail!("bun build succeeded but dist/index.js was not produced");
    }
    let size = std::fs::metadata(&dist_index)?.len();
    println!(
        "✓ bundled dist/index.js ({}, {:.1}s) — shared externals: {}",
        format_size(size),
        t.elapsed().as_secs_f32(),
        CURRENT.shared_externals.join(", ")
    );

    if manifest.get("has_ui").and_then(|v| v.as_bool()).unwrap_or(false) {
        build_ui(&path, &manifest)?;
    }

    Ok(())
}

fn format_size(bytes: u64) -> String {
    if bytes < 1024 {
        format!("{} B", bytes)
    } else if bytes < 1024 * 1024 {
        format!("{:.1} KB", bytes as f64 / 1024.0)
    } else {
        format!("{:.2} MB", bytes as f64 / 1024.0 / 1024.0)
    }
}

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

    #[test]
    fn resolve_build_arch_rejects_all_and_parses() {
        assert!(resolve_build_arch(Some("all".into())).is_err());
        assert_eq!(resolve_build_arch(Some("arm64".into())).unwrap(), Arch::Arm64);
    }

    #[test]
    fn crate_lib_name_prefers_lib_and_underscores() {
        let dir = tempfile::tempdir().unwrap();
        let mut f = std::fs::File::create(dir.path().join("Cargo.toml")).unwrap();
        write!(
            f,
            "[package]\nname = \"node-app-foo\"\nversion = \"0.1.0\"\n[lib]\nname = \"node_app_foo\"\ncrate-type = [\"cdylib\"]\n"
        )
        .unwrap();
        assert_eq!(crate_lib_name(dir.path()).unwrap(), "node_app_foo");
    }

    #[test]
    fn crate_lib_name_falls_back_to_package_name_underscored() {
        let dir = tempfile::tempdir().unwrap();
        let mut f = std::fs::File::create(dir.path().join("Cargo.toml")).unwrap();
        write!(f, "[package]\nname = \"node-app-bar\"\nversion = \"0.1.0\"\n").unwrap();
        assert_eq!(crate_lib_name(dir.path()).unwrap(), "node_app_bar");
    }

    #[test]
    fn standalone_detection_and_bin_name() {
        let dir = tempfile::tempdir().unwrap();
        assert!(!is_rust_standalone(dir.path()));
        std::fs::write(dir.path().join("Cargo.toml"), "[package]\nname=\"x\"\nversion=\"0.1.0\"\n").unwrap();
        assert!(is_rust_standalone(dir.path()));
        assert_eq!(standalone_bin_name("foo"), "node-app-foo");
    }

    #[test]
    fn bun_entry_resolution_prefers_src_index_ts() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(dir.path().join("src")).unwrap();
        std::fs::write(dir.path().join("src/index.ts"), "").unwrap();
        assert_eq!(resolve_bun_entry(dir.path()).unwrap(), dir.path().join("src/index.ts"));
    }
}