use crate::commands::validate;
use crate::manifest::{AppManifest, AppType};
use anyhow::{bail, Context, Result};
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DockerMode {
Auto,
Force,
Disable,
}
pub fn run(
project_path: &Path,
target: &str,
version: Option<&str>,
out: Option<&Path>,
docker_mode: DockerMode,
) -> Result<()> {
let manifest = validate::run(project_path)?;
match (target, manifest.app_type) {
("all", AppType::Native) => {
bail!("native (cdylib) apps cannot ship as Architecture: all; use --target arm64 or amd64")
}
("amd64" | "arm64", AppType::Bun) => {
bail!("bun apps must ship as Architecture: all; use --target all")
}
("amd64" | "arm64" | "all", _) => {}
(other, _) => bail!("unsupported --target '{}': use amd64, arm64, or all", other),
}
if matches!(manifest.app_type, AppType::Standalone) {
bail!("standalone app_type .deb packaging is not yet implemented in `node-app package`");
}
let project_path = project_path.canonicalize()?;
let use_docker = decide_use_docker(&manifest, target, docker_mode)?;
if use_docker {
println!("» build mode: docker (cross-toolchain in container)");
} else {
println!("» build mode: host");
}
match manifest.app_type {
AppType::Bun => build_bun(&project_path, &manifest)?,
AppType::Native => build_native(&project_path, &manifest, target, use_docker)?,
AppType::Standalone => unreachable!("validated above"),
}
let pkg_version = version
.map(str::to_string)
.unwrap_or_else(|| manifest.version.clone());
let pkg_name = format!("node-app-{}", manifest.name);
let deb_filename = format!("{}_{}_{}.deb", pkg_name, pkg_version, target);
let stage_root = tempdir_prefixed("node-app-deb-")?;
let stage = stage_root.path.clone();
let install_path = format!("usr/lib/node/apps/{}", manifest.name);
fs::create_dir_all(stage.join("DEBIAN"))?;
fs::create_dir_all(stage.join(&install_path))?;
fs::create_dir_all(stage.join(format!("usr/share/doc/{}", pkg_name)))?;
fs::copy(
project_path.join("manifest.json"),
stage.join(&install_path).join("manifest.json"),
)
.context("copy manifest.json into stage")?;
stage_payload(&project_path, &stage, &install_path, &manifest, target)?;
stage_ui(&project_path, &stage, &install_path, &manifest)?;
write_copyright(&stage, &pkg_name)?;
render_control(&project_path, &stage, &manifest, &pkg_version, target)?;
copy_maintainer_scripts(&project_path, &stage, &manifest.name)?;
inject_port_registry_snippet(&stage, &manifest)?;
chmod_recursive_a_plus_rx(&stage)?;
let out_rel = out.map(Path::to_path_buf);
let out_dir = out_rel.unwrap_or_else(|| project_path.join("dist/app-debs"));
fs::create_dir_all(&out_dir).with_context(|| format!("mkdir {}", out_dir.display()))?;
let deb_output = out_dir.join(&deb_filename);
pack_deb(&stage, &deb_output, use_docker)?;
sanity_size_check(&deb_output)?;
println!("✓ Built {}", deb_output.display());
Ok(())
}
fn build_bun(project_path: &Path, manifest: &AppManifest) -> Result<()> {
run_step(
"bun install",
Command::new("bun").arg("install").current_dir(project_path),
)?;
run_step(
"bun run build",
Command::new("bun").args(["run", "build"]).current_dir(project_path),
)
.ok();
if manifest.has_ui {
let ui_root_segment = manifest
.ui_path
.as_deref()
.unwrap_or("ui/dist")
.split('/')
.next()
.unwrap_or("ui");
let ui_dir = project_path.join(ui_root_segment);
if ui_dir.is_dir() {
run_step(
"bun install (ui)",
Command::new("bun").arg("install").current_dir(&ui_dir),
)
.ok();
run_step(
"bun run build (ui)",
Command::new("bun").args(["run", "build"]).current_dir(&ui_dir),
)
.ok();
}
}
Ok(())
}
fn build_native(
project_path: &Path,
manifest: &AppManifest,
target: &str,
use_docker: bool,
) -> Result<()> {
let rust_target = rust_target_for(target)?;
if use_docker {
docker_cargo_build(project_path, rust_target)?;
} else {
run_step(
"cargo build --release",
Command::new("cargo")
.args(["build", "--release", "--target", rust_target])
.current_dir(project_path),
)?;
}
let crate_underscored = manifest.name.replace('-', "_");
let so_src = project_path
.join("target")
.join(rust_target)
.join("release")
.join(format!("lib{}.so", crate_underscored));
if !so_src.exists() {
bail!(
"expected cdylib output not found: {} — does Cargo.toml set crate-type = [\"cdylib\"]?",
so_src.display()
);
}
Ok(())
}
fn rust_target_for(target: &str) -> Result<&'static str> {
Ok(match target {
"amd64" => "x86_64-unknown-linux-gnu",
"arm64" => "aarch64-unknown-linux-gnu",
other => bail!("rust cross target undefined for --target {}", other),
})
}
fn stage_payload(
project_path: &Path,
stage: &Path,
install_path: &str,
manifest: &AppManifest,
target: &str,
) -> Result<()> {
let dst_root = stage.join(install_path);
match manifest.app_type {
AppType::Native => {
let rust_target = rust_target_for(target)?;
let crate_underscored = manifest.name.replace('-', "_");
let so_src = project_path
.join("target")
.join(rust_target)
.join("release")
.join(format!("lib{}.so", crate_underscored));
let so_dst = dst_root.join("app.so");
fs::copy(&so_src, &so_dst)
.with_context(|| format!("copy {} -> {}", so_src.display(), so_dst.display()))?;
fs::set_permissions(&so_dst, fs::Permissions::from_mode(0o644))?;
}
AppType::Bun => {
let entrypoint = manifest
.entrypoint
.clone()
.unwrap_or_else(|| "dist/index.js".to_string());
let src = project_path.join(&entrypoint);
if !src.exists() {
bail!(
"bun entrypoint not found: {} — run `bun build` first",
src.display()
);
}
let entry_dir = Path::new(&entrypoint)
.parent()
.filter(|p| !p.as_os_str().is_empty());
match entry_dir {
None => {
let dst = dst_root.join(Path::new(&entrypoint).file_name().unwrap());
fs::copy(&src, &dst)?;
}
Some(dir) => {
let dst_dir = dst_root.join(dir);
fs::create_dir_all(&dst_dir)?;
copy_dir_recursive(&project_path.join(dir), &dst_dir)?;
}
}
}
AppType::Standalone => unreachable!("rejected earlier"),
}
Ok(())
}
fn stage_ui(
project_path: &Path,
stage: &Path,
install_path: &str,
manifest: &AppManifest,
) -> Result<()> {
if !manifest.has_ui {
return Ok(());
}
let ui_path = manifest
.ui_path
.as_deref()
.ok_or_else(|| anyhow::anyhow!("has_ui=true but ui_path is missing"))?;
let src = project_path.join(ui_path);
if !src.is_dir() {
bail!(
"has_ui=true but UI bundle not found: {} — run the UI build first",
src.display()
);
}
let dst = stage.join(install_path).join(ui_path);
fs::create_dir_all(&dst)?;
copy_dir_recursive(&src, &dst)?;
Ok(())
}
fn write_copyright(stage: &Path, pkg_name: &str) -> Result<()> {
let body = format!(
"{pkg_name}\n\
This package is part of the Node Lightning OS project.\n\
Source: https://github.com/econ-v1/node\n\
Licensed under the project's primary license (see https://github.com/econ-v1/node/blob/main/LICENSE).\n"
);
fs::write(
stage.join(format!("usr/share/doc/{}/copyright", pkg_name)),
body,
)?;
Ok(())
}
fn render_control(
project_path: &Path,
stage: &Path,
manifest: &AppManifest,
pkg_version: &str,
target: &str,
) -> Result<()> {
let template_path = project_path.join("debian/control.template");
if !template_path.is_file() {
bail!(
"control.template not found: {} — create one with placeholders {{{{name}}}} {{{{version}}}} {{{{arch}}}} {{{{maintainer}}}} {{{{description}}}}",
template_path.display()
);
}
let template = fs::read_to_string(&template_path)
.with_context(|| format!("read {}", template_path.display()))?;
let maintainer = std::env::var("MAINTAINER")
.ok()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "Node Project <maintainer@node.example>".to_string());
let description = manifest.description.clone().unwrap_or_default();
let rendered = template
.replace("{{name}}", &manifest.name)
.replace("{{version}}", pkg_version)
.replace("{{arch}}", target)
.replace("{{maintainer}}", &maintainer)
.replace("{{description}}", &description);
let leftovers = regex::Regex::new(r"\{\{[A-Za-z_]+\}\}").unwrap();
if let Some(m) = leftovers.find(&rendered) {
bail!(
"un-substituted placeholder remains in rendered DEBIAN/control: {}",
m.as_str()
);
}
fs::write(stage.join("DEBIAN/control"), rendered)?;
Ok(())
}
fn copy_maintainer_scripts(project_path: &Path, stage: &Path, app_name: &str) -> Result<()> {
for script in ["postinst", "prerm", "postrm"] {
let src = project_path.join("debian").join(script);
if !src.is_file() {
continue;
}
let dst = stage.join("DEBIAN").join(script);
let content =
fs::read_to_string(&src).with_context(|| format!("read {}", src.display()))?;
let rendered = content.replace("<name>", app_name);
fs::write(&dst, rendered)?;
fs::set_permissions(&dst, fs::Permissions::from_mode(0o755))?;
}
Ok(())
}
fn inject_port_registry_snippet(stage: &Path, manifest: &AppManifest) -> Result<()> {
let preferred_port = manifest
.tcp
.as_ref()
.and_then(|t| t.preferred_port)
.map(|p| p.to_string());
let Some(preferred_port) = preferred_port else {
return Ok(());
};
let app_name = &manifest.name;
let postinst_head = format!(
"#!/bin/sh\n\
# AUTO-GENERATED port-registry snippet (470). Allocates the app's TCP port\n\
# via node-ctl before any other postinst logic runs. DO NOT EDIT.\n\
set -e\n\
APP_NAME=\"{app_name}\"\n\
PREFERRED_PORT=\"{preferred_port}\"\n\
if command -v node-ctl >/dev/null 2>&1; then\n\
retries=10\n\
while [ $retries -gt 0 ] && [ ! -S /run/node/control.sock ]; do\n\
sleep 1; retries=$((retries - 1))\n\
done\n\
if [ -S /run/node/control.sock ]; then\n\
node-ctl ports allocate \"$APP_NAME\" --preferred \"$PREFERRED_PORT\" >/dev/null \\\n\
|| echo \"warning: port allocation for $APP_NAME failed; app may be disabled\" >&2\n\
else\n\
echo \"warning: /run/node/control.sock not ready within retry budget; port allocation skipped for $APP_NAME\" >&2\n\
fi\n\
fi\n",
app_name = app_name,
preferred_port = preferred_port,
);
let prerm_head = format!(
"#!/bin/sh\n\
# AUTO-GENERATED port-registry snippet (470). Releases the app's TCP port via\n\
# node-ctl. DO NOT EDIT.\n\
set -e\n\
APP_NAME=\"{app_name}\"\n\
if command -v node-ctl >/dev/null 2>&1 && [ -S /run/node/control.sock ]; then\n\
node-ctl ports release \"$APP_NAME\" >/dev/null || true\n\
fi\n",
app_name = app_name,
);
prepend_or_create(&stage.join("DEBIAN/postinst"), &postinst_head)?;
prepend_or_create(&stage.join("DEBIAN/prerm"), &prerm_head)?;
println!(
"» injected port-registry postinst/prerm snippet (preferred_port={})",
preferred_port
);
Ok(())
}
fn prepend_or_create(path: &Path, head: &str) -> Result<()> {
let combined = if path.is_file() {
let existing = fs::read_to_string(path)?;
let stripped: String = existing
.lines()
.filter(|line| {
let t = line.trim();
t != "#!/bin/sh" && t != "set -e"
})
.collect::<Vec<_>>()
.join("\n");
format!("{head}\n{stripped}\n")
} else {
head.to_string()
};
fs::write(path, combined)?;
fs::set_permissions(path, fs::Permissions::from_mode(0o755))?;
Ok(())
}
fn pack_deb(stage: &Path, deb_output: &Path, use_docker: bool) -> Result<()> {
if !use_docker && command_available("dpkg-deb") {
return run_step(
"dpkg-deb --build",
Command::new("dpkg-deb")
.args(["--root-owner-group", "--build"])
.arg(stage)
.arg(deb_output),
);
}
if !command_available("docker") {
bail!(
"neither `dpkg-deb` nor `docker` is available; install one to pack the .deb \
(on macOS: `colima start` or Docker Desktop; on Debian: `apt install dpkg-dev`)"
);
}
let out_dir = deb_output
.parent()
.ok_or_else(|| anyhow::anyhow!("deb output has no parent"))?;
fs::create_dir_all(out_dir)?;
let deb_filename = deb_output
.file_name()
.ok_or_else(|| anyhow::anyhow!("deb output has no filename"))?
.to_string_lossy()
.to_string();
run_step(
"dpkg-deb (docker)",
Command::new("docker")
.args(["run", "--rm"])
.args([
"-v",
&format!("{}:/stage:ro", stage.display()),
"-v",
&format!("{}:/out", out_dir.display()),
"debian:bookworm-slim",
"sh",
"-c",
])
.arg(format!(
"apt-get update -qq >/dev/null && apt-get install -y -qq dpkg-dev >/dev/null && \
cp -a /stage /tmp/stage && \
dpkg-deb --root-owner-group --build /tmp/stage /out/{deb_filename}",
deb_filename = deb_filename
)),
)
}
fn sanity_size_check(deb_output: &Path) -> Result<()> {
let meta = fs::metadata(deb_output)?;
let max = 100 * 1024 * 1024;
if meta.len() > max {
bail!(
"built .deb exceeds 100 MB sanity cap: {} bytes ({})",
meta.len(),
deb_output.display()
);
}
Ok(())
}
fn decide_use_docker(manifest: &AppManifest, target: &str, mode: DockerMode) -> Result<bool> {
match mode {
DockerMode::Disable => Ok(false),
DockerMode::Force => {
if !command_available("docker") {
bail!(
"--docker passed but `docker` is not available on PATH; install Docker Desktop or colima"
);
}
Ok(true)
}
DockerMode::Auto => {
if !matches!(manifest.app_type, AppType::Native) {
return Ok(false);
}
if target == "all" {
return Ok(false);
}
let host_ready = host_native_toolchain_ready(target)?;
if !host_ready {
if !command_available("docker") {
bail!(
"host cross-toolchain for --target {target} is missing AND `docker` is not on PATH.\n\
Either install Docker (recommended for cross-platform dev), or install:\n\
- rustup target add {triple}\n\
- the `{linker}` linker via your distro package manager",
target = target,
triple = rust_target_for(target)?,
linker = host_linker_for(target)?,
);
}
return Ok(true);
}
Ok(false)
}
}
}
fn host_linker_for(target: &str) -> Result<&'static str> {
Ok(match target {
"amd64" => "x86_64-linux-gnu-gcc",
"arm64" => "aarch64-linux-gnu-gcc",
other => bail!("unknown linker for --target {}", other),
})
}
fn host_native_toolchain_ready(target: &str) -> Result<bool> {
let triple = rust_target_for(target)?;
let linker = host_linker_for(target)?;
let installed = Command::new("rustup")
.args(["target", "list", "--installed"])
.output();
let target_installed = match installed {
Ok(o) if o.status.success() => {
String::from_utf8_lossy(&o.stdout).lines().any(|l| l.trim() == triple)
}
_ => false,
};
if !target_installed {
return Ok(false);
}
if same_arch_as_host(target) {
return Ok(true);
}
Ok(command_available(linker))
}
fn same_arch_as_host(target: &str) -> bool {
let host = std::env::consts::ARCH;
matches!(
(target, host),
("amd64", "x86_64") | ("arm64", "aarch64") | ("arm64", "arm64")
)
}
fn docker_cargo_build(project_path: &Path, rust_target: &str) -> Result<()> {
let dockerfile = find_repo_dockerfile(project_path);
let (compiler_pkg, linker) = match rust_target {
"aarch64-unknown-linux-gnu" => (
"gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libc6-dev-arm64-cross",
"aarch64-linux-gnu-gcc",
),
"x86_64-unknown-linux-gnu" => (
"gcc-x86-64-linux-gnu g++-x86-64-linux-gnu libc6-dev-amd64-cross",
"x86_64-linux-gnu-gcc",
),
other => bail!("docker cross-compile undefined for target {}", other),
};
if !command_available("docker") {
bail!("docker is not available on PATH");
}
let image_tag = format!(
"node-app-cross:{}",
rust_target.replace('-', "_")
);
if let Some(df) = dockerfile.as_deref() {
let parent = df
.parent()
.ok_or_else(|| anyhow::anyhow!("dockerfile has no parent"))?;
let context = parent
.parent()
.and_then(|p| p.parent())
.unwrap_or(project_path);
run_step(
"docker build (cross-compile image)",
Command::new("docker")
.args(["build", "-f"])
.arg(df)
.args([
"--build-arg",
&format!("TARGET_TRIPLE={}", rust_target),
"--build-arg",
&format!("COMPILER_PACKAGE={}", compiler_pkg),
"--build-arg",
&format!("LINKER={}", linker),
"-t",
&image_tag,
])
.arg(context),
)?;
} else {
let inline = inline_dockerfile(rust_target, compiler_pkg, linker);
let mut child = Command::new("docker")
.args(["build", "-f", "-", "-t", &image_tag, "."])
.current_dir(project_path)
.stdin(std::process::Stdio::piped())
.spawn()
.context("spawn docker build")?;
use std::io::Write;
child
.stdin
.as_mut()
.unwrap()
.write_all(inline.as_bytes())
.context("write inline Dockerfile to docker build stdin")?;
let status = child.wait().context("wait for docker build")?;
if !status.success() {
bail!("docker build (inline cross-compile image) exited with {}", status);
}
}
let cargo_cmd = format!(
"cargo build --release --target {target} \
&& ls -lh target/{target}/release/*.so 2>/dev/null || true",
target = rust_target
);
run_step(
"cargo build --release (docker)",
Command::new("docker")
.args(["run", "--rm"])
.args([
"-v",
&format!("{}:/app", project_path.display()),
"-w",
"/app",
&image_tag,
"sh",
"-c",
])
.arg(cargo_cmd),
)
}
fn find_repo_dockerfile(start: &Path) -> Option<PathBuf> {
let mut cur = Some(start);
while let Some(dir) = cur {
let candidate = dir.join("infra/docker/Dockerfile.app-cross-compile");
if candidate.is_file() {
return Some(candidate);
}
cur = dir.parent();
}
None
}
fn inline_dockerfile(target_triple: &str, compiler_pkg: &str, linker: &str) -> String {
let env_var = match target_triple {
"aarch64-unknown-linux-gnu" => "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER",
"x86_64-unknown-linux-gnu" => "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER",
_ => "CARGO_LINKER",
};
format!(
"FROM rust:bookworm\n\
ARG TARGET_TRIPLE={target_triple}\n\
ARG COMPILER_PACKAGE=\"{compiler_pkg}\"\n\
ARG LINKER={linker}\n\
RUN apt-get update && apt-get install -y pkg-config libssl-dev libudev-dev ${{COMPILER_PACKAGE}} && rm -rf /var/lib/apt/lists/*\n\
RUN rustup target add ${{TARGET_TRIPLE}}\n\
ENV {env_var}=${{LINKER}}\n\
WORKDIR /app\n",
target_triple = target_triple,
compiler_pkg = compiler_pkg,
linker = linker,
env_var = env_var,
)
}
fn run_step(label: &str, cmd: &mut Command) -> Result<()> {
println!("» {}", label);
let status = cmd.status().with_context(|| format!("spawning {}", label))?;
if !status.success() {
bail!("{} failed (exit {})", label, status);
}
Ok(())
}
fn command_available(name: &str) -> bool {
Command::new(name)
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
for entry in walkdir::WalkDir::new(src) {
let entry = entry?;
let rel = entry.path().strip_prefix(src)?;
let target = dst.join(rel);
if entry.file_type().is_dir() {
fs::create_dir_all(&target)?;
} else if entry.file_type().is_file() {
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(entry.path(), &target)?;
}
}
Ok(())
}
fn chmod_recursive_a_plus_rx(root: &Path) -> Result<()> {
for entry in walkdir::WalkDir::new(root) {
let entry = entry?;
let meta = entry.metadata()?;
let mut mode = meta.permissions().mode();
mode |= 0o444;
if meta.is_dir() || (mode & 0o100) != 0 {
mode |= 0o111;
}
fs::set_permissions(entry.path(), fs::Permissions::from_mode(mode))?;
}
Ok(())
}
struct TempDirGuard {
path: PathBuf,
}
impl Drop for TempDirGuard {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
fn tempdir_prefixed(prefix: &str) -> Result<TempDirGuard> {
let base = std::env::temp_dir();
for _ in 0..16 {
let suffix: String = std::iter::repeat_with(fastrand_alnum).take(8).collect();
let candidate = base.join(format!("{}{}", prefix, suffix));
match fs::create_dir(&candidate) {
Ok(()) => return Ok(TempDirGuard { path: candidate }),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e.into()),
}
}
bail!("failed to create temp dir after 16 attempts")
}
fn fastrand_alnum() -> char {
use std::time::SystemTime;
let nanos = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0);
let pid = std::process::id();
let idx = ((nanos.wrapping_mul(2654435761)).wrapping_add(pid)) % 36;
let alphabet = b"abcdefghijklmnopqrstuvwxyz0123456789";
alphabet[idx as usize] as char
}