node-app-build 6.3.0

Mini app developer CLI: scaffold, validate, package node-app-* Debian packages
//! Infrastructure context for node docker infrastructure.
//!
//! Locates the infra directory (postgres + RGS server) relative to the
//! project or monorepo path, and exposes health / db / snapshot helpers.

pub mod db;
pub mod health;
pub mod ops;
pub mod snapshot;

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

/// Resolved path to the directory containing docker-compose.yml.
#[derive(Debug, Clone)]
pub struct InfraContext {
    /// Absolute path to the directory that contains docker-compose.yml.
    /// e.g. `~/.cache/node-app/node-infrastructure/compose/`
    pub dir: PathBuf,
}

impl InfraContext {
    /// Try to resolve the infra compose directory.
    ///
    /// Resolution order:
    /// 1. `NODE_INFRA_PATH` env var (root of infra repo — appends `compose/`)
    /// 2. `~/.cache/node-app/node-infrastructure/compose/` (auto-cloned)
    /// 3. Sibling dirs of project/monorepo: `node-infra/`, `node-infrastructure/`
    pub fn try_resolve_from(
        project_path: Option<&Path>,
        monorepo_path: Option<&Path>,
    ) -> Option<Self> {
        // Priority 1: explicit env override
        if let Ok(p) = std::env::var("NODE_INFRA_PATH") {
            let root = PathBuf::from(p);
            return Self::from_root(root);
        }

        // Priority 2: auto-cloned data dir
        if let Some(home) = dirs_home() {
            let data = home.join(".cache/node-app/node-infrastructure");
            if let Some(ctx) = Self::from_root(data) {
                return Some(ctx);
            }
        }

        // Priority 3: walk up from project/monorepo path, check sibling dirs
        let mut search_starts: Vec<PathBuf> = Vec::new();
        if let Ok(cwd) = std::env::current_dir() {
            search_starts.push(cwd);
        }
        if let Some(p) = project_path { search_starts.push(p.to_path_buf()); }
        if let Some(p) = monorepo_path { search_starts.push(p.to_path_buf()); }

        for start in search_starts {
            if let Some(ctx) = Self::search_siblings(&start) {
                return Some(ctx);
            }
        }
        None
    }

    fn from_root(root: PathBuf) -> Option<Self> {
        // Infra compose file lives in `<root>/compose/docker-compose.yml`
        let compose_dir = root.join("compose");
        if compose_dir.join("docker-compose.yml").exists() {
            if let Ok(dir) = compose_dir.canonicalize() {
                return Some(Self { dir });
            }
        }
        None
    }

    fn search_siblings(start: &Path) -> Option<Self> {
        // Walk up to find a parent, then look for sibling infra dirs
        let mut dir = start.to_path_buf();
        loop {
            {
                let parent = dir.parent()?;
                for name in &["node-infra", "node-infrastructure"] {
                    if let Some(ctx) = Self::from_root(parent.join(name)) {
                        return Some(ctx);
                    }
                }
                dir = parent.to_path_buf();
            }
            // Stop at filesystem root
            dir.parent()?;
        }
    }

    #[allow(dead_code)]
    pub fn clone_or_pull(&self, verbose: bool) -> anyhow::Result<()> {
        use std::process::Command;

        let repo = "https://github.com/econ-v1/econ-v1.git";

        if self.dir.join(".git").exists() {
            // Pull
            let status = Command::new("git")
                .args(["-C", &self.dir.to_string_lossy(), "pull", "--ff-only"])
                .status();
            if verbose {
                match status {
                    Ok(s) if s.success() => {}
                    Ok(s) => eprintln!("infra: git pull exited {}", s),
                    Err(e) => eprintln!("infra: git pull failed: {}", e),
                }
            }
        } else {
            let parent = self.dir.parent().unwrap_or(Path::new("."));
            let name = self.dir.file_name().unwrap_or_default();
            let status = Command::new("git")
                .args(["clone", "--depth=1", repo, &name.to_string_lossy()])
                .current_dir(parent)
                .status();
            if verbose {
                match status {
                    Ok(s) if s.success() => {}
                    Ok(s) => eprintln!("infra: git clone exited {}", s),
                    Err(e) => eprintln!("infra: git clone failed: {}", e),
                }
            }
        }
        Ok(())
    }

