use anyhow::Result;
use super::style::*;
use crate::{
cli::{UpdateArgs, UpdateCommand},
config,
};
pub async fn cmd_update(sub: UpdateCommand) -> Result<()> {
match sub {
UpdateCommand::Run(args) => do_update(&args).await?,
UpdateCommand::Status => update_status().await?,
UpdateCommand::Wizard => {
banner(&format!(
"rsclaw update wizard v{}",
option_env!("RSCLAW_BUILD_VERSION").unwrap_or("dev")
));
warn_msg("update wizard: not yet implemented");
println!(" {}", dim("use `rsclaw update run` for now"));
}
}
Ok(())
}
fn version_cmp(a: &str, b: &str) -> std::cmp::Ordering {
let parse = |s: &str| {
s.split('.')
.map(|p| p.parse::<u64>().unwrap_or(0))
.collect::<Vec<u64>>()
};
parse(a).cmp(&parse(b))
}
const RSCLAW_VERSION_URL: &str = "https://app.rsclaw.ai/api/version";
fn proxy_url(url: &str) -> String {
if let Ok(proxy) = std::env::var("GITHUB_PROXY") {
let proxy = proxy.trim_end_matches('/');
if !proxy.is_empty() {
return format!("{}/{}", proxy, url);
}
}
url.to_owned()
}
fn build_update_client(timeout_secs: u64) -> Result<reqwest::Client> {
Ok(reqwest::Client::builder()
.user_agent("rsclaw/dev")
.timeout(std::time::Duration::from_secs(timeout_secs))
.build()?)
}
async fn do_update(args: &UpdateArgs) -> Result<()> {
let quiet = args.json;
if !quiet {
banner(&format!(
"rsclaw update v{}",
option_env!("RSCLAW_BUILD_VERSION").unwrap_or("dev")
));
}
let timeout_secs = args.timeout.unwrap_or(30);
let client = build_update_client(timeout_secs)?;
if let Ok(current_exe) = std::env::current_exe() {
let stale_backup = current_exe.with_extension("old");
if stale_backup.exists() {
let _ = std::fs::remove_file(&stale_backup);
}
}
if !quiet {
println!(" {} checking for updates...", dim("[..]"));
}
let release: serde_json::Value = {
let mut data = None;
let sources = [
RSCLAW_VERSION_URL.to_owned(),
proxy_url(&format!(
"https://api.github.com/repos/{}/releases?per_page=10",
"rsclaw-ai/rsclaw"
)),
];
for url in &sources {
if let Ok(resp) = client.get(url).send().await {
if resp.status().is_success() {
let body = resp.bytes().await.unwrap_or_default();
if let Some(found) = parse_release_body(&body) {
if found["assets"].is_array() {
data = Some(found);
break;
}
}
}
}
}
data.unwrap_or_default()
};
let latest_version = release["tag_name"]
.as_str()
.unwrap_or("")
.trim_start_matches('v')
.to_owned();
let current = option_env!("RSCLAW_BUILD_VERSION").unwrap_or("dev");
if !quiet {
kv("Current:", current);
kv("Latest:", &latest_version);
}
if latest_version.is_empty() {
if quiet {
println!(
"{}",
serde_json::json!({
"currentVersion": current,
"status": "version-check-failed",
})
);
} else {
println!(" {} could not determine latest version", yellow("[!]"));
}
return Ok(());
}
if version_cmp(&latest_version, current) != std::cmp::Ordering::Greater {
if quiet {
println!(
"{}",
serde_json::json!({
"currentVersion": current,
"latestVersion": latest_version,
"updateAvailable": false,
"status": "up-to-date",
})
);
} else {
println!(" {} already up to date", green("[ok]"));
}
return Ok(());
}
if args.dry_run {
if quiet {
println!(
"{}",
serde_json::json!({
"currentVersion": current,
"latestVersion": latest_version,
"updateAvailable": true,
"dryRun": true,
"status": "dry-run",
})
);
} else {
println!(
" {} would update to {latest_version} (dry run)",
dim("[..]")
);
}
return Ok(());
}
let (os, arch) = (std::env::consts::OS, std::env::consts::ARCH);
let candidates: Vec<&str> = match (os, arch) {
("macos", "aarch64") => vec!["aarch64-apple-darwin"],
("macos", "x86_64") => vec!["x86_64-apple-darwin"],
("linux", "x86_64") => vec!["x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl"],
("linux", "aarch64") => vec!["aarch64-unknown-linux-gnu", "aarch64-unknown-linux-musl"],
("windows", "x86_64") => vec!["x86_64-pc-windows-msvc"],
("windows", "aarch64") => vec!["aarch64-pc-windows-msvc"],
_ => anyhow::bail!("unsupported platform: {os}-{arch}"),
};
let assets = release["assets"].as_array();
let mut asset_name = candidates[0];
let download_url = assets.and_then(|arr| {
for candidate in &candidates {
if let Some(url) = arr.iter().find_map(|a| {
let name = a["name"].as_str().unwrap_or("");
if name.contains(candidate) {
a["browser_download_url"].as_str().map(|s| s.to_owned())
} else {
None
}
}) {
asset_name = candidate;
return Some(url);
}
}
None
});
let Some(url) = download_url else {
if quiet {
println!(
"{}",
serde_json::json!({
"currentVersion": current,
"latestVersion": latest_version,
"updateAvailable": true,
"status": "no-prebuilt-binary",
"platform": format!("{os}-{arch}"),
"fromSource": "cd /path/to/rsclaw && git pull && cargo build --release",
})
);
} else {
println!(" {} no pre-built binary for {os}-{arch}", yellow("[!]"));
println!(" Update from source:");
println!(" cd /path/to/rsclaw && git pull && cargo build --release");
}
return Ok(());
};
if !quiet {
println!(" {} downloading {asset_name}...", dim("[..]"));
}
let download = if url.contains("github.com") || url.contains("githubusercontent.com") {
proxy_url(&url)
} else {
url.clone()
};
let downloaded = client.get(&download).send().await?.bytes().await?;
if downloaded.is_empty() {
anyhow::bail!("downloaded binary is empty");
}
let sum_url = release["assets"].as_array().and_then(|arr| {
arr.iter().find_map(|a| {
let name = a["name"].as_str().unwrap_or("");
if name.eq_ignore_ascii_case("SHA256SUMS.txt")
|| name.eq_ignore_ascii_case("SHA256SUMS")
{
a["browser_download_url"].as_str().map(|s| s.to_owned())
} else {
None
}
})
});
if let Some(su) = sum_url {
let su_dl = if su.contains("github.com") || su.contains("githubusercontent.com") {
proxy_url(&su)
} else {
su.clone()
};
let sums = client
.get(&su_dl)
.send()
.await?
.text()
.await
.unwrap_or_default();
let asset_filename = url.rsplit('/').next().unwrap_or("");
let expected = sums.lines().find_map(|line| {
let mut parts = line.split_whitespace();
let hex = parts.next()?;
let name = parts.next()?;
let name = name.trim_start_matches('*').trim_start_matches("./");
if name == asset_filename {
Some(hex.to_lowercase())
} else {
None
}
});
match expected {
Some(expected_hex) => {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(&downloaded);
let actual_hex = format!("{:x}", hasher.finalize());
if actual_hex != expected_hex {
anyhow::bail!(
"SHA256 mismatch for {asset_filename}: expected {expected_hex}, got {actual_hex}. Aborting update."
);
}
if !quiet {
println!(" {} SHA256 verified", green("[ok]"));
}
}
None => {
if !quiet {
println!(
" {} no SHA256 entry for {asset_filename} in SHA256SUMS — proceeding without verify",
yellow("[!]")
);
}
}
}
} else if !quiet {
println!(
" {} release has no SHA256SUMS asset — proceeding without verify",
yellow("[!]")
);
}
let binary_name = if std::env::consts::OS == "windows" {
"rsclaw.exe"
} else {
"rsclaw"
};
let url_lower = download.to_lowercase();
let looks_archived = url_lower.ends_with(".tar.gz")
|| url_lower.ends_with(".tgz")
|| url_lower.ends_with(".zip");
let binary = if looks_archived {
extract_binary(&downloaded, binary_name, &download)?
} else {
downloaded.to_vec()
};
let current_exe = std::env::current_exe()?;
let new_path = current_exe.with_extension("new");
let backup = current_exe.with_extension("old");
{
use std::io::Write;
let mut f = std::fs::File::create(&new_path)?;
f.write_all(&binary)?;
f.sync_all()?;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&new_path, std::fs::Permissions::from_mode(0o755))?;
}
if backup.exists() {
let _ = std::fs::remove_file(&backup);
}
std::fs::rename(¤t_exe, &backup)?;
if let Err(e) = std::fs::rename(&new_path, ¤t_exe) {
let _ = std::fs::rename(&backup, ¤t_exe);
let _ = std::fs::remove_file(&new_path);
anyhow::bail!("update: atomic swap failed: {e}");
}
if !quiet {
println!(" {} updated to {latest_version}", green("[ok]"));
kv("Binary:", ¤t_exe.display().to_string());
kv("Backup:", &backup.display().to_string());
}
if !args.no_restart {
let pid_file = config::loader::pid_file();
if pid_file.exists() {
if !quiet {
println!(" {} restarting gateway...", dim("[..]"));
}
if let Ok(pid_str) = std::fs::read_to_string(&pid_file) {
if let Ok(pid) = pid_str.trim().parse::<i32>() {
let _ = crate::sys::process_terminate(pid as u32);
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
#[allow(unused_mut)]
let mut upd = std::process::Command::new(¤t_exe);
upd.arg("gateway").arg("start");
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
upd.creation_flags(0x08000000);
}
let _ = upd.spawn();
if !quiet {
println!(" {} gateway restarted", green("[ok]"));
}
}
}
}
}
if quiet {
println!(
"{}",
serde_json::json!({
"currentVersion": current,
"latestVersion": latest_version,
"updateAvailable": true,
"status": "updated",
"binary": current_exe.display().to_string(),
"backup": backup.display().to_string(),
})
);
}
if !quiet {
println!();
}
Ok(())
}
fn parse_release_body(body: &[u8]) -> Option<serde_json::Value> {
if let Ok(arr) = serde_json::from_slice::<Vec<serde_json::Value>>(body) {
return arr.into_iter().find(|r| {
r["tag_name"]
.as_str()
.is_some_and(|t| t.starts_with('v') && !t.starts_with("app-"))
});
}
if let Ok(obj) = serde_json::from_slice::<serde_json::Value>(body) {
if obj["tag_name"]
.as_str()
.is_some_and(|t| t.starts_with('v') && !t.starts_with("app-"))
{
return Some(obj);
}
}
None
}
fn extract_binary(data: &[u8], filename: &str, url: &str) -> Result<Vec<u8>> {
let url_lower = url.to_lowercase();
if url_lower.ends_with(".tar.gz") || url_lower.ends_with(".tgz") {
let tar = flate2::read::GzDecoder::new(data);
let mut archive = tar::Archive::new(tar);
for entry in archive.entries()? {
let mut entry = entry?;
let path = entry.path()?;
if path.file_name().map(|n| n == filename).unwrap_or(false) {
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut entry, &mut buf)?;
return Ok(buf);
}
}
anyhow::bail!("{filename} not found in tar.gz archive");
}
if url_lower.ends_with(".zip") {
let reader = std::io::Cursor::new(data);
let mut archive = zip::ZipArchive::new(reader)?;
for i in 0..archive.len() {
let mut file = archive.by_index(i)?;
let name = file.name();
if std::path::Path::new(name)
.file_name()
.map(|n| n == filename)
.unwrap_or(false)
{
let mut buf = Vec::new();
std::io::copy(&mut file, &mut buf)?;
return Ok(buf);
}
}
anyhow::bail!("{filename} not found in zip archive");
}
anyhow::bail!("unsupported archive format: {url}");
}
async fn update_status() -> Result<()> {
banner(&format!(
"rsclaw update status v{}",
option_env!("RSCLAW_BUILD_VERSION").unwrap_or("dev")
));
let client = build_update_client(10)?;
kv(
"Current:",
option_env!("RSCLAW_BUILD_VERSION").unwrap_or("dev"),
);
let mut latest_tag: Option<String> = None;
let sources = [
RSCLAW_VERSION_URL.to_owned(),
proxy_url(&format!(
"https://api.github.com/repos/{}/releases?per_page=10",
"rsclaw-ai/rsclaw"
)),
];
for url in &sources {
if let Ok(resp) = client.get(url).send().await {
if resp.status().is_success() {
let body = resp.bytes().await.unwrap_or_default();
if let Some(release) = parse_release_body(&body) {
if let Some(tag) = release["tag_name"].as_str() {
if tag.starts_with('v') && !tag.starts_with("app-") {
latest_tag = Some(tag.to_owned());
break;
}
}
}
}
}
}
match latest_tag {
Some(tag) => {
let latest = tag.trim_start_matches('v');
kv("Latest:", latest);
let current = option_env!("RSCLAW_BUILD_VERSION").unwrap_or("dev");
if version_cmp(latest, current) == std::cmp::Ordering::Greater {
println!(" {} update available: {latest}", yellow("[!]"));
println!(" Run: rsclaw update");
} else {
println!(" {} up to date", green("[ok]"));
}
}
None => {
println!(" {} could not check for updates", yellow("[!]"));
}
}
println!();
Ok(())
}
#[cfg(test)]
mod tests {
use std::cmp::Ordering;
use super::version_cmp;
#[test]
fn newer_release_is_greater() {
assert_eq!(version_cmp("2026.5.20", "2026.5.18"), Ordering::Greater);
}
#[test]
fn segments_compare_numerically_not_lexically() {
assert_eq!(version_cmp("2026.5.10", "2026.5.9"), Ordering::Greater);
}
#[test]
fn equal_versions_are_equal() {
assert_eq!(version_cmp("2026.5.18", "2026.5.18"), Ordering::Equal);
}
#[test]
fn local_build_ahead_of_release_is_less_so_no_downgrade() {
assert_eq!(version_cmp("2026.5.18", "2026.5.20"), Ordering::Less);
}
#[test]
fn dev_build_is_behind_any_release() {
assert_eq!(version_cmp("dev", "2026.5.18"), Ordering::Less);
}
}