ref:main
use crate::client::Client;
use crate::output;
use crate::runner::{self, RunnerConfig};
use clap::{Args, Subcommand};
use reqwest::header::{HeaderValue, AUTHORIZATION, CONTENT_TYPE};
#[derive(Args)]
pub struct RunnerArgs {
#[command(subcommand)]
pub command: RunnerCommand,
}
#[derive(Subcommand)]
pub enum RunnerCommand {
// === Runner execution commands ===
/// Register this machine as a CI runner
Configure {
/// Anvil server URL
#[arg(long)]
url: String,
/// Registration token (or set ANVIL_RUNNER_TOKEN env var)
#[arg(long)]
token: Option<String>,
/// Runner name (defaults to hostname)
#[arg(long)]
name: Option<String>,
/// Labels (comma-separated)
#[arg(long, default_value = "self-hosted")]
labels: String,
/// Working directory for job workspaces
#[arg(long)]
work_dir: Option<String>,
/// Max concurrent jobs
#[arg(long, default_value = "1")]
parallel: u32,
/// Config file path
#[arg(long)]
config: Option<String>,
},
/// Start polling for and executing CI jobs
Start {
/// Run one job then exit
#[arg(long)]
once: bool,
/// Deregister after one job
#[arg(long)]
ephemeral: bool,
/// Cleanup strategy: always, never, on-success
#[arg(long, default_value = "never")]
cleanup: String,
/// Config file path
#[arg(long)]
config: Option<String>,
},
/// Show runner configuration
Status {
/// Config file path
#[arg(long)]
config: Option<String>,
},
/// Deregister from server and remove config
Unconfigure {
/// Config file path
#[arg(long)]
config: Option<String>,
},
/// Manage OS service (systemd/launchd)
Service(ServiceArgs),
// === Admin commands (use PAT auth) ===
/// List runners for an org or repo
List {
/// Organization slug
#[arg(long)]
org: Option<String>,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// View runner details (admin)
View {
/// Runner ID
id: String,
},
/// Update a runner (admin)
Update {
/// Runner ID
id: String,
/// New labels (comma-separated)
#[arg(long)]
labels: Option<String>,
/// New name
#[arg(long)]
name: Option<String>,
},
/// Remove a runner (admin)
Remove {
/// Runner ID
id: String,
},
/// Generate a registration token
Token {
/// Organization slug (mutually exclusive with --repo)
#[arg(long)]
org: Option<String>,
/// Repository (org/repo, mutually exclusive with --org)
#[arg(long)]
repo: Option<String>,
},
}
#[derive(Args)]
pub struct ServiceArgs {
#[command(subcommand)]
pub command: ServiceCommand,
}
#[derive(Subcommand)]
pub enum ServiceCommand {
/// Install as a system service
Install {
/// Config file path
#[arg(long)]
config: Option<String>,
},
/// Uninstall the system service
Uninstall,
/// Start the service
#[command(name = "svc-start")]
Start,
/// Stop the service
#[command(name = "svc-stop")]
Stop,
/// Show service status
#[command(name = "svc-status")]
SvcStatus,
}
pub async fn run(args: RunnerArgs) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
match args.command {
// Runner execution
RunnerCommand::Configure {
url,
token,
name,
labels,
work_dir,
parallel,
config,
} => {
configure(
&url,
token.as_deref(),
name.as_deref(),
&labels,
work_dir.as_deref(),
parallel,
config.as_deref(),
)
.await
}
RunnerCommand::Start {
once,
ephemeral,
cleanup,
config,
} => start(once, ephemeral, &cleanup, config.as_deref()).await,
RunnerCommand::Status { config } => status(config.as_deref()).await,
RunnerCommand::Unconfigure { config } => unconfigure(config.as_deref()).await,
RunnerCommand::Service(svc) => service(svc).await,
// Admin
RunnerCommand::List { org, repo } => list(org.as_deref(), repo.as_deref()).await,
RunnerCommand::View { id } => view(&id).await,
RunnerCommand::Update { id, labels, name } => {
update(&id, labels.as_deref(), name.as_deref()).await
}
RunnerCommand::Remove { id } => remove(&id).await,
RunnerCommand::Token { org, repo } => token(org.as_deref(), repo.as_deref()).await,
}
}
// === Runner execution commands ===
async fn configure(
url: &str,
token: Option<&str>,
name: Option<&str>,
labels: &str,
work_dir: Option<&str>,
parallel: u32,
config_path: Option<&str>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let token = match token {
Some(t) => t.to_string(),
None => std::env::var("ANVIL_RUNNER_TOKEN")
.map_err(|_| "no token provided — use --token or set ANVIL_RUNNER_TOKEN")?,
};
let runner_name = match name {
Some(n) => n.to_string(),
None => hostname::get()
.map(|h| h.to_string_lossy().to_string())
.unwrap_or_else(|_| "unnamed".to_string()),
};
let work_dir = work_dir.map(|s| s.to_string()).unwrap_or_else(|| {
dirs::home_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join(".anvil-runner")
.join("_work")
.to_string_lossy()
.to_string()
});
let os = std::env::consts::OS.to_string();
let arch = std::env::consts::ARCH.to_string();
// Register with server
let register_url = format!("{}/api/v1/runners/register", url.trim_end_matches('/'));
let client = reqwest::Client::new();
let resp = client
.post(&register_url)
.header(CONTENT_TYPE, HeaderValue::from_static("application/json"))
.json(&serde_json::json!({
"token": token,
"name": runner_name,
"labels": labels.split(',').map(|s| s.trim()).collect::<Vec<&str>>(),
"os": os,
"arch": arch,
}))
.send()
.await?;
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(format!("registration failed: {text}").into());
}
let body: serde_json::Value = resp.json().await?;
let data = body
.get("runner")
.or_else(|| body.get("data"))
.unwrap_or(&body);
let runner_id = data
.get("runner_id")
.or_else(|| data.get("id"))
.and_then(|v| v.as_str())
.ok_or("missing runner_id in response")?;
let runner_token = data
.get("runner_token")
.or_else(|| data.get("token"))
.and_then(|v| v.as_str())
.ok_or("missing runner_token in response")?;
let config = RunnerConfig {
server_url: url.to_string(),
runner_id: runner_id.to_string(),
runner_token: runner_token.to_string(),
name: runner_name.clone(),
labels: labels.split(',').map(|s| s.trim().to_string()).collect(),
work_dir,
parallel,
poll_interval_ms: 5000,
heartbeat_interval_ms: 30000,
once: false,
ephemeral: false,
cleanup: "never".to_string(),
};
config.save(config_path)?;
output::success(&format!("Registered runner '{runner_name}'"));
output::detail("ID", runner_id);
output::detail(
"Config",
&RunnerConfig::path(config_path).display().to_string(),
);
output::info("Start with: anvil runner start");
Ok(())
}
async fn start(
once: bool,
ephemeral: bool,
cleanup: &str,
config_path: Option<&str>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut config = RunnerConfig::load(config_path)?;
config.once = once;
config.ephemeral = ephemeral;
config.cleanup = cleanup.to_string();
runner::loop_runner::start(config).await
}
async fn status(config_path: Option<&str>) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let config = RunnerConfig::load(config_path)?;
output::header("Runner Configuration");
output::detail("Server", &config.server_url);
output::detail("Runner ID", &config.runner_id);
output::detail("Name", &config.name);
output::detail("Labels", &config.labels.join(", "));
output::detail("Work dir", &config.work_dir);
output::detail("Parallel", &config.parallel.to_string());
output::detail("Poll interval", &format!("{}ms", config.poll_interval_ms));
output::detail(
"Heartbeat interval",
&format!("{}ms", config.heartbeat_interval_ms),
);
output::detail(
"Config",
&RunnerConfig::path(config_path).display().to_string(),
);
Ok(())
}
async fn unconfigure(
config_path: Option<&str>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let config = RunnerConfig::load(config_path)?;
// Deregister from server
let client = reqwest::Client::new();
let url = config.api_url(&format!("/runners/{}", config.runner_id));
let resp = client
.delete(&url)
.header(
AUTHORIZATION,
HeaderValue::from_str(&config.auth_header()).unwrap(),
)
.send()
.await;
match resp {
Ok(r) if r.status().is_success() => {
output::success("Deregistered from server");
}
Ok(r) => {
output::warn(&format!("Deregistration returned {}", r.status()));
}
Err(e) => {
output::warn(&format!("Could not reach server: {e}"));
}
}
// Remove config file
RunnerConfig::delete(config_path)?;
output::success("Config removed");
Ok(())
}
async fn service(args: ServiceArgs) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
match args.command {
ServiceCommand::Install { config } => service_install(config.as_deref()),
ServiceCommand::Uninstall => service_uninstall(),
ServiceCommand::Start => service_cmd("start"),
ServiceCommand::Stop => service_cmd("stop"),
ServiceCommand::SvcStatus => service_cmd("status"),
}
}
fn service_install(
config_path: Option<&str>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let exe = std::env::current_exe()?;
let exe_str = exe.to_str().unwrap();
let config_file = RunnerConfig::path(config_path);
let config_str = config_file.to_str().unwrap();
if cfg!(target_os = "macos") {
// launchd plist
let plist_dir = dirs::home_dir().unwrap().join("Library/LaunchAgents");
std::fs::create_dir_all(&plist_dir)?;
let plist_path = plist_dir.join("com.anvil.runner.plist");
let log_dir = dirs::home_dir().unwrap().join(".anvil-runner");
std::fs::create_dir_all(&log_dir)?;
let plist = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.anvil.runner</string>
<key>ProgramArguments</key>
<array>
<string>{exe_str}</string>
<string>runner</string>
<string>start</string>
<string>--config</string>
<string>{config_str}</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>{log_out}</string>
<key>StandardErrorPath</key>
<string>{log_err}</string>
<key>EnvironmentVariables</key>
<dict>
<key>HOME</key>
<string>{home}</string>
</dict>
</dict>
</plist>"#,
log_out = log_dir.join("runner.log").display(),
log_err = log_dir.join("runner.err.log").display(),
home = dirs::home_dir().unwrap().display(),
);
std::fs::write(&plist_path, plist)?;
output::success(&format!(
"Installed launchd service: {}",
plist_path.display()
));
output::info("Start with: anvil runner service svc-start");
} else {
// systemd service
let is_root = unsafe { libc::getuid() } == 0;
let (unit_dir, user_flag) = if is_root {
("/etc/systemd/system".to_string(), "")
} else {
let dir = dirs::home_dir().unwrap().join(".config/systemd/user");
std::fs::create_dir_all(&dir)?;
(dir.to_string_lossy().to_string(), " --user")
};
let unit_path = format!("{unit_dir}/anvil-runner.service");
let unit = format!(
r#"[Unit]
Description=Anvil CI Runner
After=network.target
[Service]
Type=simple
ExecStart={exe_str} runner start --config {config_str}
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
"#,
);
std::fs::write(&unit_path, unit)?;
// Enable
let _ = std::process::Command::new("systemctl")
.args(if user_flag.is_empty() {
vec!["enable", "anvil-runner"]
} else {
vec!["--user", "enable", "anvil-runner"]
})
.output();
output::success(&format!("Installed systemd service: {unit_path}"));
output::info("Start with: anvil runner service svc-start");
}
Ok(())
}
fn service_uninstall() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
if cfg!(target_os = "macos") {
let plist = dirs::home_dir()
.unwrap()
.join("Library/LaunchAgents/com.anvil.runner.plist");
let _ = std::process::Command::new("launchctl")
.args(["unload", plist.to_str().unwrap()])
.output();
if plist.exists() {
std::fs::remove_file(&plist)?;
}
output::success("Uninstalled launchd service");
} else {
let is_root = unsafe { libc::getuid() } == 0;
let args: Vec<&str> = if is_root {
vec!["disable", "anvil-runner"]
} else {
vec!["--user", "disable", "anvil-runner"]
};
let _ = std::process::Command::new("systemctl").args(&args).output();
let unit = if is_root {
"/etc/systemd/system/anvil-runner.service".to_string()
} else {
dirs::home_dir()
.unwrap()
.join(".config/systemd/user/anvil-runner.service")
.to_string_lossy()
.to_string()
};
if std::path::Path::new(&unit).exists() {
std::fs::remove_file(&unit)?;
}
output::success("Uninstalled systemd service");
}
Ok(())
}
fn service_cmd(action: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
if cfg!(target_os = "macos") {
let plist = dirs::home_dir()
.unwrap()
.join("Library/LaunchAgents/com.anvil.runner.plist");
let launchctl_action = match action {
"start" => "load",
"stop" => "unload",
"status" => {
let output = std::process::Command::new("launchctl")
.args(["list", "com.anvil.runner"])
.output()?;
println!("{}", String::from_utf8_lossy(&output.stdout));
return Ok(());
}
_ => return Err(format!("unknown action: {action}").into()),
};
let output = std::process::Command::new("launchctl")
.args([launchctl_action, plist.to_str().unwrap()])
.output()?;
if output.status.success() {
output::success(&format!("Service {action}ed"));
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("launchctl {launchctl_action} failed: {stderr}").into());
}
} else {
let is_root = unsafe { libc::getuid() } == 0;
let mut args: Vec<&str> = Vec::new();
if !is_root {
args.push("--user");
}
args.push(action);
args.push("anvil-runner");
let output = std::process::Command::new("systemctl")
.args(&args)
.output()?;
if action == "status" {
println!("{}", String::from_utf8_lossy(&output.stdout));
} else if output.status.success() {
output::success(&format!("Service {action}ed"));
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("systemctl {action} failed: {stderr}").into());
}
}
Ok(())
}
// === Admin commands (PAT auth) ===
async fn list(
org: Option<&str>,
repo: Option<&str>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let client = Client::from_config()?;
let path = match (org, repo) {
(Some(org_slug), _) => format!("/runners/orgs/{org_slug}"),
(_, Some(repo_ref)) => {
let parts: Vec<&str> = repo_ref.splitn(2, '/').collect();
if parts.len() != 2 {
return Err("--repo must be in org/repo format".into());
}
format!("/runners/repos/{}/{}", parts[0], parts[1])
}
_ => return Err("specify --org or --repo".into()),
};
let resp: serde_json::Value = client.get(&path).await?;
let runners: Vec<serde_json::Value> = if let Some(arr) = resp.get("runners") {
serde_json::from_value(arr.clone()).unwrap_or_default()
} else if let Some(arr) = resp.get("data") {
serde_json::from_value(arr.clone()).unwrap_or_default()
} else {
serde_json::from_value(resp).unwrap_or_default()
};
output::header("Runners");
let rows: Vec<Vec<String>> = runners
.iter()
.map(|r| {
vec![
r.get("id")
.and_then(|v| v.as_str())
.unwrap_or("?")
.chars()
.take(8)
.collect(),
r.get("name")
.and_then(|v| v.as_str())
.unwrap_or("?")
.to_string(),
output::colorize_status(r.get("status").and_then(|v| v.as_str()).unwrap_or("?")),
r.get("labels")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
r.get("os")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
r.get("arch")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
]
})
.collect();
output::print_table(&["ID", "NAME", "STATUS", "LABELS", "OS", "ARCH"], &rows);
Ok(())
}
async fn view(id: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let client = Client::from_config()?;
let resp: serde_json::Value = client.get(&format!("/runners/{id}")).await?;
let runner = resp
.get("runner")
.or_else(|| resp.get("data"))
.unwrap_or(&resp);
output::header(&format!(
"Runner: {}",
runner.get("name").and_then(|v| v.as_str()).unwrap_or(id)
));
if let Some(id) = runner.get("id").and_then(|v| v.as_str()) {
output::detail("ID", id);
}
if let Some(status) = runner.get("status").and_then(|v| v.as_str()) {
output::detail("Status", &output::colorize_status(status));
}
if let Some(labels) = runner.get("labels").and_then(|v| v.as_str()) {
output::detail("Labels", labels);
}
if let Some(os) = runner.get("os").and_then(|v| v.as_str()) {
output::detail("OS", os);
}
if let Some(arch) = runner.get("arch").and_then(|v| v.as_str()) {
output::detail("Arch", arch);
}
if let Some(last_seen) = runner.get("last_heartbeat_at").and_then(|v| v.as_str()) {
output::detail("Last seen", &output::format_time(last_seen));
}
Ok(())
}
async fn update(
id: &str,
labels: Option<&str>,
name: Option<&str>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let client = Client::from_config()?;
let mut body = serde_json::Map::new();
if let Some(l) = labels {
body.insert("labels".into(), serde_json::Value::String(l.to_string()));
}
if let Some(n) = name {
body.insert("name".into(), serde_json::Value::String(n.to_string()));
}
let _resp: serde_json::Value = client
.patch(&format!("/runners/{id}"), &serde_json::Value::Object(body))
.await?;
output::success(&format!("Updated runner {}", &id[..8.min(id.len())]));
Ok(())
}
async fn remove(id: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let client = Client::from_config()?;
client.delete_empty(&format!("/runners/{id}")).await?;
output::success(&format!("Removed runner {}", &id[..8.min(id.len())]));
Ok(())
}
async fn token(
org: Option<&str>,
repo: Option<&str>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let client = Client::from_config()?;
let path = match (org, repo) {
(Some(org_slug), _) => format!("/runners/orgs/{org_slug}/tokens"),
(_, Some(repo_ref)) => {
let parts: Vec<&str> = repo_ref.splitn(2, '/').collect();
if parts.len() != 2 {
return Err("--repo must be in org/repo format".into());
}
format!("/runners/repos/{}/{}/tokens", parts[0], parts[1])
}
_ => return Err("specify --org or --repo".into()),
};
let resp: serde_json::Value = client.post(&path, &serde_json::json!({})).await?;
let token = resp
.pointer("/token/value")
.or_else(|| resp.pointer("/data/token"))
.or_else(|| resp.get("token"))
.and_then(|v| v.as_str())
.unwrap_or("?");
output::success("Generated registration token:");
println!("\n {token}\n");
output::info("Use with: anvil runner configure --url <URL> --token <token>");
Ok(())
}