diff --git a/docs/commands-reference.md b/docs/commands-reference.md index c15fc8514..e570d468c 100644 --- a/docs/commands-reference.md +++ b/docs/commands-reference.md @@ -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` diff --git a/docs/getting-started/macos-update-uninstall.md b/docs/getting-started/macos-update-uninstall.md index 944cd4ce3..f08bc5042 100644 --- a/docs/getting-started/macos-update-uninstall.md +++ b/docs/getting-started/macos-update-uninstall.md @@ -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 diff --git a/src/main.rs b/src/main.rs index 913ed6139..978235848 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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:?}"), + } + } } diff --git a/src/update.rs b/src/update.rs index b0b328e44..b86b6cbb1 100644 --- a/src/update.rs +++ b/src/update.rs @@ -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 { 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(¤t_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(¤t_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); + } +}