use anyhow::{bail, Context, Result};
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::commands::{crossbuild, package_docker, package_sign};
#[derive(clap::ValueEnum, Clone, Copy, Debug)]
pub enum DockerMode {
Auto,
Force,
Never,
}
fn control_file(
name: &str,
version: &str,
arch: &str,
maintainer: &str,
depends: &[&str],
recommends: &[&str],
short_desc: &str,
) -> String {
let mut s = format!(
"Package: node-app-{name}\nVersion: {version}\nArchitecture: {arch}\nMaintainer: {maintainer}\n"
);
if !depends.is_empty() {
s.push_str(&format!("Depends: {}\n", depends.join(", ")));
}
if !recommends.is_empty() {
s.push_str(&format!("Recommends: {}\n", recommends.join(", ")));
}
s.push_str(&format!("Description: {short_desc}\n"));
s
}
#[inline]
fn deb_within_cap(size: u64) -> bool {
const MAX_DEB_SIZE: u64 = 100 * 1024 * 1024;
size <= MAX_DEB_SIZE
}
#[allow(clippy::too_many_arguments)]
fn finalize_deb(
staging: &Path,
deb_path: &Path,
docker: DockerMode,
manifest_path: &Path,
out: &Path,
name: &str,
version: &str,
sign: bool,
gpg_key: &Option<String>,
) -> Result<()> {
build_deb(staging, deb_path, docker)?;
let size = std::fs::metadata(deb_path)?.len();
if !deb_within_cap(size) {
bail!(
"built .deb exceeds the 100 MB cap: {} bytes ({})",
size,
deb_path.display()
);
}
println!("✓ packaged {} ({})", deb_path.display(), format_size(size));
if sign {
let key = gpg_key.clone().ok_or_else(|| {
anyhow::anyhow!(
"--sign requires --gpg-key <ID> (or set GPG_KEY env var). \
Run `gpg --list-secret-keys` to find a key ID."
)
})?;
let sig_path = package_sign::sign_sidecar(manifest_path, out, name, version, &key)?;
let sig_size = std::fs::metadata(&sig_path)?.len();
println!(
"✓ signed sidecar {} ({})",
sig_path.display(),
format_size(sig_size)
);
}
Ok(())
}
fn build_deb(staging: &Path, deb_path: &Path, docker: DockerMode) -> Result<()> {
let use_docker = match docker {
DockerMode::Force => true,
DockerMode::Never => false,
DockerMode::Auto => !cfg!(target_os = "linux"),
};
if use_docker {
package_docker::run_in_container(staging, deb_path)?;
} else if cfg!(target_os = "linux") {
let status = Command::new("dpkg-deb")
.arg("--build")
.arg(staging)
.arg(deb_path)
.status()
.with_context(|| "failed to run `dpkg-deb` — is `dpkg-deb` on PATH?")?;
if !status.success() {
bail!("`dpkg-deb --build` failed");
}
} else {
bail!(
"node-app package on non-Linux requires Docker (auto-detected by default). \
Pass --docker auto or --docker force; --docker never requires a Linux host \
with `dpkg-deb` on PATH."
);
}
Ok(())
}
fn validate_arch_for_type(app_type: &str, arch: &str) -> Result<()> {
match app_type {
"bun" if arch == "all" => Ok(()),
"bun" => bail!("bun apps are arch-agnostic; use --arch all"),
"native" | "standalone" if arch == "all" => {
bail!("{app_type} apps need --arch arm64 or amd64, not 'all'")
}
"native" | "standalone" => crossbuild::Arch::from_deb_arch(arch).map(|_| ()),
other => bail!("unrecognized app_type: {other}"),
}
}
fn stage_native(
project: &Path,
app_root: &Path,
manifest: &serde_json::Value,
arch: crossbuild::Arch,
) -> Result<()> {
let entrypoint = manifest
.get("entrypoint")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("manifest.entrypoint missing"))?;
let built = crossbuild::staged_artifact_dir(project, arch).join(entrypoint);
if !built.exists() {
bail!(
"native artifact not found at {}. Run `node-app build --arch {}` first.",
built.display(),
arch.deb_arch()
);
}
std::fs::create_dir_all(app_root)
.with_context(|| format!("mkdir -p {}", app_root.display()))?;
let dest = app_root.join(entrypoint);
std::fs::copy(&built, &dest)
.with_context(|| format!("stage {} -> {}", built.display(), dest.display()))?;
let mut perms = std::fs::metadata(&dest)?.permissions();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
perms.set_mode(0o644);
}
std::fs::set_permissions(&dest, perms).ok();
Ok(())
}
fn stage_ui(project: &Path, app_root: &Path, manifest: &serde_json::Value) -> Result<()> {
if !manifest.get("has_ui").and_then(|v| v.as_bool()).unwrap_or(false) {
return Ok(());
}
let ui_path = manifest.get("ui_path").and_then(|v| v.as_str()).unwrap_or("ui/dist");
let src = project.join(ui_path);
if !src.is_dir() {
bail!("has_ui=true but UI bundle not found at {}. Run `node-app build` first.", src.display());
}
let dest = app_root.join(ui_path);
copy_dir_recursive(&src, &dest)?;
Ok(())
}
pub fn run(
path: PathBuf,
arch: String,
out: PathBuf,
docker: DockerMode,
sign: bool,
gpg_key: Option<String>,
) -> Result<()> {
let manifest_path = path.join("manifest.json");
let manifest_raw = std::fs::read_to_string(&manifest_path)
.with_context(|| format!("manifest.json not found at {}", manifest_path.display()))?;
let manifest: serde_json::Value = serde_json::from_str(&manifest_raw)
.with_context(|| format!("manifest.json is not valid JSON: {}", manifest_path.display()))?;
let app_type = manifest
.get("app_type")
.and_then(|v| v.as_str())
.unwrap_or("bun")
.to_string();
let name = manifest
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("manifest.name missing"))?
.to_string();
let version = manifest
.get("version")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("manifest.version missing"))?
.to_string();
let description = manifest
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let maintainer = manifest
.get("maintainer")
.and_then(|v| v.as_str())
.unwrap_or("Unknown <unknown@example.com>")
.to_string();
validate_arch_for_type(&app_type, &arch)?;
match app_type.as_str() {
"bun" => {
run_bun(
&path, &arch, &out, docker, sign, gpg_key,
&manifest, &manifest_path, &name, &version, &description, &maintainer,
)?;
}
"native" => {
let a = crossbuild::Arch::from_deb_arch(&arch)?;
run_native(
&path, &out, docker, sign, gpg_key,
&manifest, &manifest_path, &name, &version, &description, &maintainer, a,
)?;
}
"standalone" => {
let a = crossbuild::Arch::from_deb_arch(&arch)?;
std::fs::create_dir_all(&out)
.with_context(|| format!("mkdir -p {}", out.display()))?;
let staging = out.join("staging");
let _ = std::fs::remove_dir_all(&staging);
stage_standalone(&path, &staging, &manifest, &name, a)?;
let deps = crate::blueprint::apt_deps("standalone");
let recs = crate::blueprint::recommends("standalone");
let control = control_file(
&name,
&version,
a.deb_arch(),
&maintainer,
deps,
recs,
description.lines().next().unwrap_or("").trim(),
);
std::fs::write(staging.join("DEBIAN/control"), control)
.with_context(|| "write DEBIAN/control")?;
let deb_path =
out.join(format!("node-app-{}_{}_{}.deb", name, version, a.deb_arch()));
finalize_deb(&staging, &deb_path, docker, &manifest_path, &out, &name, &version, sign, &gpg_key)?;
}
other => bail!("unsupported app_type: {other}"),
}
Ok(())
}
fn standalone_postinst(name: &str) -> String {
format!(
"#!/bin/sh\nset -e\ncase \"$1\" in\n configure)\n systemctl daemon-reload || true\n systemctl enable --now node-app-{name}.service || true\n ;;\nesac\nexit 0\n"
)
}
fn standalone_prerm(name: &str) -> String {
format!(
"#!/bin/sh\nset -e\ncase \"$1\" in\n remove|deconfigure)\n systemctl stop node-app-{name}.service || true\n ;;\nesac\nexit 0\n"
)
}
fn standalone_postrm(name: &str) -> String {
format!(
"#!/bin/sh\nset -e\ncase \"$1\" in\n remove|purge)\n systemctl disable node-app-{name}.service || true\n systemctl daemon-reload || true\n ;;\nesac\nexit 0\n"
)
}
fn stage_standalone(
project: &Path,
staging: &Path,
_manifest: &serde_json::Value,
name: &str,
arch: crossbuild::Arch,
) -> Result<()> {
let bin_name = format!("node-app-{name}");
let built = crossbuild::staged_artifact_dir(project, arch).join(&bin_name);
if !built.exists() {
bail!(
"standalone binary not found at {}. Run `node-app build --arch {}` first.",
built.display(),
arch.deb_arch()
);
}
let bin_dir = staging.join("usr/bin");
std::fs::create_dir_all(&bin_dir)?;
let bin_dest = bin_dir.join(&bin_name);
std::fs::copy(&built, &bin_dest)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&bin_dest, std::fs::Permissions::from_mode(0o755))?;
}
let unit_src = project.join(format!("systemd/node-app-{name}.service"));
if !unit_src.exists() {
bail!("systemd unit not found at {}", unit_src.display());
}
let unit_dir = staging.join("lib/systemd/system");
std::fs::create_dir_all(&unit_dir)?;
std::fs::copy(&unit_src, unit_dir.join(format!("node-app-{name}.service")))?;
let app_root = staging.join(format!("usr/lib/node/apps/{name}"));
std::fs::create_dir_all(&app_root)?;
std::fs::copy(project.join("manifest.json"), app_root.join("manifest.json"))?;
let debian = staging.join("DEBIAN");
std::fs::create_dir_all(&debian)?;
for (file, body) in [
("postinst", standalone_postinst(name)),
("prerm", standalone_prerm(name)),
("postrm", standalone_postrm(name)),
] {
let p = debian.join(file);
std::fs::write(&p, body)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755))?;
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn run_bun(
path: &Path,
_arch: &str,
out: &Path,
docker: DockerMode,
sign: bool,
gpg_key: Option<String>,
manifest: &serde_json::Value,
manifest_path: &Path,
name: &str,
version: &str,
description: &str,
maintainer: &str,
) -> Result<()> {
let dist_index = path.join("dist/index.js");
if !dist_index.exists() {
bail!(
"dist/index.js not found at {}. Run `node-app build` first.",
dist_index.display()
);
}
std::fs::create_dir_all(out)
.with_context(|| format!("mkdir -p {}", out.display()))?;
let staging = out.join("staging");
let _ = std::fs::remove_dir_all(&staging);
let app_root = staging.join(format!("usr/lib/node/apps/{}", name));
std::fs::create_dir_all(app_root.join("dist"))
.with_context(|| format!("mkdir -p {}", app_root.join("dist").display()))?;
std::fs::create_dir_all(staging.join("DEBIAN"))
.with_context(|| format!("mkdir -p {}", staging.join("DEBIAN").display()))?;
std::fs::copy(manifest_path, app_root.join("manifest.json"))
.with_context(|| "copy manifest.json into staging")?;
let pkg_json = path.join("package.json");
if pkg_json.exists() {
std::fs::copy(&pkg_json, app_root.join("package.json"))
.with_context(|| "copy package.json into staging")?;
}
std::fs::copy(&dist_index, app_root.join("dist/index.js"))
.with_context(|| "copy dist/index.js into staging")?;
let private_runtime: Vec<String> = manifest
.get("nodeApp")
.and_then(|n| n.get("privateRuntime"))
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
if !private_runtime.is_empty() {
let pm_dir = app_root.join("private_modules");
std::fs::create_dir_all(&pm_dir)
.with_context(|| format!("mkdir -p {}", pm_dir.display()))?;
for pkg in &private_runtime {
let src = path.join("node_modules").join(pkg);
if !src.exists() {
bail!(
"manifest.nodeApp.privateRuntime declares `{}` but {} not found. \
Run `bun install` first.",
pkg,
src.display()
);
}
let dst = pm_dir.join(pkg);
copy_dir_recursive(&src, &dst).with_context(|| {
format!("stage private_modules/{} from {}", pkg, src.display())
})?;
}
println!(
"✓ staged private_modules/ ({} packages: {})",
private_runtime.len(),
private_runtime.join(", ")
);
} else {
println!("✓ no private_modules/ (manifest.nodeApp.privateRuntime is empty)");
}
stage_ui(path, &app_root, manifest)?;
let deps = crate::blueprint::apt_deps("bun");
let control = control_file(
name,
version,
"all",
maintainer,
deps,
&[],
description.lines().next().unwrap_or("").trim(),
);
std::fs::write(staging.join("DEBIAN/control"), control)
.with_context(|| "write DEBIAN/control")?;
let deb_path = out.join(format!("node-app-{}_{}_all.deb", name, version));
finalize_deb(&staging, &deb_path, docker, manifest_path, out, name, version, sign, &gpg_key)?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn run_native(
path: &Path,
out: &Path,
docker: DockerMode,
sign: bool,
gpg_key: Option<String>,
manifest: &serde_json::Value,
manifest_path: &Path,
name: &str,
version: &str,
description: &str,
maintainer: &str,
a: crossbuild::Arch,
) -> Result<()> {
std::fs::create_dir_all(out)
.with_context(|| format!("mkdir -p {}", out.display()))?;
let staging = out.join("staging");
let _ = std::fs::remove_dir_all(&staging);
let app_root = staging.join(format!("usr/lib/node/apps/{}", name));
std::fs::create_dir_all(staging.join("DEBIAN"))
.with_context(|| format!("mkdir -p {}", staging.join("DEBIAN").display()))?;
stage_native(path, &app_root, manifest, a)?;
std::fs::copy(manifest_path, app_root.join("manifest.json"))
.with_context(|| "copy manifest.json into staging")?;
stage_ui(path, &app_root, manifest)?;
let deps = crate::blueprint::apt_deps("native");
let control = control_file(
name,
version,
a.deb_arch(),
maintainer,
deps,
&[],
description.lines().next().unwrap_or("").trim(),
);
std::fs::write(staging.join("DEBIAN/control"), control)
.with_context(|| "write DEBIAN/control")?;
let deb_path = out.join(format!("node-app-{}_{}_{}.deb", name, version, a.deb_arch()));
finalize_deb(&staging, &deb_path, docker, manifest_path, out, name, version, sign, &gpg_key)?;
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)
}
}
fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
let src_meta = std::fs::symlink_metadata(src)
.with_context(|| format!("stat {}", src.display()))?;
if src_meta.file_type().is_symlink() {
let resolved = std::fs::canonicalize(src)
.with_context(|| format!("resolve symlink {}", src.display()))?;
return copy_dir_recursive(&resolved, dst);
}
if src_meta.is_file() {
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("mkdir -p {}", parent.display()))?;
}
std::fs::copy(src, dst)
.with_context(|| format!("copy {} -> {}", src.display(), dst.display()))?;
return Ok(());
}
if !src_meta.is_dir() {
return Ok(());
}
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 name = entry.file_name();
let from = src.join(&name);
let to = dst.join(&name);
copy_dir_recursive(&from, &to)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn size_cap_boundary() {
const CAP: u64 = 100 * 1024 * 1024;
assert!(deb_within_cap(0));
assert!(deb_within_cap(CAP));
assert!(!deb_within_cap(CAP + 1));
assert!(!deb_within_cap(u64::MAX));
}
#[test]
fn control_omits_empty_dep_lines_and_uses_node() {
let c = control_file("foo", "0.1.0", "arm64", "Jane <j@x>", &[], &["node"], "desc");
assert!(c.contains("Architecture: arm64"));
assert!(!c.contains("Depends:"));
assert!(c.contains("Recommends: node"));
assert!(!c.contains("econ-v1"));
let c2 = control_file("foo", "0.1.0", "all", "Jane <j@x>", &["node-app-bun-runtime"], &[], "desc");
assert!(c2.contains("Depends: node-app-bun-runtime"));
assert!(!c2.contains("Recommends:"));
}
#[test]
fn arch_rules_per_app_type() {
assert!(validate_arch_for_type("bun", "all").is_ok());
assert!(validate_arch_for_type("bun", "arm64").is_err());
assert!(validate_arch_for_type("native", "all").is_err());
assert!(validate_arch_for_type("native", "arm64").is_ok());
assert!(validate_arch_for_type("standalone", "amd64").is_ok());
}
#[test]
fn maintainer_scripts_target_the_named_service() {
let pi = standalone_postinst("foo");
assert!(pi.starts_with("#!/bin/sh"));
assert!(pi.contains("enable --now node-app-foo.service"));
assert!(standalone_prerm("foo").contains("stop node-app-foo.service"));
assert!(standalone_postrm("foo").contains("disable node-app-foo.service"));
for s in [standalone_postinst("foo"), standalone_prerm("foo"), standalone_postrm("foo")] {
assert!(!s.contains("econ-v1"));
}
}
#[test]
fn stage_standalone_lays_out_bin_unit_and_scripts() {
let proj = tempfile::tempdir().unwrap();
let arch = crossbuild::Arch::Amd64;
std::fs::create_dir_all(crossbuild::staged_artifact_dir(proj.path(), arch)).unwrap();
std::fs::write(
crossbuild::staged_artifact_dir(proj.path(), arch).join("node-app-foo"),
b"bin",
)
.unwrap();
std::fs::create_dir_all(proj.path().join("systemd")).unwrap();
std::fs::write(proj.path().join("systemd/node-app-foo.service"), "[Unit]\n").unwrap();
std::fs::write(proj.path().join("manifest.json"), "{\"name\":\"foo\"}").unwrap();
let staging = proj.path().join("staging");
let manifest = serde_json::json!({"name":"foo"});
stage_standalone(proj.path(), &staging, &manifest, "foo", arch).unwrap();
assert!(staging.join("usr/bin/node-app-foo").exists());
assert!(staging.join("lib/systemd/system/node-app-foo.service").exists());
assert!(staging.join("DEBIAN/postinst").exists());
}
#[test]
fn stage_native_copies_so_as_entrypoint() {
let proj = tempfile::tempdir().unwrap();
let arch = crossbuild::Arch::Arm64;
let art = crossbuild::staged_artifact_dir(proj.path(), arch);
std::fs::create_dir_all(&art).unwrap();
std::fs::write(art.join("libfoo.so"), b"\x7fELF-fake").unwrap();
let manifest: serde_json::Value = serde_json::json!({"entrypoint": "libfoo.so"});
let app_root = proj.path().join("staging/usr/lib/node/apps/foo");
stage_native(proj.path(), &app_root, &manifest, arch).unwrap();
assert!(app_root.join("libfoo.so").exists());
}
#[test]
fn stage_ui_noop_without_has_ui_and_copies_when_set() {
let proj = tempfile::tempdir().unwrap();
let app_root = proj.path().join("app");
stage_ui(proj.path(), &app_root, &serde_json::json!({})).unwrap(); std::fs::create_dir_all(proj.path().join("ui/dist")).unwrap();
std::fs::write(proj.path().join("ui/dist/index.html"), "<html>").unwrap();
stage_ui(proj.path(), &app_root, &serde_json::json!({"has_ui": true})).unwrap();
assert!(app_root.join("ui/dist/index.html").exists());
}
}