node-app-build 6.4.3

Mini app developer CLI: scaffold, validate, package node-app-* Debian packages
//! Blueprint — codifies node-app conventions as data so the CLI can
//! `show` them, `audit` against them, `build` and `package` apps from them.
//!
//! See econ-v1/node#868 for the multi-phase consolidation plan.
//!
//! ## Versioning
//!
//! The blueprint is versioned. `V1` matches the pre-tightening behavior
//! (wholesale `node_modules/` allowed in the .deb, soft-warn on a small
//! handful of patterns). `V2` (phase 5a of #868) drops wholesale
//! `node_modules/` and replaces it with `private_modules/<pkg>` for the
//! native deps declared in `manifest.json#nodeApp.privateRuntime`.
//!
//! Apps pin compatibility via `manifest.json#nodeApp.blueprint: ">=N"`.
//! The CLI bumps `CURRENT` to the new version each release; apps pinned
//! at lower versions get a one-release-cycle warning grace period before
//! the older variant's hard errors land.

use serde::Serialize;

/// Layout the .deb writes to when staging a node-app under `/usr/lib/node/apps/`.
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[allow(dead_code)] // v1 variant kept as a static reference for blueprint pin diffing
pub enum DebLayout {
    /// Original v1 layout:
    /// `/usr/lib/node/apps/<slug>/{manifest.json, package.json, dist/index.js,
    /// node_modules/, [private_modules/]}` — wholesale `node_modules/` is
    /// permitted (loud warning, not an error).
    PerAppInUsrLibNodeApps,
    /// v2 layout (phase 5a of #868):
    /// `/usr/lib/node/apps/<slug>/{manifest.json, package.json, dist/index.js,
    /// [private_modules/<pkg>/]}` — wholesale `node_modules/` is FORBIDDEN.
    /// Only the packages listed in `manifest.json#nodeApp.privateRuntime`
    /// are staged, each into its own `private_modules/<pkg>` subtree. Apps
    /// without a `privateRuntime` list get NO `private_modules/` directory.
    PerAppMinimal,
}

/// A single auditable rule. The `Pattern` enum stays an enum so the rule set
/// is exhaustive and the audit phase can branch by variant.
#[derive(Debug, Clone, Serialize)]
pub enum Pattern {
    /// The .deb must not stage the wholesale `node_modules/` from the project
    /// root when `dist/index.js` is present.
    ///
    /// Severity is blueprint-version-dependent: warning under v1 (backward
    /// compat for one release cycle), hard error under v2.
    NoWholesaleNodeModulesWhenBundled,
    /// `package.json` versions for SHARED_EXTERNALS must satisfy the
    /// shared-deps pin in `infra/packaging/shared-deps/package.json`.
    SharedExternalsMatchPin,
    /// Manifest `nodeApp.privateRuntime` must list every native dep the app
    /// actually imports (anything under `@img/sharp-*`, `better-sqlite3`'s
    /// native bindings, etc.). Phase 1 only warns; phase 6 fails CI.
    PrivateNativeDepsDeclared,
    /// `package.json` scripts must not use `bun build --compile` for
    /// `app_type: "bun"` apps (incompatible with shared runtime — supervisor
    /// needs JS to spawn as a Worker).
    NoBunBuildCompileForBunApps,
    /// (v2-only) Every package listed in `manifest.json#nodeApp.privateRuntime`
    /// must actually be staged into `private_modules/<pkg>/` when `dist/` is
    /// also present (i.e. the staging output is consistent with the
    /// manifest's declared private runtime). No-op for apps without a
    /// `privateRuntime` list.
    PrivateNativeDepsInPrivateModulesDir,
}

#[derive(Debug, Clone, Serialize)]
pub struct Blueprint {
    /// Blueprint version. Apps pin compatibility via
    /// `manifest.json#nodeApp.blueprint: ">=N"`.
    pub version: u32,
    /// Manifest schema version the CLI understands.
    pub manifest_schema: u32,
    /// Externalized at bundle time; resolved at runtime via NODE_PATH from
    /// `/usr/lib/node/shared/node_modules`. Source of truth: keep in lock-step
    /// with `infra/scripts/build-bun-app.sh`'s `SHARED_EXTERNALS` array and
    /// the dependencies in `infra/packaging/shared-deps/package.json`.
    pub shared_externals: &'static [&'static str],
    /// Apt packages every node-app's .deb should `Depends:` on.
    pub apt_dep_chain: &'static [&'static str],
    /// Where staged files land in the .deb tree.
    pub deb_layout: DebLayout,
    /// Auditable rules surfaced via `node-app audit`.
    pub patterns: &'static [Pattern],
}

