node-app-build 7.5.18

Mini app developer CLI: scaffold, validate, package node-app-* Debian packages
//! `--daemon URL` mode: don't orchestrate a daemon at all. The user
//! launched it (cargo run, `apt install + systemctl`, whatever) and we
//! just connect to its socket.
//!
//! For SSH-tunneled access (testing on a real Pi), the convention is:
//! pre-establish the tunnel:
//!
//! ```sh
//! ssh -fNL /tmp/pi-control.sock:/run/node/control.sock pi@pi.local
//! node-app dev --daemon /tmp/pi-control.sock --dev-dir ~/dev-apps
//! ```
//!
//! The CLI does NOT spawn ssh itself in this mode — that keeps the
//! security boundary clean (the developer owns the tunnel) and avoids
//! the gnarliness of correctly forwarding the socket on every dev cycle.

use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use std::time::Duration;

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

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

pub struct RemoteHost {
    socket_path: PathBuf,
    dev_dir_override: Option<PathBuf>,
    log_tx: Option<LogTx>,
}

impl RemoteHost {
    pub fn new(
        socket_path: PathBuf,
        dev_dir_override: Option<PathBuf>,
        log_tx: Option<LogTx>,
    ) -> Self {
        Self {
            socket_path,
            dev_dir_override,
            log_tx,
        }
    }
}

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

    fn apps_dir(&self) -> Option<PathBuf> {
        lane_apps_dir(&self.socket_path)
    }

    fn ensure_running(&self) -> Result<DaemonHandle> {
        if !self.socket_path.exists() {
            return Err(anyhow!(
                "socket '{}' does not exist locally. \
                 Either start the daemon, or set up an SSH tunnel:\n  \
                 ssh -fNL {}:/run/node/control.sock pi@pi.local",
                self.socket_path.display(),
                self.socket_path.display(),
            ));
        }

        let dev_dir = self
            .dev_dir_override
            .clone()
            .unwrap_or_else(default_dev_dir);
        std::fs::create_dir_all(&dev_dir).ok();

        tui::update_status(
            self.log_tx.as_ref(),
            LogSource::Daemon,
            ServiceStatus::Ready,
            Some(format!("remote socket: {}", self.socket_path.display())),
        );

        Ok(DaemonHandle {
            name: String::new(),
            builtin_apps_dir: lane_apps_dir(&self.socket_path),
            socket_path: self.socket_path.clone(),
            dev_dir,
            banner: format!(
                "remote daemon at {} (you started it; we just sideload)",
                self.socket_path.display()
            ),
            api_base_url: None,
        })
    }

    fn restart(&self) -> Result<()> {
        // Send `app.restart` JSON-RPC over IPC socket.
        tui::sys_log(self.log_tx.as_ref(), "→ sending app.restart over IPC…");

        let result = ipc_call(&self.socket_path, "app.restart", serde_json::json!({}));
        match result {
            Ok(_) => {
                tui::sys_log(self.log_tx.as_ref(), "✓ restart request sent to remote daemon");
                tui::update_status(
                    self.log_tx.as_ref(),
                    LogSource::Daemon,
                    ServiceStatus::Starting,
                    Some("restart sent via IPC".into()),
                );
            }
            Err(e) => {
                tui::sys_log(
                    self.log_tx.as_ref(),
                    format!(
                        "⚠ app.restart IPC failed: {e}\n  \
                         The remote daemon may not support IPC restart. \
                         Restart it manually."
                    ),
                );
            }
        }
        Ok(())
    }
}

fn ipc_call(socket_path: &std::path::Path, method: &str, params: serde_json::Value) -> Result<serde_json::Value> {
    let stream = UnixStream::connect(socket_path).with_context(|| {
        format!("connect to {}", socket_path.display())
    })?;
    stream.set_read_timeout(Some(Duration::from_secs(10))).ok();
    stream.set_write_timeout(Some(Duration::from_secs(10))).ok();

    let req = serde_json::json!({
        "jsonrpc": "2.0",
        "id": 1,
        "method": method,
        "params": params,
    });
    let mut line = serde_json::to_string(&req)?;
    line.push('\n');
    let mut stream = stream;
    stream.write_all(line.as_bytes())?;
    stream.flush().ok();

    let response_line = BufReader::new(&stream)
        .lines()
        .next()
        .ok_or_else(|| anyhow!("daemon closed connection without responding"))??;
    let v: serde_json::Value = serde_json::from_str(&response_line)?;
    if let Some(err) = v.get("error") {
        bail!("IPC error: {}", err);
    }
    Ok(v["result"].clone())
}

// Bring anyhow Context into scope for the with_context() call above.
use anyhow::Context;

/// The apps directory of a `node-app dev` lane reached through its control
/// socket. The lane writes `daemon.env` beside the socket, and the first value
/// of a key wins there, as dotenvy reads it. `None` for any other daemon.
pub(crate) fn lane_apps_dir(socket_path: &Path) -> Option<PathBuf> {
    let env = std::fs::read_to_string(socket_path.parent()?.join("daemon.env")).ok()?;
    let first = |key: &str| {
        env.lines()
            .find_map(|line| line.trim().strip_prefix(key)?.strip_prefix('='))
            .map(str::trim)
    };
    let declared = Path::new(first("NODE_IPC_SOCKET")?).canonicalize().ok()?;
    if declared != socket_path.canonicalize().ok()? {
        return None;
    }
    let apps_dir = PathBuf::from(first("APT_APPS_DIR")?);
    (apps_dir.is_absolute() && apps_dir.is_dir()).then_some(apps_dir)
}

fn default_dev_dir() -> PathBuf {
    if let Ok(env) = std::env::var("NODE_DEV_APPS_DIR") {
        if !env.is_empty() {
            return PathBuf::from(env);
        }
    }
    let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_default();
    home.join(".local/share/node-app/dev-apps")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_lane_socket_names_its_apps_dir_and_any_other_socket_does_not() {
        let tmp = tempfile::tempdir().unwrap();
        let lane = tmp.path().canonicalize().unwrap();
        let modules = lane.join("node/modules");
        std::fs::create_dir_all(&modules).unwrap();
        let socket = lane.join("control.sock");
        std::fs::write(&socket, "").unwrap();
        // The lane's block comes first; the base env file's relative value after it loses.
        std::fs::write(
            lane.join("daemon.env"),
            format!(
                "# APT_APPS_DIR: load apps from modules/\nAPT_APPS_DIR={}\nNODE_IPC_SOCKET={}\nAPT_APPS_DIR=../../modules\n",
                modules.display(),
                socket.display()
            ),
        )
        .unwrap();

        assert_eq!(lane_apps_dir(&socket), Some(modules));

        let elsewhere = lane.join("other.sock");
        std::fs::write(&elsewhere, "").unwrap();
        assert_eq!(lane_apps_dir(&elsewhere), None, "the env file names another socket");
        assert_eq!(lane_apps_dir(&tmp.path().join("nowhere/control.sock")), None);
    }
}