node-app-build 5.23.1

Mini app developer CLI: scaffold, validate, package node-app-* Debian packages
//! `node-app` — Mini app developer CLI.
//!
//! Subcommands:
//!   - `new [name] [--type TYPE]`  scaffold a project (wizard when --type omitted)
//!   - `validate`                   validate manifest.json + tier rules
//!   - `package --target <arch>`    wraps the .deb build pipeline
//!   - `skills list`                list agent skills in the agentskills repo
//!   - `skills install [names...]`  install skills into ~/.claude/skills/
//!   - `completions <shell>`        print shell completion script to stdout
//!
//! Spec: `specs/456-node-app-distribution-infrastructure/`
//! Tasks: T113 (crate), T114 (new), T115 (validate), T116 (package),
//!        T117 (templates), T118 (validate rejects non-bundled cdylib),
//!        T123 (scaffold integration test).

use anyhow::Result;
use clap::{builder::ValueHint, CommandFactory, Parser, Subcommand, ValueEnum};
use clap_complete::Shell;
use std::path::PathBuf;

mod commands;
mod manifest;
mod tui;


#[derive(Parser, Debug)]
#[command(
    name = "node-app",
    about = "Scaffold, validate, and package Node mini-app .deb files",
    long_about = None,
    version,
)]
struct Cli {
    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand, Debug)]
enum Command {
    /// Scaffold a new mini app project. When --type is omitted an interactive
    /// wizard guides you through all options; pass --type to skip the wizard
    /// for scripted use.
    ///
    /// Templates are fetched from a private GitHub repo at scaffold time (no
    /// compile-time embedding). Requires the `gh` CLI to be authenticated.
    /// Override the template repo with --templates or NODE_APP_TEMPLATES_REPO.
    New {
        /// Name of the new app (lowercase alphanumeric + hyphens).
        /// Wizard will ask if omitted.
        name: Option<String>,
        /// Template type. Wizard will ask if omitted.
        #[arg(short = 't', long = "type", value_enum)]
        kind: Option<AppKind>,
        /// Destination directory (defaults to ./<name>/).
        #[arg(short, long, value_hint = ValueHint::DirPath)]
        out: Option<PathBuf>,
        /// Initialize a git repo in the scaffolded directory and create an
        /// initial commit. Implied by --github.
        #[arg(long)]
        git: bool,
        /// Create a GitHub repo for the scaffold and push the initial commit.
        /// Format: `<org>/<repo>` (e.g. `econ-v1/node-app-foo`). Requires the
        /// `gh` CLI to be authenticated.
        #[arg(long, value_name = "ORG/REPO")]
        github: Option<String>,
        /// Skip the post-scaffold dependency-fetch step (`bun install` for Bun
        /// templates, `cargo generate-lockfile` for cdylib/standalone-rust).
        #[arg(long)]
        no_deps_update: bool,
        /// Template repository to clone (GitHub `org/repo` slug or local
        /// directory path). Defaults to econ-v1/node-app-templates.
        /// Can also be set via NODE_APP_TEMPLATES_REPO env var.
        #[arg(long, value_name = "ORG/REPO-OR-PATH")]
        templates: Option<String>,
        /// Maintainer name for the package (defaults to `git config user.name`).
        /// Substituted into debian/control.template, Cargo.toml authors, package.json author.
        #[arg(long, value_name = "NAME")]
        maintainer_name: Option<String>,
        /// Maintainer email for the package (defaults to `git config user.email`).
        #[arg(long, value_name = "EMAIL")]
        maintainer_email: Option<String>,
    },
    /// Validate a mini app project (run from project root or use --path).
    Validate {
        /// Path to the project root (defaults to current directory).
        #[arg(short, long, default_value = ".", value_hint = ValueHint::DirPath)]
        path: PathBuf,
    },
    /// Package the project into a `.deb` for the given target architecture.
    Package {
        /// Path to the project root (defaults to current directory).
        #[arg(short, long, default_value = ".", value_hint = ValueHint::DirPath)]
        path: PathBuf,
        /// Target Debian architecture.
        #[arg(short, long, value_enum, default_value_t = DebTarget::All)]
        target: DebTarget,
        /// Override manifest.version with this string.
        #[arg(long)]
        version: Option<String>,
        /// Output directory (default: dist/app-debs).
        #[arg(short, long, value_hint = ValueHint::DirPath)]
        out: Option<PathBuf>,
    },
    /// Manage agent skills from econ-v1/agentskills.
    Agentskills {
        #[command(subcommand)]
        action: SkillsAction,
    },
    /// Hot-reload dev loop: bring up a daemon, build the app, sideload
    /// over IPC, watch source files, rebuild + reload on every save
    /// (plan 457 phase C + C+).
    ///
    /// Daemon-host modes (autodetected if --daemon omitted):
    ///   --daemon deb                            apt + systemd (Linux only, sudo)
    ///   --daemon docker                         docker compose (cross-platform)
    ///   --daemon clone                          gh clone econ-v1/node + cargo run
    ///   --daemon monorepo --monorepo-path PATH  use a local checkout (cargo run)
    ///     add --instances alice,bob             run two nodes for P2P/Lightning testing
    ///   --socket /path/to/control.sock          connect to existing daemon socket
    ///
    /// Autodetect priority: monorepo cwd → existing /run/node/control.sock
    /// → linux+apt → docker → error with explicit instructions.
    Dev {
        /// Path to the project root (defaults to current directory).
        #[arg(short, long, default_value = ".", value_hint = ValueHint::DirPath)]
        path: PathBuf,
        /// Daemon-host mode.
        #[arg(long, value_parser = ["deb", "docker", "clone", "monorepo"])]
        daemon: Option<String>,
        /// Path to a local monorepo checkout. Required when --daemon monorepo.
        #[arg(long, value_hint = ValueHint::DirPath)]
        monorepo_path: Option<PathBuf>,
        /// Override the daemon IPC socket path (legacy MVP flag — when
        /// no --daemon/--monorepo is given, implies --daemon <socket>).
        #[arg(long, value_hint = ValueHint::FilePath)]
        socket: Option<PathBuf>,
        /// Override the daemon's $NODE_DEV_APPS_DIR. Each host has its
        /// own default (e.g. monorepo writes under XDG cache).
        #[arg(long, value_hint = ValueHint::DirPath)]
        dev_dir: Option<PathBuf>,
        /// Skip the source-file watcher and exit after one build+sideload.
        /// Useful for CI / scripted one-shot loads.
        #[arg(long)]
        once: bool,
        /// Path to a dependency app repo to build and stage before the dev
        /// loop. Repeat for multiple deps (e.g. --dep ../node-app-cron).
        #[arg(long, value_name = "PATH", action = clap::ArgAction::Append, value_hint = ValueHint::DirPath)]
        dep: Vec<PathBuf>,
        /// Disable the split-pane TUI and print raw interleaved logs instead.
        /// Auto-disabled when stdout is not a TTY (CI, pipe).
        #[arg(long)]
        no_tui: bool,
        /// Override an app config key for this dev session (KEY=VALUE).
        /// Repeat for multiple keys. Also reads from node-app.toml [config].
        /// Example: --config UART_DEVICE=/dev/ttyUSB0
        #[arg(long = "config", value_name = "KEY=VALUE", action = clap::ArgAction::Append)]
        config_overrides: Vec<String>,
        /// Node instances to run. Built-in names: alice (http=3001, p2p=9937)
        /// and bob (http=3002, p2p=9536). Default: alice.
        /// Comma-separated or repeatable: --instances alice,bob
        /// Only supported with --daemon monorepo.
        #[arg(long, value_parser = ["alice", "bob"], value_delimiter = ',', action = clap::ArgAction::Append)]
        instances: Vec<String>,
    },
    /// Monitor and control the node development infrastructure
    /// (postgres + RGS rapid-gossip-sync server).
    ///
    /// Override infra directory with NODE_INFRA_PATH=/path/to/infra.
    Infra {
        #[command(subcommand)]
        action: Option<commands::infra::InfraAction>,
    },
    /// Generate or install shell completion scripts.
    #[command(hide = true)]
    Completions {
        #[command(subcommand)]
        action: CompletionsAction,
    },
}

