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())
}
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
)
}),
}
}
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(())
}
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(())
}
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(())
}
fn is_rust_standalone(path: &Path) -> bool {
path.join("Cargo.toml").exists()
}
fn standalone_bin_name(name: &str) -> String {
format!("node-app-{name}")
}
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<()> {
let path = path
.canonicalize()
.with_context(|| format!("could not canonicalize project path '{}'", path.display()))?;
let manifest = read_manifest(&path)?;
let app_type = manifest_str(&manifest, "app_type").unwrap_or("");
match app_type {
"bun" => {
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)?;
return Ok(());
}
other => bail!("unrecognized app_type in manifest: {:?}", other),
}
let pkg_path = path.join("package.json");
if !pkg_path.exists() {
bail!("package.json not found at {}", pkg_path.display());
}
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}})")
})?;
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());
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");
}
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"));
}
}