use crate::AppKind;
use anyhow::{anyhow, bail, Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
use walkdir::WalkDir;
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 => "bun",
AppKind::Cdylib | AppKind::CdylibFullstack | AppKind::StandaloneRust => "cdylib",
}
}
fn family_github_repo(kind: AppKind) -> &'static str {
match kind {
AppKind::Bun | AppKind::BunFullstack | AppKind::StandaloneBun =>
"econ-v1/node-app-template-bun",
AppKind::Cdylib | AppKind::CdylibFullstack | AppKind::StandaloneRust =>
"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,
) -> Result<()> {
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(),
out,
git: git || github.is_some(),
github,
run_deps_update: !no_deps_update,
templates_repo,
},
(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,
out,
git: wiz.git,
github: wiz.github,
run_deps_update: !no_deps_update,
templates_repo,
}
}
};
scaffold(args)
}
struct ScaffoldArgs {
name: String,
kind: AppKind,
description: String,
systemd_order: String,
out: Option<PathBuf>,
git: bool,
github: Option<String>,
run_deps_update: bool,
templates_repo: 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(&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()))?;
fetch_and_write_template(
&args.templates_repo,
args.kind,
&dest,
&args.name,
&args.description,
&args.systemd_order,
)?;
strip_for_kind(&dest, args.kind)?;
patch_sdk_versions(&dest);
match args.kind {
AppKind::Cdylib | AppKind::CdylibFullstack => {
eprintln!(
"⚠ Reminder: cdylib (native) apps must be GPG-signed by an econ-v1 \
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."
);
}
_ => {}
}
println!(
"✓ Scaffolded {} app '{}' at {}",
args.kind.label(),
args.name,
dest.display()
);
if args.run_deps_update {
run_deps_update(&dest, args.kind)?;
}
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(())
}
struct WizardResult {
name: String,
kind: AppKind,
description: String,
systemd_order: String,
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)")
.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")
.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) {
#[derive(Clone, PartialEq, Eq)]
enum Order { Before, After }
let order = cliclack::select("Systemd ordering")
.item(Order::Before, "Before platform", "Before=econ-v1.service — LCD, recovery, OTA, pre-boot")
.item(Order::After, "After platform", "After=econ-v1.service — depends on platform being up")
.interact()
.context("systemd ordering selection")?;
match order {
Order::Before => "Before=econ-v1.service".to_string(),
Order::After => "After=econ-v1.service".to_string(),
}
} else {
String::new()
};
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, git, github })
}
fn fetch_and_write_template(
templates_spec: &str,
kind: AppKind,
dest: &Path,
name: &str,
description: &str,
systemd_order: &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);
}
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);
let _ = fs::remove_dir_all(&tmp);
result
}
fn write_template_from_path(
src: &Path,
dest: &Path,
name: &str,
description: &str,
systemd_order: &str,
) -> Result<()> {
for entry in WalkDir::new(src).min_depth(1) {
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))
.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))
.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")?;
rm("debian/postinst.standalone")?;
rm("debian/prerm.standalone")?;
}
AppKind::BunFullstack => {
rm("src/ipc.ts")?;
rm_dir("systemd")?;
rm("debian/postinst.standalone")?;
rm("debian/prerm.standalone")?;
}
AppKind::StandaloneBun => {
rm_dir("ui")?;
promote_standalone_debian(dest)?;
}
AppKind::Cdylib => {
rm("src/main.rs")?;
rm("src/ipc.rs")?;
rm("Cargo.standalone.toml")?;
rm_dir("systemd")?;
rm_dir("ui")?;
rm("debian/postrm")?;
rm("debian/postinst.standalone")?;
rm("debian/prerm.standalone")?;
}
AppKind::CdylibFullstack => {
rm("src/main.rs")?;
rm("src/ipc.rs")?;
rm("Cargo.standalone.toml")?;
rm_dir("systemd")?;
rm("debian/postrm")?;
rm("debian/postinst.standalone")?;
rm("debian/prerm.standalone")?;
}
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")?;
}
promote_standalone_debian(dest)?;
}
}
patch_manifest(dest, kind)?;
Ok(())
}
fn promote_standalone_debian(dest: &Path) -> Result<()> {
for base in &["postinst", "prerm"] {
let platform = dest.join("debian").join(base);
let standalone = dest.join("debian").join(format!("{}.standalone", base));
if standalone.exists() {
if platform.exists() {
fs::remove_file(&platform)
.with_context(|| format!("remove platform debian/{}", base))?;
}
fs::rename(&standalone, &platform)
.with_context(|| format!("rename debian/{}.standalone -> debian/{}", base, base))?;
}
}
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"))?;
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"));
}
AppKind::BunFullstack => {
obj.insert("app_type".into(), serde_json::json!("bun"));
obj.insert("has_ui".into(), serde_json::json!(true));
}
AppKind::StandaloneBun => {
obj.insert("app_type".into(), serde_json::json!("standalone"));
obj.insert("has_ui".into(), serde_json::json!(false));
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.remove("ui_path");
}
AppKind::CdylibFullstack => {
obj.insert("app_type".into(), serde_json::json!("native"));
obj.insert("has_ui".into(), serde_json::json!(true));
}
AppKind::StandaloneRust => {
obj.insert("app_type".into(), serde_json::json!("standalone"));
obj.insert("has_ui".into(), serde_json::json!(false));
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");
}
}
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) -> String {
template
.replace("{{name}}", name)
.replace("{{description}}", description)
.replace("{{systemd_order}}", systemd_order)
}
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 => {
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 => {
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(())
}
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 {
matches!(
path.file_name().and_then(|s| s.to_str()),
Some("postinst") | Some("prerm") | Some("postrm") | Some("preinst")
)
}
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(())
}