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 BIND_ADDR_ENV: &str = "MOADIM_BIND_ADDR";
pub fn bind_addr() -> String {
std::env::var(BIND_ADDR_ENV).unwrap_or_else(|_| BIND_ADDR.to_string())
}
const PROBE_TIMEOUT: Duration = Duration::from_millis(750);
const DAEMONIZED_ENV: &str = "MOADIM_DAEMONIZED";
pub const EXIT_NOT_RUNNING: i32 = 3;
fn liveness_exit_code(running: bool) -> i32 {
if running {
0
} else {
EXIT_NOT_RUNNING
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Command {
Foreground,
Background,
Restart,
Stop {
json: bool,
quiet: bool,
},
Status {
json: bool,
},
Cleanup {
json: bool,
},
Trigger {
id: String,
},
Install,
Uninstall,
Help,
Version,
Data(Vec<String>),
Machine(Vec<String>),
}
pub(crate) const DATA_COMMANDS: &[&str] = &["cron-jobs", "routines", "schedule", "agents", "echo"];
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(first) if DATA_COMMANDS.contains(&first) => Command::Data(args),
Some("machine") => Command::Machine(args[1..].to_vec()),
Some("restart") => Command::Restart,
Some("stop") => Command::Stop {
json: wants_json(&args[1..]),
quiet: wants_quiet(&args[1..]),
},
Some("status") => Command::Status {
json: wants_json(&args[1..]),
},
Some("cleanup") => Command::Cleanup {
json: wants_json(&args[1..]),
},
Some("trigger" | "run") => match args.get(1) {
Some(id) => Command::Trigger { id: id.clone() },
None => Command::Help,
},
Some("install") => Command::Install,
Some("uninstall") => Command::Uninstall,
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")
}
fn wants_quiet(rest: &[String]) -> bool {
rest.iter().any(|arg| arg == "--quiet" || arg == "-q")
}
pub fn print_help() {
let bind_addr = bind_addr();
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 restart stop a running server (if any) and start a fresh background one\n\
\x20 stop [--json] [-q] stop a running background server (-q/--quiet: no stdout)\n\
\x20 status [--json] show whether a server is running\n\
\x20 cleanup [--json] reap finished, expired routine workbenches now\n\
\x20 trigger <id> trigger a routine to run now, outside its schedule\n\
\x20 install register moadim as an OS service (launchd / systemd user)\n\
\x20 uninstall remove the OS service registration and managed crontab blocks\n\
\x20 machine <show|set|list> show/set this machine's identity, or list machines referenced\n\
\x20 help, -h, --help show this help\n\
\x20 version, -V show the version\n\
\n\
DATA COMMANDS (talk to the running server over HTTP; pass --help for flags):\n\
\x20 cron-jobs <create|list|get|update|replace|delete|trigger|logs> ...\n\
\x20 routines <create|list|get|update|replace|delete|trigger|logs|ical> ...\n\
\x20 schedule trigger <id> trigger a routine or cron job by ID (used by run.sh wrappers)\n\
\x20 agents list available agent keys\n\
\x20 echo <message> echo a message via the server\n\
\n\
Pass --json to `stop`/`status`/`cleanup` for a single-line machine-readable object.\n\
`status`/`cleanup`/`stop` exit 0 when a server is running and 3 when none is, so scripts\n\
can branch on $? without parsing stdout.\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 {}", crate::build_info::long_version());
}
pub fn run_background() -> anyhow::Result<()> {
if is_running() {
let pid = read_pid_file()
.map(|process_id| format!(" (pid {process_id})"))
.unwrap_or_default();
println!("moadim is already running{pid}; stopping it to start a fresh instance");
crate::restart::stop_running_and_wait()?;
}
start_detached_and_report("started")
}
pub fn restart() -> anyhow::Result<()> {
let old_pid = if is_running() {
let pid = read_pid_file();
let suffix = pid
.map(|process_id| format!(" (pid {process_id})"))
.unwrap_or_default();
println!("moadim is running{suffix}; stopping it");
crate::restart::stop_running_and_wait()?;
pid
} else {
println!("moadim is not running; starting a fresh instance");
None
};
let new_pid = spawn_detached()?;
println!("{}", restart_rotation_line(old_pid, new_pid));
report_endpoints();
Ok(())
}
fn restart_rotation_line(old: Option<u32>, new: u32) -> String {
let old = old.map_or_else(|| "none".to_string(), |pid| pid.to_string());
format!("restarted: pid {old} -> {new}")
}
fn start_detached_and_report(verb: &str) -> anyhow::Result<()> {
let pid = spawn_detached()?;
println!(
"moadim {verb} in the background (pid {pid}) at http://{}",
bind_addr()
);
report_endpoints();
Ok(())
}
fn report_endpoints() {
println!(" UI http://{}", bind_addr());
println!(" stop moadim stop (or use the STOP button in the UI)");
println!(" logs {}", paths_daemon_log());
}
pub fn stop(json: bool, quiet: bool) -> anyhow::Result<i32> {
let pid = read_pid_file();
match http_request("POST", "/api/v1/shutdown") {
Ok(200) => {
if json {
println!("{}", stop_json(true, pid));
} else if !quiet {
println!("moadim is shutting down");
}
Ok(liveness_exit_code(true))
}
Ok(status) => {
anyhow::bail!("unexpected response from server: HTTP {status}");
}
Err(_) => {
if json {
println!("{}", stop_json(false, pid));
} else if !quiet {
println!("moadim is not running");
}
Ok(liveness_exit_code(false))
}
}
}
fn stop_json(running: bool, pid: Option<u32>) -> String {
serde_json::json!({
"running": running,
"pid": pid,
"address": bind_addr(),
})
.to_string()
}
pub fn cleanup(json: bool) -> anyhow::Result<i32> {
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(liveness_exit_code(true))
}
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(liveness_exit_code(false))
}
}
}
pub fn trigger(id: String) -> anyhow::Result<i32> {
match http_request("POST", &format!("/api/v1/routines/{id}/trigger")) {
Ok(200) => {
println!("triggered routine {id}");
Ok(liveness_exit_code(true))
}
Ok(404) => {
anyhow::bail!("no routine with id {id}");
}
Ok(status) => {
anyhow::bail!("unexpected response from server: HTTP {status}");
}
Err(_) => {
println!("moadim is not running");
Ok(liveness_exit_code(false))
}
}
}
pub fn status(json: bool) -> anyhow::Result<i32> {
let running = is_running();
let pid = read_pid_file();
if json {
let health = if running { fetch_health() } else { None };
println!("{}", status_json(running, pid, health));
return Ok(liveness_exit_code(running));
}
if running {
let pid_suffix = pid
.map(|process_id| format!(" (pid {process_id})"))
.unwrap_or_default();
println!("moadim is running{pid_suffix} at http://{}", bind_addr());
} else {
println!("moadim is not running");
}
Ok(liveness_exit_code(running))
}
#[derive(Debug, PartialEq, Eq)]
struct HealthInfo {
uptime_secs: u64,
version: String,
}
fn status_json(running: bool, pid: Option<u32>, health: Option<HealthInfo>) -> String {
let uptime_secs = health.as_ref().map(|info| info.uptime_secs);
let version = health.as_ref().map(|info| info.version.as_str());
serde_json::json!({
"running": running,
"pid": pid,
"address": bind_addr(),
"uptime_secs": uptime_secs,
"version": version,
})
.to_string()
}
fn fetch_health() -> Option<HealthInfo> {
let (status, body) = http_request_with_body("GET", "/api/v1/health").ok()?;
(status == 200).then(|| parse_health(&body)).flatten()
}
fn parse_health(body: &str) -> Option<HealthInfo> {
let value: serde_json::Value = serde_json::from_str(body).ok()?;
let uptime_secs = value.get("uptime_secs")?.as_u64()?;
let version = value.get("version")?.as_str()?.to_string();
Some(HealthInfo {
uptime_secs,
version,
})
}
fn cleanup_json(removed: usize, running: bool) -> String {
serde_json::json!({
"running": running,
"removed": removed,
"address": bind_addr(),
})
.to_string()
}
pub fn write_pid_file() -> anyhow::Result<()> {
let path = crate::paths::pid_file();
std::fs::create_dir_all(path.parent().expect("pid file path has a parent dir"))?;
ensure_config_gitignore();
std::fs::write(&path, std::process::id().to_string())?;
Ok(())
}
fn ensure_config_gitignore() {
const REQUIRED: &[&str] = &["*.pid", "*.log", "*.local.*"];
let gitignore = crate::paths::config_gitignore_path();
let existing = std::fs::read_to_string(&gitignore).unwrap_or_default();
let lines: Vec<&str> = existing.lines().collect();
let missing: Vec<&str> = REQUIRED
.iter()
.copied()
.filter(|pat| !lines.iter().any(|line| line.trim() == *pat))
.collect();
if missing.is_empty() {
return;
}
let mut content = existing.clone();
if !content.is_empty() && !content.ends_with('\n') {
content.push('\n');
}
for pattern in &missing {
content.push_str(pattern);
content.push('\n');
}
let _ = std::fs::write(&gitignore, &content);
}
pub fn clear_pid_file() {
let _ = std::fs::remove_file(crate::paths::pid_file());
}
pub(crate) fn read_pid_file() -> Option<u32> {
let pid = std::fs::read_to_string(crate::paths::pid_file())
.ok()?
.trim()
.parse()
.ok()?;
if process_is_alive(pid) {
Some(pid)
} else {
clear_pid_file();
None
}
}
#[cfg(unix)]
fn process_is_alive(pid: u32) -> bool {
if pid > i32::MAX as u32 {
return false;
}
std::process::Command::new("kill")
.args(["-0", &pid.to_string()])
.output()
.is_ok_and(|out| out.status.success())
}
#[cfg(not(unix))]
fn process_is_alive(_pid: u32) -> bool {
true
}
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)
}
const DATA_OP_TIMEOUT: Duration = Duration::from_secs(10);
fn http_request_with_body(method: &str, path: &str) -> std::io::Result<(u16, String)> {
http_request_core(method, path, None, PROBE_TIMEOUT)
}
pub(crate) fn http_request_json(
method: &str,
path: &str,
body: Option<&str>,
) -> std::io::Result<(u16, String)> {
http_request_core(method, path, body, DATA_OP_TIMEOUT)
}
fn http_request_core(
method: &str,
path: &str,
body: Option<&str>,
timeout: Duration,
) -> std::io::Result<(u16, String)> {
let addr_str = bind_addr();
let addr: SocketAddr = addr_str
.parse()
.expect("bind address is a valid socket address");
let mut stream = std::net::TcpStream::connect_timeout(&addr, timeout)?;
stream
.set_read_timeout(Some(timeout))
.expect("set read timeout on loopback TCP stream");
stream
.set_write_timeout(Some(timeout))
.expect("set write timeout on loopback TCP stream");
let payload = body.unwrap_or_default();
let req = format!(
"{method} {path} HTTP/1.1\r\nHost: {addr_str}\r\nContent-Type: application/json\r\n\
Content-Length: {}\r\nConnection: close\r\n\r\n{payload}",
payload.len()
);
stream
.write_all(req.as_bytes())
.expect("write HTTP request to local server");
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> {
spawn_detached_with(|cmd| {
cmd.arg("--interactive").env(DAEMONIZED_ENV, "1");
})
}
pub fn spawn_restart() -> anyhow::Result<u32> {
spawn_detached_with(|cmd| {
cmd.arg("--background");
})
}
fn spawn_detached_with(configure: impl FnOnce(&mut std::process::Command)) -> anyhow::Result<u32> {
use std::process::{Command as Proc, Stdio};
let exe = std::env::current_exe().expect("resolve current executable path");
let log_path = crate::paths::daemon_log_file();
std::fs::create_dir_all(log_path.parent().expect("daemon log path has a parent dir"))?;
let out = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)?;
let err = out
.try_clone()
.expect("clone log file handle for stderr redirect");
let mut cmd = Proc::new(exe);
cmd.stdin(Stdio::null())
.stdout(Stdio::from(out))
.stderr(Stdio::from(err));
configure(&mut cmd);
detach(&mut cmd);
#[allow(clippy::zombie_processes)]
let child = cmd.spawn().expect("spawn detached moadim child process");
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;