Menu
Public repository / fe90d53c2211592322c0759894ee42ec692f741d
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")
);
}
}