use crate::manifest::{parse_manifest, validate_manifest, AppManifest, AppType};
use anyhow::{bail, Context, Result};
use std::path::{Path, PathBuf};
pub fn run(project_path: &Path) -> Result<AppManifest> {
let project_path = project_path.canonicalize().with_context(|| {
format!(
"could not canonicalize project path '{}'",
project_path.display()
)
})?;
let manifest_path = project_path.join("manifest.json");
if !manifest_path.exists() {
bail!(
"no manifest.json at {} — did you run `node-app new` first?",
manifest_path.display()
);
}
let manifest = parse_manifest(&manifest_path)?;
let is_apt_target = true;
let signed_pathway = detects_signing_pipeline(&project_path);
validate_manifest(&manifest, is_apt_target, signed_pathway)?;
let ep = 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 entry_full = project_path.join(&ep);
if !entry_full.exists() {
match manifest.app_type {
AppType::Bun => {
eprintln!(
"ℹ entrypoint '{}' not found yet (will be built during `package`)",
ep
);
}
AppType::Native => {
eprintln!(
"ℹ entrypoint '{}' not found yet (cdylib build artifact missing)",
ep
);
}
AppType::Standalone => {
eprintln!(
"ℹ entrypoint '{}' not found yet (binary build artifact missing; run `cargo build --release`)",
ep
);
}
AppType::PlatformRuntime => {
eprintln!(
"ℹ runtime entrypoint '{}' not found yet (package build artifact missing)",
ep
);
}
}
}
if manifest.has_ui {
let ui_full: PathBuf = project_path.join(&manifest.ui_path);
if !ui_full.is_dir() {
eprintln!(
"ℹ has_ui=true but {} is not yet a directory (build the UI before packaging)",
ui_full.display()
);
}
}
println!("✓ schema v{} valid", manifest.manifest_version);
println!("✓ all capabilities recognised");
if let Some(abi) = &manifest.abi {
println!("✓ ABI {} supported", abi);
} else {
println!("✓ ABI default (v1) supported");
}
match manifest.app_type {
AppType::Native => {
println!("✓ tier policy ok (native app — org-signed FirstParty pipeline detected)");
}
AppType::Bun => println!("✓ tier policy ok (bun app)"),
AppType::Standalone => println!("✓ tier policy ok (standalone app)"),
AppType::PlatformRuntime => println!("✓ tier policy ok (platform runtime package)"),
}
Ok(manifest)
}
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
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn write_workflow(dir: &Path, name: &str, body: &str) {
let wf = dir.join(".github").join("workflows");
fs::create_dir_all(&wf).unwrap();
fs::write(wf.join(name), body).unwrap();
}
#[test]
fn detects_signed_release_workflow() {
let proj = tempfile::tempdir().unwrap();
write_workflow(
proj.path(),
"release.yml",
"steps:\n - name: Publish manifest sidecar\n run: |\n cp manifest.json \"$SIDECAR\"\n gpg --detach-sign --armor \"$SIDECAR\"\n",
);
assert!(detects_signing_pipeline(proj.path()));
}
#[test]
fn no_workflows_dir_is_not_signed() {
let proj = tempfile::tempdir().unwrap();
assert!(!detects_signing_pipeline(proj.path()));
}
#[test]
fn workflow_without_signing_is_not_signed() {
let proj = tempfile::tempdir().unwrap();
write_workflow(
proj.path(),
"ci.yml",
"steps:\n - run: cargo build --release\n",
);
assert!(!detects_signing_pipeline(proj.path()));
}
#[test]
fn unrelated_gpg_without_sidecar_is_not_signed() {
let proj = tempfile::tempdir().unwrap();
write_workflow(
proj.path(),
"release.yml",
"steps:\n - run: gpg --detach-sign --armor artifact.bin\n",
);
assert!(!detects_signing_pipeline(proj.path()));
}
#[test]
fn detects_signing_in_yaml_extension() {
let proj = tempfile::tempdir().unwrap();
write_workflow(
proj.path(),
"publish.yaml",
"run: |\n SIDECAR=\"out/app.json\"\n gpg --detach-sign --armor \"$SIDECAR\"\n",
);
assert!(detects_signing_pipeline(proj.path()));
}
#[test]
fn broad_manifests_token_alone_is_not_signed() {
let proj = tempfile::tempdir().unwrap();
write_workflow(
proj.path(),
"release.yml",
"steps:\n - run: gpg --detach-sign --armor artifact.bin\n - run: cp x Manifests/\n",
);
assert!(!detects_signing_pipeline(proj.path()));
}
}