use crate::context::WorkspaceContext;
use crate::docker::DockerCompose;
use crate::env_writer::EnvWriter;
use anyhow::{Context, Result, bail};
use colored::Colorize;
use std::collections::BTreeMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use std::time::{Duration, Instant};
use std::io::Write;
macro_rules! safe_println {
() => {
let _ = writeln!(std::io::stdout());
};
($($arg:tt)*) => {
let _ = writeln!(std::io::stdout(), $($arg)*);
};
}
const HEALTH_CHECK_INTERVAL: Duration = Duration::from_secs(5);
pub struct ShutdownSignal(Arc<AtomicBool>);
impl ShutdownSignal {
pub fn install() -> Result<Self> {
let flag = Arc::new(AtomicBool::new(false));
let handler_flag = flag.clone();
ctrlc::set_handler(move || handler_flag.store(true, Ordering::SeqCst))
.context("Failed to install signal handler")?;
Ok(Self(flag))
}
pub fn requested(&self) -> bool {
self.0.load(Ordering::SeqCst)
}
}
pub struct Supervisor;
impl Supervisor {
pub fn run_dev(
ctx: &WorkspaceContext,
compose: &DockerCompose<'_>,
shutdown: &ShutdownSignal,
dev_command: Option<&str>,
) -> Result<()> {
let expected = compose.running_services()?;
safe_println!();
safe_println!(
"{}",
"═══════════════════════════════════════════════════════".green()
);
safe_println!(
" {} Services are up and running in workspace",
"ai-igniter:".bold()
);
safe_println!(" Workspace: {}", ctx.workspace_path.display());
if ctx.root_path != ctx.workspace_path {
safe_println!(" Root: {}", ctx.root_path.display());
}
safe_println!(" Compose project: {}", ctx.compose_project.cyan());
safe_println!(" Base port: {}", ctx.base_port.to_string().yellow());
let images = ctx.service_images();
for (name, port) in &ctx.port_allocations {
let image_info = images
.get(name)
.map(|img| format!(" ({img})"))
.unwrap_or_default();
safe_println!(
" - {:<14} localhost:{}{}",
format!("{}:", name),
port,
image_info.dimmed()
);
}
let interpolated_cmd = dev_command.map(|cmd| ctx.interpolate(cmd)).transpose()?;
safe_println!();
if let Some(cmd) = &interpolated_cmd {
safe_println!(" Executing dev command: {}", cmd.cyan().bold());
} else {
safe_println!(" Keeping services alive in foreground.");
}
safe_println!(
" Press {} to gracefully stop all services.",
"Ctrl+C".bold()
);
safe_println!(
"{}",
"═══════════════════════════════════════════════════════".green()
);
safe_println!();
let mut child = if let Some(ref cmd) = interpolated_cmd {
let mut command = create_dev_command(ctx, cmd)?;
let spawned = command
.spawn()
.with_context(|| format!("Failed to spawn dev command: '{cmd}'"))?;
Some(spawned)
} else {
None
};
let mut last_check = Instant::now();
let mut child_exit_status = None;
while !shutdown.requested() {
if let Some(ref mut c) = child {
match c.try_wait() {
Ok(Some(status)) => {
child_exit_status = Some(status);
break;
}
Ok(None) => {}
Err(e) => {
eprintln!(
"{} Error checking dev command status: {:#}",
"[dev]".yellow().bold(),
e
);
break;
}
}
}
thread::sleep(Duration::from_millis(100));
if last_check.elapsed() < HEALTH_CHECK_INTERVAL {
continue;
}
last_check = Instant::now();
let Ok(running) = compose.running_services() else {
continue;
};
let stopped: Vec<&str> = expected.difference(&running).map(String::as_str).collect();
if !stopped.is_empty() && !shutdown.requested() {
if let Some(ref mut c) = child {
let _ = c.kill();
}
bail!("Service(s) stopped unexpectedly: {}", stopped.join(", "));
}
}
if let (Some(mut c), None) = (child, child_exit_status) {
#[cfg(unix)]
unsafe {
let pid = c.id() as i32;
libc_kill(pid, 15); }
let grace_start = Instant::now();
let mut terminated = false;
while grace_start.elapsed() < Duration::from_millis(1500) {
if let Ok(Some(_)) = c.try_wait() {
terminated = true;
break;
}
thread::sleep(Duration::from_millis(100));
}
if !terminated {
let _ = c.kill();
let _ = c.wait();
}
}
if shutdown.requested() {
safe_println!("\n{} Shutdown signal received.", "[dev]".yellow().bold());
} else if let Some(status) = child_exit_status.filter(|s| !s.success()) {
safe_println!(
"\n{} Dev command exited with status: {}",
"[dev]".yellow().bold(),
status
);
}
Ok(())
}
}
pub fn dev_env(ctx: &WorkspaceContext) -> BTreeMap<String, String> {
let (mut env, _warnings) = EnvWriter::compute_env(ctx);
env.entry("PORT".to_string())
.or_insert_with(|| ctx.base_port.to_string());
if let Some(url) = ctx.template_vars().remove("services.postgres.url") {
for key in ["DATABASE_URL", "DATABASE_MIGRATION_URL"] {
env.entry(key.to_string()).or_insert_with(|| url.clone());
}
}
env
}
pub fn create_dev_command(ctx: &WorkspaceContext, cmd: &str) -> Result<std::process::Command> {
let interpolated = ctx.interpolate(cmd)?;
let mut command = if cfg!(windows) {
let mut c = std::process::Command::new("cmd");
c.args(["/C", &interpolated]);
c
} else {
let mut c = std::process::Command::new("sh");
c.args(["-c", &interpolated]);
c
};
command.current_dir(&ctx.workspace_path);
command.envs(dev_env(ctx));
Ok(command)
}
#[cfg(unix)]
unsafe fn libc_kill(pid: i32, sig: i32) {
unsafe extern "C" {
fn kill(pid: i32, sig: i32) -> i32;
}
unsafe {
kill(pid, sig);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsStr;
#[test]
fn dev_command_interpolates_and_injects_env() {
let toml = r#"
name = "test-app"
[env_template]
CUSTOM_VAR = "hello-{{project.name}}"
"#;
let ctx = crate::context::tests::test_ctx(toml, Some(4123));
let cmd = create_dev_command(&ctx, "bun run dev -- --port {{ports.base}} $PORT").unwrap();
let args: Vec<&OsStr> = cmd.get_args().collect();
if cfg!(windows) {
assert_eq!(args, &["/C", "bun run dev -- --port 4123 $PORT"]);
} else {
assert_eq!(args, &["-c", "bun run dev -- --port 4123 $PORT"]);
}
let envs: std::collections::HashMap<&OsStr, Option<&OsStr>> = cmd.get_envs().collect();
assert_eq!(
envs.get(OsStr::new("PORT")).and_then(|v| *v),
Some(OsStr::new("4123"))
);
assert_eq!(
envs.get(OsStr::new("CUSTOM_VAR")).and_then(|v| *v),
Some(OsStr::new("hello-test-app"))
);
}
#[test]
fn dev_command_fails_on_unknown_placeholder() {
let ctx = crate::context::tests::test_ctx("name = \"test-app\"", Some(4123));
let result = create_dev_command(&ctx, "bun run dev -- --port {{unknown.var}}");
assert!(result.is_err());
}
}