use crate::AppKind;
use anyhow::{anyhow, bail, Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use walkdir::WalkDir;
#[derive(Clone, Debug)]
struct MaintainerInfo {
name: String,
email: String,
combined: String,
}
fn get_git_config(key: &str) -> Option<String> {
let out = Command::new("git").args(["config", key]).output().ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if s.is_empty() { None } else { Some(s) }
}
fn resolve_maintainer(cli_name: Option<&str>, cli_email: Option<&str>) -> MaintainerInfo {
let name = cli_name
.map(str::to_string)
.or_else(|| get_git_config("user.name"))
.unwrap_or_else(|| "Unknown".to_string());
let email = cli_email
.map(str::to_string)
.or_else(|| get_git_config("user.email"))
.unwrap_or_else(|| "unknown@example.com".to_string());
let combined = format!("{} <{}>", name, email);
MaintainerInfo { name, email, combined }
}
fn current_year() -> String {
Command::new("date")
.arg("+%Y")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "2026".to_string())
}
pub const DEFAULT_TEMPLATES_REPO: &str = "econ-v1/node-app-templates";
fn family_dir_name(kind: AppKind) -> &'static str {
match kind {
AppKind::Bun
| AppKind::BunFullstack
| AppKind::StandaloneBun
| AppKind::StandaloneBunFullstack => "bun",
AppKind::Cdylib
| AppKind::CdylibFullstack
| AppKind::StandaloneRust
| AppKind::StandaloneRustFullstack => "cdylib",
}
}
fn family_github_repo(kind: AppKind) -> &'static str {
match kind {
AppKind::Bun
| AppKind::BunFullstack
| AppKind::StandaloneBun
| AppKind::StandaloneBunFullstack => "econ-v1/node-app-template-bun",
AppKind::Cdylib
| AppKind::CdylibFullstack
| AppKind::StandaloneRust
| AppKind::StandaloneRustFullstack => "econ-v1/node-app-template-cdylib",
}
}
pub fn run(
name_hint: Option<String>,
kind_hint: Option<AppKind>,
out: Option<PathBuf>,
git: bool,
github: Option<String>,
no_deps_update: bool,
templates_repo: String,
maintainer_name: Option<String>,
maintainer_email: Option<String>,
) -> Result<()> {
let maintainer = resolve_maintainer(maintainer_name.as_deref(), maintainer_email.as_deref());
let copyright_year = current_year();
let args = match (name_hint, kind_hint) {
(Some(name), Some(kind)) => ScaffoldArgs {
name,
kind,
description: "A Node mini app".to_string(),
systemd_order: String::new(),
tcp: TcpManifestFields::default(),
out,
git: git || github.is_some(),
github,
run_deps_update: !no_deps_update,
templates_repo,
maintainer,
copyright_year,
},
(name_hint, kind_hint) => {
let wiz = wizard_prompt(name_hint, kind_hint, git, github)?;
ScaffoldArgs {
name: wiz.name,
kind: wiz.kind,
description: wiz.description,
systemd_order: wiz.systemd_order,
tcp: wiz.tcp,
out,
git: wiz.git,
github: wiz.github,
run_deps_update: !no_deps_update,
templates_repo,
maintainer,
copyright_year,
}
}
};
scaffold(args)
}
struct ScaffoldArgs {
name: String,
kind: AppKind,
description: String,
systemd_order: String,
tcp: TcpManifestFields,
out: Option<PathBuf>,
git: bool,
github: Option<String>,
run_deps_update: bool,
templates_repo: String,
maintainer: MaintainerInfo,
copyright_year: String,
}
fn scaffold(args: ScaffoldArgs) -> Result<()> {
validate_name(&args.name)?;
if let Some(ref slug) = args.github {
validate_github_slug(slug)?;
}
let dest = args
.out
.clone()
.unwrap_or_else(|| PathBuf::from(format!("node-app-{}", args.name)));
if dest.exists() && fs::read_dir(&dest)?.next().is_some() {
bail!(
"destination '{}' already exists and is not empty",
dest.display()
);
}
fs::create_dir_all(&dest)
.with_context(|| format!("create destination {}", dest.display()))?;
let templates_is_local = PathBuf::from(&args.templates_repo).is_dir();
let use_github_template_flow = args.github.is_some() && !templates_is_local;
if use_github_template_flow {
let slug = args.github.as_deref().expect("guarded above");
let template_repo = if args.templates_repo == DEFAULT_TEMPLATES_REPO {
family_github_repo(args.kind)
} else {
args.templates_repo.as_str()
};
github_template_scaffold(
&dest,
slug,
template_repo,
args.kind,
&args.name,
&args.description,
&args.systemd_order,
&args.maintainer,
&args.copyright_year,
)?;
} else {
fetch_and_write_template(
&args.templates_repo,
args.kind,
&dest,
&args.name,
&args.description,
&args.systemd_order,
&args.maintainer,
&args.copyright_year,
)?;
}
strip_for_kind(&dest, args.kind)?;
verify_scaffold(&dest, args.kind)?;
patch_sdk_versions(&dest);
match args.kind {
AppKind::Cdylib | AppKind::CdylibFullstack => {
eprintln!(
"⚠ Reminder: cdylib (native) apps must be GPG-signed by a node \
org keyring to load as FirstParty tier in production. The standalone \
release.yml workflow handles this automatically when you push a tag."
);
}
AppKind::StandaloneRust | AppKind::StandaloneBun => {
eprintln!(
"ℹ Standalone apps run as their own systemd service and are NOT \
loaded by the node platform. They communicate with the platform \
via /run/node/control.sock (JSON-RPC 2.0) when it is available."
);
}
AppKind::StandaloneRustFullstack | AppKind::StandaloneBunFullstack => {
eprintln!(
"ℹ Standalone fullstack apps run as their own systemd service and \
serve their own React UI on a configurable port (NODE_APP_HTTP_PORT, \
default 7000). UI assets are embedded into the binary/bundle for \
single-artifact distribution. They optionally talk to the platform \
via /run/node/control.sock when it is available."
);
}
_ => {}
}
println!(
"✓ Scaffolded {} app '{}' at {}",
args.kind.label(),
args.name,
dest.display()
);
inject_tcp_into_manifest(&dest, &args.tcp)?;
if args.run_deps_update {
run_deps_update(&dest, args.kind)?;
}
if use_github_template_flow {
let slug = args.github.as_deref().expect("guarded above");
commit_and_push_scaffold(&dest, &args.name, slug)?;
upload_release_secrets(slug);
} else {
if args.git {
init_git(&dest, &args.name)?;
}
if let Some(ref slug) = args.github {
create_github_repo(&dest, slug)?;
}
}
println!();
println!("Next steps:");
println!(" cd {}", dest.display());
match args.github.as_deref() {
None => println!(" node-app dev # hot-reload inner-loop"),
Some(slug) => println!(
" node-app dev # hot-reload inner-loop, your repo is on GitHub at {}",
slug
),
}
Ok(())
}
#[derive(Debug, Clone, Default)]
struct TcpManifestFields {
preferred_port: Option<u16>,
direct_bind: bool,
}
struct WizardResult {
name: String,
kind: AppKind,
description: String,
systemd_order: String,
tcp: TcpManifestFields,
git: bool,
github: Option<String>,
}
fn wizard_prompt(
name_hint: Option<String>,
kind_hint: Option<AppKind>,
git_flag: bool,
github_flag: Option<String>,
) -> Result<WizardResult> {
cliclack::intro(" node-app new ").ok();
let name = match name_hint {
Some(n) => n,
None => cliclack::input("App name")
.validate(|s: &String| validate_name(s).map_err(|e| e.to_string()))
.interact()
.context("app name prompt")?,
};
let kind = match kind_hint {
Some(k) => k,
None => {
#[derive(Clone, PartialEq, Eq)]
enum Lang { Bun, Rust }
let lang = cliclack::select("Language")
.item(Lang::Bun, "TypeScript (Bun)", "Interpreted · Architecture: all · bun runtime")
.item(Lang::Rust, "Native Rust", "Compiled cdylib/.so or binary · amd64 + arm64")
.interact()
.context("language selection")?;
match lang {
Lang::Bun => cliclack::select("Variant")
.item(AppKind::Bun, "Platform app", "IPC capabilities, optional simple HTML UI · node-ctl deploy")
.item(AppKind::BunFullstack, "Platform fullstack", "Platform app + embedded React/Vite UI served by the platform")
.item(AppKind::StandaloneBun,"Standalone service", "Own systemd unit · /run/node/control.sock (optional)")
.item(AppKind::StandaloneBunFullstack,"Standalone fullstack", "Own systemd unit + Bun.serve HTTP server with embedded React/Vite UI")
.interact()
.context("variant selection")?,
Lang::Rust => cliclack::select("Variant")
.item(AppKind::Cdylib, "Platform cdylib", "Compiled .so loaded by the daemon · GPG-signed FirstParty")
.item(AppKind::CdylibFullstack,"Platform fullstack", "cdylib + embedded React/Vite/Tailwind UI")
.item(AppKind::StandaloneRust, "Standalone service", "Binary with own systemd unit · ideal for LCD/OTA/recovery")
.item(AppKind::StandaloneRustFullstack,"Standalone fullstack", "Binary with own systemd unit + Axum HTTP server with rust-embed React/Vite UI")
.interact()
.context("variant selection")?,
}
}
};
let description: String = cliclack::input("Description")
.placeholder("A Node mini app")
.default_input("A Node mini app")
.interact()
.context("description prompt")?;
let systemd_order = if matches!(
kind,
AppKind::StandaloneRust
| AppKind::StandaloneBun
| AppKind::StandaloneRustFullstack
| AppKind::StandaloneBunFullstack
) {
#[derive(Clone, PartialEq, Eq)]
enum Order { Before, After }
let order = cliclack::select("Systemd ordering")
.item(Order::Before, "Before platform", "Before=node.service — LCD, recovery, OTA, pre-boot")
.item(Order::After, "After platform", "After=node.service — depends on platform being up")
.interact()
.context("systemd ordering selection")?;
match order {
Order::Before => "Before=node.service".to_string(),
Order::After => "After=node.service".to_string(),
}
} else {
String::new()
};
let tcp = if matches!(
kind,
AppKind::StandaloneBunFullstack | AppKind::StandaloneRustFullstack
) {
let preferred_str: String = cliclack::input("Preferred TCP port (1024-65535)")
.placeholder("7001")
.default_input("7001")
.validate(|s: &String| match s.parse::<u16>() {
Ok(p) if p >= 1024 => Ok(()),
Ok(_) => Err("port must be >= 1024".to_string()),
Err(_) => Err("must be a valid u16".to_string()),
})
.interact()
.context("preferred port prompt")?;
let preferred_port: u16 = preferred_str.parse().expect("validated above");
let direct_bind = cliclack::confirm(
"Direct-bind iframe (only for restart-survival apps like OTA)?",
)
.initial_value(false)
.interact()
.context("direct_bind confirm")?;
TcpManifestFields {
preferred_port: Some(preferred_port),
direct_bind,
}
} else {
TcpManifestFields::default()
};
let git = git_flag
|| github_flag.is_some()
|| cliclack::confirm("Initialize git repo?")
.initial_value(true)
.interact()
.context("git confirm")?;
let github = if github_flag.is_some() {
github_flag
} else if git {
let slug: String = cliclack::input("GitHub repo (org/repo, blank to skip)")
.placeholder("econ-v1/node-app-my-app")
.required(false)
.interact()
.context("github prompt")?;
let trimmed = slug.trim().to_string();
if trimmed.is_empty() { None } else { Some(trimmed) }
} else {
None
};
cliclack::outro(format!("Scaffolding {}...", name)).ok();
Ok(WizardResult { name, kind, description, systemd_order, tcp, git, github })
}
fn inject_tcp_into_manifest(dest: &Path, tcp: &TcpManifestFields) -> Result<()> {
let Some(port) = tcp.preferred_port else {
return Ok(());
};
let manifest_path = dest.join("manifest.json");
if !manifest_path.exists() {
return Ok(());
}
let raw = fs::read_to_string(&manifest_path)
.with_context(|| format!("read {}", manifest_path.display()))?;
let mut value: serde_json::Value =
serde_json::from_str(&raw).with_context(|| {
format!("parse {} as JSON", manifest_path.display())
})?;
let obj = value.as_object_mut().with_context(|| {
format!(
"{} is not a JSON object — cannot inject tcp block",
manifest_path.display()
)
})?;
let mut tcp_block = serde_json::Map::new();
tcp_block.insert(
"preferred_port".to_string(),
serde_json::Value::from(port),
);
if tcp.direct_bind {
tcp_block.insert("direct_bind".to_string(), serde_json::Value::Bool(true));
}
obj.insert("tcp".to_string(), serde_json::Value::Object(tcp_block));
let pretty = serde_json::to_string_pretty(&value)
.with_context(|| "serialize manifest.json")?;
fs::write(&manifest_path, format!("{pretty}\n"))
.with_context(|| format!("write {}", manifest_path.display()))?;
Ok(())
}
fn fetch_and_write_template(
templates_spec: &str,
kind: AppKind,
dest: &Path,
name: &str,
description: &str,
systemd_order: &str,
maintainer: &MaintainerInfo,
copyright_year: &str,
) -> Result<()> {
let local = PathBuf::from(templates_spec);
if local.is_dir() {
let subdir = local.join(family_dir_name(kind));
if !subdir.is_dir() {
bail!(
"Local templates directory '{}' has no '{}/' subdirectory.\n\
Expected a '{}/' directory containing the base template.",
templates_spec,
family_dir_name(kind),
family_dir_name(kind)
);
}
return write_template_from_path(
&subdir, dest, name, description, systemd_order, maintainer, copyright_year,
);
}
ensure_gh()?;
let repo = if templates_spec == DEFAULT_TEMPLATES_REPO {
family_github_repo(kind)
} else {
templates_spec
};
println!("→ fetching {} template from {}...", kind.label(), repo);
let tmp = tmp_dir()?;
let clone_ok = Command::new("gh")
.args([
"repo",
"clone",
repo,
tmp.to_str().unwrap_or_default(),
"--",
"--depth=1",
"--quiet",
])
.status()
.with_context(|| format!("invoke `gh repo clone {}`", repo))?
.success();
if !clone_ok {
let _ = fs::remove_dir_all(&tmp);
bail!(
"Failed to clone template repo '{}'.\n\
Ensure you have read access and are authenticated (`gh auth login`).\n\
Override with --templates <org/repo-or-path> or NODE_APP_TEMPLATES_REPO.",
repo
);
}
let template_src = if templates_spec != DEFAULT_TEMPLATES_REPO {
let subdir = tmp.join(family_dir_name(kind));
if subdir.is_dir() { subdir } else { tmp.clone() }
} else {
tmp.clone()
};
let result = write_template_from_path(
&template_src,
dest,
name,
description,
systemd_order,
maintainer,
copyright_year,
);
let _ = fs::remove_dir_all(&tmp);
result
}
#[allow(clippy::too_many_arguments)]
fn write_template_from_path(
src: &Path,
dest: &Path,
name: &str,
description: &str,
systemd_order: &str,
maintainer: &MaintainerInfo,
copyright_year: &str,
) -> Result<()> {
for entry in WalkDir::new(src)
.min_depth(1)
.into_iter()
.filter_entry(|e| !(e.depth() == 1 && e.file_name() == ".git"))
{
let entry = entry.with_context(|| "iterate template files")?;
let rel = entry
.path()
.strip_prefix(src)
.expect("walkdir always under src");
let rel_rendered: PathBuf = rel
.components()
.map(|c| {
render(
c.as_os_str().to_string_lossy().as_ref(),
name,
description,
systemd_order,
maintainer,
copyright_year,
)
})
.collect();
let dest_path = dest.join(rel_rendered);
if entry.file_type().is_dir() {
fs::create_dir_all(&dest_path)
.with_context(|| format!("create dir {}", dest_path.display()))?;
continue;
}
if let Some(parent) = dest_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("create parent {}", parent.display()))?;
}
let raw = fs::read(entry.path())
.with_context(|| format!("read {}", entry.path().display()))?;
if raw.contains(&0u8) {
fs::write(&dest_path, &raw)
.with_context(|| format!("write {}", dest_path.display()))?;
} else {
let text = std::str::from_utf8(&raw).with_context(|| {
format!("template file {} is not valid UTF-8", entry.path().display())
})?;
fs::write(
&dest_path,
render(text, name, description, systemd_order, maintainer, copyright_year),
)
.with_context(|| format!("write {}", dest_path.display()))?;
}
if is_executable_template(entry.path()) {
set_executable(&dest_path)?;
}
}
Ok(())
}
fn strip_for_kind(dest: &Path, kind: AppKind) -> Result<()> {
let rm = |p: &str| -> Result<()> {
let path = dest.join(p);
if path.is_file() {
fs::remove_file(&path).with_context(|| format!("remove {}", p))?;
}
Ok(())
};
let rm_dir = |p: &str| -> Result<()> {
let path = dest.join(p);
if path.is_dir() {
fs::remove_dir_all(&path).with_context(|| format!("remove dir {}", p))?;
}
Ok(())
};
match kind {
AppKind::Bun => {
rm("src/ipc.ts")?;
rm_dir("systemd")?;
rm_dir("ui/src")?;
rm("ui/package.json")?;
rm("ui/tsconfig.json")?;
rm("ui/vite.config.ts")?;
rm("ui/postcss.config.js")?;
rm("ui/tailwind.config.js")?;
remove_standalone_leftovers(dest)?;
}
AppKind::BunFullstack => {
rm("src/ipc.ts")?;
rm_dir("systemd")?;
remove_standalone_leftovers(dest)?;
}
AppKind::StandaloneBun => {
rm_dir("ui")?;
rm("src/index.standalone-fullstack.ts")?;
promote_standalone_debian(dest)?;
remove_standalone_leftovers(dest)?;
}
AppKind::Cdylib => {
rm("src/main.rs")?;
rm("src/ipc.rs")?;
rm("Cargo.standalone.toml")?;
rm_dir("systemd")?;
rm_dir("ui")?;
rm("debian/postrm")?;
remove_standalone_leftovers(dest)?;
}
AppKind::CdylibFullstack => {
rm("src/main.rs")?;
rm("src/ipc.rs")?;
rm("Cargo.standalone.toml")?;
rm_dir("systemd")?;
rm("debian/postrm")?;
remove_standalone_leftovers(dest)?;
}
AppKind::StandaloneRust => {
rm("src/lib.rs")?;
rm_dir("ui")?;
let cargo_main = dest.join("Cargo.toml");
let cargo_standalone = dest.join("Cargo.standalone.toml");
if cargo_standalone.exists() {
if cargo_main.exists() {
fs::remove_file(&cargo_main)
.with_context(|| "remove cdylib Cargo.toml")?;
}
fs::rename(&cargo_standalone, &cargo_main)
.with_context(|| "rename Cargo.standalone.toml -> Cargo.toml")?;
}
rm("Cargo.standalone-fullstack.toml")?;
rm("src/main.standalone-fullstack.rs")?;
promote_standalone_debian(dest)?;
remove_standalone_leftovers(dest)?;
}
AppKind::StandaloneRustFullstack => {
rm("src/lib.rs")?;
let cargo_main = dest.join("Cargo.toml");
let cargo_fullstack = dest.join("Cargo.standalone-fullstack.toml");
let cargo_standalone = dest.join("Cargo.standalone.toml");
let chosen_cargo = if cargo_fullstack.is_file() {
Some(cargo_fullstack.clone())
} else if cargo_standalone.is_file() {
Some(cargo_standalone.clone())
} else {
None
};
match chosen_cargo {
Some(src) => {
if cargo_main.exists() {
fs::remove_file(&cargo_main)
.with_context(|| "remove cdylib Cargo.toml")?;
}
fs::rename(&src, &cargo_main).with_context(|| {
format!(
"rename {} -> Cargo.toml",
src.file_name().and_then(|s| s.to_str()).unwrap_or("?")
)
})?;
}
None => {
anyhow::bail!(
"template missing Cargo.standalone-fullstack.toml or \
Cargo.standalone.toml — cannot scaffold a standalone \
binary; the cdylib Cargo.toml would not compile"
);
}
}
rm("Cargo.standalone-fullstack.toml")?;
rm("Cargo.standalone.toml")?;
let main_rs = dest.join("src/main.rs");
let main_fullstack = dest.join("src/main.standalone-fullstack.rs");
if main_fullstack.exists() {
if main_rs.exists() {
fs::remove_file(&main_rs)
.with_context(|| "remove cdylib src/main.rs")?;
}
fs::rename(&main_fullstack, &main_rs).with_context(|| {
"rename src/main.standalone-fullstack.rs -> src/main.rs"
})?;
}
if !main_rs.is_file() {
anyhow::bail!(
"template missing src/main.rs (and no \
src/main.standalone-fullstack.rs variant) — cannot \
scaffold a standalone binary"
);
}
promote_standalone_debian(dest)?;
promote_standalone_fullstack(dest)?;
remove_standalone_leftovers(dest)?;
}
AppKind::StandaloneBunFullstack => {
let index_ts = dest.join("src/index.ts");
let index_fullstack = dest.join("src/index.standalone-fullstack.ts");
if index_fullstack.exists() {
if index_ts.exists() {
fs::remove_file(&index_ts)
.with_context(|| "remove platform src/index.ts")?;
}
fs::rename(&index_fullstack, &index_ts).with_context(|| {
"rename src/index.standalone-fullstack.ts -> src/index.ts"
})?;
}
promote_standalone_debian(dest)?;
promote_standalone_fullstack(dest)?;
remove_standalone_leftovers(dest)?;
}
}
patch_manifest(dest, kind)?;
Ok(())
}
fn verify_scaffold(dest: &Path, kind: AppKind) -> Result<()> {
let cargo_toml = dest.join("Cargo.toml");
let needs_cdylib_lib = matches!(kind, AppKind::Cdylib | AppKind::CdylibFullstack);
let needs_standalone_bin = matches!(
kind,
AppKind::StandaloneRust | AppKind::StandaloneRustFullstack
);
if needs_cdylib_lib || needs_standalone_bin {
if !cargo_toml.is_file() {
anyhow::bail!(
"scaffolded tree is missing Cargo.toml at {}",
cargo_toml.display()
);
}
let body = fs::read_to_string(&cargo_toml)
.with_context(|| format!("read {}", cargo_toml.display()))?;
let has_lib_cdylib = body.contains("crate-type") && body.contains("cdylib");
let has_bin_section = body.contains("[[bin]]");
let lib_rs = dest.join("src/lib.rs");
let main_rs = dest.join("src/main.rs");
if needs_cdylib_lib {
if !has_lib_cdylib {
anyhow::bail!(
"Cargo.toml at {} declares no cdylib `[lib]` but kind {:?} \
requires one",
cargo_toml.display(),
kind
);
}
if !lib_rs.is_file() {
anyhow::bail!(
"kind {:?} requires src/lib.rs but it is missing at {}",
kind,
lib_rs.display()
);
}
}
if needs_standalone_bin {
if has_lib_cdylib && !lib_rs.is_file() {
anyhow::bail!(
"Cargo.toml at {} declares a cdylib `[lib]` but kind {:?} \
is a standalone binary and src/lib.rs is absent — the \
template's standalone Cargo override did not get promoted. \
Check that the template ships Cargo.standalone.toml or \
Cargo.standalone-fullstack.toml.",
cargo_toml.display(),
kind
);
}
if !has_bin_section && !main_rs.is_file() {
anyhow::bail!(
"kind {:?} requires a `[[bin]]` Cargo target or src/main.rs \
— both are missing",
kind
);
}
if !main_rs.is_file() {
anyhow::bail!(
"kind {:?} requires src/main.rs but it is missing at {}",
kind,
main_rs.display()
);
}
}
}
if matches!(
kind,
AppKind::Bun
| AppKind::BunFullstack
| AppKind::StandaloneBun
| AppKind::StandaloneBunFullstack
) {
let pkg_json = dest.join("package.json");
if !pkg_json.is_file() {
anyhow::bail!(
"kind {:?} requires package.json but it is missing at {}",
kind,
pkg_json.display()
);
}
}
Ok(())
}
fn promote_standalone_debian(dest: &Path) -> Result<()> {
let swaps: &[&str] = &[
"debian/control.template",
"debian/postinst",
"debian/prerm",
"debian/postrm",
"Makefile",
"README.md",
];
for rel in swaps {
let platform = dest.join(rel);
let standalone = dest.join(format!("{}.standalone", rel));
if standalone.exists() {
if platform.exists() {
fs::remove_file(&platform)
.with_context(|| format!("remove platform {}", rel))?;
}
fs::rename(&standalone, &platform)
.with_context(|| format!("rename {}.standalone -> {}", rel, rel))?;
}
}
Ok(())
}
fn promote_standalone_fullstack(dest: &Path) -> Result<()> {
let swaps: &[&str] = &["Makefile", "README.md"];
for rel in swaps {
let current = dest.join(rel);
let fullstack = dest.join(format!("{}.standalone-fullstack", rel));
if fullstack.exists() {
if current.exists() {
fs::remove_file(¤t)
.with_context(|| format!("remove current {}", rel))?;
}
fs::rename(&fullstack, ¤t).with_context(|| {
format!("rename {}.standalone-fullstack -> {}", rel, rel)
})?;
}
}
Ok(())
}
fn remove_standalone_leftovers(dest: &Path) -> Result<()> {
for entry in WalkDir::new(dest).into_iter().filter_map(|e| e.ok()) {
if !entry.file_type().is_file() {
continue;
}
if let Some(name) = entry.path().file_name().and_then(|s| s.to_str()) {
let is_standalone_leftover = name.ends_with(".standalone")
|| (name.contains(".standalone.") && !name.starts_with('.'));
let is_fullstack_leftover = name.ends_with(".standalone-fullstack")
|| (name.contains(".standalone-fullstack.") && !name.starts_with('.'));
if is_standalone_leftover || is_fullstack_leftover {
fs::remove_file(entry.path())
.with_context(|| format!("remove leftover {}", entry.path().display()))?;
}
}
}
Ok(())
}
fn patch_manifest(dest: &Path, kind: AppKind) -> Result<()> {
let path = dest.join("manifest.json");
if !path.exists() {
return Ok(());
}
let raw = fs::read_to_string(&path).context("read manifest.json")?;
let mut v: serde_json::Value = serde_json::from_str(&raw).context("parse manifest.json")?;
let obj = v.as_object_mut().ok_or_else(|| anyhow!("manifest.json is not a JSON object"))?;
obj.remove("_scaffolder");
let app_name = obj
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("app")
.to_string();
match kind {
AppKind::Bun => {
obj.insert("app_type".into(), serde_json::json!("bun"));
obj.insert("has_ui".into(), serde_json::json!(true));
obj.insert("ui_path".into(), serde_json::json!("ui"));
obj.insert("entrypoint".into(), serde_json::json!("dist/index.js"));
}
AppKind::BunFullstack => {
obj.insert("app_type".into(), serde_json::json!("bun"));
obj.insert("has_ui".into(), serde_json::json!(true));
obj.insert("entrypoint".into(), serde_json::json!("dist/index.js"));
}
AppKind::StandaloneBun => {
obj.insert("app_type".into(), serde_json::json!("standalone"));
obj.insert("has_ui".into(), serde_json::json!(false));
obj.insert("entrypoint".into(), serde_json::json!("dist/index.js"));
obj.remove("ui_path");
if let Some(caps) = obj.get_mut("capabilities") {
if let Some(c) = caps.as_object_mut() {
c.insert("requires".into(), serde_json::json!([]));
c.insert("provides".into(), serde_json::json!([]));
}
}
obj.remove("provides");
}
AppKind::Cdylib => {
obj.insert("app_type".into(), serde_json::json!("native"));
obj.insert("has_ui".into(), serde_json::json!(false));
obj.insert("entrypoint".into(), serde_json::json!("app.so"));
obj.remove("ui_path");
}
AppKind::CdylibFullstack => {
obj.insert("app_type".into(), serde_json::json!("native"));
obj.insert("has_ui".into(), serde_json::json!(true));
obj.insert("entrypoint".into(), serde_json::json!("app.so"));
}
AppKind::StandaloneRust => {
obj.insert("app_type".into(), serde_json::json!("standalone"));
obj.insert("has_ui".into(), serde_json::json!(false));
obj.insert(
"entrypoint".into(),
serde_json::json!(format!("node-app-{}", app_name)),
);
obj.remove("ui_path");
if let Some(caps) = obj.get_mut("capabilities") {
if let Some(c) = caps.as_object_mut() {
c.insert("requires".into(), serde_json::json!([]));
c.insert("provides".into(), serde_json::json!([]));
}
}
obj.remove("provides");
}
AppKind::StandaloneRustFullstack => {
obj.insert("app_type".into(), serde_json::json!("standalone"));
obj.insert("has_ui".into(), serde_json::json!(true));
obj.insert("ui_path".into(), serde_json::json!("dist"));
obj.insert(
"entrypoint".into(),
serde_json::json!(format!("node-app-{}", app_name)),
);
}
AppKind::StandaloneBunFullstack => {
obj.insert("app_type".into(), serde_json::json!("standalone"));
obj.insert("has_ui".into(), serde_json::json!(true));
obj.insert("entrypoint".into(), serde_json::json!("dist/index.js"));
obj.insert("ui_path".into(), serde_json::json!("ui-dist"));
if let Some(caps) = obj.get_mut("capabilities") {
if let Some(c) = caps.as_object_mut() {
c.insert("requires".into(), serde_json::json!([]));
c.insert("provides".into(), serde_json::json!([]));
}
}
obj.remove("provides");
}
}
let app_type_str = obj
.get("app_type")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let is_standalone = app_type_str == "standalone";
let hot_reload_is_placeholder = obj
.get("hot_reload")
.and_then(|v| v.as_str())
.map(|s| s.starts_with("{{") && s.ends_with("}}"))
.unwrap_or(false);
if is_standalone {
obj.remove("hot_reload");
} else if hot_reload_is_placeholder {
obj.insert("hot_reload".into(), serde_json::json!("experimental"));
}
if is_standalone {
let socket = format!("/run/node-app-{}.sock", app_name);
match obj.get_mut("standalone").and_then(|v| v.as_object_mut()) {
Some(s) => {
s.insert("socket_path".into(), serde_json::json!(socket));
}
None => {
obj.insert(
"standalone".into(),
serde_json::json!({ "socket_path": socket }),
);
}
}
} else {
obj.remove("standalone");
}
let out = serde_json::to_string_pretty(&v).context("serialize manifest.json")?;
fs::write(&path, out + "\n").context("write manifest.json")?;
Ok(())
}
fn render(
template: &str,
name: &str,
description: &str,
systemd_order: &str,
maintainer: &MaintainerInfo,
copyright_year: &str,
) -> String {
let name_upper = name_to_env_token(name);
template
.replace("{{name}}", name)
.replace("{{NAME_UPPER}}", &name_upper)
.replace("{{description}}", description)
.replace("{{systemd_order}}", systemd_order)
.replace("{{maintainer_name}}", &maintainer.name)
.replace("{{maintainer_email}}", &maintainer.email)
.replace("{{maintainer}}", &maintainer.combined)
.replace("{{copyright_year}}", copyright_year)
}
fn name_to_env_token(name: &str) -> String {
name.to_ascii_uppercase().replace('-', "_")
}
fn patch_sdk_versions(dest: &Path) {
if let Some(v) = latest_npm_version("@econ-v1/app-sdk") {
patch_npm_dep(dest, "@econ-v1/app-sdk", &v);
}
if let Some(v) = latest_cargo_version("node-app-sdk-rust") {
patch_cargo_dep(dest, "node-app-sdk-rust", &v);
}
}
fn latest_npm_version(pkg: &str) -> Option<String> {
let encoded = pkg.replace('/', "%2F");
let url = format!("https://registry.npmjs.org/{encoded}/latest");
let resp: serde_json::Value = ureq::get(&url)
.set("Accept", "application/json")
.call()
.ok()?
.into_json()
.ok()?;
resp.get("version")?.as_str().map(String::from)
}
fn latest_cargo_version(krate: &str) -> Option<String> {
let url = format!("https://crates.io/api/v1/crates/{krate}");
let resp: serde_json::Value = ureq::get(&url)
.set("Accept", "application/json")
.set("User-Agent", "node-app-build/0.1.0 (https://github.com/econ-v1/node)")
.call()
.ok()?
.into_json()
.ok()?;
resp.pointer("/crate/newest_version")?.as_str().map(String::from)
}
fn patch_npm_dep(dest: &Path, pkg: &str, version: &str) {
let path = dest.join("package.json");
if !path.exists() {
return;
}
let Ok(text) = fs::read_to_string(&path) else { return };
let Ok(mut json) = serde_json::from_str::<serde_json::Value>(&text) else { return };
let patched = patch_json_dep(&mut json, pkg, version);
if patched {
if let Ok(out) = serde_json::to_string_pretty(&json) {
let _ = fs::write(&path, out + "\n");
println!(" → @econ-v1/app-sdk pinned to {version} (latest)");
}
}
}
fn patch_json_dep(json: &mut serde_json::Value, pkg: &str, version: &str) -> bool {
let mut patched = false;
for section in ["dependencies", "devDependencies", "peerDependencies"] {
if let Some(deps) = json.get_mut(section).and_then(|d| d.as_object_mut()) {
if deps.contains_key(pkg) {
deps.insert(pkg.to_string(), serde_json::Value::String(version.to_string()));
patched = true;
}
}
}
patched
}
fn patch_cargo_dep(dest: &Path, krate: &str, version: &str) {
for candidate in [dest.join("Cargo.toml")] {
if !candidate.exists() {
continue;
}
let Ok(text) = fs::read_to_string(&candidate) else { continue };
let mut changed = false;
let new_text: String = text
.lines()
.map(|line| {
if line.trim_start().starts_with(krate)
&& (line.contains("= \"") || line.contains("version ="))
{
changed = true;
replace_toml_version(line, version)
} else {
line.to_string()
}
})
.collect::<Vec<_>>()
.join("\n")
+ "\n";
if changed {
let _ = fs::write(&candidate, new_text);
println!(" → node-app-sdk-rust pinned to {version} (latest)");
}
}
}
fn replace_toml_version(line: &str, new_version: &str) -> String {
let mut result = String::new();
let mut chars = line.chars().peekable();
let mut replaced = false;
while let Some(c) = chars.next() {
if c == '"' && !replaced {
result.push('"');
for inner in chars.by_ref() {
if inner == '"' {
break;
}
}
result.push_str(new_version);
result.push('"');
replaced = true;
} else {
result.push(c);
}
}
result
}
fn validate_name(name: &str) -> Result<()> {
if name.is_empty() {
bail!("name cannot be empty");
}
if name.starts_with("node-app-") {
bail!("name must not start with 'node-app-'; the .deb script adds the prefix");
}
if !name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
bail!("name '{}' must be lowercase alphanumeric + hyphens", name);
}
if !name
.chars()
.next()
.map(|c| c.is_ascii_lowercase())
.unwrap_or(false)
{
bail!("name '{}' must start with a lowercase letter", name);
}
Ok(())
}
fn validate_github_slug(slug: &str) -> Result<()> {
let parts: Vec<&str> = slug.split('/').collect();
if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
bail!("--github value '{}' must be in the form 'org/repo'", slug);
}
if slug.chars().any(|c| c.is_whitespace()) {
bail!("--github value '{}' contains whitespace", slug);
}
Ok(())
}
fn run_deps_update(dest: &Path, kind: AppKind) -> Result<()> {
match kind {
AppKind::Bun
| AppKind::BunFullstack
| AppKind::StandaloneBun
| AppKind::StandaloneBunFullstack => {
if which("bun").is_some() {
run_in(dest, "bun", &["install"], "bun install")?;
} else {
eprintln!(
"→ skipping `bun install` (bun not found on PATH; install from https://bun.sh)"
);
}
}
AppKind::Cdylib
| AppKind::CdylibFullstack
| AppKind::StandaloneRust
| AppKind::StandaloneRustFullstack => {
if which("cargo").is_some() {
run_in(dest, "cargo", &["update"], "cargo update")?;
} else {
eprintln!(
"→ skipping `cargo update` (cargo not found on PATH; \
install Rust from https://rustup.rs)"
);
}
}
}
Ok(())
}
fn init_git(dest: &Path, name: &str) -> Result<()> {
if which("git").is_none() {
eprintln!("→ skipping `git init` (git not found on PATH)");
return Ok(());
}
let git_exists = dest.join(".git").is_dir();
if !git_exists {
run_in(dest, "git", &["init", "-q", "-b", "main"], "git init")?;
}
run_in(dest, "git", &["add", "."], "git add .")?;
let has_staged = Command::new("git")
.current_dir(dest)
.args(["diff", "--cached", "--quiet"])
.status()
.map(|s| !s.success()) .unwrap_or(false);
if has_staged {
let msg = format!("chore: scaffold {} via node-app new", name);
run_in(dest, "git", &["commit", "-q", "-m", &msg], "git commit")?;
}
println!(
"✓ Initialized git repo in {} (branch: main)",
dest.display()
);
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn github_template_scaffold(
dest: &Path,
slug: &str,
template_repo: &str,
_kind: AppKind,
name: &str,
description: &str,
systemd_order: &str,
maintainer: &MaintainerInfo,
copyright_year: &str,
) -> Result<()> {
ensure_gh()?;
let auth_ok = Command::new("gh")
.args(["auth", "status"])
.status()
.map(|s| s.success())
.unwrap_or(false);
if !auth_ok {
eprintln!(
"→ `gh auth status` failed — running `gh auth login` first is recommended.\n\
Continuing; you may be prompted to authenticate."
);
}
println!(
"→ creating GitHub repo {} from template {}...",
slug, template_repo
);
let create_ok = Command::new("gh")
.args([
"repo",
"create",
slug,
"--template",
template_repo,
"--private",
])
.status()
.with_context(|| {
format!(
"invoke `gh repo create {} --template {} --private`",
slug, template_repo
)
})?
.success();
if !create_ok {
bail!(
"Failed to create GitHub repo '{}' from template '{}'.\n\
Common causes: the repo already exists, the template repo isn't \
marked as a GitHub Template (Settings → Template repository), \
insufficient org permissions, or expired auth.\n\
Local scaffold has not been written yet.",
slug,
template_repo
);
}
if dest.exists() {
if fs::read_dir(dest)
.with_context(|| format!("read_dir {}", dest.display()))?
.next()
.is_some()
{
bail!(
"destination '{}' unexpectedly non-empty before clone",
dest.display()
);
}
fs::remove_dir(dest)
.with_context(|| format!("remove empty dest {}", dest.display()))?;
}
let url = format!("https://github.com/{}.git", slug);
print!("→ waiting for template contents to be ready");
let _ = std::io::Write::flush(&mut std::io::stdout());
let mut populated = false;
for _ in 0..60 {
let has_refs = Command::new("git")
.args(["ls-remote", "--heads", "--exit-code", &url])
.output()
.map(|o| o.status.success() && !o.stdout.is_empty())
.unwrap_or(false);
if has_refs {
populated = true;
break;
}
print!(".");
let _ = std::io::Write::flush(&mut std::io::stdout());
thread::sleep(Duration::from_secs(1));
}
println!();
if !populated {
bail!(
"Timed out (60s) waiting for template population in {}.\n\
The GitHub repo was created but appears empty.\n\
Try cloning manually in a moment:\n git clone {} {}",
slug,
url,
dest.display()
);
}
let clone_ok = Command::new("git")
.args(["clone", "-q", &url, dest.to_str().unwrap_or_default()])
.status()
.with_context(|| format!("invoke `git clone {}`", url))?
.success();
if !clone_ok {
bail!(
"Failed to clone the newly-created repo {}.\n\
The GitHub repo was created — clone it manually:\n git clone {} {}",
slug,
url,
dest.display()
);
}
transform_template_in_place(
dest,
name,
description,
systemd_order,
maintainer,
copyright_year,
)?;
Ok(())
}
fn transform_template_in_place(
root: &Path,
name: &str,
description: &str,
systemd_order: &str,
maintainer: &MaintainerInfo,
copyright_year: &str,
) -> Result<()> {
let entries: Vec<_> = WalkDir::new(root)
.min_depth(1)
.contents_first(true)
.into_iter()
.filter_entry(|e| !(e.depth() == 1 && e.file_name() == ".git"))
.filter_map(|e| e.ok())
.collect();
for entry in &entries {
if !entry.file_type().is_file() {
continue;
}
let path = entry.path();
let raw = fs::read(path).with_context(|| format!("read {}", path.display()))?;
if raw.contains(&0u8) {
continue; }
let text = match std::str::from_utf8(&raw) {
Ok(t) => t,
Err(_) => continue,
};
let rendered = render(
text,
name,
description,
systemd_order,
maintainer,
copyright_year,
);
if rendered != text {
fs::write(path, rendered)
.with_context(|| format!("write {}", path.display()))?;
}
}
for entry in &entries {
let path = entry.path();
let basename = match path.file_name() {
Some(b) => b.to_string_lossy().into_owned(),
None => continue,
};
if !basename.contains("{{") {
continue;
}
let rendered_basename = render(
&basename,
name,
description,
systemd_order,
maintainer,
copyright_year,
);
if rendered_basename == basename {
continue;
}
let parent = match path.parent() {
Some(p) => p,
None => continue,
};
let new_path = parent.join(&rendered_basename);
fs::rename(path, &new_path).with_context(|| {
format!("rename {} -> {}", path.display(), new_path.display())
})?;
}
Ok(())
}
fn commit_and_push_scaffold(dest: &Path, name: &str, slug: &str) -> Result<()> {
if !dest.join(".git").is_dir() {
bail!(
"expected git repo at {} but .git/ is missing — \
github_template_scaffold did not run as expected",
dest.display()
);
}
run_in(dest, "git", &["add", "-A"], "git add -A")?;
let has_staged = Command::new("git")
.current_dir(dest)
.args(["diff", "--cached", "--quiet"])
.status()
.map(|s| !s.success())
.unwrap_or(false);
if has_staged {
let msg = format!("chore: scaffold {} via node-app new", name);
run_in(dest, "git", &["commit", "-q", "-m", &msg], "git commit")?;
}
run_in(dest, "git", &["push", "-q", "origin", "HEAD"], "git push")?;
println!(
"✓ Created GitHub repo https://github.com/{} from template and pushed scaffold customizations",
slug
);
Ok(())
}
fn create_github_repo(dest: &Path, slug: &str) -> Result<()> {
if which("gh").is_none() {
eprintln!(
"→ skipping `gh repo create` (gh CLI not found on PATH; install from https://cli.github.com).\n\
Local scaffold is still ready. To bootstrap manually:\n \
gh repo create {} --private --source={} --push",
slug,
dest.display()
);
return Ok(());
}
let auth_ok = Command::new("gh")
.args(["auth", "status"])
.status()
.map(|s| s.success())
.unwrap_or(false);
if !auth_ok {
eprintln!(
"→ `gh auth status` failed — running `gh auth login` first is recommended.\n\
Continuing with repo creation; you may be prompted to authenticate."
);
}
let create_ok = Command::new("gh")
.current_dir(dest)
.args(["repo", "create", slug, "--private"])
.status()
.with_context(|| format!("invoke `gh repo create {}`", slug))?
.success();
if !create_ok {
bail!(
"gh repo create failed. Common causes: repo already exists, \
org permissions missing, or auth expired. Local scaffold remains intact at {}.",
dest.display()
);
}
let remote_url = format!("https://github.com/{}.git", slug);
let has_origin = Command::new("git")
.current_dir(dest)
.args(["remote", "get-url", "origin"])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if has_origin {
run_in(
dest,
"git",
&["remote", "set-url", "origin", &remote_url],
"git remote set-url origin",
)?;
} else {
run_in(
dest,
"git",
&["remote", "add", "origin", &remote_url],
"git remote add origin",
)?;
}
run_in(
dest,
"git",
&["push", "-u", "origin", "HEAD"],
"git push",
)?;
println!(
"✓ Created GitHub repo https://github.com/{} and pushed initial commit",
slug
);
upload_release_secrets(slug);
Ok(())
}
fn upload_release_secrets(slug: &str) {
let keys_dir = std::env::var_os("NODE_DEV_KEYS_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| {
std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_default()
.join(".config/node")
});
let secrets: &[(&str, &str, &str)] = &[
(
"GPG_PRIVATE_KEY",
"gpg-private-key.asc",
"Generate with `gpg --armor --export-secret-keys <key-id>` and save to this path",
),
(
"GPG_PASSPHRASE",
"gpg-passphrase.txt",
"Plain-text passphrase matching GPG_PRIVATE_KEY",
),
(
"APT_REPO_DISPATCH_TOKEN",
"apt-repo-dispatch-token.txt",
"GitHub fine-grained PAT with `actions:write` on econ-v1/node-releases",
),
];
for (name, basename, hint) in secrets {
let path = keys_dir.join(basename);
if !path.exists() {
eprintln!(
"→ secret {} not found at {}. {}.\n \
Set manually later: `gh secret set {} --repo {} < /path/to/secret`",
name,
path.display(),
hint,
name,
slug
);
continue;
}
let status = Command::new("gh")
.args(["secret", "set", name, "--repo", slug])
.stdin(fs::File::open(&path).expect("opened above"))
.status();
match status {
Ok(s) if s.success() => println!("✓ Set GitHub secret {} on {}", name, slug),
Ok(s) => eprintln!(
"→ `gh secret set {}` exited {} — set manually with: \
`gh secret set {} --repo {} < {}`",
name,
s.code().unwrap_or(-1),
name,
slug,
path.display()
),
Err(e) => eprintln!(
"→ failed to invoke `gh secret set {}`: {}. \
Set manually: `gh secret set {} --repo {} < {}`",
name, e, name, slug, path.display()
),
}
}
}
fn ensure_gh() -> Result<()> {
if which("gh").is_none() {
bail!(
"gh CLI not found on PATH.\n\
Install from https://cli.github.com then authenticate with `gh auth login`.\n\
Templates are fetched from GitHub — gh is required for remote repos.\n\
For offline use, set NODE_APP_TEMPLATES_REPO to a local directory path."
);
}
Ok(())
}
fn run_in(dest: &Path, cmd: &str, args: &[&str], label: &str) -> Result<()> {
let status = Command::new(cmd)
.current_dir(dest)
.args(args)
.status()
.with_context(|| format!("invoke `{}`", label))?;
if !status.success() {
bail!("`{}` exited {}", label, status.code().unwrap_or(-1));
}
Ok(())
}
fn which(bin: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path) {
let candidate = dir.join(bin);
if candidate.is_file() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = candidate.metadata() {
if meta.permissions().mode() & 0o111 != 0 {
return Some(candidate);
}
}
}
#[cfg(not(unix))]
{
return Some(candidate);
}
}
}
None
}
fn tmp_dir() -> Result<PathBuf> {
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
Ok(std::env::temp_dir().join(format!("node-app-templates-{ts}")))
}
fn is_executable_template(path: &Path) -> bool {
let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
return false;
};
let base = name.strip_suffix(".standalone").unwrap_or(name);
if matches!(base, "postinst" | "prerm" | "postrm" | "preinst") {
return true;
}
name.ends_with(".sh")
}
fn set_executable(path: &Path) -> Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perm = fs::metadata(path)?.permissions();
perm.set_mode(0o755);
fs::set_permissions(path, perm)?;
}
#[cfg(not(unix))]
{
let _ = path;
}
Ok(())
}