//! update: compare the installed version with the latest available one and //! reinstall when an update exists. use super::*; use crate::backup; use crate::events::{Event, EventKind}; use crate::installers; use crate::runner::{Runner, SystemRunner}; use anyhow::{bail, Result}; use std::cmp::Ordering; use std::path::PathBuf; pub fn run( app: &App, agent_name: Option<&str>, all: bool, rollback: Option<&str>, ) -> Result { // 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(); if policy == UpdatePolicy::None && all { app.log.warn("update policy is 'none'; 'am update --all' is a no-op"); return Ok(0); } if let Some(name) = agent_name { if policy == UpdatePolicy::None && !app.yes() { app.log.warn("update policy is 'none'; use --yes to force a single-agent update"); } return update_one(app, &runner, name); } if all { let entries = app.state.all()?; let names: Vec = 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; app.log.error(&format!("{name}: {e:#}")); } } return Ok(if failed > 0 { 1 } else { 0 }); } bail!("update requires an agent name, or --all"); } fn update_one(app: &App, runner: &dyn Runner, name: &str) -> Result { let agent = require_agent(app, name)?; // Issue #65: honor per-agent pinned version. if let Some(pin) = agent.pin_version.as_deref() { let current = app .state .get(&agent.name) .ok() .flatten() .and_then(|e| e.version.clone()); if current.as_deref() == Some(pin) { app.log.success(&format!( "{} is pinned to v{pin} and already at that version", agent.title() )); } else { app.log.warn(&format!( "{} is pinned to v{pin} but currently at {}; update skipped", agent.title(), current.as_deref().unwrap_or("unknown") )); } return Ok(0); } let Some(entry) = app.state.get(&agent.name).ok().flatten() else { bail!( "agent '{}' is not installed by agent-manager — use 'am install {}'", agent.name, agent.name ); }; let spec = agent .install .as_ref() .ok_or_else(|| anyhow::anyhow!("agent '{}' has no install definition", agent.name))?; let methods = spec.all_methods(); // Prefer the method that was originally used. let method = methods .iter() .find(|m| m.kind.as_str() == entry.method) .copied() .unwrap_or(methods[0]); let latest = if app.dry_run() { None } else { installers::latest_version(app, agent, method, runner)? }; let current = entry.version.clone().or_else(|| { entry .bins .first() .and_then(|b| installers::detect_version(runner, &PathBuf::from(b))) }); match (&latest, ¤t) { (Some(l), Some(c)) => match crate::version::compare(l, c) { Some(Ordering::Greater) => { app.log.info(&format!("update available: {c} -> {l}")); } Some(Ordering::Equal) | Some(Ordering::Less) => { app.log .success(&format!("{} is up to date (v{c})", agent.title())); return Ok(0); } None => { app.log.warn(&format!("cannot compare versions ({c} vs {l}); reinstalling")); } }, (Some(l), None) => app.log.info(&format!( "latest available: {l} (installed version unknown); reinstalling" )), (None, Some(c)) => app.log.warn(&format!( "cannot determine the latest version (installed: {c}); reinstalling" )), (None, None) => app.log.warn("cannot determine versions; reinstalling"), } if app.dry_run() { app.log .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); new_entry.installed_at = entry.installed_at.clone(); new_entry.updated_at = Some(installers::now_rfc3339()); let version = new_entry.version.clone().unwrap_or_else(|| "unknown".to_string()); app.state.set(&new_entry)?; app.emit( &Event::now(EventKind::Update) .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() )); 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 { 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 ' 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) }