ref:67d3a41c689b9b1742e0371ffb8f50423168f023

feat(cli): anvil update — self-update via the runner-download endpoints

Adds \`anvil update [--check] [--force] [--no-verify]\`. It reads the configured server URL, queries /runner/version, and on mismatch downloads /runner/download?os=&arch= using the same release artifacts the install script uses. The downloaded bytes are verified against /runner/checksums (a standard SHA256SUMS file) before being swapped in. Replacement is an atomic rename onto the current executable path, which works on Linux while the binary is running (the kernel keeps the old inode alive for the running process). The platform-mapping, checksum-line parser, and SHA256 verification are unit-tested. Binary swap and HTTP paths are not — they require a running server. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SHA: 67d3a41c689b9b1742e0371ffb8f50423168f023
Author: Cole Christensen <cole.christensen@gmail.com>
Date: 2026-05-29 02:12
Parents: 27f01c2
2 files changed +268 -0
Type
src/commands/mod.rs +4 −0
@@ -15,5 +15,6 @@
pub mod requirement;
pub mod runner;
pub mod ssh_key;
pub mod update;
use clap::{Parser, Subcommand};
@@ -71,6 +72,8 @@
Board(board::BoardArgs),
/// Milestone operations
Milestone(milestone::MilestoneArgs),
/// Replace this binary with the latest release from the server
Update(update::UpdateArgs),
}
pub async fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
@@ -96,5 +99,6 @@
Command::SshKey(args) => ssh_key::run(args).await,
Command::Board(args) => board::run(args).await,
Command::Milestone(args) => milestone::run(args).await,
Command::Update(args) => update::run(args).await,
}
}
src/commands/update.rs +264 −0
@@ -1,0 +1,264 @@
use crate::config::Config;
use crate::output;
use clap::Args;
use sha2::{Digest, Sha256};
use std::io::Write;
const VERSION: &str = include_str!(concat!(env!("OUT_DIR"), "/version.txt"));
#[derive(Args)]
pub struct UpdateArgs {
/// Check for an update without installing.
#[arg(long)]
pub check: bool,
/// Reinstall even if the current version matches the latest.
#[arg(long)]
pub force: bool,
/// Skip SHA256 verification of the downloaded binary (not recommended).
#[arg(long)]
pub no_verify: bool,
}
pub async fn run(args: UpdateArgs) -> Result<(), Box<dyn std::error::Error>> {
let config = Config::load()?;
let server = config.server_url()?.trim_end_matches('/').to_string();
let current = current_version();
let latest: VersionResponse = reqwest::get(format!("{server}/runner/version"))
.await?
.json()
.await?;
output::detail("Current", &current);
output::detail("Latest", &latest.version);
if !args.force && current == latest.version {
output::success("Already up to date.");
return Ok(());
}
if args.check {
output::info(&format!("Update available: {current} → {}", latest.version));
return Ok(());
}
let (os, arch) = detect_platform()?;
let target = platform_target(os, arch)?;
output::info(&format!("Downloading anvil for {os}/{arch}..."));
let bytes = download_binary(&server, os, arch).await?;
if !args.no_verify {
output::info("Verifying SHA256...");
let expected = fetch_expected_checksum(&server, target, &latest.version).await?;
verify_sha256(&bytes, &expected)?;
}
let exe_path = std::env::current_exe()?;
swap_executable(&exe_path, &bytes)?;
output::success(&format!(
"Updated to {} at {}",
latest.version,
exe_path.display()
));
Ok(())
}
#[derive(Debug, serde::Deserialize)]
struct VersionResponse {
version: String,
#[allow(dead_code)]
platforms: Option<Vec<String>>,
}
fn current_version() -> String {
VERSION.trim().to_string()
}
/// Map Rust's compile-time os/arch constants to the values the server's
/// `/runner/download` endpoint expects (which are derived from `uname -s` /
/// `uname -m` on the host).
fn detect_platform() -> Result<(&'static str, &'static str), Box<dyn std::error::Error>> {
let os = match std::env::consts::OS {
"linux" => "Linux",
"macos" => "Darwin",
other => return Err(format!("unsupported OS: {other}").into()),
};
let arch = match std::env::consts::ARCH {
"x86_64" => "x86_64",
"aarch64" => "aarch64",
other => return Err(format!("unsupported architecture: {other}").into()),
};
Ok((os, arch))
}
/// Server's release-asset naming convention: `anvil_{target}_{version}` where
/// target is one of `linux_amd64`, `linux_arm64`, `macos_amd64`, `macos_arm64`.
fn platform_target(os: &str, arch: &str) -> Result<&'static str, Box<dyn std::error::Error>> {
match (os, arch) {
("Linux", "x86_64") => Ok("linux_amd64"),
("Linux", "aarch64") => Ok("linux_arm64"),
("Darwin", "x86_64") => Ok("macos_amd64"),
("Darwin", "aarch64") => Ok("macos_arm64"),
_ => Err(format!("unsupported platform: {os}/{arch}").into()),
}
}
async fn download_binary(
server: &str,
os: &str,
arch: &str,
) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let resp = reqwest::get(format!("{server}/runner/download?os={os}&arch={arch}")).await?;
if !resp.status().is_success() {
return Err(format!("download failed: HTTP {}", resp.status()).into());
}
Ok(resp.bytes().await?.to_vec())
}
async fn fetch_expected_checksum(
server: &str,
target: &str,
version: &str,
) -> Result<String, Box<dyn std::error::Error>> {
let body = reqwest::get(format!("{server}/runner/checksums"))
.await?
.error_for_status()
.map_err(|e| format!("checksums fetch failed: {e}"))?
.text()
.await?;
let filename = format!("anvil_{target}_{version}");
parse_checksum_line(&body, &filename)
.ok_or_else(|| format!("no SHA256 entry for {filename} in checksums file").into())
}
/// Parse a single SHA256SUMS entry. Lines look like `<hash> <filename>` —
/// standard `sha256sum` output. Returns the hash for the matching filename.
fn parse_checksum_line(body: &str, filename: &str) -> Option<String> {
for line in body.lines() {
let mut parts = line.split_whitespace();
let hash = parts.next()?;
let name = parts.next()?;
if name == filename {
return Some(hash.to_string());
}
}
None
}
fn verify_sha256(bytes: &[u8], expected_hex: &str) -> Result<(), Box<dyn std::error::Error>> {
let mut hasher = Sha256::new();
hasher.update(bytes);
let got = format!("{:x}", hasher.finalize());
if got.eq_ignore_ascii_case(expected_hex) {
Ok(())
} else {
Err(format!("SHA256 mismatch: expected {expected_hex}, got {got}").into())
}
}
/// Write the new binary to a sibling temp path, chmod +x, then atomically
/// rename over the current executable. On Linux, replacing a running
/// executable works: the kernel keeps the old inode alive for the running
/// process while new invocations pick up the new file.
fn swap_executable(
exe_path: &std::path::Path,
bytes: &[u8],
) -> Result<(), Box<dyn std::error::Error>> {
let parent = exe_path
.parent()
.ok_or("could not determine install directory")?;
let file_name = exe_path
.file_name()
.ok_or("could not determine executable name")?
.to_string_lossy()
.to_string();
let tmp_path = parent.join(format!(".{file_name}.update"));
{
let mut f = std::fs::File::create(&tmp_path).map_err(|e| {
format!(
"could not write to {} — try running with sudo or reinstall via the package manager: {e}",
tmp_path.display()
)
})?;
f.write_all(bytes)?;
f.sync_all()?;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&tmp_path)?.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&tmp_path, perms)?;
}
std::fs::rename(&tmp_path, exe_path).map_err(|e| {
// Best-effort cleanup; if rename failed, the temp file is left around.
let _ = std::fs::remove_file(&tmp_path);
format!("could not replace {}: {e}", exe_path.display())
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_checksum_line_matches_filename() {
let body = "abc123 anvil_linux_amd64_2026.05.6\n\
deadbeef anvil_linux_arm64_2026.05.6\n\
cafebabe SHA256SUMS_2026.05.6\n";
assert_eq!(
parse_checksum_line(body, "anvil_linux_arm64_2026.05.6").as_deref(),
Some("deadbeef")
);
}
#[test]
fn parse_checksum_line_missing_returns_none() {
let body = "abc anvil_linux_amd64_1.0.0\n";
assert!(parse_checksum_line(body, "anvil_linux_arm64_1.0.0").is_none());
}
#[test]
fn parse_checksum_tolerates_extra_whitespace() {
let body = " abc123 anvil_linux_amd64_1.0.0 \n";
assert_eq!(
parse_checksum_line(body, "anvil_linux_amd64_1.0.0").as_deref(),
Some("abc123")
);
}
#[test]
fn platform_target_maps_known_platforms() {
assert_eq!(platform_target("Linux", "x86_64").unwrap(), "linux_amd64");
assert_eq!(platform_target("Linux", "aarch64").unwrap(), "linux_arm64");
assert_eq!(platform_target("Darwin", "x86_64").unwrap(), "macos_amd64");
assert_eq!(platform_target("Darwin", "aarch64").unwrap(), "macos_arm64");
}
#[test]
fn platform_target_rejects_unknown() {
assert!(platform_target("BeOS", "x86_64").is_err());
assert!(platform_target("Linux", "riscv64").is_err());
}
#[test]
fn verify_sha256_accepts_correct_hash() {
let bytes = b"hello world";
let expected = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9";
assert!(verify_sha256(bytes, expected).is_ok());
assert!(verify_sha256(bytes, &expected.to_uppercase()).is_ok());
}
#[test]
fn verify_sha256_rejects_wrong_hash() {
let err = verify_sha256(b"hello", "deadbeef").unwrap_err().to_string();
assert!(err.contains("SHA256 mismatch"));
}
}