use std::collections::HashMap;
use std::path::Path;
use std::process::ExitCode;
use anyhow::{anyhow, Context, Result};
use clap::{Parser, Subcommand};
use running_process::client::{connect_or_start, ClientError, DaemonClient};
use running_process::proto::daemon::{DaemonResponse, ServiceConfig, StatusCode};
#[derive(Parser)]
#[command(
name = "runpm",
about = "PM2-style process supervisor for running-process. See https://github.com/zackees/running-process/issues/106"
)]
struct Cli {
#[arg(long, global = true)]
start_daemon: bool,
#[arg(long, global = true)]
stop_daemon: bool,
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand)]
enum Commands {
Start {
#[arg(required = true, num_args = 1..)]
cmd: Vec<String>,
#[arg(long)]
name: Option<String>,
#[arg(long)]
cwd: Option<String>,
#[arg(long = "env")]
env: Vec<String>,
#[arg(long)]
no_autorestart: bool,
#[arg(long, default_value_t = 0u32)]
max_restarts: u32,
},
Stop {
target: String,
},
Restart {
target: String,
},
Delete {
target: String,
},
#[command(alias = "ls", alias = "status")]
List,
#[command(alias = "describe")]
Show {
target: String,
},
Logs {
target: Option<String>,
#[arg(long, default_value_t = 100u32)]
lines: u32,
#[arg(long)]
follow: bool,
},
Flush {
target: Option<String>,
},
Save,
Resurrect,
Startup,
Unstartup,
Ping,
Kill,
}
fn main() -> ExitCode {
let cli = Cli::parse();
if cli.start_daemon {
return run_to_exit(cmd_start_daemon());
}
if cli.stop_daemon {
return run_to_exit(cmd_kill());
}
let Some(command) = cli.command else {
eprintln!("error: a subcommand is required (try `runpm --help`)");
return ExitCode::from(2);
};
let result = match command {
Commands::Start {
cmd,
name,
cwd,
env,
no_autorestart,
max_restarts,
} => cmd_start(cmd, name, cwd, env, !no_autorestart, max_restarts),
Commands::Stop { target } => cmd_simple_target("stop", &target, |c, t| c.service_stop(t)),
Commands::Restart { target } => {
cmd_simple_target("restart", &target, |c, t| c.service_restart(t))
}
Commands::Delete { target } => {
cmd_simple_target("delete", &target, |c, t| c.service_delete(t))
}
Commands::List => cmd_list(),
Commands::Show { target } => cmd_show(&target),
Commands::Logs {
target,
lines,
follow,
} => cmd_logs(target.as_deref().unwrap_or(""), lines, follow),
Commands::Flush { target } => {
cmd_simple_target("flush", target.as_deref().unwrap_or("all"), |c, t| {
c.service_flush(t)
})
}
Commands::Save => cmd_no_arg("save", |c| c.service_save()),
Commands::Resurrect => cmd_no_arg("resurrect", |c| c.service_resurrect()),
Commands::Startup => cmd_phase4_stub("startup"),
Commands::Unstartup => cmd_phase4_stub("unstartup"),
Commands::Ping => cmd_ping(),
Commands::Kill => cmd_kill(),
};
run_to_exit(result)
}
fn run_to_exit(result: Result<()>) -> ExitCode {
match result {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("error: {err:#}");
ExitCode::FAILURE
}
}
}
fn cmd_start_daemon() -> Result<()> {
let mut client = connect_or_start(None).context("failed to connect to or start daemon")?;
let resp = client.ping().map_err(client_err)?;
let server_time = resp.ping.map(|p| p.server_time_ms).unwrap_or(0);
let status = client.status().map_err(client_err)?;
if let Some(s) = status.status {
println!(
"daemon ready (server_time_ms={server_time}, socket={})",
s.socket_path
);
} else {
println!("daemon ready (server_time_ms={server_time})");
}
Ok(())
}
fn cmd_ping() -> Result<()> {
let mut client = connect()?;
let resp = client.ping().map_err(client_err)?;
let server_time = resp.ping.map(|p| p.server_time_ms).unwrap_or(0);
println!("OK (server_time_ms={server_time})");
Ok(())
}
fn cmd_kill() -> Result<()> {
let mut client = match DaemonClient::connect(None) {
Ok(c) => c,
Err(_) => {
println!("daemon is not running");
return Ok(());
}
};
client.shutdown(true, 5.0).map_err(client_err)?;
println!("daemon shutting down");
Ok(())
}
fn cmd_start(
cmd: Vec<String>,
name: Option<String>,
cwd: Option<String>,
env_args: Vec<String>,
autorestart: bool,
max_restarts: u32,
) -> Result<()> {
let env = parse_env(&env_args)?;
let resolved_name = match name {
Some(n) => n,
None => default_name_from(&cmd[0])?,
};
let config = ServiceConfig {
name: resolved_name,
cmd,
cwd: cwd.unwrap_or_default(),
env,
autorestart,
max_restarts,
restart_delay_ms: 0,
kill_timeout_ms: 0,
min_uptime_ms: 0,
};
let mut client = connect()?;
let resp = client.service_start(config).map_err(client_err)?;
print_status("start", &resp)
}
fn cmd_list() -> Result<()> {
let mut client = connect()?;
let resp = client.service_list().map_err(client_err)?;
print_status("list", &resp)
}
fn cmd_show(target: &str) -> Result<()> {
let mut client = connect()?;
let resp = client.service_describe(target).map_err(client_err)?;
print_status("show", &resp)
}
fn cmd_logs(target: &str, lines: u32, follow: bool) -> Result<()> {
let mut client = connect()?;
let resp = client
.service_logs(target, lines, follow)
.map_err(client_err)?;
if let Some(payload) = &resp.service_logs {
if !payload.log_text.is_empty() {
println!("{}", payload.log_text);
}
}
print_status("logs", &resp)
}
fn cmd_simple_target<F>(label: &str, target: &str, call: F) -> Result<()>
where
F: FnOnce(&mut DaemonClient, &str) -> Result<DaemonResponse, ClientError>,
{
let mut client = connect()?;
let resp = call(&mut client, target).map_err(client_err)?;
print_status(label, &resp)
}
fn cmd_no_arg<F>(label: &str, call: F) -> Result<()>
where
F: FnOnce(&mut DaemonClient) -> Result<DaemonResponse, ClientError>,
{
let mut client = connect()?;
let resp = call(&mut client).map_err(client_err)?;
print_status(label, &resp)
}
fn cmd_phase4_stub(label: &str) -> Result<()> {
println!("runpm: {label} not yet implemented (Phase 4 — see #106)");
Ok(())
}
fn connect() -> Result<DaemonClient> {
DaemonClient::connect(None)
.map_err(|_| anyhow!("daemon is not running (try `runpm --start-daemon`)"))
}
fn client_err(err: ClientError) -> anyhow::Error {
anyhow!(err.to_string())
}
fn print_status(label: &str, resp: &DaemonResponse) -> Result<()> {
if resp.code == StatusCode::Ok as i32 {
println!("OK: {label}");
Ok(())
} else {
Err(anyhow!(
"{}",
if resp.message.is_empty() {
format!("{label} failed (code={})", resp.code)
} else {
resp.message.clone()
}
))
}
}
fn parse_env(args: &[String]) -> Result<HashMap<String, String>> {
let mut out = HashMap::new();
for entry in args {
let (key, value) = entry
.split_once('=')
.ok_or_else(|| anyhow!("--env value `{entry}` must be in KEY=VALUE form"))?;
if key.is_empty() {
return Err(anyhow!("--env value `{entry}` has empty key"));
}
out.insert(key.to_string(), value.to_string());
}
Ok(out)
}
fn default_name_from(cmd: &str) -> Result<String> {
let stem = Path::new(cmd)
.file_stem()
.and_then(|s| s.to_str())
.filter(|s| !s.is_empty());
stem.map(|s| s.to_string())
.ok_or_else(|| anyhow!("could not derive default --name from `{cmd}`; pass --name"))
}