AkurAI Build
Menu

Public repository / d2de65b4934c81cfa0049cf6b956720e001fe1ec

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

Latest change 802bc0c4c24b7a3a3474557988d3e930b2422c3f - feat(cli): add 'akurai update' self-update subcommand 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";

/// 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 platform asset and atomically replace the running binary.
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 url = format!("https://github.com/{REPO}/releases/latest/download/{asset}");
    download(&url, &new_path)?;

    // Mark the freshly downloaded file executable before swapping it in.
    set_executable(&new_path)?;

    // Atomic swap: rename over the running binary. The live process keeps its
    // original inode, so this is safe while we're still executing.
    std::fs::rename(&new_path, &exe).map_err(|e| {
        // Leave the old binary untouched; clean up the partial download.
        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`) or reinstall via install.sh",
                exe.display()
            )
        } else {
            format!("failed to replace {}: {e}", exe.display())
        }
    })?;

    println!("akurai: updated {current} -> {latest}");
    Ok(())
}

/// 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::*;

    #[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")
        );
    }
}