node-app-build 5.25.1

Mini app developer CLI: scaffold, validate, package node-app-* Debian packages
//! `--daemon docker` mode: bring up `node-server` in a container via
//! docker compose. Cross-platform; the obvious default on macOS / Windows
//! where the daemon doesn't natively compile.

use std::path::{Path, PathBuf};
use std::process::{Child, Stdio};
use std::sync::Mutex;

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

use super::{DaemonHandle, DaemonHost};
use crate::tui::{self, LogSource, LogTx, ServiceStatus, TuiEvent};

const COMPOSE_TEMPLATE: &str = include_str!("../../../../assets/docker-compose.dev.yml");
const IMAGE: &str = "ghcr.io/econ-v1/node-server:dev";
const COMPOSE_SERVICE: &str = "node-server";

pub struct DockerHost {
    socket_override: Option<PathBuf>,
    dev_dir_override: Option<PathBuf>,
    log_tx: Option<LogTx>,
    logs_child: Mutex<Option<Child>>,
}

impl DockerHost {
    pub fn new(
        socket_override: Option<&Path>,
        dev_dir_override: Option<&Path>,
        log_tx: Option<LogTx>,
    ) -> Self {
        Self {
            socket_override: socket_override.map(PathBuf::from),
            dev_dir_override: dev_dir_override.map(PathBuf::from),
            log_tx,
            logs_child: Mutex::new(None),
        }
    }

    fn syslog(&self, msg: impl Into<String>) {
        tui::sys_log(self.log_tx.as_ref(), msg);
    }
}

impl DaemonHost for DockerHost {
    fn pre_start_dev_dir(&self) -> Option<PathBuf> {
        self.dev_dir_override.clone().or_else(|| {
            cache_root()
                .ok()
                .map(|cache| cache.join("docker-dev-apps"))
        })
    }

    fn ensure_running(&self) -> Result<DaemonHandle> {
        if which("docker").is_none() {
            bail!(
                "--daemon docker requires Docker.\n\
                 Install from https://docs.docker.com/get-docker/, then re-run."
            );
        }

        let cache_dir = cache_root()?;
        std::fs::create_dir_all(&cache_dir).ok();
        let compose_path = cache_dir.join("docker-compose.dev.yml");
        if !compose_path.exists() {
            std::fs::write(&compose_path, COMPOSE_TEMPLATE)
                .map_err(|e| anyhow!("write {}: {}", compose_path.display(), e))?;
            self.syslog(format!(
                "→ extracted compose template to {}",
                compose_path.display()
            ));
        }

        if !is_image_pullable() {
            bail!(
                "--daemon docker requires the image '{IMAGE}' which is not reachable.\n\
                 \n\
                 Try:\n  docker pull {IMAGE}\n\
                 \n\
                 To bypass: NODE_APP_BUILD_DOCKER_FORCE=1 node-app dev --daemon docker\n\
                 \n\
                 Compose template staged at {}",
                compose_path.display()
            );
        }

        tui::update_status(
            self.log_tx.as_ref(),
            LogSource::Daemon,
            ServiceStatus::Starting,
            Some("docker compose up…".into()),
        );
        bring_up_docker_compose(&compose_path, &cache_dir)?;

        let socket_path = self
            .socket_override
            .clone()
            .unwrap_or_else(|| cache_dir.join("control.sock"));
        let dev_dir = self
            .dev_dir_override
            .clone()
            .unwrap_or_else(|| cache_dir.join("docker-dev-apps"));
        std::fs::create_dir_all(&dev_dir).ok();

        tui::update_status(
            self.log_tx.as_ref(),
            LogSource::Daemon,
            ServiceStatus::Ready,
            Some(format!("docker:{IMAGE}")),
        );

        Ok(DaemonHandle {
            name: String::new(),
            banner: format!(
                "docker daemon ({}; socket={}, dev-dir={})",
                IMAGE,
                socket_path.display(),
                dev_dir.display()
            ),
            socket_path,
            dev_dir,
            api_base_url: None,
        })
    }

