use std::time::Duration;
const DAEMONIZED_ENV: &str = "MOADIM_DAEMONIZED";
pub const EXIT_NOT_RUNNING: i32 = 3;
pub const EXIT_USAGE: i32 = 2;
const 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 {
json: bool,
quiet: bool,
interactive: bool,
},
Stop {
json: bool,
quiet: bool,
},
Status {
json: bool,
wait_secs: Option<u64>,
},
Cleanup {
json: bool,
},
Trigger {
id: String,
},
Logs {
id: String,
},
Install,
Uninstall,
Help,
Usage(String),
Version,
Completions(Option<String>),
Data(Vec<String>),
Machine(Vec<String>),
}
pub(crate) const DATA_COMMANDS: &[&str] = &["routines", "schedule", "agents", "enable", "disable"];
pub fn parse(args: impl IntoIterator<Item = String>) -> Command {
let args: Vec<String> = args.into_iter().collect();
match args.first().map(String::as_str) {
Some(first) if DATA_COMMANDS.contains(&first) => Command::Data(args),
Some("machine") => Command::Machine(args[1..].to_vec()),
Some("restart") => Command::Restart {
json: wants_json(&args[1..]),
quiet: wants_quiet(&args[1..]),
interactive: wants_interactive(&args[1..]),
},
Some("stop") => Command::Stop {
json: wants_json(&args[1..]),
quiet: wants_quiet(&args[1..]),
},
Some("status") => Command::Status {
json: wants_json(&args[1..]),
wait_secs: wants_wait(&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("logs") => match args.get(1) {
Some(id) => Command::Logs { id: id.clone() },
None => Command::Help,
},
Some("install") => Command::Install,
Some("uninstall") => Command::Uninstall,
Some("completions") => Command::Completions(args.get(1).cloned()),
Some("-h" | "--help" | "help") => Command::Help,
Some("-V" | "--version" | "version") => Command::Version,
Some("-i" | "--interactive" | "-f" | "--foreground") => Command::Foreground,
None | Some("-b" | "--background" | "-d" | "--detach" | "--daemon") => Command::Background,
Some(other) => Command::Usage(other.to_string()),
}
}
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")
}
fn wants_interactive(rest: &[String]) -> bool {
rest.iter().any(|arg| arg == "--interactive" || arg == "-i")
}
const DEFAULT_WAIT_SECS: u64 = 30;
fn wants_wait(rest: &[String]) -> Option<u64> {
rest.iter().find_map(|arg| {
if arg == "--wait" {
Some(DEFAULT_WAIT_SECS)
} else {
arg.strip_prefix("--wait=")
.and_then(|secs| secs.parse().ok())
}
})
}
pub fn help_text() -> String {
let bind_addr = bind_addr();
format!(
"moadim — routine scheduler with an MCP/REST API and 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); aliases: -f, --foreground\n\
\x20 -b, --background start the server detached in the background (explicit default); aliases: -d, --detach, --daemon\n\
\n\
COMMANDS:\n\
\x20 restart [--json] [-q] [-i] stop a running server (if any) and start a fresh one\n\
\x20 (-q/--quiet: rotation line only; -i/--interactive: foreground)\n\
\x20 stop [--json] [-q] stop a running background server (-q/--quiet: no stdout)\n\
\x20 status [--json] [--wait[=SECS]] show whether a server is running (--wait: poll until\n\
\x20 reachable or SECS elapse, default 30, instead of checking once)\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 logs <id> print a routine's newest run log (agent.log) to stdout\n\
\x20 install register moadim as an OS service (launchd / systemd user)\n\
\x20 uninstall remove the OS service registration and the managed crontab block\n\
\x20 machine <show|set|list> show/set this machine's identity, or list machines referenced\n\
\x20 completions <shell> print a completion script for bash/zsh/fish/powershell/elvish\n\
\x20 (e.g. `moadim completions zsh > _moadim`)\n\
\x20 help, -h, --help show this help\n\
\x20 version, -V, --version show the version\n\
\n\
DATA COMMANDS (talk to the running server over HTTP; pass --help for flags):\n\
\x20 routines <create|list|get|update|replace|delete|trigger|logs|ical> ...\n\
\x20 schedule trigger <id> trigger a routine by ID (used by the routines crontab line)\n\
\x20 enable <routine> [--json] turn a routine on (set enabled=true) by id or slug\n\
\x20 disable <routine> [--json] turn a routine off (set enabled=false) by id or slug\n\
\x20 agents list available agent keys\n\
\n\
Pass --json to `restart`/`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\
`stop` only stops the daemon process; a routine agent already running in its own detached\n\
tmux session keeps running until it finishes or a later daemon start reaps it.\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_usage_error(arg: &str) {
eprintln!("moadim: unknown command: {arg}");
eprintln!("Run `moadim help` for usage.");
}
pub fn print_help() {
println!("{}", help_text());
}
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(crate) fn stop_existing_for_restart(quiet: bool) -> anyhow::Result<Option<u32>> {
if is_running() {
let pid = read_pid_file();
if !quiet {
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()?;
Ok(pid)
} else {
if !quiet {
println!("moadim is not running; starting a fresh instance");
}
Ok(None)
}
}
pub fn ensure_not_running_for_foreground() -> anyhow::Result<()> {
if std::env::var_os(DAEMONIZED_ENV).is_some() {
return Ok(());
}
foreground_preflight(is_running(), read_pid_file())
}
fn foreground_preflight(running: bool, pid: Option<u32>) -> anyhow::Result<()> {
if running {
anyhow::bail!("{}", foreground_already_running_message(pid));
}
Ok(())
}
fn foreground_already_running_message(pid: Option<u32>) -> String {
let suffix = pid
.map(|process_id| format!(" (pid {process_id})"))
.unwrap_or_default();
format!(
"moadim is already running{suffix}; refusing to start a second foreground instance. \
Stop it with `moadim stop`, or replace it with `moadim restart`."
)
}
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()
}
#[path = "bind.rs"]
mod cli_bind;
pub use cli_bind::{bind_addr, classify_bind, remote_bind_allowed, BindDecision, BIND_ADDR};
#[cfg(test)]
pub(crate) use cli_bind::{bind_addr_is_loopback, BIND_ADDR_ENV};
#[path = "query.rs"]
mod cli_query;
pub use cli_query::{cleanup, logs, status, trigger};
#[cfg(test)]
use cli_query::{
cleanup_json, fetch_health, humanize_bytes, parse_health, status_json, HealthInfo,
};
#[path = "system.rs"]
mod cli_system;
pub use cli_system::{clear_pid_file, spawn_restart, write_pid_file};
pub(crate) use cli_system::{http_request, http_request_json, is_running, read_pid_file};
use cli_system::{
http_request_with_body, parse_freed_bytes, parse_removed_count, paths_daemon_log,
spawn_detached, wait_until,
};
#[cfg(test)]
pub(crate) use cli_system::{parse_body, parse_status_code, DAEMON_LOG_MAX_BYTES};
pub(crate) use cli_system::{rotate_daemon_log_if_due, LOG_ROTATION_CHECK_INTERVAL};
#[path = "restart.rs"]
mod cli_restart;
pub use cli_restart::restart;
use cli_restart::start_detached_and_report;
#[cfg(all(test, any(target_os = "macos", target_os = "linux")))]
use cli_restart::{maybe_hint_install, should_hint_install};
#[cfg(test)]
use cli_restart::{restart_json, restart_rotation_line};
#[path = "completions.rs"]
mod cli_completions;
pub use cli_completions::completions;
#[cfg(test)]
use cli_completions::{build_cli, write_completions};
#[cfg(test)]
#[path = "tests.rs"]
mod cli_tests;
#[cfg(test)]
#[path = "completions_tests.rs"]
mod cli_completions_tests;
#[cfg(test)]
#[path = "cleanup_bytes_tests.rs"]
mod cli_cleanup_bytes_tests;
#[cfg(test)]
#[path = "help_tests.rs"]
mod cli_help_tests;
#[cfg(test)]
#[path = "json_tests.rs"]
mod cli_json_tests;
#[cfg(test)]
#[path = "spawn_tests.rs"]
mod cli_spawn_tests;
#[cfg(test)]
#[path = "spawn_error_tests.rs"]
mod cli_spawn_error_tests;