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 = "econ-v1";
const INSTALL_SH_URL: &str = "https://apt.economy1.cloud/install.sh";
const SYSTEMD_UNIT: &str = "econ-v1";
const SYSTEMD_DROP_IN: &str = "/etc/systemd/system/econ-v1.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));
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));
}
}
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)
);
}
}
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 econ-v1` 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 (econ-v1 systemd; socket={}, dev-dir={})",
socket_path.display(),
dev_dir.display()
),
socket_path,
dev_dir,
})
}
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 econ-v1 | grep app={}",
app_name
);
return;
}
};
tui::sys_log(
Some(&log_tx),
"→ streaming journalctl (sudo journalctl -fu econ-v1)…",
);
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"))
}