    #[allow(dead_code)]
    pub fn pull_if_stale_background(dir: PathBuf) {
        std::thread::spawn(move || {
            // Check mtime of the dir itself as a rough freshness proxy.
            let is_stale = std::fs::metadata(&dir)
                .and_then(|m| m.modified())
                .map(|t| {
                    t.elapsed()
                        .map(|e| e.as_secs() > 3600)
                        .unwrap_or(false)
                })
                .unwrap_or(false);

            if is_stale {
                let ctx = InfraContext { dir };
                let _ = ctx.clone_or_pull(false);
            }
        });
    }

    /// Returns the base URL for the RGS (rapid gossip sync) server.
    pub fn rgs_url(&self) -> String {
        "http://localhost:8011".to_string()
    }

    /// Returns the docker compose project directory.
    pub fn compose_dir(&self) -> &Path {
        &self.dir
    }
}

fn dirs_home() -> Option<PathBuf> {
    std::env::var_os("HOME").map(PathBuf::from)
}

// ── Subcommand ────────────────────────────────────────────────────────────────

use anyhow::{bail, Result};
use clap::Subcommand;

#[derive(Subcommand, Debug)]
pub enum InfraAction {
    /// Start infrastructure services.
    Up,
    /// Stop infrastructure services.
    Down,
    /// Show service health status.
    Status,
    /// Tail service logs.
    Logs {
        /// Specific service (rgs or postgres). All services if omitted.
        service: Option<String>,
    },
    /// Decode the RGS binary snapshot from the container.
    Snapshot,
    /// Show PostgreSQL table row counts.
    DbSummary,
    /// Print the RGS_SERVER_URL value.
    RgsUrl,
    /// Pull the latest infra repo update.
    Update,
}

pub fn run(action: Option<InfraAction>) -> Result<()> {
    let ctx = match InfraContext::try_resolve_from(None, None) {
        Some(ctx) => ctx,
        None => bail!(
            "Could not find node infrastructure.\n\
             Searched:\n  ~/.cache/node-app/node-infrastructure/\n\
             Sibling dirs: node-infra/, node-infrastructure/\n\n\
             Set NODE_INFRA_PATH=/path/to/infra to override."
        ),
    };

    match action {
        None => {
            println!("Infrastructure: {}", ctx.dir.display());
            println!("RGS URL: {}", ctx.rgs_url());
            println!();
            println!("Tip: use `node-app dev` for the interactive TUI (press I for infra view).");
            println!("Subcommands: up · down · status · logs · snapshot · db-summary · rgs-url · update");
            Ok(())
        }
        Some(InfraAction::Up) => compose_run(&ctx, &["up", "-d"]),
        Some(InfraAction::Down) => compose_run(&ctx, &["down"]),
        Some(InfraAction::Status) => {
            health::print_status(&ctx);
            Ok(())
        }
        Some(InfraAction::Logs { service }) => {
            let mut args = vec!["logs", "-f", "--no-color"];
            let svc;
            if let Some(ref s) = service {
                svc = s.as_str();
                args.push(svc);
            }
            compose_run(&ctx, &args)
        }
        Some(InfraAction::Snapshot) => {
            let s = snapshot::fetch_snapshot_summary(&ctx, None)?;
            println!("Version:   v{}", s.version);
            println!("Timestamp: {}", s.timestamp_str);
            println!("Chain:     {}", s.chain_hash);
            println!("Nodes:     {}", s.node_count);
            println!("Channels:  {}", s.channel_count);
            println!("Updates:   {}", s.update_count);
            Ok(())
        }
        Some(InfraAction::DbSummary) => db::print_summary(&ctx),
        Some(InfraAction::RgsUrl) => {
            println!("{}", ctx.rgs_url());
            Ok(())
        }
        Some(InfraAction::Update) => {
            ctx.clone_or_pull(true)?;
            println!("Infrastructure repo up to date.");
            Ok(())
        }
    }
}

fn compose_run(ctx: &InfraContext, args: &[&str]) -> Result<()> {
    let status = std::process::Command::new("docker")
        .arg("compose")
        .args(args)
        .current_dir(ctx.compose_dir())
        .status()
        .map_err(|e| anyhow::anyhow!("docker compose: {e}"))?;
    if !status.success() {
        bail!("docker compose {} failed (exit {status})", args[0]);
    }
    Ok(())
}