    fn tail_logs(&self, app_name: &str) {
        // Stream `docker logs -f node-server` into the daemon pane.
        let log_tx = match &self.log_tx {
            Some(tx) => tx.clone(),
            None => {
                eprintln!(
                    "→ tailing docker logs — run in another terminal: \
                     docker logs -f {} | grep app={}",
                    COMPOSE_SERVICE, app_name
                );
                return;
            }
        };

        tui::sys_log(
            Some(&log_tx),
            format!("→ streaming docker logs ({COMPOSE_SERVICE})…"),
        );

        let child = std::process::Command::new("docker")
            .args(["logs", "-f", "--tail", "200", COMPOSE_SERVICE])
            .stdin(Stdio::null())
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .spawn();

        match child {
            Ok(mut child) => {
                if let Some(stdout) = child.stdout.take() {
                    let tx = log_tx.clone();
                    std::thread::spawn(move || {
                        use std::io::BufRead;
                        for line in std::io::BufReader::new(stdout).lines().map_while(Result::ok) {
                            let _ = tx.send(TuiEvent::Log(crate::tui::LogEntry {
                                source: LogSource::Daemon,
                                line,
                            }));
                        }
                    });
                }
                if let Some(stderr) = child.stderr.take() {
                    let tx = log_tx.clone();
                    std::thread::spawn(move || {
                        use std::io::BufRead;
                        for line in std::io::BufReader::new(stderr).lines().map_while(Result::ok) {
                            let _ = tx.send(TuiEvent::Log(crate::tui::LogEntry {
                                source: LogSource::Daemon,
                                line,
                            }));
                        }
                    });
                }
                *self.logs_child.lock().unwrap() = Some(child);
            }
            Err(e) => {
                tui::sys_log(
                    Some(&log_tx),
                    format!("⚠ could not stream docker logs: {e}"),
                );
            }
        }
    }

    fn shutdown(&self) {
        if let Some(mut child) = self.logs_child.lock().unwrap().take() {
            let _ = child.kill();
            let _ = child.wait();
        }
    }

    fn restart(&self) -> Result<()> {
        tui::update_status(
            self.log_tx.as_ref(),
            LogSource::Daemon,
            ServiceStatus::Starting,
            Some("restarting container…".into()),
        );
        tui::sys_log(
            self.log_tx.as_ref(),
            format!("→ docker compose restart {COMPOSE_SERVICE}"),
        );

        let status = std::process::Command::new("docker")
            .args(["compose", "restart", COMPOSE_SERVICE])
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status()
            .map_err(|e| anyhow!("docker compose restart: {}", e))?;

        if !status.success() {
            tui::update_status(
                self.log_tx.as_ref(),
                LogSource::Daemon,
                ServiceStatus::Failed(format!("restart exit {}", status.code().unwrap_or(-1))),
                None,
            );
            bail!(
                "docker compose restart failed (exit {})",
                status.code().unwrap_or(-1)
            );
        }
        tui::sys_log(self.log_tx.as_ref(), "✓ container restarted");
        tui::update_status(
            self.log_tx.as_ref(),
            LogSource::Daemon,
            ServiceStatus::Ready,
            Some(format!("docker:{IMAGE} restarted")),
        );
        Ok(())
    }
}

fn is_image_pullable() -> bool {
    if std::env::var("NODE_APP_BUILD_DOCKER_FORCE")
        .map(|v| v == "1")
        .unwrap_or(false)
    {
        return true;
    }
    std::process::Command::new("docker")
        .args(["manifest", "inspect", IMAGE])
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()
        .map(|s| s.success())
        .unwrap_or(false)
}

fn bring_up_docker_compose(compose_path: &Path, sock_dir: &Path) -> Result<()> {
    let status = std::process::Command::new("docker")
        .args(["compose", "-f"])
        .arg(compose_path)
        .args(["up", "-d", COMPOSE_SERVICE])
        .env("NODE_APP_BUILD_SOCK_DIR", sock_dir)
        .status()
        .map_err(|e| anyhow!("invoke docker compose up: {}", e))?;
    if !status.success() {
        bail!("docker compose up exited {}", status.code().unwrap_or(-1));
    }
    let started = std::time::Instant::now();
    while started.elapsed() < std::time::Duration::from_secs(60) {
        let healthy = std::process::Command::new("curl")
            .args(["-fsS", "http://localhost:3001/api/v1/system/health"])
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status()
            .map(|s| s.success())
            .unwrap_or(false);
        if healthy {
            return Ok(());
        }
        std::thread::sleep(std::time::Duration::from_secs(2));
    }
    bail!("docker daemon did not become healthy within 60s — check `docker compose logs {COMPOSE_SERVICE}`")
}

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() {
            return Some(candidate);
        }
    }
    None
}

fn cache_root() -> Result<PathBuf> {
    if let Ok(c) = std::env::var("XDG_CACHE_HOME") {
        if !c.is_empty() {
            return Ok(PathBuf::from(c).join("node-app/compose"));
        }
    }
    let home = std::env::var_os("HOME").ok_or_else(|| anyhow!("$HOME not set"))?;
    Ok(PathBuf::from(home).join(".cache/node-app/compose"))
}