AkurAI Build
Menu

Public repository / master

AkurAI-Framework / crates/cli/src/cmd_update.rs

Latest change 4720fbe4494a004ff49418b8a5df799b4f8849f2 - feat: self-healing 'akurai update' — back up the running binary, swap, verify --version, retry with remediation, and roll back to the backup on total failure by Ólafur Búi Ólafsson

//! `akurai update` — self-update the running CLI from the latest GitHub release.
//!
//! The framework links no external crates, and `std` ships no HTTPS client, so
//! network I/O shells out to the system `curl` binary (a system tool, not a
//! linked dependency — the same approach `install.sh` uses). GitHub's JSON is
//! parsed with the project's own `akurai_json` crate, dogfooding it.
//!
//! ## Honesty / caveat
//! This command only works once the repository is **public** and a release has
//! been **published**. `olibuijr/AkurAI-Framework` is currently private, so
//! until a release is cut the GitHub API returns 404 and the command reports
//! "no release found" and exits non-zero **without writing anything**.
//!
//! ## How the self-replace works
//! The new binary is downloaded to a sibling `*.new` file next to the running
//! executable (never `/tmp`), marked executable, then `rename`d over the
//! running binary. On Linux/macOS a rename-over-self is atomic and safe: the
//! running process keeps its original inode open, and the next launch picks up
//! the replacement.

use std::cmp::Ordering;
use std::path::{Path, PathBuf};
use std::process::Command;

const REPO: &str = "olibuijr/AkurAI-Framework";

/// How many times to re-download → swap → verify before rolling back. The
/// running binary is backed up once before the loop, so even a total failure
/// leaves a working `akurai` in place.
const MAX_RETRIES: u32 = 3;

/// Entry point for `akurai update [--check]`.
///
/// `args` is everything after the `update` subcommand word.
pub fn run(args: &[String], current: &str) -> Result<(), String> {
    let check_only = match args.first().map(String::as_str) {
        None => false,
        Some("--check") => true,
        Some(other) => {
            return Err(format!(
                "unknown flag '{other}' (try: akurai update --check)"
            ))
        }
    };

    let latest = fetch_latest_tag()?;

    match compare_versions(current, &latest) {
        Ordering::Less => {
            println!("akurai: update available {current} -> {latest}");
            if check_only {
                return Ok(());
            }
            install_latest(current, &latest)
        }
        Ordering::Equal | Ordering::Greater => {
            println!("akurai: already on the latest version ({current})");
            Ok(())
        }
    }
}

/// Query the GitHub API for the latest release tag and normalize it.
fn fetch_latest_tag() -> Result<String, String> {
    let url = format!("https://api.github.com/repos/{REPO}/releases/latest");
    let out = Command::new("curl")
        .args([
            "-fsSL",
            "-H",
            "Accept: application/vnd.github+json",
            // GitHub rejects API requests without a User-Agent.
            "-H",
            "User-Agent: akurai-cli",
            &url,
        ])
        .output()
        .map_err(|e| format!("failed to run curl (is it installed?): {e}"))?;

    if !out.status.success() {
        // curl -f makes HTTP 4xx/5xx a non-zero exit (e.g. 404 for a private
        // repo or a repo with no releases yet).
        return Err(format!(
            "no release found for {REPO} — the repo may be private or have no \
             published release yet (curl exit {})",
            out.status.code().unwrap_or(-1)
        ));
    }

    let body = String::from_utf8_lossy(&out.stdout);
    parse_latest_tag(&body)
}

/// Extract and normalize `tag_name` from a GitHub release JSON body.
fn parse_latest_tag(body: &str) -> Result<String, String> {
    let value = akurai_json::parse(body).map_err(|e| format!("invalid GitHub response: {e}"))?;
    let tag = value
        .get("tag_name")
        .and_then(|v| v.as_str())
        .ok_or("GitHub response had no tag_name (no release found)")?;
    let normalized = normalize_tag(tag);
    if normalized.is_empty() {
        return Err("GitHub response had an empty tag_name".to_string());
    }
    Ok(normalized.to_string())
}

