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