use std::io::{Read as _, Write as _};
use std::net::SocketAddr;
use std::time::Duration;
pub const BIND_ADDR: &str = "127.0.0.1:5784";
const PROBE_TIMEOUT: Duration = Duration::from_millis(750);
const DAEMONIZED_ENV: &str = "MOADIM_DAEMONIZED";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Command {
Foreground,
Background,
Stop,
Status { json: bool },
Cleanup { json: bool },
Help,
Version,
}
pub fn parse(args: impl IntoIterator<Item = String>) -> Command {
let args: Vec<String> = args.into_iter().collect();
match args.first().map(String::as_str) {
None => Command::Background,
Some("stop") => Command::Stop,
Some("status") => Command::Status {
json: wants_json(&args[1..]),
},
Some("cleanup") => Command::Cleanup {
json: wants_json(&args[1..]),
},
Some("-h" | "--help" | "help") => Command::Help,
Some("-V" | "--version" | "version") => Command::Version,
Some("-i" | "--interactive" | "-f" | "--foreground") => Command::Foreground,
Some("-b" | "--background" | "-d" | "--detach" | "--daemon") => Command::Background,
Some(_) => Command::Help,
}
}
fn wants_json(rest: &[String]) -> bool {
rest.iter().any(|arg| arg == "--json")
}
pub fn print_help() {
println!(
"moadim — cron/MCP/REST server with a web control panel\n\
\n\
USAGE:\n\
\x20 moadim [MODE]\n\
\x20 moadim <COMMAND>\n\
\n\
MODES:\n\
\x20 (default) start the server in the background and exit\n\
\x20 -i, --interactive run in the foreground, attached to the terminal (Ctrl-C to stop)\n\
\x20 -b, --background start the server detached in the background (explicit default)\n\
\n\
COMMANDS:\n\
\x20 stop stop a running background server\n\
\x20 status [--json] show whether a server is running\n\
\x20 cleanup [--json] reap finished, expired routine workbenches now\n\
\x20 help, -h, --help show this help\n\
\x20 version, -V show the version\n\
\n\
Pass --json to `status`/`cleanup` for a single-line machine-readable object.\n\
\n\
Once running, manage the server from the web client at http://{BIND_ADDR}\n\
(the STOP button) or with `moadim stop`."
);
}
pub fn print_version() {
println!("moadim {}", env!("CARGO_PKG_VERSION"));
}
pub fn run_background() -> anyhow::Result<()> {
if is_running() {
let pid = read_pid_file()
.map(|p| format!(" (pid {p})"))
.unwrap_or_default();
println!("moadim is already running{pid}; stopping it to start a fresh instance");
crate::restart::stop_running_and_wait()?;
}
let pid = spawn_detached()?;
println!("moadim started in the background (pid {pid}) at http://{BIND_ADDR}");
println!(" UI http://{BIND_ADDR}");
println!(" stop moadim stop (or use the STOP button in the UI)");
println!(" logs {}", paths_daemon_log());
Ok(())
}
pub fn stop() -> anyhow::Result<()> {
match http_request("POST", "/api/v1/shutdown") {
Ok(200) => {
println!("moadim is shutting down");
Ok(())
}
Ok(status) => {
anyhow::bail!("unexpected response from server: HTTP {status}");
}
Err(_) => {
println!("moadim is not running");
Ok(())
}
}
}
pub fn cleanup(json: bool) -> anyhow::Result<()> {
match http_request_with_body("POST", "/api/v1/routines/cleanup") {
Ok((200, body)) => {
let removed = parse_removed_count(&body).unwrap_or(0);
if json {
println!("{}", cleanup_json(removed, true));
} else {
let plural = if removed == 1 { "" } else { "es" };
println!("cleanup removed {removed} workbench{plural}");
}
Ok(())
}
Ok((status, _)) => {
anyhow::bail!("unexpected response from server: HTTP {status}");
}
Err(_) => {
if json {
println!("{}", cleanup_json(0, false));
} else {
println!("moadim is not running");
}
Ok(())
}
}
}
pub fn status(json: bool) -> anyhow::Result<()> {
let running = is_running();
let pid = read_pid_file();
if json {
println!("{}", status_json(running, pid));
return Ok(());
}
if running {
let pid_suffix = pid.map(|p| format!(" (pid {p})")).unwrap_or_default();
println!("moadim is running{pid_suffix} at http://{BIND_ADDR}");
} else {
println!("moadim is not running");
}
Ok(())
}
fn status_json(running: bool, pid: Option<u32>) -> String {
serde_json::json!({
"running": running,
"pid": pid,
"address": BIND_ADDR,
})
.to_string()
}
fn cleanup_json(removed: usize, running: bool) -> String {
serde_json::json!({
"running": running,
"removed": removed,
})
.to_string()
}
pub fn write_pid_file() -> anyhow::Result<()> {
let path = crate::paths::pid_file();
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
ensure_config_gitignore();
std::fs::write(&path, std::process::id().to_string())?;
Ok(())
}
fn ensure_config_gitignore() {
let gitignore = crate::paths::config_gitignore_path();
if !gitignore.exists() {
let _ = std::fs::write(&gitignore, "*.pid\n*.log\n");
}
}
pub fn clear_pid_file() {
let _ = std::fs::remove_file(crate::paths::pid_file());
}
pub(crate) fn read_pid_file() -> Option<u32> {
std::fs::read_to_string(crate::paths::pid_file())
.ok()?
.trim()
.parse()
.ok()
}
fn paths_daemon_log() -> String {
crate::paths::daemon_log_file().display().to_string()
}
pub(crate) fn is_running() -> bool {
matches!(http_request("GET", "/api/v1/health"), Ok(200))
}
pub(crate) fn http_request(method: &str, path: &str) -> std::io::Result<u16> {
http_request_with_body(method, path).map(|(status, _)| status)
}
fn http_request_with_body(method: &str, path: &str) -> std::io::Result<(u16, String)> {
let addr: SocketAddr = BIND_ADDR
.parse()
.expect("BIND_ADDR is a valid socket address");
let mut stream = std::net::TcpStream::connect_timeout(&addr, PROBE_TIMEOUT)?;
stream.set_read_timeout(Some(PROBE_TIMEOUT))?;
stream.set_write_timeout(Some(PROBE_TIMEOUT))?;
let req = format!(
"{method} {path} HTTP/1.1\r\nHost: {BIND_ADDR}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
);
stream.write_all(req.as_bytes())?;
let mut resp = String::new();
let _ = stream.read_to_string(&mut resp);
let status = parse_status_code(&resp).ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
"no HTTP status line in response",
)
})?;
Ok((status, parse_body(&resp)))
}
fn parse_status_code(resp: &str) -> Option<u16> {
resp.lines().next()?.split_whitespace().nth(1)?.parse().ok()
}
fn parse_body(resp: &str) -> String {
resp.split_once("\r\n\r\n")
.map(|(_, body)| body.to_string())
.unwrap_or_default()
}
fn parse_removed_count(body: &str) -> Option<usize> {
let value: serde_json::Value = serde_json::from_str(body).ok()?;
value.get("removed")?.as_u64().map(|n| n as usize)
}
fn spawn_detached() -> anyhow::Result<u32> {
use std::process::{Command as Proc, Stdio};
let exe = std::env::current_exe()?;
let log_path = crate::paths::daemon_log_file();
if let Some(dir) = log_path.parent() {
std::fs::create_dir_all(dir)?;
}
let out = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)?;
let err = out.try_clone()?;
let mut cmd = Proc::new(exe);
cmd.arg("--interactive")
.env(DAEMONIZED_ENV, "1")
.stdin(Stdio::null())
.stdout(Stdio::from(out))
.stderr(Stdio::from(err));
detach(&mut cmd);
let child = cmd.spawn()?;
Ok(child.id())
}
#[cfg(unix)]
fn detach(cmd: &mut std::process::Command) {
use std::os::unix::process::CommandExt as _;
cmd.process_group(0);
}
#[cfg(not(unix))]
fn detach(_cmd: &mut std::process::Command) {}
#[cfg(test)]
#[path = "cli_tests.rs"]
mod cli_tests;