hooks : on_install/on_start/on_stop/on_update (issue #34) #83
@@ -143,6 +143,11 @@ pub fn run(app: &App, agent_name: &str, method_sel: Option<&str>, force: bool) -
|
||||
.with_agent(agent.name.clone())
|
||||
.with_args(vec![format!("method={}", method.kind)]),
|
||||
);
|
||||
crate::hooks::run_hooks(
|
||||
app,
|
||||
"on_install",
|
||||
&std::env::current_dir().unwrap_or_default(),
|
||||
);
|
||||
app.log.success(&format!(
|
||||
"installed {} (v{version}) in {}",
|
||||
agent.title(),
|
||||
|
||||
@@ -61,6 +61,8 @@ fn start_one(
|
||||
let bin = PathBuf::from(&exec.program);
|
||||
let is_managed = app.state.get(&agent.name).ok().flatten().is_some();
|
||||
let ctx = crate::context::detect(&std::env::current_dir().unwrap_or_default());
|
||||
let current = std::env::current_dir().unwrap_or_default();
|
||||
crate::hooks::run_hooks(app, "on_start", cwd.unwrap_or(¤t));
|
||||
if background {
|
||||
let pid = process::spawn_background(app, &agent.name, &bin, &exec.args, &exec.env, cwd)?;
|
||||
if app.dry_run() {
|
||||
@@ -194,6 +196,8 @@ fn stop_one(app: &App, agent: &AgentDef, force: bool, timeout: u64) -> Result<i3
|
||||
);
|
||||
let _ = sessions::finish_agent(app, &agent.name, Some(pid), 0, true);
|
||||
app.state.update_pid(&agent.name, None, None)?;
|
||||
let current = std::env::current_dir().unwrap_or_default();
|
||||
crate::hooks::run_hooks(app, "on_stop", ¤t);
|
||||
return Ok(0);
|
||||
}
|
||||
app.log.info(&format!("stopping '{}' (pid {pid})", agent.name));
|
||||
@@ -210,6 +214,8 @@ fn stop_one(app: &App, agent: &AgentDef, force: bool, timeout: u64) -> Result<i3
|
||||
app.emit(&ev);
|
||||
let _ = sessions::finish_agent(app, &agent.name, Some(pid), if stopped { 0 } else { 1 }, stopped);
|
||||
app.state.update_pid(&agent.name, None, None)?;
|
||||
let current = std::env::current_dir().unwrap_or_default();
|
||||
crate::hooks::run_hooks(app, "on_stop", ¤t);
|
||||
if stopped {
|
||||
app.log.success(&format!("{} stopped", agent.title()));
|
||||
Ok(0)
|
||||
|
||||
@@ -102,6 +102,11 @@ fn update_one(app: &App, runner: &dyn Runner, name: &str) -> Result<i32> {
|
||||
.with_agent(agent.name.clone())
|
||||
.with_args(vec![format!("method={}", method.kind)]),
|
||||
);
|
||||
crate::hooks::run_hooks(
|
||||
app,
|
||||
"on_update",
|
||||
&std::env::current_dir().unwrap_or_default(),
|
||||
);
|
||||
app.log.success(&format!(
|
||||
"updated {} to v{version}",
|
||||
agent.title()
|
||||
|
||||
@@ -73,6 +73,10 @@ pub struct Settings {
|
||||
/// --theme flag overrides it).
|
||||
#[serde(default)]
|
||||
pub theme: Option<String>,
|
||||
/// Lifecycle hooks: on_install / on_start / on_stop / on_update command
|
||||
/// lines (global; project profiles can add their own).
|
||||
#[serde(default)]
|
||||
pub hooks: BTreeMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
/// A project profile: everything am needs to know about one project.
|
||||
@@ -497,6 +501,9 @@ pub fn merge(base: &mut Config, overlay: Config) {
|
||||
if o.self_update_repo.is_some() {
|
||||
s.self_update_repo = o.self_update_repo;
|
||||
}
|
||||
for (k, v) in o.hooks {
|
||||
s.hooks.insert(k, v);
|
||||
}
|
||||
for (k, v) in overlay.aliases {
|
||||
base.aliases.insert(k, v);
|
||||
}
|
||||
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
//! Hooks: user-defined commands run around agent lifecycle events
|
||||
//! (on_install, on_start, on_stop, on_update). Global settings.hooks run
|
||||
//! first, then the hooks of the project profile containing the cwd.
|
||||
|
||||
use crate::app::App;
|
||||
use crate::runner::SystemRunner;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
/// Run every hook registered for an event, in order. Failures are warnings
|
||||
/// and never break the main command; --dry-run announces them without
|
||||
/// executing anything.
|
||||
pub fn run_hooks(app: &App, hook: &str, cwd: &Path) {
|
||||
let mut cmds: Vec<String> = Vec::new();
|
||||
if let Some(global) = app.config.settings.hooks.get(hook) {
|
||||
cmds.extend(global.iter().cloned());
|
||||
}
|
||||
if let Some((_, profile)) = crate::projects::profile_for(app, cwd) {
|
||||
if let Some(list) = profile.hooks.get(hook) {
|
||||
cmds.extend(list.iter().cloned());
|
||||
}
|
||||
}
|
||||
if cmds.is_empty() {
|
||||
return;
|
||||
}
|
||||
let runner = SystemRunner::new(app.dry_run(), app.cli.verbose, &app.log);
|
||||
for cmd in &cmds {
|
||||
let tokens = shell_words::split(cmd).unwrap_or_else(|_| vec![cmd.clone()]);
|
||||
if tokens.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let prog = tokens[0].clone();
|
||||
let args: Vec<String> = tokens[1..].to_vec();
|
||||
app.log.info(&format!("hook {hook}: {cmd}"));
|
||||
if let Err(e) = crate::installers::sh(&runner, &prog, &args, &BTreeMap::new(), Some(cwd)) {
|
||||
app.log.warn(&format!("hook {hook} failed: {e:#}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::Parser;
|
||||
|
||||
fn app_with(dir: &std::path::Path) -> App {
|
||||
let cfg = dir.join("config.yaml");
|
||||
std::fs::write(
|
||||
&cfg,
|
||||
concat!(
|
||||
"version: \"1.0\"\n",
|
||||
"settings:\n",
|
||||
" auto_install_deps: false\n",
|
||||
"agents: []\n",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
let mut app = App::from_cli(cli).unwrap();
|
||||
let mut p = app.paths.clone();
|
||||
p.install_dir = dir.join("agents");
|
||||
p.bin_dir = dir.join("agents").join("bin");
|
||||
p.log_dir = dir.join("logs");
|
||||
p.state_file = dir.join("state.json");
|
||||
p.probe_cache_file = dir.join("probe-cache.json");
|
||||
p.config_dir = Some(dir.join("config"));
|
||||
app.paths = p;
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_runs_and_writes_marker() {
|
||||
let guard = tempfile::tempdir().unwrap();
|
||||
let dir = guard.path().to_path_buf();
|
||||
std::mem::forget(guard);
|
||||
let marker = dir.join("marker.txt");
|
||||
let mut app = app_with(&dir);
|
||||
let cmd = format!("sh -c \"echo ran > {}\"", marker.display().to_string().replace("\\", "/"));
|
||||
app.config.settings.hooks.insert("on_start".to_string(), vec![cmd]);
|
||||
run_hooks(&app, "on_start", &dir);
|
||||
assert!(marker.exists(), "hook a ecrit le marqueur");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failing_hook_warns_without_panicking() {
|
||||
let guard = tempfile::tempdir().unwrap();
|
||||
let dir = guard.path().to_path_buf();
|
||||
std::mem::forget(guard);
|
||||
let mut app = app_with(&dir);
|
||||
app.config.settings.hooks.insert("on_start".to_string(), vec!["sh -c exit 3".to_string()]);
|
||||
run_hooks(&app, "on_start", &dir); // ne panique pas
|
||||
run_hooks(&app, "on_update", &dir); // aucun hook : no-op
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dry_run_announces_without_executing() {
|
||||
let guard = tempfile::tempdir().unwrap();
|
||||
let dir = guard.path().to_path_buf();
|
||||
std::mem::forget(guard);
|
||||
let marker = dir.join("marker2.txt");
|
||||
let mut app = app_with(&dir);
|
||||
let cmd = format!("sh -c \"echo ran > {}\"", marker.display().to_string().replace("\\", "/"));
|
||||
app.config.settings.hooks.insert("on_start".to_string(), vec![cmd]);
|
||||
app.cli.dry_run = true;
|
||||
run_hooks(&app, "on_start", &dir);
|
||||
assert!(!marker.exists(), "dry-run ne doit rien executer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_hooks_parse_from_yaml() {
|
||||
let cfg: crate::config::Config = serde_yaml::from_str(
|
||||
"version: \"1.0\"\nsettings:\n hooks:\n on_start: [\"sh -c echo hi\"]\nagents: []\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
cfg.settings.hooks["on_start"],
|
||||
vec!["sh -c echo hi".to_string()]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ pub mod download;
|
||||
pub mod events;
|
||||
pub mod help;
|
||||
pub mod history;
|
||||
pub mod hooks;
|
||||
pub mod installers;
|
||||
pub mod nav;
|
||||
pub mod output;
|
||||
|
||||
Reference in New Issue
Block a user