use crate::AppKind;
use anyhow::{bail, Context, Result};
use dialoguer::{Confirm, Input, Select};
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";
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,
)?;
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> {
let name = match name_hint {
Some(n) => n,
None => Input::<String>::new()
.with_prompt("App name")
.validate_with(|s: &String| validate_name(s).map_err(|e| e.to_string()))
.interact_text()
.context("app name prompt")?,
};
let kind = match kind_hint {
Some(k) => k,
None => {
const OPTIONS: &[(&str, &str, AppKind)] = &[
(
"TypeScript (Bun) ",
"Lightweight subprocess, IPC-based capabilities",
AppKind::Bun,
),
(
"TypeScript Fullstack ",
"TypeScript + embedded React UI served by the platform",
AppKind::BunFullstack,
),
(
"Native Rust (cdylib) ",
"Compiled shared library, max performance",
AppKind::Cdylib,
),
(
"Native Fullstack ",
"Native Rust cdylib + embedded React UI",
AppKind::CdylibFullstack,
),
(
"Standalone Rust ",
"Independent systemd service (Rust binary), ideal for LCD/OTA/recovery",
AppKind::StandaloneRust,
),
(
"Standalone Bun ",
"Independent systemd service (Bun process)",
AppKind::StandaloneBun,
),
];
let labels: Vec<String> = OPTIONS
.iter()
.map(|(label, desc, _)| format!("{} {}", label, desc))
.collect();
let idx = Select::new()
.with_prompt("Select app type")
.items(&labels)
.default(0)
.interact()
.context("app type selection")?;
OPTIONS[idx].2
}
};
let description = Input::<String>::new()
.with_prompt("Description (optional, Enter to skip)")
.allow_empty(true)
.default("A Node mini app".to_string())
.interact_text()
.context("description prompt")?;
let systemd_order = if matches!(kind, AppKind::StandaloneRust | AppKind::StandaloneBun) {
const CHOICES: &[&str] = &[
"Before platform (Before=econ-v1.service — LCD, recovery, OTA)",
"After platform (After=econ-v1.service — depends on platform being up)",
];
const VALUES: &[&str] = &["Before=econ-v1.service", "After=econ-v1.service"];
let idx = Select::new()
.with_prompt("Systemd dependency ordering")
.items(CHOICES)
.default(0)
.interact()
.context("systemd ordering selection")?;
VALUES[idx].to_string()
} else {
String::new()
};
let git = git_flag
|| github_flag.is_some()
|| Confirm::new()
.with_prompt("Initialize git repo?")
.default(true)
.interact()
.context("git confirm")?;
let github = if github_flag.is_some() {
github_flag
} else if git {
let input = Input::<String>::new()
.with_prompt("GitHub repo (org/repo, blank to skip)")
.allow_empty(true)
.interact_text()
.context("github prompt")?;
let trimmed = input.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
} else {
None
};
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 template_src = local.join(kind.template_dir_name());
if !template_src.is_dir() {
bail!(
"Local templates directory '{}' has no '{}/' subdirectory.",
templates_spec,
kind.template_dir_name()
);
}
return write_template_from_path(&template_src, dest, name, description, systemd_order);
}
ensure_gh()?;
let tmp = tmp_dir()?;
println!(
"→ fetching {} template from {}...",
kind.label(),
templates_spec
);
let clone_ok = Command::new("gh")
.args([
"repo",
"clone",
templates_spec,
tmp.to_str().unwrap_or_default(),
"--",
"--depth=1",
"--quiet",
])
.status()
.with_context(|| format!("invoke `gh repo clone {}`", templates_spec))?
.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.",
templates_spec
);
}
let template_src = tmp.join(kind.template_dir_name());
if !template_src.is_dir() {
let _ = fs::remove_dir_all(&tmp);
bail!(
"Template repo '{}' has no '{}/' directory.\n\
Check that the repo contains a top-level subdirectory for each app type.",
templates_spec,
kind.template_dir_name()
);
}
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 render(template: &str, name: &str, description: &str, systemd_order: &str) -> String {
template
.replace("{{name}}", name)
.replace("{{description}}", description)
.replace("{{systemd_order}}", systemd_order)
}
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",
&["generate-lockfile"],
"cargo generate-lockfile",
)?;
} else {
eprintln!(
"→ skipping `cargo generate-lockfile` (cargo not found on PATH; \
install Rust from https://rustup.rs)"
);
}
}
}
Ok(())
}
fn init_git(dest: &Path, name: &str) -> Result<()> {
if dest.join(".git").is_dir() {
eprintln!(
"→ skipping `git init` ({}/.git already exists)",
dest.display()
);
return Ok(());
}
if which("git").is_none() {
eprintln!("→ skipping `git init` (git not found on PATH)");
return Ok(());
}
run_in(dest, "git", &["init", "-q", "-b", "main"], "git init")?;
run_in(dest, "git", &["add", "."], "git add .")?;
let msg = format!("chore: initial scaffold of {} 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 status = Command::new("gh")
.current_dir(dest)
.args(["repo", "create", slug, "--private", "--source=.", "--push"])
.status()
.with_context(|| format!("invoke `gh repo create {}`", slug))?;
if !status.success() {
bail!(
"gh repo create failed (exit {}). Common causes: repo already exists, \
org permissions missing, or auth expired. Local scaffold remains intact at {}.",
status.code().unwrap_or(-1),
dest.display()
);
}
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(())
}