/// Download the latest asset and replace the running binary with a safety net:
/// **back up → swap → verify → (retry with remediation) → roll back on failure.**
///
/// 1. Copy the running binary to `<exe>.bak` before any change — the rollback target.
/// 2. Up to [`MAX_RETRIES`] times: download → chmod → atomic swap → verify the
///    new binary reports `latest` via `--version`. A failed attempt clears the
///    partial download, backs off briefly, and retries ("reiterate after fixes").
/// 3. If every attempt fails, restore `<exe>.bak` so the machine is never left
///    without a working `akurai`.
fn install_latest(current: &str, latest: &str) -> Result<(), String> {
    let asset = asset_name(std::env::consts::OS, std::env::consts::ARCH).ok_or_else(|| {
        format!(
            "unsupported platform {}-{} — no prebuilt asset; install from source instead",
            std::env::consts::OS,
            std::env::consts::ARCH
        )
    })?;

    let exe = std::env::current_exe()
        .map_err(|e| format!("could not locate the running executable: {e}"))?;
    let new_path = sibling_path(&exe);
    let bak_path = backup_path(&exe);
    let url = format!("https://github.com/{REPO}/releases/latest/download/{asset}");

    // 1. Back up the current binary before touching it.
    backup_binary(&exe, &bak_path)?;

    let mut last_err = String::new();
    for attempt in 1..=MAX_RETRIES {
        match try_install_once(&url, &new_path, &exe, latest) {
            Ok(()) => {
                println!(
                    "akurai: updated {current} -> {latest}  (backup kept at {})",
                    bak_path.display()
                );
                return Ok(());
            }
            Err(e) => {
                last_err = e;
                eprintln!("akurai: update attempt {attempt}/{MAX_RETRIES} failed: {last_err}");
                remediate(&new_path, attempt);
            }
        }
    }

    // 3. Every attempt failed — restore the backup so a working binary remains.
    match rollback(&bak_path, &exe) {
        Ok(()) => Err(format!(
            "update failed after {MAX_RETRIES} attempts ({last_err}); rolled back to {current}"
        )),
        Err(rb) => Err(format!(
            "update failed ({last_err}) AND rollback failed ({rb}); \
             the backup is at {} — restore it manually",
            bak_path.display()
        )),
    }
}

/// One download → chmod → atomic-swap → verify cycle. `<exe>` ends up replaced
/// only if verification passes; otherwise the caller retries (the pre-loop
/// backup is the ultimate safety net).
fn try_install_once(url: &str, new_path: &Path, exe: &Path, latest: &str) -> Result<(), String> {
    download(url, new_path)?;
    set_executable(new_path)?;
    atomic_swap(new_path, exe)?;
    verify_binary(exe, latest)
}

/// `<exe>.bak` — the rollback target.
fn backup_path(exe: &Path) -> PathBuf {
    let mut s = exe.as_os_str().to_os_string();
    s.push(".bak");
    PathBuf::from(s)
}

/// Copy the running binary to `bak`, preserving the executable bit.
fn backup_binary(exe: &Path, bak: &Path) -> Result<(), String> {
    std::fs::copy(exe, bak).map_err(|e| {
        format!(
            "could not back up {} to {}: {e}",
            exe.display(),
            bak.display()
        )
    })?;
    set_executable(bak)
}

/// Atomic rename of the downloaded binary over the running one. The live process
/// keeps its original inode, so replacing the file underneath it is safe. Cleans
/// up the partial download on failure so a retry starts clean.
fn atomic_swap(new_path: &Path, exe: &Path) -> Result<(), String> {
    std::fs::rename(new_path, exe).map_err(|e| {
        let _ = std::fs::remove_file(new_path);
        if e.kind() == std::io::ErrorKind::PermissionDenied {
            format!(
                "permission denied replacing {} — re-run with the right permissions \
                 (e.g. `sudo akurai update`)",
                exe.display()
            )
        } else {
            format!("failed to replace {}: {e}", exe.display())
        }
    })
}

/// Run the freshly installed binary's `--version` and confirm it reports the
/// version we expected — catches a corrupt download, wrong-arch asset, or a
/// truncated file before we trust the update.
fn verify_binary(exe: &Path, latest: &str) -> Result<(), String> {
    let out = Command::new(exe)
        .arg("--version")
        .output()
        .map_err(|e| format!("new binary failed to execute: {e}"))?;
    if !out.status.success() {
        return Err(format!(
            "new binary exited non-zero on --version (code {})",
            out.status.code().unwrap_or(-1)
        ));
    }
    let stdout = String::from_utf8_lossy(&out.stdout);
    if stdout.contains(latest) {
        Ok(())
    } else {
        Err(format!(
            "new binary reports the wrong version (expected {latest}, got '{}')",
            stdout.trim()
        ))
    }
}