#[derive(Subcommand, Debug)]
enum SkillsAction {
    /// List available skills in the remote repository.
    List {
        /// Source repository slug (e.g. org/repo).
        #[arg(long, default_value = "econ-v1/agentskills")]
        repo: String,
    },
    /// Download and install skills into the Claude Code skills directory.
    ///
    /// With no names given, installs all available skills.
    /// Default install location: ~/.claude/skills/ (global, all projects).
    /// Use --local to install into .claude/skills/ in the current directory.
    Install {
        /// Names of skills to install (omit to install all).
        names: Vec<String>,
        /// Install into .claude/skills/ relative to the current directory.
        #[arg(long)]
        local: bool,
        /// Overwrite already-installed skills.
        #[arg(long)]
        update: bool,
        /// Source repository slug (e.g. org/repo).
        #[arg(long, default_value = "econ-v1/agentskills")]
        repo: String,
    },
}

#[derive(Subcommand, Debug)]
enum CompletionsAction {
    /// Print the completion script to stdout (pipe to a file to install manually).
    Generate {
        /// Target shell.
        shell: Shell,
    },
    /// Install the completion script to the canonical per-user path for your shell.
    ///
    /// Shell is auto-detected from $SHELL. Override with --shell.
    /// Supported paths:
    ///   bash  → ~/.local/share/bash-completion/completions/node-app  (auto-loaded)
    ///   zsh   → ~/.zfunc/_node-app  (add fpath + compinit to ~/.zshrc)
    ///   fish  → ~/.config/fish/completions/node-app.fish  (auto-loaded)
    Install {
        /// Shell to install for (auto-detected from $SHELL if omitted).
        #[arg(long)]
        shell: Option<Shell>,
    },
}

