node-app-build 6.5.0

Mini app developer CLI: scaffold, validate, package node-app-* Debian packages
//! `--daemon deb` mode: install the published `node` apt package on
//! the host, configure systemd to set `$NODE_DEV_APPS_DIR`, and reload.
//! Linux + Debian/Ubuntu/Pi only. Requires sudo.

use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};

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

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

const PACKAGE_NAME: &str = "node";
const INSTALL_SH_URL: &str = "https://apt.economy1.cloud/install.sh";
const SYSTEMD_UNIT: &str = "node";
const SYSTEMD_DROP_IN: &str = "/etc/systemd/system/node.service.d/dev-apps-dir.conf";
const DEFAULT_SOCKET: &str = "/run/node/control.sock";

pub struct DebHost {
    socket_override: Option<PathBuf>,
    dev_dir_override: Option<PathBuf>,
    log_tx: Option<LogTx>,
}

impl DebHost {
    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,
        }
    }

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

impl DaemonHost for DebHost {
    fn pre_start_dev_dir(&self) -> Option<PathBuf> {
        self.dev_dir_override
            .clone()
            .or_else(|| default_dev_dir().ok())
    }

    fn ensure_running(&self) -> Result<DaemonHandle> {
        if !cfg!(target_os = "linux") {
            bail!(
                "--daemon deb is Linux-only (current target: {}). \
                 Use --daemon docker on macOS / Windows.",
                std::env::consts::OS
            );
        }

        let dev_dir = self
            .dev_dir_override
            .clone()
            .unwrap_or_else(|| default_dev_dir().unwrap_or_else(|_| PathBuf::from("/var/lib/node-app/dev-apps")));
        let socket_path = self
            .socket_override
            .clone()
            .unwrap_or_else(|| PathBuf::from(DEFAULT_SOCKET));

        // 1. Is node already installed?
        let installed = Command::new("dpkg-query")
            .args(["-W", "-f=${Status}", PACKAGE_NAME])
            .output()
            .map(|o| String::from_utf8_lossy(&o.stdout).contains("install ok installed"))
            .unwrap_or(false);

        if !installed {
            self.syslog(format!(
                "{} not installed. Will install via:\n  curl -fsSL {} | sudo bash\n",
                PACKAGE_NAME, INSTALL_SH_URL
            ));
            eprintln!("Continue? [y/N]: ");
            let mut answer = String::new();
            std::io::stdin()
                .read_line(&mut answer)
                .context("read user confirmation")?;
            if !matches!(answer.trim().to_lowercase().as_str(), "y" | "yes") {
                bail!("aborted by user — install manually or use a different --daemon mode");
            }
            let install_cmd = format!("curl -fsSL {} | bash", INSTALL_SH_URL);
            let status = Command::new("sudo")
                .args(["sh", "-c", &install_cmd])
                .status()
                .with_context(|| "sudo curl + bash install")?;
            if !status.success() {
                bail!("apt install failed (exit {})", status.code().unwrap_or(-1));
            }
        }

        // 2. Configure $NODE_DEV_APPS_DIR via a systemd drop-in.
        std::fs::create_dir_all(&dev_dir)
            .with_context(|| format!("create dev dir {}", dev_dir.display()))?;
        let drop_in_content = format!(
            "[Service]\nEnvironment=\"NODE_DEV_APPS_DIR={}\"\n",
            dev_dir.display()
        );
        let needs_update = fs::read_to_string(SYSTEMD_DROP_IN)
            .map(|cur| cur != drop_in_content)
            .unwrap_or(true);
        if needs_update {
            self.syslog(format!(
                "→ writing systemd drop-in: {} (sudo)",
                SYSTEMD_DROP_IN
            ));
            let mut child = Command::new("sudo")
                .args(["tee", SYSTEMD_DROP_IN])
                .stdin(Stdio::piped())
                .stdout(Stdio::null())
                .spawn()
                .with_context(|| "sudo tee systemd drop-in")?;
            if let Some(stdin) = child.stdin.as_mut() {
                stdin.write_all(drop_in_content.as_bytes())?;
            }
            let status = child.wait()?;
            if !status.success() {
                bail!(
                    "failed to write systemd drop-in (exit {})",
                    status.code().unwrap_or(-1)
                );
            }
            let _ = Command::new("sudo")
                .args(["systemctl", "daemon-reload"])
                .status();

            let restart = Command::new("sudo")
                .args(["systemctl", "restart", SYSTEMD_UNIT])
                .status()
                .with_context(|| format!("sudo systemctl restart {SYSTEMD_UNIT}"))?;
            if !restart.success() {
                bail!(
                    "systemctl restart failed (exit {})",
                    restart.code().unwrap_or(-1)
                );
            }
        }

        // 3. Wait for socket.
        tui::update_status(
            self.log_tx.as_ref(),
            LogSource::Daemon,
            ServiceStatus::Starting,
            Some("waiting for socket…".into()),
        );
        let started = std::time::Instant::now();
        while !socket_path.exists() && started.elapsed() < std::time::Duration::from_secs(20) {
            std::thread::sleep(std::time::Duration::from_millis(500));
        }
        if !socket_path.exists() {
            tui::update_status(
                self.log_tx.as_ref(),
                LogSource::Daemon,
                ServiceStatus::Failed("socket never appeared".into()),
                None,
            );
            bail!(
                "daemon socket did not appear at {} after restart. \
                 Check `sudo journalctl -fu node` for errors.",
                socket_path.display()
            );
        }

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

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

    fn tail_logs(&self, app_name: &str) {
        let log_tx = match &self.log_tx {
            Some(tx) => tx.clone(),
            None => {
                eprintln!(
                    "→ tailing daemon logs — run in another terminal: \
                     sudo journalctl -fu node | grep app={}",
                    app_name
                );
                return;
            }
        };

        tui::sys_log(
            Some(&log_tx),
            "→ streaming journalctl (sudo journalctl -fu node)…",
        );

        let child = Command::new("sudo")
            .args(["journalctl", "-fu", SYSTEMD_UNIT, "--output=cat"])
            .stdout(Stdio::piped())
            .stderr(Stdio::null())
            .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,
                            }));
                        }
                    });
                }
                std::mem::forget(child);
            }
            Err(e) => {
                tui::sys_log(
                    Some(&log_tx),
                    format!("⚠ could not start journalctl: {e}"),
                );
            }
        }
    }

    fn restart(&self) -> Result<()> {
        tui::update_status(
            self.log_tx.as_ref(),
            LogSource::Daemon,
            ServiceStatus::Starting,
            Some("sudo systemctl restart…".into()),
        );
        self.syslog(format!("→ sudo systemctl restart {SYSTEMD_UNIT}"));

        let status = Command::new("sudo")
            .args(["systemctl", "restart", SYSTEMD_UNIT])
            .status()
            .map_err(|e| anyhow!("sudo systemctl 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!(
                "systemctl restart failed (exit {})",
                status.code().unwrap_or(-1)
            );
        }
        tui::sys_log(self.log_tx.as_ref(), "✓ systemd unit restarted");
        tui::update_status(
            self.log_tx.as_ref(),
            LogSource::Daemon,
            ServiceStatus::Ready,
            Some(format!("systemd:{SYSTEMD_UNIT} restarted")),
        );
        Ok(())
    }
}

fn default_dev_dir() -> Result<PathBuf> {
    let home = std::env::var_os("HOME").ok_or_else(|| anyhow!("$HOME not set"))?;
    Ok(PathBuf::from(home).join(".local/share/node-app/dev-apps"))
}