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