/// All scaffold template variants.
#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
pub enum AppKind {
    /// TypeScript/Bun subprocess app with IPC-based capabilities.
    Bun,
    /// TypeScript/Bun app with an embedded React UI served by the platform.
    #[value(name = "bun-fullstack")]
    BunFullstack,
    /// Native Rust compiled shared library (cdylib) for maximum performance.
    Cdylib,
    /// Native Rust cdylib with an embedded React UI served by the platform.
    #[value(name = "cdylib-fullstack")]
    CdylibFullstack,
    /// Standalone Rust binary that runs as its own systemd service.
    /// Communicates with the platform via /run/node/control.sock when available.
    #[value(name = "standalone-rust")]
    StandaloneRust,
    /// Standalone Bun/TypeScript daemon that runs as its own systemd service.
    /// Communicates with the platform via /run/node/control.sock when available.
    #[value(name = "standalone-bun")]
    StandaloneBun,
    /// Standalone Rust binary that serves its own embedded React/Vite UI over
    /// HTTP, running as its own systemd service. UI assets are embedded into
    /// the binary at compile time (rust-embed) for single-binary distribution.
    #[value(name = "standalone-rust-fullstack")]
    StandaloneRustFullstack,
    /// Standalone Bun/TypeScript daemon that serves its own embedded React/Vite
    /// UI via Bun.serve, running as its own systemd service.
    #[value(name = "standalone-bun-fullstack")]
    StandaloneBunFullstack,
}

impl AppKind {
    pub fn label(self) -> &'static str {
        match self {
            AppKind::Bun => "bun",
            AppKind::BunFullstack => "bun-fullstack",
            AppKind::Cdylib => "cdylib",
            AppKind::CdylibFullstack => "cdylib-fullstack",
            AppKind::StandaloneRust => "standalone-rust",
            AppKind::StandaloneBun => "standalone-bun",
            AppKind::StandaloneRustFullstack => "standalone-rust-fullstack",
            AppKind::StandaloneBunFullstack => "standalone-bun-fullstack",
        }
    }

    /// Subdirectory name in the template repo/directory.
    pub fn template_dir_name(self) -> &'static str {
        self.label()
    }
}

#[derive(Copy, Clone, Debug, ValueEnum)]
pub enum DebTarget {
    Amd64,
    Arm64,
    All,
}

impl DebTarget {
    pub fn as_str(self) -> &'static str {
        match self {
            DebTarget::Amd64 => "amd64",
            DebTarget::Arm64 => "arm64",
            DebTarget::All => "all",
        }
    }
}

fn main() -> Result<()> {
    let cli = Cli::parse();
    match cli.command {
        Command::Agentskills { action } => match action {
            SkillsAction::List { repo } => commands::skills::list(&repo),
            SkillsAction::Install {
                names,
                local,
                update,
                repo,
            } => commands::skills::install(commands::skills::SkillsInstallArgs {
                names: &names,
                global: !local,
                update,
                repo: &repo,
            }),
        },
        Command::New {
            name,
            kind,
            out,
            git,
            github,
            no_deps_update,
            templates,
            maintainer_name,
            maintainer_email,
        } => {
            let templates_repo = templates
                .or_else(|| std::env::var("NODE_APP_TEMPLATES_REPO").ok())
                .unwrap_or_else(|| commands::new::DEFAULT_TEMPLATES_REPO.to_string());
            commands::new::run(
                name,
                kind,
                out,
                git,
                github,
                no_deps_update,
                templates_repo,
                maintainer_name,
                maintainer_email,
            )
        }
        Command::Validate { path } => commands::validate::run(&path).map(|_| ()),
        Command::Package {
            path,
            target,
            version,
            out,
        } => commands::package::run(&path, target.as_str(), version.as_deref(), out.as_deref()),
        Command::Dev {
            path,
            daemon,
            monorepo_path,
            socket,
            dev_dir,
            once,
            dep,
            no_tui,
            config_overrides,
            instances,
        } => {
            let config: Vec<(String, String)> = config_overrides
                .into_iter()
                .filter_map(|s| {
                    let (k, v) = s.split_once('=')?;
                    Some((k.to_string(), v.to_string()))
                })
                .collect();
            commands::dev::run(commands::dev::DevArgs {
                project_path: &path,
                daemon: daemon.as_deref(),
                monorepo_path: monorepo_path.as_deref(),
                socket_override: socket.as_deref(),
                dev_dir_override: dev_dir.as_deref(),
                once,
                dep_paths: dep,
                no_tui,
                config,
                instances,
            })
        }
        Command::Infra { action } => commands::infra::run(action),
        Command::Completions { action } => match action {
            CompletionsAction::Generate { shell } => {
                commands::completions::generate_to_stdout(shell, &mut Cli::command());
                Ok(())
            }
            CompletionsAction::Install { shell } => {
                commands::completions::install(shell, &mut Cli::command())
            }
        },
    }
}