/// Restore the backup over the running binary, re-marking it executable.
fn rollback(bak: &Path, exe: &Path) -> Result<(), String> {
    std::fs::copy(bak, exe).map_err(|e| format!("restore from {} failed: {e}", bak.display()))?;
    set_executable(exe)
}

/// Best-effort remediation between attempts: clear a partial download and back
/// off briefly so a transient network/permission hiccup can clear before retry.
fn remediate(new_path: &Path, attempt: u32) {
    let _ = std::fs::remove_file(new_path);
    std::thread::sleep(std::time::Duration::from_millis(500 * u64::from(attempt)));
}

/// Download `url` to `dest` via curl. On failure, removes any partial file.
fn download(url: &str, dest: &Path) -> Result<(), String> {
    let status = Command::new("curl")
        .args(["-fsSL", "-H", "User-Agent: akurai-cli", "-o"])
        .arg(dest)
        .arg(url)
        .status()
        .map_err(|e| format!("failed to run curl: {e}"))?;

    if !status.success() {
        let _ = std::fs::remove_file(dest);
        return Err(format!(
            "download failed (curl exit {}): {url}",
            status.code().unwrap_or(-1)
        ));
    }
    Ok(())
}

/// Set the executable bit (0o755) on a freshly downloaded binary.
fn set_executable(path: &Path) -> Result<(), String> {
    use std::os::unix::fs::PermissionsExt;
    let perms = std::fs::Permissions::from_mode(0o755);
    std::fs::set_permissions(path, perms)
        .map_err(|e| format!("could not mark {} executable: {e}", path.display()))
}

/// The sibling download path: `<exe>.new`, next to the current executable so
/// the final `rename` stays on the same filesystem (no cross-device copy).
fn sibling_path(exe: &Path) -> PathBuf {
    let mut name = exe.file_name().map(|n| n.to_owned()).unwrap_or_default();
    name.push(".new");
    exe.with_file_name(name)
}

/// Strip a single leading `v` from a release tag (`v1.2.3` -> `1.2.3`).
fn normalize_tag(tag: &str) -> &str {
    tag.strip_prefix('v').unwrap_or(tag)
}

/// Map an `(os, arch)` pair to its release asset name, matching `install.sh`.
///
/// Convention: `akurai-<os>-<arch>` where `<os>` is `linux` or `macos` and
/// `<arch>` is `x86_64` or `aarch64`. Returns `None` for anything unsupported.
fn asset_name(os: &str, arch: &str) -> Option<String> {
    let os = match os {
        "linux" => "linux",
        "macos" => "macos",
        _ => return None,
    };
    let arch = match arch {
        "x86_64" => "x86_64",
        "aarch64" => "aarch64",
        _ => return None,
    };
    Some(format!("akurai-{os}-{arch}"))
}

