use anyhow::{bail, Context, Result};
use include_dir::{include_dir, Dir, DirEntry};
use std::io::Write;
use std::path::{Path, PathBuf};
static PROFILE_BUN: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/profiles/bun");
static PROFILE_BUN_STAGE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/profiles/bun-stage");
static PROFILE_STAGE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/profiles/stage");
static PROFILE_BUN_FULLSTACK: Dir<'_> =
include_dir!("$CARGO_MANIFEST_DIR/profiles/bun-fullstack");
static PROFILE_NATIVE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/profiles/native");
static PROFILE_NATIVE_FULLSTACK: Dir<'_> =
include_dir!("$CARGO_MANIFEST_DIR/profiles/native-fullstack");
static PROFILE_STANDALONE_BUN: Dir<'_> =
include_dir!("$CARGO_MANIFEST_DIR/profiles/standalone-bun");
static PROFILE_STANDALONE_NATIVE: Dir<'_> =
include_dir!("$CARGO_MANIFEST_DIR/profiles/standalone-native");
#[derive(Debug, Copy, Clone, PartialEq, Eq, clap::ValueEnum)]
pub enum Profile {
Bun,
BunStage,
Stage,
BunFullstack,
Native,
NativeFullstack,
StandaloneBun,
StandaloneNative,
}
impl Profile {
fn embedded(self) -> &'static Dir<'static> {
match self {
Profile::Bun => &PROFILE_BUN,
Profile::BunStage => &PROFILE_BUN_STAGE,
Profile::Stage => &PROFILE_STAGE,
Profile::BunFullstack => &PROFILE_BUN_FULLSTACK,
Profile::Native => &PROFILE_NATIVE,
Profile::NativeFullstack => &PROFILE_NATIVE_FULLSTACK,
Profile::StandaloneBun => &PROFILE_STANDALONE_BUN,
Profile::StandaloneNative => &PROFILE_STANDALONE_NATIVE,
}
}
fn label(self) -> &'static str {
match self {
Profile::Bun => "bun",
Profile::BunStage => "bun-stage",
Profile::Stage => "stage",
Profile::BunFullstack => "bun-fullstack",
Profile::Native => "native",
Profile::NativeFullstack => "native-fullstack",
Profile::StandaloneBun => "standalone-bun",
Profile::StandaloneNative => "standalone-native",
}
}
fn requires_friction_confirm(self) -> bool {
matches!(self, Profile::StandaloneBun | Profile::StandaloneNative)
}
fn alternative_for_friction(self) -> &'static str {
match self {
Profile::StandaloneBun => "--profile bun",
Profile::StandaloneNative => "--profile native",
_ => "--profile bun",
}
}
}
#[derive(Debug)]
struct Substitutions {
name: String,
description: String,
author: String,
author_email: String,
}
impl Substitutions {
fn apply(&self, content: &str) -> String {
content
.replace("{{name}}", &self.name)
.replace("{{description}}", &self.description)
.replace("{{author}}", &self.author)
.replace("{{author_email}}", &self.author_email)
}
}
#[derive(Debug)]
pub struct ProfileScaffoldArgs {
pub name: String,
pub profile: Profile,
pub output_dir: Option<PathBuf>,
pub description: Option<String>,
pub author: Option<String>,
pub author_email: Option<String>,
pub force: bool,
pub yes: bool,
}
pub fn run(args: ProfileScaffoldArgs) -> Result<()> {
validate_name(&args.name)?;
if args.profile.requires_friction_confirm() && !args.yes
&& !confirm_standalone(args.profile)? {
println!("Aborted.");
return Ok(());
}
let target = args
.output_dir
.clone()
.unwrap_or_else(|| PathBuf::from(&args.name));
if target.exists() && !args.force {
bail!(
"target directory already exists: {} (use --force to overwrite)",
target.display()
);
}
let subs = Substitutions {
name: args.name.clone(),
description: args
.description
.unwrap_or_else(|| format!("A node-app: {}", args.name)),
author: args.author.unwrap_or_default(),
author_email: args.author_email.unwrap_or_default(),
};
std::fs::create_dir_all(&target)
.with_context(|| format!("create target dir {}", target.display()))?;
write_dir(&target, args.profile.embedded(), &subs)?;
println!(
"✓ scaffolded {} ({} profile) at {}",
args.name,
args.profile.label(),
target.display()
);
println!();
println!("Next steps:");
println!(" cd {}", target.display());
match args.profile {
Profile::Bun
| Profile::BunStage
| Profile::Stage
| Profile::BunFullstack
| Profile::StandaloneBun => {
println!(" bun install");
}
Profile::Native | Profile::NativeFullstack | Profile::StandaloneNative => {
println!(" cargo check");
}
}
if args.profile == Profile::Stage {
println!(" bun run build # stamps manifest.json's ui.integrity");
}
println!(" node-app audit # check this app against the blueprint");
println!(" node-app dev # hot-reload dev loop");
Ok(())
}
fn confirm_standalone(profile: Profile) -> Result<bool> {
println!();
println!("⚠ You're scaffolding a STANDALONE app.");
println!();
println!(" Standalone apps run as their own systemd unit. They are explicitly");
println!(
" EXCLUDED from the shared Bun runtime memory-saving feature (econ-v1/node#810)"
);
println!(" because the platform's BunLoader does not own their lifecycle.");
println!();
println!(" Use standalone only when you genuinely need:");
println!(" - Own systemd unit (LCD daemons, OTA controllers, hardware bridges)");
println!(" - Independent process lifecycle the platform shouldn't own");
println!();
println!(
" For most apps, prefer {} instead.",
profile.alternative_for_friction()
);
println!();
print!(" Continue with standalone? [y/N] ");
std::io::stdout().flush().context("flush stdout")?;
let mut answer = String::new();
std::io::stdin()
.read_line(&mut answer)
.context("read confirmation")?;
Ok(matches!(answer.trim().to_lowercase().as_str(), "y" | "yes"))
}
fn validate_name(name: &str) -> Result<()> {
if name.is_empty() {
bail!("name must not be empty");
}
let bytes = name.as_bytes();
if !bytes[0].is_ascii_lowercase() {
bail!(
"name must start with a lowercase letter (got {:?}); use lowercase letters, digits, and hyphens only",
name
);
}
for &b in bytes {
let ok = b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-';
if !ok {
bail!(
"name must contain only lowercase letters, digits, and hyphens (got {:?})",
name
);
}
}
if name.ends_with('-') {
bail!("name must not end with a hyphen (got {:?})", name);
}
if name.contains("--") {
bail!("name must not contain consecutive hyphens (got {:?})", name);
}
Ok(())
}
const CARGO_MANIFEST_TEMPLATE: &str = "Cargo.toml.template";
fn rendered_fixture_basename(basename: &str, subs: &Substitutions) -> String {
if basename == CARGO_MANIFEST_TEMPLATE {
"Cargo.toml".to_string()
} else {
subs.apply(basename)
}
}
fn write_dir(dest: &Path, dir: &Dir<'_>, subs: &Substitutions) -> Result<()> {
for entry in dir.entries() {
match entry {
DirEntry::Dir(subdir) => {
let basename = subdir
.path()
.file_name()
.with_context(|| format!("embedded subdir has no basename: {:?}", subdir.path()))?
.to_str()
.with_context(|| format!("embedded subdir basename is not UTF-8: {:?}", subdir.path()))?;
let rendered_basename = subs.apply(basename);
let subdest = dest.join(&rendered_basename);
std::fs::create_dir_all(&subdest)
.with_context(|| format!("create {}", subdest.display()))?;
write_dir(&subdest, subdir, subs)?;
}
DirEntry::File(file) => {
let basename = file
.path()
.file_name()
.with_context(|| format!("embedded file has no basename: {:?}", file.path()))?
.to_str()
.with_context(|| format!("embedded file basename is not UTF-8: {:?}", file.path()))?;
let rendered_basename = rendered_fixture_basename(basename, subs);
let path = dest.join(&rendered_basename);
let raw = file.contents();
let content = std::str::from_utf8(raw).with_context(|| {
format!("embedded file {:?} is not UTF-8", file.path())
})?;
let rendered = subs.apply(content);
std::fs::write(&path, rendered)
.with_context(|| format!("write {}", path.display()))?;
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn name_validation_accepts_simple() {
assert!(validate_name("foo").is_ok());
assert!(validate_name("foo-bar").is_ok());
assert!(validate_name("foo123").is_ok());
assert!(validate_name("a").is_ok());
}
#[test]
fn name_validation_rejects_bad() {
assert!(validate_name("").is_err());
assert!(validate_name("Foo").is_err());
assert!(validate_name("foo_bar").is_err());
assert!(validate_name("123foo").is_err());
assert!(validate_name("foo-").is_err());
assert!(validate_name("-foo").is_err());
assert!(validate_name("foo--bar").is_err());
assert!(validate_name("foo bar").is_err());
assert!(validate_name("foo.bar").is_err());
}
#[test]
fn substitution_replaces_all_placeholders() {
let subs = Substitutions {
name: "my-app".to_string(),
description: "A test app".to_string(),
author: "Daisy".to_string(),
author_email: "daisy@example.com".to_string(),
};
let input = "name={{name}} desc={{description}} who={{author}}<{{author_email}}>";
let got = subs.apply(input);
assert_eq!(
got,
"name=my-app desc=A test app who=Daisy<daisy@example.com>"
);
}
#[test]
fn bun_profile_fixture_has_required_files() {
let dir = PROFILE_BUN.entries();
let names: Vec<_> = dir
.iter()
.filter_map(|e| e.path().file_name().and_then(|s| s.to_str()))
.collect();
for required in [
"manifest.json",
"package.json",
"node-app.toml",
"AGENTS.md",
"README.md",
".gitignore",
"src",
] {
assert!(
names.contains(&required),
"embedded bun profile missing {:?}; got {:?}",
required,
names
);
}
}
#[test]
fn bun_profile_manifest_has_no_unsubstituted_placeholders_after_render() {
let subs = Substitutions {
name: "demo-app".to_string(),
description: "demo".to_string(),
author: "T".to_string(),
author_email: "t@example.com".to_string(),
};
let manifest = PROFILE_BUN
.get_file("manifest.json")
.expect("manifest.json must exist in embedded bun profile");
let content = std::str::from_utf8(manifest.contents()).unwrap();
let rendered = subs.apply(content);
assert!(
!rendered.contains("{{"),
"rendered manifest still contains an unsubstituted placeholder:\n{}",
rendered
);
assert!(
rendered.contains("\"app_type\": \"bun\""),
"rendered manifest must keep app_type=bun:\n{}",
rendered
);
}
#[test]
fn bun_fullstack_profile_has_ui_directory_and_has_ui_flag() {
let ui_pkg = PROFILE_BUN_FULLSTACK
.get_file("ui/package.json")
.expect("bun-fullstack profile must ship ui/package.json");
let content = std::str::from_utf8(ui_pkg.contents()).unwrap();
assert!(
content.contains("\"react\""),
"bun-fullstack ui/package.json must depend on React"
);
let manifest = PROFILE_BUN_FULLSTACK
.get_file("manifest.json")
.expect("bun-fullstack profile must ship manifest.json");
let manifest_str = std::str::from_utf8(manifest.contents()).unwrap();
assert!(
manifest_str.contains("\"has_ui\": true"),
"bun-fullstack manifest must set has_ui=true"
);
assert!(
manifest_str.contains("\"app_type\": \"bun\""),
"bun-fullstack manifest must keep app_type=bun (shared-runtime eligible)"
);
}
#[test]
fn bun_stage_profile_is_framework_free_and_stage_shaped() {
let ui_pkg = PROFILE_BUN_STAGE
.get_file("ui/package.json")
.expect("bun-stage profile must ship ui/package.json");
let content = std::str::from_utf8(ui_pkg.contents()).unwrap();
assert!(
!content.contains("\"react\"") && !content.contains("\"vue\""),
"bun-stage ui/package.json must not depend on React or Vue"
);
let manifest = PROFILE_BUN_STAGE
.get_file("manifest.json")
.expect("bun-stage profile must ship manifest.json");
let manifest_str = std::str::from_utf8(manifest.contents()).unwrap();
assert!(manifest_str.contains("\"kind\": \"stage\""));
assert!(manifest_str.contains("\"entry\": \"ui/dist/main.js\""));
assert!(manifest_str.contains("\"has_ui\": true"));
assert!(
!manifest_str.contains("\"integrity\""),
"bun-stage manifest must leave ui.integrity for staged generation only"
);
}
#[test]
fn stage_profile_is_framework_free_and_ships_placeholder_integrity() {
let ui_pkg = PROFILE_STAGE
.get_file("ui/package.json")
.expect("stage profile must ship ui/package.json");
let content = std::str::from_utf8(ui_pkg.contents()).unwrap();
assert!(
!content.contains("\"react\"") && !content.contains("\"vue\""),
"stage ui/package.json must not depend on React or Vue"
);
assert!(
content.contains("stamp-integrity.mjs"),
"stage ui/package.json build script must wire in the integrity stamping step"
);
let manifest = PROFILE_STAGE
.get_file("manifest.json")
.expect("stage profile must ship manifest.json");
let manifest_str = std::str::from_utf8(manifest.contents()).unwrap();
assert!(manifest_str.contains("\"kind\": \"stage\""));
assert!(manifest_str.contains("\"entry\": \"ui/dist/main.js\""));
assert!(manifest_str.contains("\"has_ui\": true"));
assert!(
manifest_str.contains("\"integrity\": {}"),
"stage manifest must ship an explicit (empty) ui.integrity placeholder that \
`ui/scripts/stamp-integrity.mjs` fills in on build:\n{}",
manifest_str
);
assert!(
PROFILE_STAGE
.get_file("ui/scripts/stamp-integrity.mjs")
.is_some(),
"stage profile must ship ui/scripts/stamp-integrity.mjs"
);
}
#[test]
fn stage_profile_manifest_has_no_unsubstituted_placeholders_after_render() {
let subs = Substitutions {
name: "demo-stage".to_string(),
description: "demo".to_string(),
author: "T".to_string(),
author_email: "t@example.com".to_string(),
};
for path in [
"manifest.json",
"package.json",
"src/index.ts",
"ui/package.json",
"ui/src/main.ts",
] {
let file = PROFILE_STAGE
.get_file(path)
.unwrap_or_else(|| panic!("stage profile must ship {}", path));
let content = std::str::from_utf8(file.contents()).unwrap();
let rendered = subs.apply(content);
assert!(
!rendered.contains("{{"),
"rendered {} still contains an unsubstituted placeholder:\n{}",
path,
rendered
);
}
}
#[test]
fn native_profile_manifest_uses_native_app_type() {
let manifest = PROFILE_NATIVE
.get_file("manifest.json")
.expect("native profile must ship manifest.json");
let content = std::str::from_utf8(manifest.contents()).unwrap();
assert!(
content.contains("\"app_type\": \"native\""),
"native manifest must set app_type=native"
);
}
#[test]
fn native_profile_has_cargo_and_lib_rs() {
assert!(
PROFILE_NATIVE.get_file(CARGO_MANIFEST_TEMPLATE).is_some(),
"native profile must ship a Cargo manifest template"
);
assert!(
PROFILE_NATIVE.get_file("src/lib.rs").is_some(),
"native profile must ship src/lib.rs"
);
}
#[test]
fn native_profile_ships_release_workflow_and_deb_metadata() {
let workflow = PROFILE_NATIVE
.get_file(".github/workflows/release.yml")
.expect("native profile must ship .github/workflows/release.yml");
let workflow_str = std::str::from_utf8(workflow.contents()).unwrap();
assert!(
workflow_str.contains("cargo deb"),
"release workflow must invoke cargo deb"
);
assert!(
workflow_str.contains("aarch64-unknown-linux-gnu"),
"release workflow must cross-build for arm64"
);
assert!(
workflow_str.contains("econ-v1/node-releases"),
"release workflow must dispatch the apt-repo aggregator"
);
let cargo = PROFILE_NATIVE
.get_file(CARGO_MANIFEST_TEMPLATE)
.expect("native profile must ship a Cargo manifest template");
let cargo_str = std::str::from_utf8(cargo.contents()).unwrap();
assert!(
cargo_str.contains("[package.metadata.deb]"),
"native Cargo.toml must declare [package.metadata.deb] for cargo-deb"
);
assert!(
cargo_str.contains("node-runtime-abi-1"),
"native cargo-deb metadata must depend on node-runtime-abi-1"
);
assert!(
cargo_str.contains("libnode_app_{{name}}.so"),
"native cargo-deb assets must stage libnode_app_<name>.so"
);
assert!(
cargo_str.contains("usr/lib/node/apps/{{name}}/"),
"native cargo-deb assets must install under /usr/lib/node/apps/<name>/"
);
}
#[test]
fn native_profile_cargo_renders_placeholders_in_deb_metadata() {
let subs = Substitutions {
name: "myapp".to_string(),
description: "A test app".to_string(),
author: "Test Author".to_string(),
author_email: "test@example.com".to_string(),
};
let cargo = PROFILE_NATIVE.get_file(CARGO_MANIFEST_TEMPLATE).unwrap();
let rendered = subs.apply(std::str::from_utf8(cargo.contents()).unwrap());
assert!(
!rendered.contains("{{"),
"rendered Cargo.toml still contains an unsubstituted placeholder:\n{}",
rendered
);
assert!(
rendered.contains("libnode_app_myapp.so"),
"rendered Cargo.toml must reference libnode_app_myapp.so:\n{}",
rendered
);
assert!(
rendered.contains("usr/lib/node/apps/myapp/"),
"rendered Cargo.toml must reference usr/lib/node/apps/myapp/:\n{}",
rendered
);
assert!(
rendered.contains("Test Author <test@example.com>"),
"rendered Cargo.toml must carry the maintainer:\n{}",
rendered
);
}
#[test]
fn native_fullstack_profile_has_ui_and_lib_rs() {
assert!(
PROFILE_NATIVE_FULLSTACK.get_file("src/lib.rs").is_some(),
"native-fullstack profile must ship src/lib.rs"
);
assert!(
PROFILE_NATIVE_FULLSTACK
.get_file("ui/package.json")
.is_some(),
"native-fullstack profile must ship ui/package.json"
);
let manifest = PROFILE_NATIVE_FULLSTACK
.get_file("manifest.json")
.expect("native-fullstack must ship manifest.json");
let content = std::str::from_utf8(manifest.contents()).unwrap();
assert!(
content.contains("\"has_ui\": true"),
"native-fullstack manifest must set has_ui=true"
);
assert!(
content.contains("\"app_type\": \"native\""),
"native-fullstack manifest must keep app_type=native"
);
}
#[test]
fn standalone_profiles_have_systemd_unit() {
let unit_bun = PROFILE_STANDALONE_BUN
.get_file("systemd/node-app-{{name}}.service");
assert!(
unit_bun.is_some(),
"standalone-bun profile must ship systemd/node-app-{{{{name}}}}.service"
);
let unit_native = PROFILE_STANDALONE_NATIVE
.get_file("systemd/node-app-{{name}}.service");
assert!(
unit_native.is_some(),
"standalone-native profile must ship systemd/node-app-{{{{name}}}}.service"
);
}
#[test]
fn standalone_profiles_use_standalone_app_type() {
for (label, profile_dir) in [
("standalone-bun", &PROFILE_STANDALONE_BUN),
("standalone-native", &PROFILE_STANDALONE_NATIVE),
] {
let manifest = profile_dir
.get_file("manifest.json")
.unwrap_or_else(|| panic!("{} must ship manifest.json", label));
let content = std::str::from_utf8(manifest.contents()).unwrap();
assert!(
content.contains("\"app_type\": \"standalone\""),
"{} manifest must set app_type=standalone, got:\n{}",
label,
content
);
}
}
#[test]
fn standalone_profiles_require_friction_confirm() {
assert!(Profile::StandaloneBun.requires_friction_confirm());
assert!(Profile::StandaloneNative.requires_friction_confirm());
assert!(!Profile::Bun.requires_friction_confirm());
assert!(!Profile::BunFullstack.requires_friction_confirm());
assert!(!Profile::Native.requires_friction_confirm());
assert!(!Profile::NativeFullstack.requires_friction_confirm());
}
#[test]
fn filename_placeholder_substitution() {
let subs = Substitutions {
name: "my-app".to_string(),
description: "demo".to_string(),
author: "T".to_string(),
author_email: "t@example.com".to_string(),
};
let rendered = subs.apply("node-app-{{name}}.service");
assert_eq!(rendered, "node-app-my-app.service");
}
#[test]
fn cargo_manifest_template_is_materialized_as_cargo_toml() {
let subs = Substitutions {
name: "my-app".to_string(),
description: "demo".to_string(),
author: "T".to_string(),
author_email: "t@example.com".to_string(),
};
assert_eq!(
rendered_fixture_basename("Cargo.toml.template", &subs),
"Cargo.toml"
);
}
#[test]
fn native_profile_writes_a_real_cargo_manifest() {
let temp = tempfile::tempdir().unwrap();
let subs = Substitutions {
name: "my-app".to_string(),
description: "demo".to_string(),
author: "T".to_string(),
author_email: "t@example.com".to_string(),
};
write_dir(temp.path(), &PROFILE_NATIVE, &subs).unwrap();
assert!(temp.path().join("Cargo.toml").is_file());
assert!(!temp.path().join(CARGO_MANIFEST_TEMPLATE).exists());
}
}