node-app-build 5.26.2

Mini app developer CLI: scaffold, validate, package node-app-* Debian packages
//! Daemon host abstractions — every way `node-app dev` can get a
//! running daemon to sideload into.
//!
//! Five modes (plan 457 phase C+):
//!
//! | Mode | Persona | Setup cost | Iter cost |
//! |---|---|---|---|
//! | [`Mode::Deb`]      | Linux app-only dev   | ~30 s `apt install` | ~1 s |
//! | [`Mode::Docker`]   | macOS / sandbox dev  | ~30 s pull          | ~1 s |
//! | [`Mode::Remote`]   | Hardware testing     | 0                   | network |
//! | [`Mode::Clone`]    | In-house contributor | ~5 min cargo build  | ~30 s |
//! | [`Mode::Monorepo`] | Platform contributor | 0 (already cloned)  | ~30 s plat / ~1 s app |
//!
//! Each mode implements [`DaemonHost`]: bring up a running daemon, return
//! the IPC socket path + dev-apps dir the orchestrator should sideload to.

use anyhow::Result;
use std::path::{Path, PathBuf};

use crate::tui::LogTx;

pub mod clone;
pub mod deb;
pub mod docker;
pub mod gh;
pub mod monorepo;
pub mod remote;

/// Named instance profile: port assignments and env file for a daemon instance.
/// Built-in profiles match the monorepo's alice.env / bob.env.
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct InstanceProfile {
    pub name: String,
    /// Backend HTTP port (alice=3001, bob=3002).
    pub http_port: u16,
    /// LDK P2P listen port (alice=9937, bob=9536).
    pub p2p_port: u16,
    /// Vite frontend dev server port (alice=5173, bob=5174).
    pub ui_port: u16,
    /// Env file name under `system/server/` (e.g. "alice.env").
    pub env_file_name: String,
}

impl InstanceProfile {
    pub fn alice() -> Self {
        Self {
            name: "alice".into(),
            http_port: 3001,
            p2p_port: 9937,
            ui_port: 5173,
            env_file_name: "alice.env".into(),
        }
    }

    pub fn bob() -> Self {
        Self {
            name: "bob".into(),
            http_port: 3002,
            p2p_port: 9536,
            ui_port: 5174,
            env_file_name: "bob.env".into(),
        }
    }

    pub fn from_name(name: &str) -> anyhow::Result<Self> {
        match name {
            "alice" => Ok(Self::alice()),
            "bob" => Ok(Self::bob()),
            other => anyhow::bail!(
                "unknown instance '{}'; built-in names: alice, bob",
                other
            ),
        }
    }
}

/// Selected daemon-host mode.
#[derive(Debug, Clone)]
pub enum Mode {
    /// `apt install node` + systemd. Linux only. Requires sudo.
    Deb,
    /// `docker compose up -d node-server`. Cross-platform; default on macOS.
    Docker,
    /// Sideload to a daemon already running somewhere — local cargo, real
    /// Pi, staging server. The user is responsible for starting it.
    Remote { socket_path: PathBuf },
    /// `gh repo clone econ-v1/node` to a cache dir, then `cargo run`.
    /// Requires `gh auth status` ok + read access on the private repo.
    Clone,
    /// Use a checked-out monorepo at this path; runs `cargo run` from it.
    /// Lets platform contributors edit BOTH app + daemon code in one loop.
    Monorepo { path: PathBuf },
}

impl Mode {
    pub fn label(&self) -> &'static str {
        match self {
            Mode::Deb => "deb (apt + systemd)",
            Mode::Docker => "docker (compose)",
            Mode::Remote { .. } => "remote (--daemon URL)",
            Mode::Clone => "clone (gh + cargo)",
            Mode::Monorepo { .. } => "monorepo (--daemon monorepo)",
        }
    }
}

/// Outcome of bringing up a daemon — what the orchestrator needs to know.
pub struct DaemonHandle {
    /// Instance name ("alice", "bob", or "" for non-profile modes).
    pub name: String,
    /// Path to the JSON-RPC IPC socket the orchestrator should call into.
    pub socket_path: PathBuf,
    /// Where to copy build artifacts (must equal the daemon's
    /// `$NODE_DEV_APPS_DIR`).
    pub dev_dir: PathBuf,
    /// Free-form mode-specific status string for the startup banner.
    pub banner: String,
    /// HTTP API base URL of the daemon (e.g. "http://127.0.0.1:3001").
    /// `Some` when the host can deterministically derive it from the
    /// instance profile (monorepo today). `None` for hosts that expose the
    /// daemon via a unix socket / external endpoint without a known TCP
    /// mapping — agent mode (`node-app dev --agent`) requires this and
    /// refuses to run on `None` hosts.
    pub api_base_url: Option<String>,
}

/// A daemon host. Implementations bring up a daemon and return where
/// the orchestrator should send IPC + put build artifacts.
pub trait DaemonHost {
    /// Start (or connect to) the daemon, returning the handle.
    fn ensure_running(&self) -> Result<DaemonHandle>;

    /// Optional: clean up any process started by `ensure_running`. Called
    /// on Ctrl-C exit. Default no-op since most modes leave the daemon
    /// running across CLI invocations.
    fn shutdown(&self) {}

    /// Tail daemon logs filtered for this app — routed to the TUI sink when
    /// available, or printed to stderr as a hint. Mode-specific:
    /// docker logs / journalctl / cargo stdout / no-op.
    fn tail_logs(&self, _app_name: &str) {}

    /// Restart the daemon in-place. Called when the user presses `r` in the
    /// TUI. Default no-op — modes that manage the daemon process override this.
    fn restart(&self) -> Result<()> {
        Ok(())
    }

    /// Return the dev-apps directory path BEFORE `ensure_running()` is
    /// called, for modes where it is deterministically computable from
    /// the host configuration alone (e.g. monorepo). This enables
    /// pre-boot dep staging so dependency apps are on disk when the
    /// daemon's inotify watcher first scans the directory at startup.
    ///
    /// Returns `None` for modes where the dev_dir is only known after
    /// the daemon is already running (the default).
    fn pre_start_dev_dir(&self) -> Option<PathBuf> {
        None
    }
}

/// Construct the right host for a [`Mode`] and [`InstanceProfile`].
///
/// `log_tx` is `Some` when the TUI is active; hosts use it to route their
/// stdout/stderr into the split-pane log view instead of the terminal.
///
/// Only `MonorepoHost` uses the `instance` profile. Other modes ignore it
/// (multi-instance is monorepo-only for now).
pub fn for_mode(
    mode: Mode,
    instance: InstanceProfile,
    socket_override: Option<&Path>,
    dev_dir_override: Option<&Path>,
    log_tx: Option<LogTx>,
) -> Box<dyn DaemonHost> {
    match mode {
        Mode::Deb => Box::new(deb::DebHost::new(socket_override, dev_dir_override, log_tx)),
        Mode::Docker => {
            Box::new(docker::DockerHost::new(socket_override, dev_dir_override, log_tx))
        }
        Mode::Remote { socket_path } => Box::new(remote::RemoteHost::new(
            socket_path,
            dev_dir_override.map(PathBuf::from),
            log_tx,
        )),
        Mode::Clone => {
            Box::new(clone::CloneHost::new(socket_override, dev_dir_override, log_tx))
        }
        Mode::Monorepo { path } => Box::new(monorepo::MonorepoHost::new(
            path,
            instance,
            socket_override.map(PathBuf::from),
            dev_dir_override.map(PathBuf::from),
            log_tx,
        )),
    }
}