feat(cli): add update command with 6-phase pipeline and rollback

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 <noreply@anthropic.com>
This commit is contained in:
argenis de la rosa 2026-03-17 11:06:05 -04:00
parent 72b7e1e647
commit 5bfa5f18e1
2 changed files with 293 additions and 2 deletions

View File

@ -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<String>,
pub is_newer: bool,
}
/// Check for available updates without downloading.
pub async fn check() -> Result<UpdateInfo> {
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(&current, &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(&current_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, &current_exe).await {
// Rollback
warn!("Swap failed, rolling back: {e}");
if let Err(rollback_err) = tokio::fs::copy(&backup_path, &current_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(&current_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, &current_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<String> {
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<u32> { 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"));
}
}

View File

@ -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?