/// Compare two `a.b.c`-style versions numerically. Missing components count as
/// zero, so `0.1` and `0.1.0` are equal. Non-numeric components compare as 0.
fn compare_versions(a: &str, b: &str) -> Ordering {
    let parse = |s: &str| -> Vec<u64> {
        s.split('.')
            .map(|p| p.trim().parse::<u64>().unwrap_or(0))
            .collect()
    };
    let av = parse(a);
    let bv = parse(b);
    let len = av.len().max(bv.len());
    for i in 0..len {
        let x = av.get(i).copied().unwrap_or(0);
        let y = bv.get(i).copied().unwrap_or(0);
        match x.cmp(&y) {
            Ordering::Equal => continue,
            other => return other,
        }
    }
    Ordering::Equal
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Scratch base: `CARGO_TARGET_TMPDIR` when present, else this crate's own
    /// `target/` — never `/tmp`.
    fn scratch_base() -> PathBuf {
        match std::env::var_os("CARGO_TARGET_TMPDIR") {
            Some(dir) => PathBuf::from(dir),
            None => PathBuf::from(env!("CARGO_MANIFEST_DIR"))
                .join("target")
                .join("update-test-tmp"),
        }
    }

    #[test]
    fn compares_versions() {
        assert_eq!(compare_versions("0.0.1", "0.1.0"), Ordering::Less);
        assert_eq!(compare_versions("0.1.0", "0.0.1"), Ordering::Greater);
        assert_eq!(compare_versions("1.2.3", "1.2.3"), Ordering::Equal);
        assert_eq!(compare_versions("2.0.0", "1.9.9"), Ordering::Greater);
    }

    #[test]
    fn compares_ragged_lengths() {
        assert_eq!(compare_versions("0.1", "0.1.0"), Ordering::Equal);
        assert_eq!(compare_versions("0.1.0", "0.1"), Ordering::Equal);
        assert_eq!(compare_versions("1", "1.0.0"), Ordering::Equal);
        assert_eq!(compare_versions("0.1", "0.1.1"), Ordering::Less);
        assert_eq!(compare_versions("1.2", "1.1.9"), Ordering::Greater);
    }

    #[test]
    fn numeric_not_lexicographic() {
        // "10" must beat "9" — string comparison would get this wrong.
        assert_eq!(compare_versions("0.10.0", "0.9.0"), Ordering::Greater);
    }

    #[test]
    fn asset_names_for_supported_platforms() {
        assert_eq!(
            asset_name("linux", "x86_64").unwrap(),
            "akurai-linux-x86_64"
        );
        assert_eq!(
            asset_name("linux", "aarch64").unwrap(),
            "akurai-linux-aarch64"
        );
        assert_eq!(
            asset_name("macos", "x86_64").unwrap(),
            "akurai-macos-x86_64"
        );
        assert_eq!(
            asset_name("macos", "aarch64").unwrap(),
            "akurai-macos-aarch64"
        );
    }

    #[test]
    fn asset_name_rejects_unsupported() {
        assert_eq!(asset_name("windows", "x86_64"), None);
        assert_eq!(asset_name("linux", "riscv64"), None);
        assert_eq!(asset_name("freebsd", "arm"), None);
    }

    #[test]
    fn normalizes_tags() {
        assert_eq!(normalize_tag("v1.2.3"), "1.2.3");
        assert_eq!(normalize_tag("1.2.3"), "1.2.3");
        // Only one leading `v` is stripped.
        assert_eq!(normalize_tag("vv1.0"), "v1.0");
        assert_eq!(normalize_tag(""), "");
    }

    #[test]
    fn parses_tag_from_github_json() {
        let body = r#"{
            "url": "https://api.github.com/repos/olibuijr/AkurAI-Framework/releases/1",
            "tag_name": "v0.4.2",
            "name": "AkurAI 0.4.2",
            "draft": false,
            "prerelease": false
        }"#;
        assert_eq!(parse_latest_tag(body).unwrap(), "0.4.2");
    }

    #[test]
    fn parses_tag_without_v_prefix() {
        let body = r#"{"tag_name":"1.0.0"}"#;
        assert_eq!(parse_latest_tag(body).unwrap(), "1.0.0");
    }

    #[test]
    fn missing_tag_is_an_error() {
        let body = r#"{"message":"Not Found","status":"404"}"#;
        assert!(parse_latest_tag(body).is_err());
    }

    #[test]
    fn empty_tag_is_an_error() {
        let body = r#"{"tag_name":""}"#;
        assert!(parse_latest_tag(body).is_err());
    }

    #[test]
    fn sibling_path_is_next_to_exe() {
        let exe = PathBuf::from("/usr/local/bin/akurai");
        assert_eq!(
            sibling_path(&exe),
            PathBuf::from("/usr/local/bin/akurai.new")
        );
    }

    #[test]
    fn backup_path_is_dot_bak_next_to_exe() {
        let exe = PathBuf::from("/usr/local/bin/akurai");
        assert_eq!(
            backup_path(&exe),
            PathBuf::from("/usr/local/bin/akurai.bak")
        );
    }

    #[test]
    fn backup_then_rollback_restores_bytes() {
        // End-to-end of the safety net's file ops: a backup captures the
        // original bytes and rollback restores them after the "exe" is clobbered.
        let dir = scratch_base().join("update_backup_rollback");
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        let exe = dir.join("akurai");
        let bak = backup_path(&exe);
        std::fs::write(&exe, b"ORIGINAL").unwrap();

        backup_binary(&exe, &bak).expect("backup");
        // Simulate a bad swap: overwrite the live binary with garbage.
        std::fs::write(&exe, b"corrupt").unwrap();
        rollback(&bak, &exe).expect("rollback");

        assert_eq!(std::fs::read(&exe).unwrap(), b"ORIGINAL");
    }
}