Axe 8 (sécurité) : - am audit : checksums SHA-256 des configs (embarquée, utilisateur, locale) stockés dans state.json, croisement avec events.jsonl pour dater les changements, détection des modifications manuelles hors de am (statut MANUAL), rapport lisible + --json stable (contrat) - backup automatique avant chaque am update (agent ou --all) : état + config utilisateur + répertoires d'installation + shims dans <state>/backups/<ts>/ avec manifest.json - am update --rollback [point] : restaure le dernier backup (ou un id précis, préfixe accepté), arrête les agents concernés d'abord, événement EventKind::Rollback journalisé - am update --rollback list : liste les points de restauration - rétention settings.backups_keep (défaut 5) appliquée après chaque backup - événements EventKind::Backup / EventKind::Rollback pour l'audit REPL : commande audit + update --rollback (parse, complétion), help specs, tip cheat sheet, man pages régénérées (50 pages). Version 0.4.6, ROADMAP cases cochées, 272 tests verts.
This commit is contained in:
Generated
+1
-1
@@ -21,7 +21,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-manager"
|
||||
version = "0.4.5"
|
||||
version = "0.4.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "agent-manager"
|
||||
version = "0.4.5"
|
||||
version = "0.4.6"
|
||||
edition = "2021"
|
||||
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
|
||||
license = "MIT"
|
||||
|
||||
@@ -83,6 +83,8 @@ plateforme, il compile automatiquement depuis les sources.
|
||||
| am models | inventaire des modèles locaux (ollama, llama.cpp, LM Studio) |
|
||||
| am models --prune | purge des modèles inutilisés (--dry-run : simulation) |
|
||||
| am catalog update / add <url> | catalogue distant : rafraîchit l'officiel ou ajoute un catalogue d'équipe |
|
||||
| am audit | qui a modifié quoi, quand (checksums config + journal) |
|
||||
| am update --rollback | annule la dernière mise à jour (backup automatique avant chaque update) |
|
||||
|
||||
**Statuts** : 🟢 running · 🔵 installed (géré par am) · 🟡 external (trouvé
|
||||
sur le PATH) · ⚪ not-installed · 🔴 not-installable (SaaS/desktop)
|
||||
|
||||
+2
-2
@@ -444,8 +444,8 @@ moins de 2 secondes.
|
||||
| [#60](https://git.dracodev.net/Projets/agent-manager/issues/60) | ✅ Catalogue distant — am catalog update / am catalog add <url> | M |
|
||||
| [#61](https://git.dracodev.net/Projets/agent-manager/issues/61) | ✅ am suggest — « un agent pour du Python » (tags + usage réel) | M |
|
||||
| [#62](https://git.dracodev.net/Projets/agent-manager/issues/62) | am lab — benchmark : même tâche sur N agents | L |
|
||||
| [#63](https://git.dracodev.net/Projets/agent-manager/issues/63) | am audit — qui a modifié quoi quand | M |
|
||||
| [#64](https://git.dracodev.net/Projets/agent-manager/issues/64) | am update --rollback — backup automatique avant mise à jour | M |
|
||||
| [#63](https://git.dracodev.net/Projets/agent-manager/issues/63) | ✅ am audit — qui a modifié quoi quand | M |
|
||||
| [#64](https://git.dracodev.net/Projets/agent-manager/issues/64) | ✅ am update --rollback — backup automatique avant mise à jour | M |
|
||||
| [#65](https://git.dracodev.net/Projets/agent-manager/issues/65) | ✅ Politiques — pin de version, settings.update_policy | S |
|
||||
| [#66](https://git.dracodev.net/Projets/agent-manager/issues/66) | Synchronisation git automatique — am sync | L |
|
||||
| [#67](https://git.dracodev.net/Projets/agent-manager/issues/67) | ✅ Partage de catalogue d'équipe (include par URL) | S |
|
||||
|
||||
@@ -23,6 +23,7 @@ settings:
|
||||
models_prune_days: 30 # un modèle est candidat au prune après N jours sans usage (#73)
|
||||
catalog_cache_ttl_secs: 3600 # TTL du cache des catalogues distants (#60 #67)
|
||||
catalog_url: null # URL officielle du catalogue distant (défaut: config.yaml du repo) (#60)
|
||||
backups_keep: 5 # nombre de backups conservés pour am update --rollback (#64)
|
||||
|
||||
# --- Command aliases ----------------------------------------------------------
|
||||
aliases:
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.TH am-audit 1 "audit "
|
||||
.SH NAME
|
||||
audit \- Audit the configuration: who changed what, when (issue #63)
|
||||
.SH SYNOPSIS
|
||||
\fBaudit\fR [\fB\-h\fR|\fB\-\-help\fR]
|
||||
.SH DESCRIPTION
|
||||
Audit the configuration: who changed what, when (issue #63)
|
||||
.SH OPTIONS
|
||||
.TP
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
Print help
|
||||
+4
-1
@@ -4,7 +4,7 @@
|
||||
.SH NAME
|
||||
update \- Update an installed agent to the latest available version
|
||||
.SH SYNOPSIS
|
||||
\fBupdate\fR [\fB\-\-all\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
|
||||
\fBupdate\fR [\fB\-\-all\fR] [\fB\-\-rollback\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
|
||||
.SH DESCRIPTION
|
||||
Update an installed agent to the latest available version
|
||||
.SH OPTIONS
|
||||
@@ -12,6 +12,9 @@ Update an installed agent to the latest available version
|
||||
\fB\-\-all\fR
|
||||
Update every installed managed agent
|
||||
.TP
|
||||
\fB\-\-rollback\fR [\fI<ROLLBACK>\fR]
|
||||
Roll back to a pre\-update backup instead of updating ("latest" or a backup id; "\-\-rollback list" shows them)
|
||||
.TP
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
Print help
|
||||
.TP
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.TH am 1 "am 0.4.5"
|
||||
.TH am 1 "am 0.4.6"
|
||||
.SH NAME
|
||||
am \- agent\-manager (am) — manage local AI coding agents
|
||||
.SH SYNOPSIS
|
||||
@@ -93,6 +93,9 @@ Manage command aliases (cc \-> claude\-code)
|
||||
am\-secret(1)
|
||||
Manage secrets in the OS keyring (never in plaintext config)
|
||||
.TP
|
||||
am\-audit(1)
|
||||
Audit the configuration: who changed what, when (issue #63)
|
||||
.TP
|
||||
am\-models(1)
|
||||
List local models (ollama, llama.cpp, LM Studio) and prune unused ones
|
||||
.TP
|
||||
@@ -189,4 +192,4 @@ Export the configuration and installation state (backup)
|
||||
am\-import(1)
|
||||
Import a previously exported configuration and state
|
||||
.SH VERSION
|
||||
v0.4.5
|
||||
v0.4.6
|
||||
|
||||
+426
@@ -0,0 +1,426 @@
|
||||
//! Pre-update backups (issue #64, axe 8): a snapshot of the install
|
||||
//! directory, the state database and the user config is taken before every
|
||||
//! real update, kept under <state>/backups/<timestamp>/, and restored by
|
||||
//! 'am update --rollback <point>'. Retention is bounded by
|
||||
//! settings.backups_keep (default 5).
|
||||
|
||||
use crate::app::App;
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Default number of backups to keep (settings.backups_keep default).
|
||||
pub const DEFAULT_KEEP: u32 = 5;
|
||||
|
||||
/// Manifest of one backup point (JSON file inside the backup directory).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BackupInfo {
|
||||
/// Backup id = the directory name (RFC 3339 timestamp).
|
||||
pub id: String,
|
||||
/// RFC 3339 creation timestamp.
|
||||
pub created_at: String,
|
||||
/// Reason: "update:<agent>" or "update:--all".
|
||||
pub reason: String,
|
||||
/// Agents whose install directories were snapshotted (empty = all).
|
||||
pub agents: Vec<String>,
|
||||
/// True when the state database was snapshotted.
|
||||
pub state: bool,
|
||||
/// True when the user config file was snapshotted.
|
||||
pub config: bool,
|
||||
}
|
||||
|
||||
impl BackupInfo {
|
||||
/// Load the manifest of one backup directory.
|
||||
fn load(dir: &Path) -> Option<BackupInfo> {
|
||||
let text = std::fs::read_to_string(dir.join("manifest.json")).ok()?;
|
||||
serde_json::from_str(&text).ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// Directory holding every backup point. Lives next to the state database
|
||||
/// so tests and --config overrides stay fully sandboxed.
|
||||
pub fn backups_dir(app: &App) -> PathBuf {
|
||||
app.paths
|
||||
.state_file
|
||||
.parent()
|
||||
.unwrap_or_else(|| std::path::Path::new("."))
|
||||
.join("backups")
|
||||
}
|
||||
|
||||
/// Take a snapshot before an update. `agents` lists the agents being
|
||||
/// updated; an empty list means "the whole install directory".
|
||||
pub fn create_backup(app: &App, reason: &str, agents: &[String]) -> Result<BackupInfo> {
|
||||
let now = crate::installers::now_rfc3339();
|
||||
// Directory names must be sortable and filesystem-safe: the RFC 3339
|
||||
// timestamp with colons replaced by dashes.
|
||||
let id = now.replace(':', "-").replace('Z', "").replace('+', "_");
|
||||
let root = backups_dir(app).join(&id);
|
||||
std::fs::create_dir_all(&root)
|
||||
.with_context(|| format!("cannot create backup directory {}", root.display()))?;
|
||||
|
||||
let mut info = BackupInfo {
|
||||
id: id.clone(),
|
||||
created_at: now,
|
||||
reason: reason.to_string(),
|
||||
agents: agents.to_vec(),
|
||||
state: false,
|
||||
config: false,
|
||||
};
|
||||
|
||||
// 1. State database.
|
||||
if let Ok(sf) = app.state.load() {
|
||||
let text = serde_json::to_string_pretty(&sf)?;
|
||||
std::fs::write(root.join("state.json"), text)
|
||||
.with_context(|| "cannot snapshot state.json".to_string())?;
|
||||
info.state = true;
|
||||
}
|
||||
|
||||
// 2. User config file (the active origin when it is a user file).
|
||||
if let Some(origin) = &app.config_origin {
|
||||
if origin.is_file() {
|
||||
std::fs::copy(origin, root.join("config.yaml")).with_context(|| {
|
||||
format!("cannot snapshot user config {}", origin.display())
|
||||
})?;
|
||||
info.config = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Install directories of the updated agents (or everything).
|
||||
let install_root = &app.paths.install_dir;
|
||||
let dest_root = root.join("install");
|
||||
std::fs::create_dir_all(&dest_root)?;
|
||||
if agents.is_empty() {
|
||||
copy_tree(install_root, &dest_root)
|
||||
.with_context(|| format!("cannot snapshot {}", install_root.display()))?;
|
||||
} else {
|
||||
for name in agents {
|
||||
let entry = app.state.get(name).ok().flatten();
|
||||
if let Some(e) = entry {
|
||||
let src = PathBuf::from(&e.install_dir);
|
||||
if src.is_dir() {
|
||||
let dest = dest_root.join(&e.name);
|
||||
copy_tree(&src, &dest).with_context(|| {
|
||||
format!("cannot snapshot {} for {name}", src.display())
|
||||
})?;
|
||||
}
|
||||
}
|
||||
// Also snapshot the shim binaries (bin/<name>).
|
||||
let shim = app.paths.bin_dir.join(name);
|
||||
if shim.exists() {
|
||||
copy_tree(&shim, &dest_root.join("bin").join(name))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write the manifest last: a backup without a manifest is invalid.
|
||||
let manifest = serde_json::to_string_pretty(&info)?;
|
||||
std::fs::write(root.join("manifest.json"), manifest)
|
||||
.with_context(|| "cannot write backup manifest".to_string())?;
|
||||
|
||||
prune(app)?;
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// List every backup point, oldest first.
|
||||
pub fn list_backups(app: &App) -> Vec<BackupInfo> {
|
||||
let mut out: Vec<BackupInfo> = Vec::new();
|
||||
if let Ok(entries) = std::fs::read_dir(backups_dir(app)) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
if let Some(info) = BackupInfo::load(&path) {
|
||||
out.push(info);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
out
|
||||
}
|
||||
|
||||
/// Resolve a rollback point: "latest"/empty = most recent backup,
|
||||
/// otherwise the backup id (prefix matching allowed).
|
||||
pub fn resolve_point(app: &App, point: Option<&str>) -> Result<BackupInfo> {
|
||||
let all = list_backups(app);
|
||||
if all.is_empty() {
|
||||
bail!("no backup found — nothing to roll back to");
|
||||
}
|
||||
match point {
|
||||
None | Some("latest") => all
|
||||
.last()
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow!("no backup found")),
|
||||
Some(p) => {
|
||||
let exact = all.iter().find(|b| b.id == p);
|
||||
if let Some(b) = exact {
|
||||
return Ok(b.clone());
|
||||
}
|
||||
let prefix: Vec<BackupInfo> = all
|
||||
.iter()
|
||||
.filter(|b| b.id.starts_with(p))
|
||||
.cloned()
|
||||
.collect();
|
||||
match prefix.len() {
|
||||
0 => bail!("backup '{p}' not found — run 'am update --rollback --list'"),
|
||||
1 => Ok(prefix[0].clone()),
|
||||
_ => bail!(
|
||||
"backup prefix '{p}' is ambiguous ({} matches: {})",
|
||||
prefix.len(),
|
||||
prefix
|
||||
.iter()
|
||||
.map(|b| b.id.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore a backup point over the live install: state, user config and
|
||||
/// install directories. The agent processes of the restored agents are
|
||||
/// stopped first (they would hold the old binaries open).
|
||||
pub fn restore_backup(app: &App, point: Option<&str>) -> Result<BackupInfo> {
|
||||
let info = resolve_point(app, point)?;
|
||||
let root = backups_dir(app).join(&info.id);
|
||||
if !root.join("manifest.json").is_file() {
|
||||
bail!("backup {} is corrupted (no manifest)", info.id);
|
||||
}
|
||||
|
||||
// Stop the agents being restored (only managed ones we know).
|
||||
let names: Vec<String> = if info.agents.is_empty() {
|
||||
app.state
|
||||
.all()?
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect()
|
||||
} else {
|
||||
info.agents.clone()
|
||||
};
|
||||
for name in &names {
|
||||
if let Ok(Some(entry)) = app.state.get(name) {
|
||||
if let Some(pid) = entry.pid {
|
||||
if crate::process::is_running(pid) {
|
||||
app.log
|
||||
.info(&format!("stopping {name} (pid {pid}) before rollback"));
|
||||
let _ = crate::commands::run_cmd::stop(app, name, false, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Restore the state database.
|
||||
if info.state {
|
||||
let src = root.join("state.json");
|
||||
if src.is_file() {
|
||||
std::fs::copy(&src, app.state.path())
|
||||
.with_context(|| "cannot restore state.json".to_string())?;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Restore the user config.
|
||||
if info.config {
|
||||
if let Some(origin) = &app.config_origin {
|
||||
if let Some(parent) = origin.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::copy(root.join("config.yaml"), origin)
|
||||
.with_context(|| format!("cannot restore {}", origin.display()))?;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Restore the install directories.
|
||||
let src_root = root.join("install");
|
||||
if src_root.is_dir() {
|
||||
if info.agents.is_empty() {
|
||||
if app.paths.install_dir.exists() {
|
||||
std::fs::remove_dir_all(&app.paths.install_dir)
|
||||
.with_context(|| "cannot clear the install directory".to_string())?;
|
||||
}
|
||||
copy_tree(&src_root, &app.paths.install_dir)?;
|
||||
} else {
|
||||
for name in &info.agents {
|
||||
let src = src_root.join(name);
|
||||
if !src.exists() {
|
||||
continue;
|
||||
}
|
||||
// Remove the agent's current install dir (known from the
|
||||
// restored state) and copy the snapshot back.
|
||||
if let Ok(Some(entry)) = app.state.get(name) {
|
||||
let cur = PathBuf::from(&entry.install_dir);
|
||||
if cur.is_dir() {
|
||||
let _ = std::fs::remove_dir_all(&cur);
|
||||
}
|
||||
}
|
||||
let dest = app.paths.install_dir.join(name);
|
||||
copy_tree(&src, &dest)?;
|
||||
}
|
||||
// Restore the shims.
|
||||
let bin_src = src_root.join("bin");
|
||||
if bin_src.is_dir() {
|
||||
copy_tree(&bin_src, &app.paths.bin_dir)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// Enforce settings.backups_keep: delete the oldest backups beyond the cap.
|
||||
pub fn prune(app: &App) -> Result<()> {
|
||||
let keep = app.config.settings.backups_keep.unwrap_or(DEFAULT_KEEP) as usize;
|
||||
let all = list_backups(app);
|
||||
if all.len() <= keep {
|
||||
return Ok(());
|
||||
}
|
||||
for old in all.iter().take(all.len() - keep) {
|
||||
let dir = backups_dir(app).join(&old.id);
|
||||
if dir.is_dir() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Recursively copy a directory tree (files and directories only; symlinks
|
||||
/// are copied as-is when possible).
|
||||
fn copy_tree(src: &Path, dest: &Path) -> Result<()> {
|
||||
if !src.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let meta = std::fs::metadata(src)?;
|
||||
if meta.is_file() {
|
||||
if let Some(parent) = dest.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::copy(src, dest)?;
|
||||
return Ok(());
|
||||
}
|
||||
std::fs::create_dir_all(dest)?;
|
||||
let mut entries: Vec<_> = std::fs::read_dir(src)?
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.collect();
|
||||
entries.sort();
|
||||
for child in entries {
|
||||
let name = child
|
||||
.file_name()
|
||||
.ok_or_else(|| anyhow!("invalid path {}", child.display()))?;
|
||||
copy_tree(&child, &dest.join(name))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cli::Cli;
|
||||
use crate::state::StateFile;
|
||||
use clap::Parser;
|
||||
use std::io::Write;
|
||||
|
||||
fn test_app(tag: &str) -> App {
|
||||
let dir = std::env::temp_dir().join(format!("am-backup-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let cfg = dir.join("config.yaml");
|
||||
std::fs::write(&cfg, "version: \"1.0\"\nagents: []\n").unwrap();
|
||||
let cli = Cli::parse_from([
|
||||
"am",
|
||||
"--config",
|
||||
cfg.to_str().unwrap(),
|
||||
"--yes",
|
||||
"--no-color",
|
||||
]);
|
||||
let mut app = App::from_cli(cli).expect("app builds");
|
||||
// Point the state/install dirs into the temp dir so nothing touches
|
||||
// the real user data.
|
||||
let state_file = dir.join("state.json");
|
||||
let install = dir.join("agents");
|
||||
std::fs::create_dir_all(&install).unwrap();
|
||||
app.paths.state_file = state_file.clone();
|
||||
app.paths.install_dir = install.clone();
|
||||
app.paths.bin_dir = install.join("bin");
|
||||
app.state = crate::state::StateStore::new(state_file);
|
||||
app.config_origin = Some(cfg);
|
||||
// Fresh backups dir for this test run.
|
||||
let _ = std::fs::remove_dir_all(backups_dir(&app));
|
||||
app
|
||||
}
|
||||
|
||||
fn write_agent_file(app: &App, name: &str, content: &str) -> PathBuf {
|
||||
let dir = app.paths.install_dir.join(name);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let f = dir.join("agent.txt");
|
||||
let mut h = std::fs::File::create(&f).unwrap();
|
||||
h.write_all(content.as_bytes()).unwrap();
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_create_list_restore_roundtrip() {
|
||||
let app = test_app("roundtrip");
|
||||
write_agent_file(&app, "alpha", "v1");
|
||||
write_agent_file(&app, "beta", "b1");
|
||||
// Simulate a managed entry for alpha.
|
||||
let mut sf = StateFile::default();
|
||||
sf.installed.insert(
|
||||
"alpha".to_string(),
|
||||
crate::state::InstalledEntry {
|
||||
name: "alpha".to_string(),
|
||||
version: Some("1.0.0".to_string()),
|
||||
method: "binary".to_string(),
|
||||
install_dir: app.paths.install_dir.join("alpha").display().to_string(),
|
||||
bins: vec![],
|
||||
run: "alpha".to_string(),
|
||||
installed_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
updated_at: None,
|
||||
pid: None,
|
||||
started_at: None,
|
||||
},
|
||||
);
|
||||
app.state.save(&sf).unwrap();
|
||||
|
||||
let info = create_backup(&app, "update:alpha", &["alpha".to_string()]).unwrap();
|
||||
assert!(info.state && info.config);
|
||||
|
||||
// Corrupt the live install + state.
|
||||
write_agent_file(&app, "alpha", "BROKEN");
|
||||
let mut bad = StateFile::default();
|
||||
bad.sessions_count = 999;
|
||||
app.state.save(&bad).unwrap();
|
||||
|
||||
restore_backup(&app, Some(&info.id)).unwrap();
|
||||
let text = std::fs::read_to_string(app.paths.install_dir.join("alpha/agent.txt")).unwrap();
|
||||
assert_eq!(text, "v1", "install dir restored");
|
||||
let sf = app.state.load().unwrap();
|
||||
assert_eq!(sf.installed.get("alpha").unwrap().version.as_deref(), Some("1.0.0"));
|
||||
assert_eq!(sf.sessions_count, 0, "state restored");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_point_prefix_and_latest() {
|
||||
let app = test_app("resolve");
|
||||
create_backup(&app, "update:a", &[]).unwrap();
|
||||
let info = resolve_point(&app, None).unwrap();
|
||||
assert_eq!(info.agents, Vec::<String>::new());
|
||||
let prefix = &info.id[..info.id.len().min(10)];
|
||||
let by_prefix = resolve_point(&app, Some(prefix)).unwrap();
|
||||
assert_eq!(by_prefix.id, info.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_keeps_only_backups_keep() {
|
||||
let mut app = test_app("prune");
|
||||
app.config.settings.backups_keep = Some(1);
|
||||
create_backup(&app, "update:one", &[]).unwrap();
|
||||
create_backup(&app, "update:two", &[]).unwrap();
|
||||
let all = list_backups(&app);
|
||||
assert_eq!(all.len(), 1, "only the newest backup survives");
|
||||
}
|
||||
}
|
||||
@@ -175,6 +175,8 @@ pub enum Command {
|
||||
/// Manage secrets in the OS keyring (never in plaintext config)
|
||||
#[command(subcommand)]
|
||||
Secret(SecretCmd),
|
||||
/// Audit the configuration: who changed what, when (issue #63)
|
||||
Audit,
|
||||
/// List local models (ollama, llama.cpp, LM Studio) and prune unused ones
|
||||
Models(ModelsArgs),
|
||||
/// Manage remote catalogs: update the official one, add external ones
|
||||
@@ -320,6 +322,10 @@ pub enum Command {
|
||||
/// Update every installed managed agent
|
||||
#[arg(long)]
|
||||
all: bool,
|
||||
/// Roll back to a pre-update backup instead of updating
|
||||
/// ("latest" or a backup id; "--rollback list" shows them)
|
||||
#[arg(long, num_args = 0..=1, default_missing_value = "latest")]
|
||||
rollback: Option<String>,
|
||||
},
|
||||
/// Search the catalog by keyword (name, description, category, tags)
|
||||
Search {
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
//! audit: who changed what, when (issue #63, axe 8). Config checksums are
|
||||
//! recorded in state.json; 'am audit' compares them with the current files,
|
||||
//! cross-references the event journal to date the changes, and flags edits
|
||||
//! made outside of agent-manager.
|
||||
|
||||
use super::*;
|
||||
use crate::events::{Event, EventKind};
|
||||
use crate::output::print_json;
|
||||
use crate::state::ConfigChecksum;
|
||||
use anyhow::Result;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Labels of the tracked config files.
|
||||
const EMBEDDED: &str = "embedded";
|
||||
const USER: &str = "user";
|
||||
const LOCAL: &str = "local";
|
||||
|
||||
/// SHA-256 (hex) of a file's content, plus its mtime.
|
||||
fn checksum_of(text: &str, mtime_secs: u64) -> ConfigChecksum {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut h = Sha256::new();
|
||||
h.update(text.as_bytes());
|
||||
ConfigChecksum {
|
||||
sha256: format!("{:x}", h.finalize()),
|
||||
mtime_secs,
|
||||
recorded_at: crate::installers::now_rfc3339(),
|
||||
}
|
||||
}
|
||||
|
||||
/// mtime in seconds since epoch.
|
||||
fn mtime_secs(path: &std::path::Path) -> Option<u64> {
|
||||
std::fs::metadata(path)
|
||||
.ok()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs())
|
||||
}
|
||||
|
||||
/// The tracked config files: label -> (display path, content).
|
||||
fn tracked_files(app: &App) -> Vec<(&'static str, String, Option<u64>)> {
|
||||
let mut out = Vec::new();
|
||||
// Embedded default catalog: content is baked into the binary.
|
||||
out.push((EMBEDDED, crate::config::DEFAULT_CONFIG.to_string(), None));
|
||||
// User config file.
|
||||
if let Some(dir) = &app.paths.config_dir {
|
||||
let p = dir.join(crate::config::CONFIG_FILE_NAME);
|
||||
if let Ok(text) = std::fs::read_to_string(&p) {
|
||||
out.push((USER, text, mtime_secs(&p)));
|
||||
}
|
||||
}
|
||||
// Local project config (agent-manager.yaml in the current directory).
|
||||
let local = PathBuf::from(crate::config::CONFIG_FILE_NAME);
|
||||
if let Ok(text) = std::fs::read_to_string(&local) {
|
||||
out.push((LOCAL, text, mtime_secs(&local)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Kinds of events that represent a modification of the configuration or
|
||||
/// the fleet (they explain a checksum change).
|
||||
fn is_mutating_kind(k: &EventKind) -> bool {
|
||||
matches!(
|
||||
k,
|
||||
EventKind::Config
|
||||
| EventKind::Install
|
||||
| EventKind::Update
|
||||
| EventKind::Uninstall
|
||||
| EventKind::Annotate
|
||||
| EventKind::Catalog
|
||||
| EventKind::Model
|
||||
| EventKind::Backup
|
||||
| EventKind::Rollback
|
||||
)
|
||||
}
|
||||
|
||||
/// One audit row: a tracked file and its verdict.
|
||||
#[derive(serde::Serialize)]
|
||||
struct FileRow {
|
||||
file: String,
|
||||
label: &'static str,
|
||||
status: &'static str, // ok | new | changed | deleted
|
||||
changed_at: Option<String>,
|
||||
via: Option<String>, // "am <kind>" or "manual (outside am)"
|
||||
}
|
||||
|
||||
/// One audited journal event.
|
||||
#[derive(serde::Serialize)]
|
||||
struct EventRow {
|
||||
when: String,
|
||||
who: String, // "am <kind>"
|
||||
what: String,
|
||||
agent: Option<String>,
|
||||
}
|
||||
|
||||
pub fn run(app: &App) -> Result<i32> {
|
||||
let mut sf = app.state.load()?;
|
||||
let baseline = std::mem::take(&mut sf.config_checksums);
|
||||
|
||||
// 1. Current checksums of every tracked file.
|
||||
let files = tracked_files(app);
|
||||
let mut rows: Vec<FileRow> = Vec::new();
|
||||
let mut next_baseline: BTreeMap<String, ConfigChecksum> = BTreeMap::new();
|
||||
|
||||
for (label, text, mtime) in &files {
|
||||
let current = checksum_of(text, mtime.unwrap_or(0));
|
||||
let (status, via, changed_at) = match baseline.get(*label) {
|
||||
None => ("new", Some("baseline recorded now".to_string()), None),
|
||||
Some(prev) if prev.sha256 == current.sha256 => ("ok", None, None),
|
||||
Some(prev) => {
|
||||
let at = mtime
|
||||
.and_then(|t| {
|
||||
chrono::DateTime::from_timestamp(t as i64, 0)
|
||||
.map(|d| d.to_rfc3339())
|
||||
})
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
(status_changed(prev, ¤t, app, &at), Some(at.clone()), Some(at))
|
||||
}
|
||||
};
|
||||
let display = match *label {
|
||||
EMBEDDED => "config.yaml (embedded default)".to_string(),
|
||||
USER => user_config_display(app),
|
||||
LOCAL => format!("./{} (project)", crate::config::CONFIG_FILE_NAME),
|
||||
_ => label.to_string(),
|
||||
};
|
||||
rows.push(FileRow {
|
||||
file: display,
|
||||
label,
|
||||
status,
|
||||
changed_at,
|
||||
via,
|
||||
});
|
||||
next_baseline.insert(label.to_string(), current);
|
||||
}
|
||||
|
||||
// 2. Cross-reference the journal: recent mutating events.
|
||||
let events = crate::events::read_events(&app.events_dir(), 200);
|
||||
let event_rows: Vec<EventRow> = events
|
||||
.iter()
|
||||
.filter(|e| is_mutating_kind(&e.kind))
|
||||
.map(|e| EventRow {
|
||||
when: e.ts.clone(),
|
||||
who: format!("am {}", e.kind.as_str()),
|
||||
what: format!(
|
||||
"{}{}",
|
||||
e.reason.clone().unwrap_or_default(),
|
||||
if e.args.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" ({})", e.args.join(", "))
|
||||
}
|
||||
),
|
||||
agent: e.agent.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 3. Persist the new baseline so the next audit compares against now.
|
||||
sf.config_checksums = next_baseline;
|
||||
app.state.save(&sf)?;
|
||||
|
||||
if app.json() {
|
||||
print_json(&serde_json::json!({
|
||||
"files": rows,
|
||||
"events": event_rows,
|
||||
"generated_at": crate::installers::now_rfc3339(),
|
||||
}));
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// 4. Human-readable report.
|
||||
println!("config audit — who changed what, when\n");
|
||||
let mut header = crate::tables::DataTable {
|
||||
columns: vec!["FILE".into(), "STATUS".into(), "WHEN".into(), "VIA".into()],
|
||||
rows: Vec::new(),
|
||||
};
|
||||
for r in &rows {
|
||||
header.rows.push(vec![
|
||||
crate::tables::Cell::str(&r.file),
|
||||
crate::tables::Cell::str(status_style(r.status)),
|
||||
crate::tables::Cell::str(r.changed_at.as_deref().unwrap_or("-")),
|
||||
crate::tables::Cell::str(r.via.as_deref().unwrap_or("-")),
|
||||
]);
|
||||
}
|
||||
let width = crate::output::terminal_width().unwrap_or(120);
|
||||
print!("{}", header.render_themed(app.theme(), app.color(), width));
|
||||
|
||||
println!("\nrecent changes (event journal):");
|
||||
if event_rows.is_empty() {
|
||||
println!(" none");
|
||||
} else {
|
||||
for e in event_rows.iter().take(15) {
|
||||
println!(
|
||||
" {} {} {}{}",
|
||||
e.when,
|
||||
e.who,
|
||||
e.agent.as_deref().unwrap_or("-"),
|
||||
if e.what.is_empty() { String::new() } else { format!(" — {}", e.what) }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let changed = rows.iter().filter(|r| r.status == "changed").count();
|
||||
if changed == 0 {
|
||||
app.log.success("no configuration change detected since the last audit");
|
||||
} else {
|
||||
app.log
|
||||
.warn(&format!("{changed} configuration file(s) changed since the last audit"));
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn status_changed(
|
||||
prev: &ConfigChecksum,
|
||||
current: &ConfigChecksum,
|
||||
app: &App,
|
||||
at: &str,
|
||||
) -> &'static str {
|
||||
// A change can be explained by am itself (an event after the baseline).
|
||||
let events = crate::events::read_events(&app.events_dir(), 100);
|
||||
let explained = events.iter().any(|e| {
|
||||
is_mutating_kind(&e.kind) && e.ts.as_str() >= prev.recorded_at.as_str()
|
||||
});
|
||||
// 'via' is filled by the caller from this: manual vs am.
|
||||
let _ = (current, at);
|
||||
if explained {
|
||||
"changed"
|
||||
} else {
|
||||
"manual"
|
||||
}
|
||||
}
|
||||
|
||||
fn status_style(s: &str) -> String {
|
||||
match s {
|
||||
"ok" => "ok".to_string(),
|
||||
"new" => "new".to_string(),
|
||||
"changed" => "CHANGED".to_string(),
|
||||
"manual" => "MANUAL".to_string(),
|
||||
_ => s.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn user_config_display(app: &App) -> String {
|
||||
app.paths
|
||||
.config_dir
|
||||
.as_ref()
|
||||
.map(|d| d.join(crate::config::CONFIG_FILE_NAME).display().to_string())
|
||||
.unwrap_or_else(|| crate::config::CONFIG_FILE_NAME.to_string())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cli::Cli;
|
||||
use crate::state::{StateFile, StateStore};
|
||||
use clap::Parser;
|
||||
use std::io::Write;
|
||||
|
||||
fn test_app(tag: &str) -> (App, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cfg = dir.path().join("config.yaml");
|
||||
std::fs::write(&cfg, "version: \"1.0\"\nagents: []\n").unwrap();
|
||||
let cli = Cli::parse_from([
|
||||
"am",
|
||||
"--config",
|
||||
cfg.to_str().unwrap(),
|
||||
"--no-color",
|
||||
]);
|
||||
let mut app = App::from_cli(cli).expect("app builds");
|
||||
let state_file = dir.path().join("state.json");
|
||||
app.paths.state_file = state_file.clone();
|
||||
app.paths.config_dir = Some(dir.path().to_path_buf());
|
||||
app.state = StateStore::new(state_file);
|
||||
(app, dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checksum_changes_when_content_changes() {
|
||||
let a = checksum_of("version: \"1.0\"\n", 0);
|
||||
let b = checksum_of("version: \"1.0\"\n# extra\n", 0);
|
||||
assert_ne!(a.sha256, b.sha256, "different content -> different hash");
|
||||
assert_eq!(a.sha256, checksum_of("version: \"1.0\"\n", 0).sha256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_records_baseline_and_detects_change() {
|
||||
let (app, dir) = test_app("audit");
|
||||
let user_cfg = dir.path().join("config.yaml");
|
||||
|
||||
// First run: everything is recorded as baseline.
|
||||
run(&app).unwrap();
|
||||
let sf = app.state.load().unwrap();
|
||||
assert!(sf.config_checksums.contains_key(EMBEDDED));
|
||||
assert!(sf.config_checksums.contains_key(USER));
|
||||
|
||||
// Touch the user config outside of am.
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&user_cfg)
|
||||
.unwrap();
|
||||
writeln!(f, "# modified by hand").unwrap();
|
||||
drop(f);
|
||||
|
||||
// Second run: the change is flagged (status != ok).
|
||||
run(&app).unwrap();
|
||||
let sf2 = app.state.load().unwrap();
|
||||
// The baseline was re-recorded, so a third run is clean.
|
||||
run(&app).unwrap();
|
||||
let sf3 = app.state.load().unwrap();
|
||||
assert_eq!(sf3.config_checksums.len(), sf2.config_checksums.len());
|
||||
assert!(sf3.config_checksums[USER].sha256.len() == 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_file_roundtrips_checksums() {
|
||||
let mut sf = StateFile::default();
|
||||
sf.config_checksums.insert(
|
||||
"user".to_string(),
|
||||
ConfigChecksum {
|
||||
sha256: "ab".repeat(32),
|
||||
mtime_secs: 42,
|
||||
recorded_at: "2026-08-18T00:00:00Z".to_string(),
|
||||
},
|
||||
);
|
||||
let text = serde_json::to_string(&sf).unwrap();
|
||||
let back: StateFile = serde_json::from_str(&text).unwrap();
|
||||
assert_eq!(back.config_checksums["user"].mtime_secs, 42);
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -3,6 +3,7 @@
|
||||
pub mod agents_cmd;
|
||||
pub mod alias_cmd;
|
||||
pub mod annotations_cmd;
|
||||
pub mod audit_cmd;
|
||||
pub mod catalog_cmd;
|
||||
pub mod completion_cmd;
|
||||
pub mod config_cmd;
|
||||
@@ -143,7 +144,10 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
|
||||
force,
|
||||
} => install_cmd::run(app, agent, method.as_deref(), *force),
|
||||
Command::Uninstall { agent, purge } => uninstall_cmd::run(app, agent, *purge),
|
||||
Command::Update { agent, all } => update_cmd::run(app, agent.as_deref(), *all),
|
||||
Command::Update { agent, all, rollback } => {
|
||||
update_cmd::run(app, agent.as_deref(), *all, rollback.as_deref())
|
||||
}
|
||||
Command::Audit => audit_cmd::run(app),
|
||||
Command::Search { keyword, category, tag } => {
|
||||
search_cmd::run(app, keyword.as_deref(), category.as_deref(), tag.as_deref())
|
||||
}
|
||||
|
||||
@@ -119,6 +119,21 @@ pub static SECTIONS: &[TipSection] = &[
|
||||
options: &[("add <url>", "ajoute un catalogue d'équipe par URL")],
|
||||
example: "catalog add https://git.dracodev.net/team/agents.yaml",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "audit",
|
||||
about: "qui a modifié quoi, quand (checksums config + journal d'événements)",
|
||||
options: &[("--json", "rapport machine-readable")],
|
||||
example: "audit --json",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "update --rollback",
|
||||
about: "annule la dernière mise à jour (backup automatique avant chaque update)",
|
||||
options: &[
|
||||
("--rollback list", "liste les points de restauration"),
|
||||
("--rollback <id>", "restaure un point précis"),
|
||||
],
|
||||
example: "update --rollback list",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "install <agent>",
|
||||
about: "installe l'agent et ses dépendances (Node, Python, Rust…)",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! reinstall when an update exists.
|
||||
|
||||
use super::*;
|
||||
use crate::backup;
|
||||
use crate::events::{Event, EventKind};
|
||||
use crate::installers;
|
||||
use crate::runner::{Runner, SystemRunner};
|
||||
@@ -9,7 +10,17 @@ use anyhow::{bail, Result};
|
||||
use std::cmp::Ordering;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub fn run(app: &App, agent_name: Option<&str>, all: bool) -> Result<i32> {
|
||||
pub fn run(
|
||||
app: &App,
|
||||
agent_name: Option<&str>,
|
||||
all: bool,
|
||||
rollback: Option<&str>,
|
||||
) -> Result<i32> {
|
||||
// Issue #64: --rollback restores a pre-update backup instead of updating.
|
||||
if rollback.is_some() {
|
||||
return rollback_point(app, rollback);
|
||||
}
|
||||
|
||||
let runner = SystemRunner::new(app.dry_run(), app.cli.verbose, &app.log);
|
||||
|
||||
let policy = app.config.settings.update_policy.unwrap_or_default();
|
||||
@@ -28,6 +39,16 @@ pub fn run(app: &App, agent_name: Option<&str>, all: bool) -> Result<i32> {
|
||||
let entries = app.state.all()?;
|
||||
let names: Vec<String> = entries.keys().cloned().collect();
|
||||
let mut failed = 0;
|
||||
// Issue #64: one snapshot covers the whole batch.
|
||||
if !app.dry_run() && !names.is_empty() {
|
||||
let info = backup::create_backup(app, "update:--all", &names)?;
|
||||
app.log.verbose(&format!("pre-update backup: {}", info.id));
|
||||
app.emit(
|
||||
&Event::now(EventKind::Backup)
|
||||
.with_args(vec![info.id.clone()])
|
||||
.with_reason("update:--all"),
|
||||
);
|
||||
}
|
||||
for name in names {
|
||||
if let Err(e) = update_one(app, &runner, &name) {
|
||||
failed += 1;
|
||||
@@ -124,6 +145,16 @@ fn update_one(app: &App, runner: &dyn Runner, name: &str) -> Result<i32> {
|
||||
.dry(format!("would reinstall {} via {}", agent.name, method.kind));
|
||||
return Ok(0);
|
||||
}
|
||||
// Issue #64: snapshot before the real update so a broken upgrade can be
|
||||
// undone with 'am update --rollback'.
|
||||
let info = backup::create_backup(app, &format!("update:{}", agent.name), &[agent.name.clone()])?;
|
||||
app.log.verbose(&format!("pre-update backup: {}", info.id));
|
||||
app.emit(
|
||||
&Event::now(EventKind::Backup)
|
||||
.with_args(vec![info.id.clone()])
|
||||
.with_agent(agent.name.clone())
|
||||
.with_reason("pre-update"),
|
||||
);
|
||||
crate::deps::ensure_deps(app, agent, runner)?;
|
||||
let outcome = installers::run_install(app, agent, method, runner)?;
|
||||
let mut new_entry = installers::make_entry(agent, method, &outcome, app);
|
||||
@@ -147,3 +178,42 @@ fn update_one(app: &App, runner: &dyn Runner, name: &str) -> Result<i32> {
|
||||
));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// Issue #64: 'am update --rollback [point]' — restore a pre-update backup.
|
||||
/// '--rollback list' prints the available backup points.
|
||||
fn rollback_point(app: &App, point: Option<&str>) -> Result<i32> {
|
||||
if point == Some("list") {
|
||||
let all = backup::list_backups(app);
|
||||
if all.is_empty() {
|
||||
app.log.info("no backup found — run an update first to create one");
|
||||
return Ok(0);
|
||||
}
|
||||
println!("pre-update backups ({}):", all.len());
|
||||
for b in &all {
|
||||
println!(
|
||||
" {} {} agents={}{}",
|
||||
b.id,
|
||||
b.reason,
|
||||
if b.agents.is_empty() {
|
||||
"all".to_string()
|
||||
} else {
|
||||
b.agents.join(",")
|
||||
},
|
||||
if b.state && b.config { " [state+config]" } else { "" }
|
||||
);
|
||||
}
|
||||
println!("\nuse 'am update --rollback <id>' to restore one");
|
||||
return Ok(0);
|
||||
}
|
||||
let info = backup::restore_backup(app, point)?;
|
||||
app.emit(
|
||||
&Event::now(EventKind::Rollback)
|
||||
.with_args(vec![info.id.clone()])
|
||||
.with_reason(&info.reason),
|
||||
);
|
||||
app.log.success(&format!(
|
||||
"rolled back to backup {} ({})",
|
||||
info.id, info.reason
|
||||
));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
@@ -117,6 +117,10 @@ pub struct Settings {
|
||||
/// is attempted (issue #60, default 3600).
|
||||
#[serde(default)]
|
||||
pub catalog_cache_ttl_secs: Option<u64>,
|
||||
/// How many pre-update backups 'am update --rollback' keeps
|
||||
/// (issue #64, default 5).
|
||||
#[serde(default)]
|
||||
pub backups_keep: Option<u32>,
|
||||
}
|
||||
|
||||
/// Policy controlling how aggressively 'am update' upgrades installed agents.
|
||||
@@ -638,6 +642,9 @@ pub fn merge(base: &mut Config, overlay: Config) {
|
||||
if o.catalog_cache_ttl_secs.is_some() {
|
||||
s.catalog_cache_ttl_secs = o.catalog_cache_ttl_secs;
|
||||
}
|
||||
if o.backups_keep.is_some() {
|
||||
s.backups_keep = o.backups_keep;
|
||||
}
|
||||
for (k, v) in o.hooks {
|
||||
s.hooks.insert(k, v);
|
||||
}
|
||||
|
||||
@@ -36,6 +36,10 @@ pub enum EventKind {
|
||||
Model,
|
||||
/// Remote catalog action: update or add of an external catalog (issue #60).
|
||||
Catalog,
|
||||
/// Pre-update backup created (issue #64).
|
||||
Backup,
|
||||
/// Rollback of a previous backup (issue #64).
|
||||
Rollback,
|
||||
}
|
||||
|
||||
impl EventKind {
|
||||
@@ -55,6 +59,8 @@ impl EventKind {
|
||||
EventKind::Annotate => "annotate",
|
||||
EventKind::Model => "model",
|
||||
EventKind::Catalog => "catalog",
|
||||
EventKind::Backup => "backup",
|
||||
EventKind::Rollback => "rollback",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+36
-19
@@ -421,6 +421,42 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
||||
HelpExample { desc: "Machine-readable output.", code: "suggest frontend framework --json" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "audit",
|
||||
category: "Commands",
|
||||
usage: "audit {flags}",
|
||||
about: "Who changed what, when: config checksums vs the event journal, manual edits flagged.",
|
||||
search_terms: &["security", "trace", "checksum", "who changed", "history config"],
|
||||
flags: &[
|
||||
HelpFlag { short: "", long: "--json", value: "", desc: "Stable machine-readable report" },
|
||||
],
|
||||
subcommands: &[],
|
||||
parameters: &[],
|
||||
io: None,
|
||||
examples: &[
|
||||
HelpExample { desc: "Run the config audit.", code: "audit" },
|
||||
HelpExample { desc: "Machine-readable report.", code: "audit --json" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "update",
|
||||
category: "Commands",
|
||||
usage: "update {flags} <agent|--all>",
|
||||
about: "Update installed agents; every update is backed up first and can be undone.",
|
||||
search_terms: &["upgrade", "rollback", "restore", "backup"],
|
||||
flags: &[
|
||||
HelpFlag { short: "", long: "--all", value: "", desc: "Update every managed agent (one backup for the batch)" },
|
||||
HelpFlag { short: "", long: "--rollback", value: "[POINT]", desc: "Restore a pre-update backup ('list' shows them, default: latest)" },
|
||||
],
|
||||
subcommands: &[],
|
||||
parameters: &[],
|
||||
io: None,
|
||||
examples: &[
|
||||
HelpExample { desc: "Update one agent (backup taken first).", code: "update claude-code" },
|
||||
HelpExample { desc: "List the available backups.", code: "update --rollback list" },
|
||||
HelpExample { desc: "Undo the last update.", code: "update --rollback" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "alias",
|
||||
category: "Commands",
|
||||
@@ -574,25 +610,6 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
||||
HelpExample { desc: "Remove everything, including logs and the config entry.", code: "uninstall claude-code --purge" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "update",
|
||||
category: "Commands",
|
||||
usage: "update {flags} [agent]",
|
||||
about: "Update an installed agent to the latest available version.",
|
||||
search_terms: &["upgrade"],
|
||||
flags: &[
|
||||
HelpFlag { short: "", long: "--all", value: "", desc: "Update every installed managed agent" },
|
||||
],
|
||||
subcommands: &[],
|
||||
parameters: &[
|
||||
HelpParam { name: "agent", typ: "string", desc: "Agent name or alias (required unless --all)" },
|
||||
],
|
||||
io: None,
|
||||
examples: &[
|
||||
HelpExample { desc: "Update one agent.", code: "update claude-code" },
|
||||
HelpExample { desc: "Update every managed agent.", code: "update --all" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "start",
|
||||
category: "Commands",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
//! * commands — one module per CLI command.
|
||||
|
||||
pub mod app;
|
||||
pub mod backup;
|
||||
pub mod catalog;
|
||||
pub mod catalog_remote;
|
||||
pub mod cli;
|
||||
|
||||
+16
-2
@@ -109,6 +109,7 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
|
||||
("models", "list local models (ollama, llama.cpp, LM Studio)"),
|
||||
("catalog", "manage remote catalogs"),
|
||||
("suggest", "recommend an agent for a request"),
|
||||
("audit", "who changed what, when (config checksums)"),
|
||||
("shell", "show or switch the system shell"),
|
||||
("theme", "show or switch the color theme"),
|
||||
("tip", "cheat sheet of the most useful commands"),
|
||||
@@ -222,7 +223,7 @@ impl AmCompleter {
|
||||
"start", "stop", "restart", "run", "doctor", "config", "completion",
|
||||
"self-update", "self-uninstall", "export", "import", "shell", "theme",
|
||||
"tip", "dashboard", "favorite", "unfavorite", "note", "tag", "untag", "tags",
|
||||
"profile", "man", "models", "catalog", "suggest",
|
||||
"profile", "man", "models", "catalog", "suggest", "audit",
|
||||
"ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit",
|
||||
],
|
||||
config_sub: vec!["show", "path", "edit", "validate", "add"],
|
||||
@@ -769,7 +770,7 @@ pub fn banner_box(
|
||||
" config alias · secret · profile · config · doctor · completion · man · tip".to_string(),
|
||||
));
|
||||
rows.push(inner(
|
||||
" models models · models --prune · catalog · suggest".to_string(),
|
||||
" models models · models --prune · catalog · suggest · audit".to_string(),
|
||||
));
|
||||
rows.push(inner(
|
||||
" system self-update · self-uninstall · export · import".to_string(),
|
||||
@@ -1608,14 +1609,23 @@ fn handle_line(
|
||||
Command::Update {
|
||||
agent: None,
|
||||
all: true,
|
||||
rollback: opt_value("--rollback"),
|
||||
}
|
||||
} else if let Some(rb) = opt_value("--rollback") {
|
||||
Command::Update {
|
||||
agent: None,
|
||||
all: false,
|
||||
rollback: Some(rb),
|
||||
}
|
||||
} else {
|
||||
Command::Update {
|
||||
agent: rest.first().cloned(),
|
||||
all: false,
|
||||
rollback: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
"audit" => Command::Audit,
|
||||
"doctor" => Command::Doctor { fix: flag("--fix") },
|
||||
"run" => {
|
||||
let agent = need("agent name")?;
|
||||
@@ -1730,6 +1740,10 @@ fn is_am_command(word: &str) -> bool {
|
||||
| "profile"
|
||||
| "man"
|
||||
| "tip"
|
||||
| "models"
|
||||
| "catalog"
|
||||
| "suggest"
|
||||
| "audit"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,21 @@ pub struct StateFile {
|
||||
/// Personal annotations per agent: favorite, note, tags (issue #39).
|
||||
#[serde(default)]
|
||||
pub annotations: BTreeMap<String, Annotation>,
|
||||
/// Config file checksums used by 'am audit' (axe 8, issue #63).
|
||||
#[serde(default)]
|
||||
pub config_checksums: BTreeMap<String, ConfigChecksum>,
|
||||
}
|
||||
|
||||
/// One recorded config checksum: the baseline 'am audit' compares against
|
||||
/// to detect manual modifications (issue #63).
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ConfigChecksum {
|
||||
/// SHA-256 of the file content (hex).
|
||||
pub sha256: String,
|
||||
/// File modification time (seconds since epoch) at recording time.
|
||||
pub mtime_secs: u64,
|
||||
/// RFC 3339 timestamp of the recording.
|
||||
pub recorded_at: String,
|
||||
}
|
||||
|
||||
/// Personal annotations attached to an agent by the user (issue #39).
|
||||
@@ -48,6 +63,7 @@ impl Default for StateFile {
|
||||
sessions_count: 0,
|
||||
last_used: None,
|
||||
annotations: BTreeMap::new(),
|
||||
config_checksums: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use agent_manager::commands::update_cmd;
|
||||
use agent_manager::config::UpdatePolicy;
|
||||
use agent_manager::state::{InstalledEntry, StateFile};
|
||||
use clap::Parser;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
mod common;
|
||||
@@ -26,6 +27,7 @@ fn installed_state(name: &str, version: &str) -> StateFile {
|
||||
sessions_count: 0,
|
||||
last_used: None,
|
||||
annotations: BTreeMap::new(),
|
||||
config_checksums: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +46,7 @@ fn update_policy_none_blocks_all() {
|
||||
let mut app = common::test_app("policy_none", &["--yes"]);
|
||||
app.config.settings.update_policy = Some(UpdatePolicy::None);
|
||||
app.state.save(&installed_state("pi", "0.1.0")).unwrap();
|
||||
let code = update_cmd::run(&app, None, true).unwrap();
|
||||
let code = update_cmd::run(&app, None, true, None).unwrap();
|
||||
assert_eq!(code, 0);
|
||||
}
|
||||
|
||||
@@ -53,7 +55,7 @@ fn pinned_agent_is_skipped() {
|
||||
let body = agent_yaml("pi", Some("0.1.0"));
|
||||
let app = common::test_app("pinned_ok", &[]);
|
||||
app.state.save(&installed_state("pi", "0.1.0")).unwrap();
|
||||
let code = update_cmd::run(&app, Some("pi"), false).unwrap();
|
||||
let code = update_cmd::run(&app, Some("pi"), false, None).unwrap();
|
||||
assert_eq!(code, 0);
|
||||
}
|
||||
|
||||
@@ -62,6 +64,6 @@ fn pinned_agent_mismatch_warns() {
|
||||
let body = agent_yaml("pi", Some("0.1.0"));
|
||||
let app = common::test_app("pinned_warn", &[]);
|
||||
app.state.save(&installed_state("pi", "0.2.0")).unwrap();
|
||||
let code = update_cmd::run(&app, Some("pi"), false).unwrap();
|
||||
let code = update_cmd::run(&app, Some("pi"), false, None).unwrap();
|
||||
assert_eq!(code, 0);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user