use anyhow::{bail, Context, Result};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::commands::{crossbuild, package_docker, package_sign, ui_bundle};
use crate::manifest::{parse_manifest, validate_manifest, AppType};
#[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<()> {
if stage_only_shortcut_enabled() {
println!("✓ staged {} (skipping .deb finalization)", staging.display());
return Ok(());
}
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 stage_only_shortcut_enabled() -> bool {
cfg!(debug_assertions)
&& std::env::var("NODE_APP_PACKAGE_STAGE_ONLY").as_deref() == Ok("1")
}
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(())
}
pub(crate) fn stage_ui_bundle(
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 src = ui_bundle::source_dir(project, manifest)?;
let dest = app_root.join(ui_bundle::destination_relative(manifest));
copy_stage_ui_tree(&src, &dest)?;
if let Some(icon) = manifest
.get("ui")
.and_then(|ui| ui.get("icon"))
.and_then(|value| value.as_str())
{
let icon_src = project.join(icon);
let icon_dst = app_root.join(icon);
if icon_src.exists() && !icon_dst.exists() {
copy_stage_ui_tree(&icon_src, &icon_dst)?;
}
}
Ok(())
}
fn copy_stage_ui_tree(src: &Path, dst: &Path) -> Result<()> {
let metadata =
std::fs::symlink_metadata(src).with_context(|| format!("stat {}", src.display()))?;
if metadata.file_type().is_symlink() {
bail!("stage UI assets must not contain symlinks: {}", src.display());
}
if metadata.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 !metadata.is_dir() {
bail!("stage UI asset is not a regular file or directory: {}", src.display());
}
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?;
copy_stage_ui_tree(&entry.path(), &dst.join(entry.file_name()))?;
}
Ok(())
}
fn normalize_relative_path(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
fn sha256_hex(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
format!("{:x}", hasher.finalize())
}
fn collect_integrity_recursive(
src: &Path,
app_root: &Path,
integrity: &mut BTreeMap<String, String>,
) -> Result<()> {
let metadata = std::fs::symlink_metadata(src)
.with_context(|| format!("stat {}", src.display()))?;
if metadata.file_type().is_symlink() {
let resolved = std::fs::canonicalize(src)
.with_context(|| format!("resolve symlink {}", src.display()))?;
return collect_integrity_recursive(&resolved, app_root, integrity);
}
if metadata.is_file() {
let relative = src
.strip_prefix(app_root)
.with_context(|| format!("strip prefix {}", src.display()))?;
let bytes = std::fs::read(src)
.with_context(|| format!("read {}", src.display()))?;
integrity.insert(normalize_relative_path(relative), sha256_hex(&bytes));
return Ok(());
}
if !metadata.is_dir() {
return Ok(());
}
for entry in std::fs::read_dir(src)
.with_context(|| format!("read_dir {}", src.display()))?
{
let entry = entry?;
collect_integrity_recursive(&entry.path(), app_root, integrity)?;
}
Ok(())
}
fn generate_staged_integrity(
app_root: &Path,
manifest: &serde_json::Value,
) -> Result<BTreeMap<String, String>> {
let ui_path = manifest
.get("ui_path")
.and_then(|value| value.as_str())
.unwrap_or("ui/dist");
let ui_root = app_root.join(ui_path);
if !ui_root.is_dir() {
bail!("stage UI root not found at {}", ui_root.display());
}
let mut integrity = BTreeMap::new();
collect_integrity_recursive(&ui_root, app_root, &mut integrity)?;
if let Some(icon) = manifest
.get("ui")
.and_then(|ui| ui.get("icon"))
.and_then(|value| value.as_str())
{
let icon_path = app_root.join(icon);
if icon_path.exists() {
collect_integrity_recursive(&icon_path, app_root, &mut integrity)?;
}
}
Ok(integrity)
}
pub(crate) fn write_staged_manifest(source_manifest_path: &Path, app_root: &Path) -> Result<PathBuf> {
let raw = std::fs::read_to_string(source_manifest_path)
.with_context(|| format!("read {}", source_manifest_path.display()))?;
let mut manifest: serde_json::Value = serde_json::from_str(&raw)
.with_context(|| format!("parse {}", source_manifest_path.display()))?;
if manifest.get("ui").and_then(|value| value.as_object()).is_some() {
let integrity = generate_staged_integrity(app_root, &manifest)?;
let map = manifest
.get_mut("ui")
.and_then(|value| value.as_object_mut())
.ok_or_else(|| anyhow::anyhow!("ui block disappeared while generating staged manifest"))?;
map.insert("integrity".into(), serde_json::to_value(integrity)?);
}
let staged_manifest_path = app_root.join("manifest.json");
let pretty = serde_json::to_string_pretty(&manifest)?;
std::fs::write(&staged_manifest_path, format!("{pretty}\n"))
.with_context(|| format!("write {}", staged_manifest_path.display()))?;
Ok(staged_manifest_path)
}
fn detects_signing_pipeline(project_path: &Path) -> bool {
let workflows_dir = project_path.join(".github").join("workflows");
let Ok(entries) = std::fs::read_dir(&workflows_dir) else {
return false;
};
for entry in entries.flatten() {
let path = entry.path();
let is_yaml = path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.eq_ignore_ascii_case("yml") || e.eq_ignore_ascii_case("yaml"))
.unwrap_or(false);
if !is_yaml {
continue;
}
let Ok(body) = std::fs::read_to_string(&path) else {
continue;
};
let signs = body.contains("--detach-sign");
let sidecar = body.contains("SIDECAR") || body.contains("manifest sidecar");
if signs && sidecar {
return true;
}
}
false
}
fn validate_staged_manifest(
source_project_path: &Path,
staged_manifest_path: &Path,
staged_app_root: &Path,
) -> Result<()> {
let manifest = parse_manifest(staged_manifest_path)?;
let signed_pathway = detects_signing_pipeline(source_project_path);
validate_manifest(&manifest, true, signed_pathway)?;
let entrypoint = manifest
.entrypoint
.clone()
.unwrap_or_else(|| match manifest.app_type {
AppType::Native => "app.so".into(),
AppType::Bun => "dist/index.js".into(),
AppType::Standalone => "app".into(),
AppType::PlatformRuntime => "bun".into(),
});
let entrypoint_path = staged_app_root.join(&entrypoint);
if !entrypoint_path.exists() {
bail!(
"staged manifest entrypoint '{}' not found at {}",
entrypoint,
entrypoint_path.display()
);
}
if manifest.has_ui {
let ui_path = if manifest.ui_path.is_empty() {
"ui/dist"
} else {
manifest.ui_path.as_str()
};
let ui_root = staged_app_root.join(ui_path);
if !ui_root.is_dir() {
bail!(
"staged ui_path '{}' not found at {}",
ui_path,
ui_root.display()
);
}
}
if let Some(ui) = &manifest.ui {
let entry = staged_app_root.join(&ui.entry);
if !entry.is_file() {
bail!("staged ui.entry '{}' not found at {}", ui.entry, entry.display());
}
if let Some(icon) = &ui.icon {
let icon_path = staged_app_root.join(icon);
if !icon_path.is_file() {
bail!("staged ui.icon '{}' not found at {}", icon, icon_path.display());
}
}
}
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()))?;
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_bundle(path, &app_root, manifest)?;
let staged_manifest_path = write_staged_manifest(manifest_path, &app_root)?;
validate_staged_manifest(path, &staged_manifest_path, &app_root)?;
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, &staged_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)?;
stage_ui_bundle(path, &app_root, manifest)?;
let staged_manifest_path = write_staged_manifest(manifest_path, &app_root)?;
validate_staged_manifest(path, &staged_manifest_path, &app_root)?;
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, &staged_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_bundle(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_bundle(proj.path(), &app_root, &serde_json::json!({"has_ui": true})).unwrap();
assert!(app_root.join("ui/dist/index.html").exists());
}
#[test]
fn stage_ui_copies_ui_dist_to_legacy_dist_destination() {
let project = tempfile::tempdir().unwrap();
let app_root = project.path().join("app");
std::fs::create_dir_all(project.path().join("ui/dist")).unwrap();
std::fs::write(project.path().join("ui/dist/index.html"), "<html>").unwrap();
stage_ui_bundle(
project.path(),
&app_root,
&serde_json::json!({"has_ui": true, "ui_path": "dist"}),
)
.unwrap();
assert!(app_root.join("dist/index.html").is_file());
assert!(!app_root.join("ui/dist/index.html").exists());
}
#[cfg(unix)]
#[test]
fn stage_ui_rejects_symlinks_instead_of_copying_host_files() {
use std::os::unix::fs::symlink;
let proj = tempfile::tempdir().unwrap();
let app_root = proj.path().join("app");
std::fs::create_dir_all(proj.path().join("ui/dist")).unwrap();
let host_file = proj.path().join("outside-secret.txt");
std::fs::write(&host_file, "host secret").unwrap();
symlink(&host_file, proj.path().join("ui/dist/leak.txt")).unwrap();
let error = stage_ui_bundle(
proj.path(),
&app_root,
&serde_json::json!({
"has_ui": true,
"ui_path": "ui/dist"
}),
)
.unwrap_err();
assert!(error.to_string().contains("symlink"));
assert!(!app_root.join("ui/dist/leak.txt").exists());
}
#[test]
fn write_staged_manifest_rehashes_ui_without_touching_source() {
let proj = tempfile::tempdir().unwrap();
let app_root = proj.path().join("staging/usr/lib/node/apps/example");
std::fs::create_dir_all(app_root.join("ui/dist")).unwrap();
std::fs::write(app_root.join("ui/dist/main.js"), "export const x = 1;\n").unwrap();
std::fs::write(app_root.join("ui/dist/icon.svg"), "<svg/>").unwrap();
let source_manifest = proj.path().join("manifest.json");
std::fs::write(
&source_manifest,
serde_json::to_string_pretty(&serde_json::json!({
"name": "example",
"version": "0.1.0",
"app_type": "bun",
"has_ui": true,
"ui_path": "ui/dist",
"ui": {
"kind": "stage",
"entry": "ui/dist/main.js",
"title": "Example",
"icon": "ui/dist/icon.svg",
"ui_api": 1,
"integrity": {
"ui/dist/main.js": "a".repeat(64),
"ui/dist/icon.svg": "b".repeat(64)
}
}
}))
.unwrap(),
)
.unwrap();
let original = std::fs::read(&source_manifest).unwrap();
let staged = write_staged_manifest(&source_manifest, &app_root).unwrap();
let value: serde_json::Value =
serde_json::from_slice(&std::fs::read(staged).unwrap()).unwrap();
assert_eq!(original, std::fs::read(&source_manifest).unwrap());
assert_ne!(
value["ui"]["integrity"]["ui/dist/main.js"].as_str().unwrap(),
&"a".repeat(64)
);
assert_eq!(
value["ui"]["integrity"]["ui/dist/main.js"].as_str().unwrap(),
sha256_hex(b"export const x = 1;\n")
);
}
#[test]
fn stage_only_shortcut_is_guarded_by_debug_builds() {
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _guard = ENV_LOCK.lock().unwrap();
let key = "NODE_APP_PACKAGE_STAGE_ONLY";
let previous = std::env::var_os(key);
std::env::set_var(key, "1");
assert_eq!(stage_only_shortcut_enabled(), cfg!(debug_assertions));
match previous {
Some(value) => std::env::set_var(key, value),
None => std::env::remove_var(key),
}
}
}