const SHARED_EXTERNALS: &[&str] = &[
    "@anthropic-ai/sdk",
    "openai",
    "@google/genai",
    "@mistralai/mistralai",
    "zod",
    // Native CJS addon (.node binary) used by node-app-distributed-backup's
    // erasure coding — `bun build` cannot bundle it, so keep it external and
    // resolve it at runtime from the app's staged node_modules.
    "@ronomon/reed-solomon",
];

const APT_DEP_CHAIN: &[&str] = &["node-app-bun-runtime"];

/// Blueprint v1 — matches the pre-tightening behavior. Wholesale
/// `node_modules/` is permitted (warned, not errored), private native deps
/// are only soft-checked. Kept around as a static reference so apps that
/// pin `nodeApp.blueprint: ">=1"` still have a known target to satisfy.
#[allow(dead_code)] // referenced by docs + future per-pin diffing; CURRENT = V2 today
pub const V1: Blueprint = Blueprint {
    version: 1,
    manifest_schema: 2,
    shared_externals: SHARED_EXTERNALS,
    apt_dep_chain: APT_DEP_CHAIN,
    deb_layout: DebLayout::PerAppInUsrLibNodeApps,
    patterns: &[
        Pattern::NoWholesaleNodeModulesWhenBundled,
        Pattern::SharedExternalsMatchPin,
        Pattern::PrivateNativeDepsDeclared,
        Pattern::NoBunBuildCompileForBunApps,
    ],
};

/// Blueprint v2 (phase 5a of #868) — tightens packaging:
///   - `DebLayout::PerAppMinimal`: NO wholesale `node_modules/` in the .deb.
///   - `private_modules/<pkg>` staged ONLY for packages declared in
///     `manifest.json#nodeApp.privateRuntime`.
///   - `Pattern::NoWholesaleNodeModulesWhenBundled` is a hard error (the
///     audit command upgrades severity when the app pins `blueprint: ">=2"`).
///   - `Pattern::PrivateNativeDepsInPrivateModulesDir`: cross-check that
///     declared private deps are actually staged.
pub const V2: Blueprint = Blueprint {
    version: 2,
    manifest_schema: 2,
    shared_externals: SHARED_EXTERNALS,
    apt_dep_chain: APT_DEP_CHAIN,
    deb_layout: DebLayout::PerAppMinimal,
    patterns: &[
        Pattern::NoWholesaleNodeModulesWhenBundled,
        Pattern::SharedExternalsMatchPin,
        Pattern::PrivateNativeDepsDeclared,
        Pattern::NoBunBuildCompileForBunApps,
        Pattern::PrivateNativeDepsInPrivateModulesDir,
    ],
};

/// The blueprint version this CLI release embeds. Phase 5a of #868 bumps
/// `CURRENT` from V1 to V2.
pub const CURRENT: Blueprint = V2;

/// The platform `.deb` package name. The platform was renamed `econ-v1` → `node`;
/// `econ-v1` survives only as a transitional dummy package. node-app control
/// files must therefore depend on `node`, never `econ-v1`.
pub const PLATFORM_PACKAGE: &str = "node";

/// `Depends:` packages for a given `app_type`.
///
/// - `bun` apps run on the shared Bun runtime → depend on `node-app-bun-runtime`.
/// - `native` cdylibs are dlopened in-process → depend on the ABI-compat
///   package `node-runtime-abi-1` (the real compatibility contract; no platform
///   version floor).
/// - `standalone` apps run as their own systemd service, independent of the
///   platform → no hard dependency (see [`recommends`]).
pub fn apt_deps(app_type: &str) -> &'static [&'static str] {
    match app_type {
        "bun" => &["node-app-bun-runtime"],
        "native" => &["node-runtime-abi-1"],
        _ => &[],
    }
}

/// `Recommends:` packages for a given `app_type`. Standalone apps talk to the
/// platform over `/run/node/control.sock` when it is present, but do not require
/// it — expressed as a soft `Recommends: node`.
pub fn recommends(app_type: &str) -> &'static [&'static str] {
    match app_type {
        "standalone" => &[PLATFORM_PACKAGE],
        _ => &[],
    }
}

#[cfg(test)]
mod dep_tests {
    use super::*;

    #[test]
    fn deps_by_app_type_never_reference_econ_v1() {
        assert_eq!(apt_deps("bun"), &["node-app-bun-runtime"]);
        assert_eq!(apt_deps("native"), &["node-runtime-abi-1"]);
        assert!(apt_deps("standalone").is_empty());
        assert_eq!(recommends("standalone"), &["node"]);
        assert!(recommends("bun").is_empty());

        for t in ["bun", "native", "standalone"] {
            for dep in apt_deps(t).iter().chain(recommends(t)) {
                assert!(!dep.contains("econ-v1"), "{t} dep leaks econ-v1: {dep}");
            }
        }
        assert_eq!(PLATFORM_PACKAGE, "node");
    }
}