feat: phase 2 — automatisation: doctor --watch, schedule, service, orchestration, conteneurs (closes #55 #56 #57 #58 #59)
This commit is contained in:
@@ -7,7 +7,10 @@ use crate::output::{boxed_table, print_json, status_style};
|
||||
use crate::runner::SystemRunner;
|
||||
use anyhow::Result;
|
||||
|
||||
pub fn run(app: &App, fix: bool) -> Result<i32> {
|
||||
pub fn run(app: &App, fix: bool, watch: Option<u64>) -> Result<i32> {
|
||||
if let Some(interval) = watch {
|
||||
return watch_loop(app, interval.max(1));
|
||||
}
|
||||
if fix {
|
||||
let repaired = doctor::fix(app)?;
|
||||
for r in &repaired {
|
||||
@@ -61,3 +64,41 @@ pub fn run(app: &App, fix: bool) -> Result<i32> {
|
||||
}
|
||||
Ok(if report.ok() { 0 } else { 1 })
|
||||
}
|
||||
|
||||
/// Issue #59: re-run the doctor checks every N seconds; alert (desktop
|
||||
/// notification + event) on the first failure, recover silently. Clean
|
||||
/// Ctrl-C via ctrlc.
|
||||
fn watch_loop(app: &App, interval: u64) -> Result<i32> {
|
||||
app.log
|
||||
.info(&format!("watching every {interval}s (Ctrl-C to stop)"));
|
||||
let mut was_ok = true;
|
||||
loop {
|
||||
let runner = SystemRunner::new(false, false, &app.log);
|
||||
let report = doctor::run(app, &runner);
|
||||
let ok = report.ok();
|
||||
if !ok && was_ok {
|
||||
// First failure: alert.
|
||||
app.emit(
|
||||
&Event::now(EventKind::Doctor)
|
||||
.with_exit_code(1)
|
||||
.with_reason(format!("{} problem(s) found", report.problems())),
|
||||
);
|
||||
app.log.error(&format!(
|
||||
"{} problem(s) found — 'am doctor --fix' repairs what it can",
|
||||
report.problems()
|
||||
));
|
||||
let _ = crate::process::desktop_notify(
|
||||
"agent-manager",
|
||||
&format!("doctor: {} problem(s)", report.problems()),
|
||||
);
|
||||
was_ok = false;
|
||||
} else if ok && !was_ok {
|
||||
app.log.success("environment recovered");
|
||||
was_ok = true;
|
||||
} else if !ok {
|
||||
app.log
|
||||
.warn(&format!("still {} problem(s)", report.problems()));
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_secs(interval));
|
||||
}
|
||||
}
|
||||
|
||||
+40
-2
@@ -24,7 +24,9 @@ pub mod open_cmd;
|
||||
pub mod profile_cmd;
|
||||
pub mod projects_cmd;
|
||||
pub mod run_cmd;
|
||||
pub mod schedule_cmd;
|
||||
pub mod search_cmd;
|
||||
pub mod service_cmd;
|
||||
pub mod secret_cmd;
|
||||
pub mod self_uninstall;
|
||||
pub mod self_update;
|
||||
@@ -184,8 +186,44 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
|
||||
Command::Tip { random } => tip_cmd::run(app, *random),
|
||||
Command::Config(sub) => config_cmd::run(app, sub),
|
||||
Command::Theme { name } => theme_cmd::run(app, name.as_deref()),
|
||||
Command::Doctor { fix } => doctor_cmd::run(app, *fix),
|
||||
Command::Run { agent, model, args } => run_cmd::run(app, agent, model.as_deref(), args),
|
||||
Command::Doctor { fix, watch } => {
|
||||
doctor_cmd::run(app, *fix, *watch)
|
||||
}
|
||||
Command::Run { agent, model, container, args } => {
|
||||
run_cmd::run(app, agent, model.as_deref(), *container, args)
|
||||
}
|
||||
Command::Service(svc) => match svc {
|
||||
crate::cli::ServiceCmd::Install { agent, autostart } => {
|
||||
service_cmd::run(app, "install", Some(agent), *autostart)
|
||||
}
|
||||
crate::cli::ServiceCmd::Uninstall { agent } => {
|
||||
service_cmd::run(app, "uninstall", Some(agent), false)
|
||||
}
|
||||
crate::cli::ServiceCmd::Status { agent } => {
|
||||
service_cmd::run(app, "status", Some(agent), false)
|
||||
}
|
||||
},
|
||||
Command::Schedule(sched) => match sched {
|
||||
crate::cli::ScheduleCmd::Add { command, at } => {
|
||||
// '--at HH:MM' may land inside the trailing command args
|
||||
// (allow_hyphen_values): strip it and recover the value.
|
||||
let mut cmd = command.clone();
|
||||
let mut at_value = at.clone();
|
||||
if let Some(idx) = cmd.iter().position(|a| a == "--at") {
|
||||
if idx + 1 < cmd.len() {
|
||||
at_value = at_value.or_else(|| Some(cmd[idx + 1].clone()));
|
||||
cmd.remove(idx + 1);
|
||||
}
|
||||
cmd.remove(idx);
|
||||
}
|
||||
schedule_cmd::run(app, "add", cmd, at_value.as_deref())
|
||||
}
|
||||
crate::cli::ScheduleCmd::List => schedule_cmd::run(app, "list", vec![], None),
|
||||
crate::cli::ScheduleCmd::Remove { id } => {
|
||||
schedule_cmd::run(app, "remove", vec![id.clone()], None)
|
||||
}
|
||||
crate::cli::ScheduleCmd::Run { id } => schedule_cmd::run(app, "run", vec![id.clone()], None),
|
||||
},
|
||||
Command::Completion { shell, installed } => completion_cmd::run(*shell, *installed),
|
||||
Command::SelfUpdate { check, to } => self_update::run(app, *check, to.as_deref()),
|
||||
Command::SelfUninstall => self_uninstall::run(app),
|
||||
|
||||
+71
-1
@@ -84,8 +84,15 @@ pub fn start_with_cwd(app: &App, opts: &StartArgs, cwd: Option<&std::path::Path>
|
||||
if members.is_empty() {
|
||||
bail!("unknown group '{group}'");
|
||||
}
|
||||
// Issue #57: --parallel launches every member back-to-back without
|
||||
// waiting (the OS spawns them concurrently); the default waits each
|
||||
// health check before the next member.
|
||||
for m in members {
|
||||
start_one(app, m, &extra_args, &extra_env, opts.notify, true, cwd)?;
|
||||
if !opts.parallel {
|
||||
// Issue #57: block until healthy before the next member.
|
||||
crate::automation::wait_health(&m.name, m.healthcheck.as_ref(), &app.log)?;
|
||||
}
|
||||
}
|
||||
return Ok(0);
|
||||
}
|
||||
@@ -309,8 +316,18 @@ pub fn restart(app: &App, opts: &StartArgs, force: bool, timeout: Option<u64>) -
|
||||
}
|
||||
|
||||
/// run: execute the agent command directly, no process management.
|
||||
pub fn run(app: &App, target: &str, model: Option<&str>, extra: &[OsString]) -> Result<i32> {
|
||||
pub fn run(
|
||||
app: &App,
|
||||
target: &str,
|
||||
model: Option<&str>,
|
||||
container: bool,
|
||||
extra: &[OsString],
|
||||
) -> Result<i32> {
|
||||
let agent = require_agent(app, target)?;
|
||||
// Issue #58: --container runs the agent inside its container profile.
|
||||
if container {
|
||||
return run_container(app, agent, extra);
|
||||
}
|
||||
let mut extra_args: Vec<String> = extra
|
||||
.iter()
|
||||
.map(|o| o.to_string_lossy().to_string())
|
||||
@@ -353,6 +370,59 @@ pub fn run(app: &App, target: &str, model: Option<&str>, extra: &[OsString]) ->
|
||||
Ok(code)
|
||||
}
|
||||
|
||||
/// Issue #58: run the agent inside its container profile. Detects the
|
||||
/// runtime (podman > docker), mounts state/log dirs and the profile
|
||||
/// volumes/network/env, then execs the container in the foreground.
|
||||
fn run_container(app: &App, agent: &crate::config::AgentDef, extra: &[OsString]) -> Result<i32> {
|
||||
let Some(spec) = &agent.container else {
|
||||
bail!(
|
||||
"agent '{}' has no container profile — add 'container: {{image: ...}}' to its definition",
|
||||
agent.name
|
||||
);
|
||||
};
|
||||
let Some(runtime) = crate::automation::detect_runtime() else {
|
||||
bail!(
|
||||
"no container runtime found (docker or podman) — install one or drop --container"
|
||||
);
|
||||
};
|
||||
let state_dir = app.paths.state_file.parent().unwrap_or(std::path::Path::new("."));
|
||||
let log_dir = &app.paths.log_dir;
|
||||
let mut cmd = crate::automation::container_command(
|
||||
&runtime,
|
||||
&agent.name,
|
||||
spec,
|
||||
state_dir,
|
||||
log_dir,
|
||||
)?;
|
||||
// The agent's run command + extra args become the container command.
|
||||
let exec = resolve_exec(app, agent, &[], &BTreeMap::new())?;
|
||||
cmd.push(exec.program);
|
||||
cmd.extend(exec.args.iter().cloned());
|
||||
cmd.extend(extra.iter().map(|o| o.to_string_lossy().to_string()));
|
||||
if app.dry_run() {
|
||||
app.log.dry(&format!("would run: {}", cmd.join(" ")));
|
||||
return Ok(0);
|
||||
}
|
||||
app.log.info(&format!(
|
||||
"running '{}' in a container ({runtime})",
|
||||
agent.name
|
||||
));
|
||||
let status = Command::new(&cmd[0])
|
||||
.args(&cmd[1..])
|
||||
.status()
|
||||
.with_context(|| format!("failed to run {}", cmd.join(" ")))?;
|
||||
let code = status.code().unwrap_or(1);
|
||||
app.emit(
|
||||
&Event::now(EventKind::Run)
|
||||
.with_agent(agent.name.clone())
|
||||
.with_reason(format!("container ({runtime})"))
|
||||
.with_args(cmd.clone())
|
||||
.with_exit_code(code),
|
||||
);
|
||||
let _ = app.state.touch(&agent.name);
|
||||
Ok(code)
|
||||
}
|
||||
|
||||
/// Current directory as a string (None when unavailable).
|
||||
fn cwd_string() -> Option<String> {
|
||||
std::env::current_dir().ok().map(|p| p.display().to_string())
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
//! schedule: plan am commands (axe 6, issue #56). Schedules live in
|
||||
//! state.json; a daily OS-level task (crontab / Task Scheduler) points back
|
||||
//! at 'am schedule-run <id>' which executes the stored command, records the
|
||||
//! result and notifies on failure.
|
||||
|
||||
use super::*;
|
||||
use crate::automation;
|
||||
use crate::events::{Event, EventKind};
|
||||
use crate::state::ScheduleEntry;
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::process::Command;
|
||||
|
||||
pub fn run(app: &App, action: &str, command: Vec<String>, at: Option<&str>) -> Result<i32> {
|
||||
match action {
|
||||
"add" => add(app, command, at),
|
||||
"list" => list(app),
|
||||
"remove" => {
|
||||
let id = command
|
||||
.first()
|
||||
.ok_or_else(|| anyhow!("schedule remove requires an id"))?;
|
||||
remove(app, id)
|
||||
}
|
||||
"run" => {
|
||||
let id = command
|
||||
.first()
|
||||
.ok_or_else(|| anyhow!("schedule-run requires an id"))?;
|
||||
run_one(app, id)
|
||||
}
|
||||
_ => bail!("usage: am schedule add <cmd...> | am schedule list | am schedule remove <id> | am schedule run <id>"),
|
||||
}
|
||||
}
|
||||
|
||||
fn task_name(id: &str) -> String {
|
||||
format!("am-schedule-{id}")
|
||||
}
|
||||
|
||||
/// Register the OS-level daily task (crontab on unix, schtasks on Windows).
|
||||
/// Best effort: the schedule stays in state.json even if the OS layer
|
||||
/// cannot be reached (warned, not fatal).
|
||||
fn register_os_task(app: &App, entry: &ScheduleEntry) {
|
||||
let am_bin = automation::am_bin_quoted();
|
||||
let id = &entry.id;
|
||||
if cfg!(windows) {
|
||||
let status = automation::schtasks_daily(&entry.at, &am_bin, id, &task_name(id))
|
||||
.status()
|
||||
.unwrap_or_default();
|
||||
if !status.success() {
|
||||
app.log
|
||||
.warn("schtasks failed — the schedule is stored but not registered at OS level");
|
||||
}
|
||||
} else {
|
||||
// Append to the user crontab (dedup first).
|
||||
let existing = Command::new("crontab")
|
||||
.arg("-l")
|
||||
.output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
|
||||
.unwrap_or_default();
|
||||
if existing.contains(&format!("schedule-run {id}")) {
|
||||
return;
|
||||
}
|
||||
let line = automation::crontab_line(&entry.at, &am_bin, id);
|
||||
let mut next = existing.trim_end().to_string();
|
||||
next.push('\n');
|
||||
next.push_str(&line);
|
||||
next.push('\n');
|
||||
let mut child = Command::new("crontab")
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.expect("crontab spawn");
|
||||
use std::io::Write;
|
||||
let _ = child
|
||||
.stdin
|
||||
.as_mut()
|
||||
.map(|s| s.write_all(next.as_bytes()));
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
fn add(app: &App, command: Vec<String>, at: Option<&str>) -> Result<i32> {
|
||||
if command.is_empty() {
|
||||
bail!("schedule add requires a command — ex: am schedule add update --all");
|
||||
}
|
||||
if app.dry_run() {
|
||||
app.log.dry(&format!("would schedule: {}", command.join(" ")));
|
||||
return Ok(0);
|
||||
}
|
||||
let id = crate::repl::session_id();
|
||||
let entry = ScheduleEntry {
|
||||
id,
|
||||
cadence: "daily".to_string(),
|
||||
command,
|
||||
at: at.unwrap_or("08:00").to_string(),
|
||||
created_at: crate::installers::now_rfc3339(),
|
||||
last_run: None,
|
||||
last_exit: None,
|
||||
};
|
||||
let mut sf = app.state.load()?;
|
||||
sf.schedules.push(entry.clone());
|
||||
app.state.save(&sf)?;
|
||||
register_os_task(app, &entry);
|
||||
app.emit(
|
||||
&Event::now(EventKind::Config)
|
||||
.with_agent("schedule".to_string())
|
||||
.with_reason(format!("schedule add {} ({})", entry.id, entry.at)),
|
||||
);
|
||||
app.log.success(&format!(
|
||||
"scheduled daily at {}: {} (id {})",
|
||||
entry.at,
|
||||
entry.command.join(" "),
|
||||
entry.id
|
||||
));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn list(app: &App) -> Result<i32> {
|
||||
let sf = app.state.load()?;
|
||||
if sf.schedules.is_empty() {
|
||||
app.log.info("no schedule — 'am schedule add <cmd>' to create one (ex: am schedule add update --all)");
|
||||
return Ok(0);
|
||||
}
|
||||
println!("schedules:");
|
||||
for e in &sf.schedules {
|
||||
let last = e
|
||||
.last_run
|
||||
.as_deref()
|
||||
.map(|t| {
|
||||
e.last_exit
|
||||
.map(|c| format!("{t} (exit {c})"))
|
||||
.unwrap_or_else(|| t.to_string())
|
||||
})
|
||||
.unwrap_or_else(|| "never".to_string());
|
||||
println!(
|
||||
" {} {} {} cmd: {} last: {}",
|
||||
e.id,
|
||||
e.cadence,
|
||||
e.at,
|
||||
e.command.join(" "),
|
||||
last
|
||||
);
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn remove(app: &App, id: &str) -> Result<i32> {
|
||||
let mut sf = app.state.load()?;
|
||||
let before = sf.schedules.len();
|
||||
sf.schedules.retain(|e| e.id != id);
|
||||
if sf.schedules.len() == before {
|
||||
bail!("unknown schedule '{id}' — 'am schedule list' shows them");
|
||||
}
|
||||
app.state.save(&sf)?;
|
||||
// Best-effort OS cleanup.
|
||||
if cfg!(windows) {
|
||||
let _ = Command::new("schtasks")
|
||||
.args(["/delete", "/tn", &task_name(id), "/f"])
|
||||
.status();
|
||||
} else {
|
||||
let existing = Command::new("crontab")
|
||||
.arg("-l")
|
||||
.output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
|
||||
.unwrap_or_default();
|
||||
let cleaned: Vec<&str> = existing
|
||||
.lines()
|
||||
.filter(|l| !l.contains(&format!("schedule-run {id}")))
|
||||
.collect();
|
||||
let text = format!("{}\n", cleaned.join("\n"));
|
||||
let mut child = Command::new("crontab")
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.expect("crontab spawn");
|
||||
use std::io::Write;
|
||||
let _ = child
|
||||
.stdin
|
||||
.as_mut()
|
||||
.map(|s| s.write_all(text.as_bytes()));
|
||||
let _ = child.wait();
|
||||
}
|
||||
app.log.success(&format!("schedule '{id}' removed"));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// Execute one schedule (invoked by the OS task). Records last_run /
|
||||
/// last_exit, emits a Schedule event and notifies on failure.
|
||||
fn run_one(app: &App, id: &str) -> Result<i32> {
|
||||
let sf = app.state.load()?;
|
||||
let entry = sf
|
||||
.schedules
|
||||
.iter()
|
||||
.find(|e| e.id == id)
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow!("unknown schedule '{id}'"))?;
|
||||
app.log.info(&format!("running schedule {id}: {}", entry.command.join(" ")));
|
||||
let code = automation::schedule_run_command(app, &entry)?;
|
||||
// Record the outcome.
|
||||
let mut sf = app.state.load()?;
|
||||
if let Some(e) = sf.schedules.iter_mut().find(|e| e.id == id) {
|
||||
e.last_run = Some(crate::installers::now_rfc3339());
|
||||
e.last_exit = Some(code);
|
||||
}
|
||||
app.state.save(&sf)?;
|
||||
app.emit(
|
||||
&Event::now(EventKind::Run)
|
||||
.with_agent("schedule".to_string())
|
||||
.with_exit_code(code)
|
||||
.with_reason(format!("schedule {id}: {}", entry.command.join(" "))),
|
||||
);
|
||||
if code == 0 {
|
||||
app.log.success(&format!("schedule {id} completed"));
|
||||
} else {
|
||||
app.log.error(&format!(
|
||||
"schedule {id} FAILED (exit {code}) — command: {}",
|
||||
entry.command.join(" ")
|
||||
));
|
||||
}
|
||||
Ok(code)
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
//! service: register an agent as a system service with autostart (issue #55).
|
||||
//! systemd user unit on Linux, launchd LaunchAgent on macOS, Task Scheduler
|
||||
//! on Windows. Logs go through the existing 'am start --background' runner.
|
||||
|
||||
use super::*;
|
||||
use crate::automation;
|
||||
use crate::events::{Event, EventKind};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
pub fn run(app: &App, action: &str, agent: Option<&str>, autostart: bool) -> Result<i32> {
|
||||
match action {
|
||||
"install" => {
|
||||
let name = agent.ok_or_else(|| anyhow!("service install requires an agent name"))?;
|
||||
install(app, name, autostart)
|
||||
}
|
||||
"uninstall" => {
|
||||
let name = agent.ok_or_else(|| anyhow!("service uninstall requires an agent name"))?;
|
||||
uninstall(app, name)
|
||||
}
|
||||
"status" => {
|
||||
let name = agent.ok_or_else(|| anyhow!("service status requires an agent name"))?;
|
||||
status(app, name)
|
||||
}
|
||||
_ => bail!("usage: am service install|uninstall|status <agent> [--autostart]"),
|
||||
}
|
||||
}
|
||||
|
||||
fn unit_path(agent: &str) -> PathBuf {
|
||||
if cfg!(target_os = "linux") {
|
||||
automation::systemd_unit_path(agent)
|
||||
} else if cfg!(target_os = "macos") {
|
||||
automation::launchd_path(agent)
|
||||
} else {
|
||||
// Windows: no file — the task lives in the Task Scheduler.
|
||||
PathBuf::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn install(app: &App, agent: &str, autostart: bool) -> Result<i32> {
|
||||
require_agent(app, agent)?;
|
||||
if app.dry_run() {
|
||||
app.log
|
||||
.dry(format!("would install '{agent}' as a system service (autostart: {autostart})"));
|
||||
return Ok(0);
|
||||
}
|
||||
let am_bin = automation::am_bin_quoted();
|
||||
let mut wrote = String::new();
|
||||
if cfg!(target_os = "linux") {
|
||||
let path = automation::systemd_unit_path(agent);
|
||||
if let Some(p) = path.parent() {
|
||||
std::fs::create_dir_all(p).with_context(|| format!("cannot create {}", p.display()))?;
|
||||
}
|
||||
std::fs::write(&path, automation::systemd_unit(agent, &am_bin))
|
||||
.with_context(|| format!("cannot write {}", path.display()))?;
|
||||
wrote = path.display().to_string();
|
||||
// Enable + start the user unit.
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["--user", "daemon-reload"])
|
||||
.status();
|
||||
if autostart {
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["--user", "enable", &format!("am-{agent}.service")])
|
||||
.status();
|
||||
}
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["--user", "start", &format!("am-{agent}.service")])
|
||||
.status();
|
||||
} else if cfg!(target_os = "macos") {
|
||||
let path = automation::launchd_path(agent);
|
||||
if let Some(p) = path.parent() {
|
||||
std::fs::create_dir_all(p).with_context(|| format!("cannot create {}", p.display()))?;
|
||||
}
|
||||
std::fs::write(&path, automation::launchd_plist(agent, &am_bin))
|
||||
.with_context(|| format!("cannot write {}", path.display()))?;
|
||||
wrote = path.display().to_string();
|
||||
if autostart {
|
||||
let _ = Command::new("launchctl")
|
||||
.args(["load", path.to_str().unwrap_or_default()])
|
||||
.status();
|
||||
}
|
||||
} else {
|
||||
// Windows: prefer a logon task. /sc onlogon needs admin on some
|
||||
// setups; fall back to the Startup folder (no admin required).
|
||||
let task = automation::windows_task_name(agent);
|
||||
let am_bin = std::env::current_exe().unwrap_or_default();
|
||||
let tr = format!("\"{}\" start {} --background --notify", am_bin.display(), agent);
|
||||
let status = Command::new("schtasks")
|
||||
.args(["/create", "/tn", &task, "/sc", "onlogon", "/tr", &tr, "/f"])
|
||||
.status()?;
|
||||
if status.success() {
|
||||
wrote = format!("Task Scheduler: {task}");
|
||||
} else {
|
||||
let startup = crate::config::home_dir()
|
||||
.map(|h| {
|
||||
h.join("AppData/Roaming/Microsoft/Windows/Start Menu/Programs/Startup")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let script = startup.join(format!("am-{agent}.cmd"));
|
||||
if let Some(p) = script.parent() {
|
||||
std::fs::create_dir_all(p)
|
||||
.with_context(|| format!("cannot create {}", p.display()))?;
|
||||
}
|
||||
std::fs::write(&script, format!("@echo off\r\nstart \"\" /b {tr}\r\n"))
|
||||
.with_context(|| format!("cannot write {}", script.display()))?;
|
||||
wrote = format!("Startup folder: {}", script.display());
|
||||
}
|
||||
}
|
||||
app.emit(
|
||||
&Event::now(EventKind::Config)
|
||||
.with_agent(agent.to_string())
|
||||
.with_reason(format!("service install (autostart: {autostart})")),
|
||||
);
|
||||
app.log.success(&format!(
|
||||
"service '{agent}' installed{} ({wrote})",
|
||||
if autostart { " with autostart" } else { "" }
|
||||
));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn uninstall(app: &App, agent: &str) -> Result<i32> {
|
||||
if app.dry_run() {
|
||||
app.log.dry(format!("would remove the service for '{agent}'"));
|
||||
return Ok(0);
|
||||
}
|
||||
let mut removed = String::new();
|
||||
if cfg!(target_os = "linux") {
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["--user", "stop", &format!("am-{agent}.service")])
|
||||
.status();
|
||||
if automation::systemd_unit_path(agent).exists() {
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["--user", "disable", &format!("am-{agent}.service")])
|
||||
.status();
|
||||
std::fs::remove_file(automation::systemd_unit_path(agent))?;
|
||||
}
|
||||
let _ = Command::new("systemctl").args(["--user", "daemon-reload"]).status();
|
||||
removed = "systemd unit removed".to_string();
|
||||
} else if cfg!(target_os = "macos") {
|
||||
let path = automation::launchd_path(agent);
|
||||
if path.exists() {
|
||||
let _ = Command::new("launchctl").args(["unload", path.to_str().unwrap_or_default()]).status();
|
||||
std::fs::remove_file(&path)?;
|
||||
}
|
||||
removed = "launchd plist removed".to_string();
|
||||
} else {
|
||||
let task = automation::windows_task_name(agent);
|
||||
let _ = Command::new("schtasks")
|
||||
.args(["/delete", "/tn", &task, "/f"])
|
||||
.status();
|
||||
let mut parts = vec![format!("Task Scheduler: {task}")];
|
||||
// Also remove the Startup folder fallback if present.
|
||||
if let Some(h) = crate::config::home_dir() {
|
||||
let script = h.join("AppData/Roaming/Microsoft/Windows/Start Menu/Programs/Startup")
|
||||
.join(format!("am-{agent}.cmd"));
|
||||
if script.exists() {
|
||||
std::fs::remove_file(&script).with_context(|| {
|
||||
format!("cannot remove {}", script.display())
|
||||
})?;
|
||||
parts.push("Startup script removed".to_string());
|
||||
}
|
||||
}
|
||||
removed = parts.join(" · ");
|
||||
}
|
||||
app.emit(
|
||||
&Event::now(EventKind::Config)
|
||||
.with_agent(agent.to_string())
|
||||
.with_reason("service uninstall"),
|
||||
);
|
||||
app.log.success(&format!("service '{agent}' removed ({removed})"));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn status(app: &App, agent: &str) -> Result<i32> {
|
||||
let mut installed = false;
|
||||
if cfg!(target_os = "linux") {
|
||||
installed = automation::systemd_unit_path(agent).exists();
|
||||
} else if cfg!(target_os = "macos") {
|
||||
installed = automation::launchd_path(agent).exists();
|
||||
} else {
|
||||
let task = automation::windows_task_name(agent);
|
||||
let status = Command::new("schtasks")
|
||||
.args(["/query", "/tn", &task])
|
||||
.status()
|
||||
.unwrap_or_default();
|
||||
installed = status.success();
|
||||
if !installed {
|
||||
// Startup folder fallback?
|
||||
installed = crate::config::home_dir()
|
||||
.map(|h| {
|
||||
h.join("AppData/Roaming/Microsoft/Windows/Start Menu/Programs/Startup")
|
||||
.join(format!("am-{agent}.cmd"))
|
||||
.exists()
|
||||
})
|
||||
.unwrap_or(false);
|
||||
}
|
||||
}
|
||||
if installed {
|
||||
println!("service '{agent}': installed");
|
||||
Ok(0)
|
||||
} else {
|
||||
app.log.info(&format!("service '{agent}': not installed"));
|
||||
Ok(1)
|
||||
}
|
||||
}
|
||||
@@ -196,6 +196,8 @@ mod tests {
|
||||
note: None,
|
||||
hidden: false,
|
||||
platforms: vec![],
|
||||
healthcheck: None,
|
||||
container: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -125,6 +125,36 @@ pub static SECTIONS: &[TipSection] = &[
|
||||
options: &[("--json", "rapport machine-readable")],
|
||||
example: "audit --json",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "service install <agent> --autostart",
|
||||
about: "agent en service système (systemd / launchd / tâche Windows) avec démarrage auto",
|
||||
options: &[
|
||||
("status <agent>", "état du service"),
|
||||
("uninstall <agent>", "retire le service"),
|
||||
],
|
||||
example: "service install claude-code --autostart",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "schedule add <cmd...> --at HH:MM",
|
||||
about: "planifie une commande am quotidienne (ex: update --all le matin)",
|
||||
options: &[
|
||||
("schedule list", "liste les planifications"),
|
||||
("schedule remove <id>", "supprime une planification"),
|
||||
],
|
||||
example: "schedule add update --all --at 06:30",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "doctor --watch <secondes>",
|
||||
about: "vérifications de l'environnement en continu, alerte dès qu'un problème apparaît",
|
||||
options: &[("--fix", "répare automatiquement ce qui peut l'être")],
|
||||
example: "doctor --watch 60",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "start group:dev --parallel",
|
||||
about: "démarre un groupe complet (ordre + attente de santé par défaut, --parallel sans attente)",
|
||||
options: &[("run <agent> --container", "exécute l'agent dans son profil conteneur (docker/podman)")],
|
||||
example: "start group:dev --parallel",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "update --rollback",
|
||||
about: "annule la dernière mise à jour (backup automatique avant chaque update)",
|
||||
|
||||
Reference in New Issue
Block a user