From 5bfa5f18e1bd6ad7cd1879054e4ffa001b37296e Mon Sep 17 00:00:00 2001 From: argenis de la rosa Date: Tue, 17 Mar 2026 11:06:05 -0400 Subject: [PATCH] feat(cli): add update command with 6-phase pipeline and rollback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `zeroclaw update` command with a 6-phase self-update pipeline: 1. Preflight — check GitHub releases API for newer version 2. Download — fetch platform-specific binary to temp dir 3. Backup — copy current binary to .bak for rollback 4. Validate — size check + --version smoke test on download 5. Swap — overwrite current binary with new version 6. Smoke test — verify updated binary runs, rollback on failure Supports --check flag for update-check-only mode without installing. Includes version comparison logic with unit tests. Co-Authored-By: Claude Opus 4.6 --- src/commands/update.rs | 259 ++++++++++++++++++++++++++++++++++++++++- src/main.rs | 36 ++++++ 2 files changed, 293 insertions(+), 2 deletions(-) diff --git a/src/commands/update.rs b/src/commands/update.rs index 3e3d86955..ee90517bc 100644 --- a/src/commands/update.rs +++ b/src/commands/update.rs @@ -1,3 +1,258 @@ //! `zeroclaw update` — self-update pipeline with rollback. -//! -//! Placeholder module for the update command (PR-06). + +use anyhow::{bail, Context, Result}; +use std::path::Path; +use tracing::{info, warn}; + +const GITHUB_RELEASES_URL: &str = + "https://api.github.com/repos/zeroclaw-labs/zeroclaw/releases/latest"; + +#[derive(Debug)] +pub struct UpdateInfo { + pub current_version: String, + pub latest_version: String, + pub download_url: Option, + pub is_newer: bool, +} + +/// Check for available updates without downloading. +pub async fn check() -> Result { + let current = env!("CARGO_PKG_VERSION").to_string(); + + let client = reqwest::Client::builder() + .user_agent(format!("zeroclaw/{current}")) + .timeout(std::time::Duration::from_secs(15)) + .build()?; + + let resp = client + .get(GITHUB_RELEASES_URL) + .send() + .await + .context("failed to reach GitHub releases API")?; + + if !resp.status().is_success() { + bail!("GitHub API returned {}", resp.status()); + } + + let release: serde_json::Value = resp.json().await?; + let tag = release["tag_name"] + .as_str() + .unwrap_or("unknown") + .trim_start_matches('v') + .to_string(); + + let download_url = find_asset_url(&release); + let is_newer = version_is_newer(¤t, &tag); + + Ok(UpdateInfo { + current_version: current, + latest_version: tag, + download_url, + is_newer, + }) +} + +/// Run the full 6-phase update pipeline. +pub async fn run() -> Result<()> { + // Phase 1: Preflight + info!("Phase 1/6: Preflight checks..."); + let update_info = check().await?; + + if !update_info.is_newer { + println!("Already up to date (v{}).", update_info.current_version); + return Ok(()); + } + + println!( + "Update available: v{} -> v{}", + update_info.current_version, update_info.latest_version + ); + + let download_url = update_info + .download_url + .context("no suitable binary found for this platform")?; + + let current_exe = + std::env::current_exe().context("cannot determine current executable path")?; + + // Phase 2: Download + info!("Phase 2/6: Downloading..."); + let temp_dir = tempfile::tempdir().context("failed to create temp dir")?; + let download_path = temp_dir.path().join("zeroclaw_new"); + download_binary(&download_url, &download_path).await?; + + // Phase 3: Backup + info!("Phase 3/6: Creating backup..."); + let backup_path = current_exe.with_extension("bak"); + tokio::fs::copy(¤t_exe, &backup_path) + .await + .context("failed to backup current binary")?; + + // Phase 4: Validate + info!("Phase 4/6: Validating download..."); + validate_binary(&download_path).await?; + + // Phase 5: Swap + info!("Phase 5/6: Swapping binary..."); + if let Err(e) = swap_binary(&download_path, ¤t_exe).await { + // Rollback + warn!("Swap failed, rolling back: {e}"); + if let Err(rollback_err) = tokio::fs::copy(&backup_path, ¤t_exe).await { + eprintln!("CRITICAL: Rollback also failed: {rollback_err}"); + eprintln!( + "Manual recovery: cp {} {}", + backup_path.display(), + current_exe.display() + ); + } + bail!("Update failed during swap: {e}"); + } + + // Phase 6: Smoke test + info!("Phase 6/6: Smoke test..."); + match smoke_test(¤t_exe).await { + Ok(()) => { + // Cleanup backup on success + let _ = tokio::fs::remove_file(&backup_path).await; + println!("Successfully updated to v{}!", update_info.latest_version); + Ok(()) + } + Err(e) => { + warn!("Smoke test failed, rolling back: {e}"); + tokio::fs::copy(&backup_path, ¤t_exe) + .await + .context("rollback after smoke test failure")?; + bail!("Update rolled back — smoke test failed: {e}"); + } + } +} + +fn find_asset_url(release: &serde_json::Value) -> Option { + let target = if cfg!(target_os = "macos") { + if cfg!(target_arch = "aarch64") { + "aarch64-apple-darwin" + } else { + "x86_64-apple-darwin" + } + } else if cfg!(target_os = "linux") { + if cfg!(target_arch = "aarch64") { + "aarch64-unknown-linux" + } else { + "x86_64-unknown-linux" + } + } else { + return None; + }; + + release["assets"] + .as_array()? + .iter() + .find(|asset| { + asset["name"] + .as_str() + .map(|name| name.contains(target)) + .unwrap_or(false) + }) + .and_then(|asset| asset["browser_download_url"].as_str().map(String::from)) +} + +fn version_is_newer(current: &str, candidate: &str) -> bool { + let parse = |v: &str| -> Vec { v.split('.').filter_map(|p| p.parse().ok()).collect() }; + let cur = parse(current); + let cand = parse(candidate); + cand > cur +} + +async fn download_binary(url: &str, dest: &Path) -> Result<()> { + let client = reqwest::Client::builder() + .user_agent(format!("zeroclaw/{}", env!("CARGO_PKG_VERSION"))) + .timeout(std::time::Duration::from_secs(300)) + .build()?; + + let resp = client + .get(url) + .send() + .await + .context("download request failed")?; + if !resp.status().is_success() { + bail!("download returned {}", resp.status()); + } + + let bytes = resp.bytes().await.context("failed to read download body")?; + tokio::fs::write(dest, &bytes) + .await + .context("failed to write downloaded binary")?; + + // Make executable on Unix + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o755); + tokio::fs::set_permissions(dest, perms).await?; + } + + Ok(()) +} + +async fn validate_binary(path: &Path) -> Result<()> { + let meta = tokio::fs::metadata(path).await?; + if meta.len() < 1_000_000 { + bail!( + "downloaded binary too small ({} bytes), likely corrupt", + meta.len() + ); + } + + // Quick check: try running --version + let output = tokio::process::Command::new(path) + .arg("--version") + .output() + .await + .context("cannot execute downloaded binary")?; + + if !output.status.success() { + bail!("downloaded binary --version check failed"); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + if !stdout.contains("zeroclaw") { + bail!("downloaded binary does not appear to be zeroclaw"); + } + + Ok(()) +} + +async fn swap_binary(new: &Path, target: &Path) -> Result<()> { + tokio::fs::copy(new, target) + .await + .context("failed to overwrite binary")?; + Ok(()) +} + +async fn smoke_test(binary: &Path) -> Result<()> { + let output = tokio::process::Command::new(binary) + .arg("--version") + .output() + .await + .context("smoke test: cannot execute updated binary")?; + + if !output.status.success() { + bail!("smoke test: updated binary returned non-zero exit code"); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_version_comparison() { + assert!(version_is_newer("0.4.3", "0.5.0")); + assert!(version_is_newer("0.4.3", "0.4.4")); + assert!(!version_is_newer("0.5.0", "0.4.3")); + assert!(!version_is_newer("0.4.3", "0.4.3")); + assert!(version_is_newer("1.0.0", "2.0.0")); + } +} diff --git a/src/main.rs b/src/main.rs index 487e6b64b..002b08ca8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -463,6 +463,25 @@ Examples: config_command: ConfigCommands, }, + /// Check for and apply updates + #[command(long_about = "\ +Check for and apply ZeroClaw updates. + +By default, downloads and installs the latest release with a \ +6-phase pipeline: preflight, download, backup, validate, swap, \ +and smoke test. Automatic rollback on failure. + +Use --check to only check for updates without installing. + +Examples: + zeroclaw update # download and install latest + zeroclaw update --check # check only, don't install")] + Update { + /// Only check for updates, don't install + #[arg(long)] + check: bool, + }, + /// Run diagnostic self-tests #[command(long_about = "\ Run diagnostic self-tests to verify the ZeroClaw installation. @@ -1224,6 +1243,23 @@ async fn main() -> Result<()> { .await } + Commands::Update { check } => { + if check { + let info = commands::update::check().await?; + if info.is_newer { + println!( + "Update available: v{} -> v{}", + info.current_version, info.latest_version + ); + } else { + println!("Already up to date (v{}).", info.current_version); + } + Ok(()) + } else { + commands::update::run().await + } + } + Commands::SelfTest { quick } => { let results = if quick { commands::self_test::run_quick(&config).await?