feat(update): add install-aware guidance and safer self-update

This commit is contained in:
argenis de la rosa 2026-02-28 15:00:57 -05:00 committed by Argenis
parent 0129b5da06
commit 1ecace23a7
4 changed files with 272 additions and 19 deletions

View File

@ -15,6 +15,7 @@ Last verified: **February 28, 2026**.
| `service` | Manage user-level OS service lifecycle |
| `doctor` | Run diagnostics and freshness checks |
| `status` | Print current configuration and system summary |
| `update` | Check or install latest ZeroClaw release |
| `estop` | Engage/resume emergency stop levels and inspect estop state |
| `cron` | Manage scheduled tasks |
| `models` | Refresh provider model catalogs |
@ -103,6 +104,18 @@ Notes:
- `zeroclaw service status`
- `zeroclaw service uninstall`
### `update`
- `zeroclaw update --check` (check for new release, no install)
- `zeroclaw update` (install latest release binary for current platform)
- `zeroclaw update --force` (reinstall even if current version matches latest)
- `zeroclaw update --instructions` (print install-method-specific guidance)
Notes:
- If ZeroClaw is installed via Homebrew, prefer `brew upgrade zeroclaw`.
- `update --instructions` detects common install methods and prints the safest path.
### `cron`
- `zeroclaw cron list`

View File

@ -20,6 +20,13 @@ If both exist, your shell `PATH` order decides which one runs.
## 2) Update on macOS
Quick way to get install-method-specific guidance:
```bash
zeroclaw update --instructions
zeroclaw update --check
```
### A) Homebrew install
```bash
@ -54,6 +61,13 @@ Re-run your download/install flow with the latest release asset, then verify:
zeroclaw --version
```
You can also use the built-in updater for manual/local installs:
```bash
zeroclaw update
zeroclaw --version
```
## 3) Uninstall on macOS
### A) Stop and remove background service first

View File

@ -333,15 +333,20 @@ the binary location.
Examples:
zeroclaw update # Update to latest version
zeroclaw update --check # Check for updates without installing
zeroclaw update --instructions # Show install-method-specific update instructions
zeroclaw update --force # Reinstall even if already up to date")]
Update {
/// Check for updates without installing
#[arg(long)]
#[arg(long, conflicts_with_all = ["force", "instructions"])]
check: bool,
/// Force update even if already at latest version
#[arg(long)]
#[arg(long, conflicts_with = "instructions")]
force: bool,
/// Show human-friendly update instructions for your installation method
#[arg(long, conflicts_with_all = ["check", "force"])]
instructions: bool,
},
/// Engage, inspect, and resume emergency-stop states.
@ -1107,9 +1112,18 @@ async fn main() -> Result<()> {
Ok(())
}
Commands::Update { check, force } => {
update::self_update(force, check).await?;
Ok(())
Commands::Update {
check,
force,
instructions,
} => {
if instructions {
update::print_update_instructions()?;
Ok(())
} else {
update::self_update(force, check).await?;
Ok(())
}
}
Commands::Estop {
@ -2630,4 +2644,41 @@ mod tests {
);
assert_eq!(payload["nested"]["non_secret"], serde_json::json!("ok"));
}
#[test]
fn update_help_mentions_instructions_flag() {
let cmd = Cli::command();
let update_cmd = cmd
.get_subcommands()
.find(|subcommand| subcommand.get_name() == "update")
.expect("update subcommand must exist");
let mut output = Vec::new();
update_cmd
.clone()
.write_long_help(&mut output)
.expect("help generation should succeed");
let help = String::from_utf8(output).expect("help output should be utf-8");
assert!(help.contains("--instructions"));
}
#[test]
fn update_cli_parses_instructions_flag() {
let cli = Cli::try_parse_from(["zeroclaw", "update", "--instructions"])
.expect("update --instructions should parse");
match cli.command {
Commands::Update {
check,
force,
instructions,
} => {
assert!(!check);
assert!(!force);
assert!(instructions);
}
other => panic!("expected update command, got {other:?}"),
}
}
}

