node-app-build 5.25.1

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::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 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(),
            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;

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")
}