View File

@ -5,6 +5,7 @@
use anyhow::{bail, Context, Result};
use std::env;
use std::fs;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use std::process::Command;
@ -26,6 +27,13 @@ struct Asset {
browser_download_url: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum InstallMethod {
Homebrew,
CargoOrLocal,
Unknown,
}
/// Get the current version of the binary
pub fn current_version() -> &'static str {
env!("CARGO_PKG_VERSION")
@ -213,6 +221,79 @@ fn get_current_exe() -> Result<PathBuf> {
env::current_exe().context("Failed to get current executable path")
}
fn detect_install_method_for_path(resolved_path: &Path, home_dir: Option<&Path>) -> InstallMethod {
let lower = resolved_path.to_string_lossy().to_ascii_lowercase();
if lower.contains("/cellar/zeroclaw/") || lower.contains("/homebrew/cellar/zeroclaw/") {
return InstallMethod::Homebrew;
}
if let Some(home) = home_dir {
if resolved_path.starts_with(home.join(".cargo").join("bin"))
|| resolved_path.starts_with(home.join(".local").join("bin"))
{
return InstallMethod::CargoOrLocal;
}
}
InstallMethod::Unknown
}
fn detect_install_method(current_exe: &Path) -> InstallMethod {
let resolved = fs::canonicalize(current_exe).unwrap_or_else(|_| current_exe.to_path_buf());
let home_dir = env::var_os("HOME").map(PathBuf::from);
detect_install_method_for_path(&resolved, home_dir.as_deref())
}
/// Print human-friendly update instructions based on detected install method.
pub fn print_update_instructions() -> Result<()> {
let current_exe = get_current_exe()?;
let install_method = detect_install_method(&current_exe);
println!("ZeroClaw update guide");
println!("Detected binary: {}", current_exe.display());
println!();
println!("1) Check if a new release exists:");
println!(" zeroclaw update --check");
println!();
match install_method {
InstallMethod::Homebrew => {
println!("Detected install method: Homebrew");
println!("Recommended update commands:");
println!(" brew update");
println!(" brew upgrade zeroclaw");
println!(" zeroclaw --version");
println!();
println!(
"Tip: avoid `zeroclaw update` on Homebrew installs unless you intentionally want to override the managed binary."
);
}
InstallMethod::CargoOrLocal => {
println!("Detected install method: local binary (~/.cargo/bin or ~/.local/bin)");
println!("Recommended update command:");
println!(" zeroclaw update");
println!("Optional force reinstall:");
println!(" zeroclaw update --force");
println!("Verify:");
println!(" zeroclaw --version");
}
InstallMethod::Unknown => {
println!("Detected install method: unknown");
println!("Try the built-in updater first:");
println!(" zeroclaw update");
println!(
"If your package manager owns the binary, use that manager's upgrade command."
);
println!("Verify:");
println!(" zeroclaw --version");
}
}
println!();
println!("Release source: https://github.com/{GITHUB_REPO}/releases/latest");
Ok(())
}
/// Replace the current binary with the new one
fn replace_binary(new_binary: &Path, current_exe: &Path) -> Result<()> {
// On Windows, we can't replace a running executable directly
@ -226,11 +307,43 @@ fn replace_binary(new_binary: &Path, current_exe: &Path) -> Result<()> {
let _ = fs::remove_file(&old_path);
}
// On Unix, we can overwrite the running executable
// On Unix, stage the binary in the destination directory first.
// This avoids cross-filesystem rename failures (EXDEV) from temp dirs.
#[cfg(unix)]
{
// Use rename for atomic replacement on Unix
fs::rename(new_binary, current_exe).context("Failed to replace binary")?;
use std::os::unix::fs::PermissionsExt;
let parent = current_exe
.parent()
.context("Current executable has no parent directory")?;
let binary_name = current_exe
.file_name()
.context("Current executable path is missing a file name")?
.to_string_lossy()
.into_owned();
let staged_path = parent.join(format!(".{binary_name}.new"));
let backup_path = parent.join(format!(".{binary_name}.bak"));
fs::copy(new_binary, &staged_path).context("Failed to stage updated binary")?;
fs::set_permissions(&staged_path, fs::Permissions::from_mode(0o755))
.context("Failed to set permissions on staged binary")?;
if let Err(err) = fs::remove_file(&backup_path) {
if err.kind() != ErrorKind::NotFound {
return Err(err).context("Failed to remove stale backup binary");
}
}
fs::rename(current_exe, &backup_path).context("Failed to backup current binary")?;
if let Err(err) = fs::rename(&staged_path, current_exe) {
let _ = fs::rename(&backup_path, current_exe);
let _ = fs::remove_file(&staged_path);
return Err(err).context("Failed to activate updated binary");
}
// Best-effort cleanup of backup.
let _ = fs::remove_file(&backup_path);
}
Ok(())
@ -258,6 +371,7 @@ pub async fn self_update(force: bool, check_only: bool) -> Result<()> {
println!();
let current_exe = get_current_exe()?;
let install_method = detect_install_method(&current_exe);
println!("Current binary: {}", current_exe.display());
println!("Current version: v{}", current_version());
println!();
@ -268,6 +382,31 @@ pub async fn self_update(force: bool, check_only: bool) -> Result<()> {
println!("Latest version: {}", release.tag_name);
if check_only {
println!();
if latest_version == current_version() {
println!("✅ Already up to date.");
} else {
println!(
"Update available: {} -> {}",
current_version(),
latest_version
);
println!("Run `zeroclaw update` to install the update.");
}
return Ok(());
}
if install_method == InstallMethod::Homebrew && !force {
println!();
println!("Detected a Homebrew-managed installation.");
println!("Use `brew upgrade zeroclaw` for the safest update path.");
println!(
"Run `zeroclaw update --force` only if you intentionally want to override Homebrew."
);
return Ok(());
}
// Check if update is needed
if latest_version == current_version() && !force {
println!();
@ -275,17 +414,6 @@ pub async fn self_update(force: bool, check_only: bool) -> Result<()> {
return Ok(());
}
if check_only {
println!();
println!(
"Update available: {} -> {}",
current_version(),
latest_version
);
println!("Run `zeroclaw update` to install the update.");
return Ok(());
}
println!();
println!(
"Updating from v{} to {}...",
@ -315,3 +443,50 @@ pub async fn self_update(force: bool, check_only: bool) -> Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn archive_name_uses_zip_for_windows_and_targz_elsewhere() {
assert_eq!(
get_archive_name("x86_64-pc-windows-msvc"),
"zeroclaw-x86_64-pc-windows-msvc.zip"
);
assert_eq!(
get_archive_name("x86_64-unknown-linux-gnu"),
"zeroclaw-x86_64-unknown-linux-gnu.tar.gz"
);
}
#[test]
fn detect_install_method_identifies_homebrew_paths() {
let path = Path::new("/opt/homebrew/Cellar/zeroclaw/0.1.7/bin/zeroclaw");
let method = detect_install_method_for_path(path, None);
assert_eq!(method, InstallMethod::Homebrew);
}
#[test]
fn detect_install_method_identifies_local_bin_paths() {
let home = Path::new("/Users/example");
let cargo_path = Path::new("/Users/example/.cargo/bin/zeroclaw");
let local_path = Path::new("/Users/example/.local/bin/zeroclaw");
assert_eq!(
detect_install_method_for_path(cargo_path, Some(home)),
InstallMethod::CargoOrLocal
);
assert_eq!(
detect_install_method_for_path(local_path, Some(home)),
InstallMethod::CargoOrLocal
);
}
#[test]
fn detect_install_method_returns_unknown_for_other_paths() {
let path = Path::new("/usr/bin/zeroclaw");
let method = detect_install_method_for_path(path, Some(Path::new("/Users/example")));
assert_eq!(method, InstallMethod::Unknown);
}
}