fabro/lib/crates/fabro-cli/src/upgrade.rs
Bryan Helmkamp eca32f68f9 Fix upgrade command failing with "No such file or directory"
The release tarball nests the binary in a subdirectory
(fabro-{triple}/fabro), but the upgrade code expected it at the
tarball root. Use the correct nested path matching the tarball structure.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 15:58:50 -04:00

551 lines
19 KiB
Rust

use std::fs;
use std::io::{IsTerminal, Write};
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use semver::Version;
use sha2::{Digest, Sha256};
use tracing::debug;
// ── Clap args ──────────────────────────────────────────────────────────────
#[derive(clap::Args)]
pub struct UpgradeArgs {
/// Target version (e.g. "0.5.0" or "v0.5.0")
#[arg(long)]
version: Option<String>,
/// Upgrade even if already on the target version
#[arg(long)]
force: bool,
/// Preview what would happen without making changes
#[arg(long)]
dry_run: bool,
}
// ── Download backend abstraction ───────────────────────────────────────────
const GITHUB_REPO: &str = "fabro-sh/fabro";
enum Backend {
Gh,
Http(reqwest::Client),
}
fn http_client() -> Result<reqwest::Client> {
reqwest::Client::builder()
.user_agent("fabro-cli")
.build()
.context("failed to build HTTP client")
}
impl Backend {
async fn fetch_latest_release_tag(&self) -> Result<String> {
match self {
Backend::Gh => {
let output = tokio::process::Command::new("gh")
.args([
"release",
"view",
"--repo",
GITHUB_REPO,
"--json",
"tagName",
"-q",
".tagName",
])
.output()
.await
.context("failed to run `gh release view`")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("gh release view failed: {stderr}");
}
Ok(String::from_utf8(output.stdout)?.trim().to_string())
}
Backend::Http(client) => {
let url = format!("https://api.github.com/repos/{GITHUB_REPO}/releases/latest");
let resp = client
.get(&url)
.send()
.await
.context("failed to fetch latest release from GitHub API")?;
if !resp.status().is_success() {
bail!(
"GitHub API returned status {} when fetching latest release",
resp.status()
);
}
let json: serde_json::Value = resp.json().await?;
let tag = json["tag_name"]
.as_str()
.context("missing tag_name in GitHub API response")?;
Ok(tag.to_string())
}
}
}
async fn download_release(&self, tag: &str, asset: &str, dest_dir: &Path) -> Result<PathBuf> {
let dest = dest_dir.join(asset);
match self {
Backend::Gh => {
let status = tokio::process::Command::new("gh")
.args([
"release",
"download",
tag,
"--repo",
GITHUB_REPO,
"--pattern",
asset,
"--dir",
&dest_dir.to_string_lossy(),
"--clobber",
])
.status()
.await
.context("failed to run `gh release download`")?;
if !status.success() {
bail!("gh release download failed with exit code {status}");
}
}
Backend::Http(client) => {
let url =
format!("https://github.com/{GITHUB_REPO}/releases/download/{tag}/{asset}");
let resp = client
.get(&url)
.send()
.await
.with_context(|| format!("failed to download {url}"))?;
if !resp.status().is_success() {
bail!("download failed: HTTP {}", resp.status());
}
let bytes = resp.bytes().await?;
let mut file = fs::File::create(&dest)?;
file.write_all(&bytes)?;
}
}
Ok(dest)
}
}
async fn select_backend() -> Backend {
// Check if gh is available
let gh_version = tokio::process::Command::new("gh")
.arg("--version")
.output()
.await;
let Ok(output) = gh_version else {
debug!("gh CLI not found, using HTTP backend");
return Backend::Http(http_client().expect("failed to build HTTP client"));
};
if !output.status.success() {
debug!("gh --version failed, using HTTP backend");
return Backend::Http(http_client().expect("failed to build HTTP client"));
}
// Check if gh is authenticated
let auth_status = tokio::process::Command::new("gh")
.args(["auth", "status"])
.output()
.await;
match auth_status {
Ok(o) if o.status.success() => {
debug!("gh CLI available and authenticated, using Gh backend");
Backend::Gh
}
_ => {
debug!("gh not authenticated, using HTTP backend");
Backend::Http(http_client().expect("failed to build HTTP client"))
}
}
}
// ── Platform detection ─────────────────────────────────────────────────────
fn detect_target() -> Result<&'static str> {
match (std::env::consts::OS, std::env::consts::ARCH) {
("macos", "aarch64") => Ok("aarch64-apple-darwin"),
("linux", "x86_64") => Ok("x86_64-unknown-linux-gnu"),
(os, arch) => bail!("unsupported platform: {os}/{arch}"),
}
}
// ── Version helpers ────────────────────────────────────────────────────────
fn parse_version_from_tag(tag: &str) -> Result<Version> {
let stripped = tag.strip_prefix('v').unwrap_or(tag);
Version::parse(stripped).with_context(|| format!("invalid version: {tag}"))
}
// ── SHA256 verification ────────────────────────────────────────────────────
fn verify_checksum(path: &Path, expected_hex: &str) -> Result<()> {
let mut hasher = Sha256::new();
let mut file = std::io::BufReader::new(
fs::File::open(path).with_context(|| format!("failed to open {}", path.display()))?,
);
std::io::copy(&mut file, &mut hasher)?;
let computed = format!("{:x}", hasher.finalize());
// The .sha256 file may contain "hash filename" or just "hash"
let expected = expected_hex
.split_whitespace()
.next()
.unwrap_or(expected_hex)
.to_lowercase();
if computed != expected {
bail!("SHA256 mismatch: expected {expected}, got {computed}");
}
Ok(())
}
// ── Upgrade check state ────────────────────────────────────────────────────
const CHECK_INTERVAL_SECS: u64 = 86400; // 24 hours
const LAST_CHECK_FILE: &str = "last_upgrade_check.json";
#[derive(serde::Serialize, serde::Deserialize)]
struct UpgradeCheckState {
checked_at: u64,
latest_version: String,
}
impl UpgradeCheckState {
fn is_stale(&self) -> bool {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
now.saturating_sub(self.checked_at) >= CHECK_INTERVAL_SECS
}
fn load(path: &Path) -> Option<Self> {
let data = fs::read_to_string(path).ok()?;
serde_json::from_str(&data).ok()
}
fn save(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let json = serde_json::to_string(self)?;
fs::write(path, json)?;
Ok(())
}
}
// ── Main upgrade command ───────────────────────────────────────────────────
pub async fn run_upgrade(args: UpgradeArgs) -> Result<()> {
let backend = select_backend().await;
let current =
Version::parse(env!("CARGO_PKG_VERSION")).context("failed to parse current version")?;
// Determine target version
let (target, tag) = if let Some(ref v) = args.version {
let version = parse_version_from_tag(v)?;
let tag = format!("v{version}");
(version, tag)
} else {
let tag = backend.fetch_latest_release_tag().await?;
let version = parse_version_from_tag(&tag)?;
(version, tag)
};
// Downgrade protection
match target.cmp(&current) {
std::cmp::Ordering::Less => {
if args.version.is_none() {
bail!(
"latest release ({target}) is older than installed version ({current}), skipping"
);
}
// Explicit --version: warn + prompt
eprintln!("Warning: downgrading from {current} to {target}");
if std::io::stdin().is_terminal() {
let confirm = dialoguer::Confirm::new()
.with_prompt("Continue with downgrade?")
.default(false)
.interact()?;
if !confirm {
bail!("downgrade cancelled");
}
} else {
bail!("downgrade requires interactive confirmation (stdin is not a tty)");
}
}
std::cmp::Ordering::Equal if !args.force => {
eprintln!("Already on version {current}");
return Ok(());
}
_ => {}
}
if args.dry_run {
eprintln!("Would upgrade fabro from {current} to {target}");
eprintln!(" tag: {tag}");
eprintln!(" target: {}", detect_target()?);
return Ok(());
}
let triple = detect_target()?;
let tarball_name = format!("fabro-{triple}.tar.gz");
let checksum_name = format!("{tarball_name}.sha256");
let current_exe = std::env::current_exe()?.canonicalize()?;
let exe_dir = current_exe
.parent()
.context("could not determine executable directory")?;
let tmp_dir = tempfile::tempdir_in(exe_dir)
.or_else(|_| tempfile::tempdir())
.context("failed to create temp directory")?;
// Download tarball and checksum in parallel
eprintln!("Downloading fabro {target}...");
let (tarball_path, checksum_path) = tokio::try_join!(
backend.download_release(&tag, &tarball_name, tmp_dir.path()),
backend.download_release(&tag, &checksum_name, tmp_dir.path()),
)?;
// Verify SHA256 using streaming hash
let checksum_content = fs::read_to_string(&checksum_path)?;
verify_checksum(&tarball_path, &checksum_content)?;
debug!("SHA256 checksum verified");
// Extract tarball
let status = std::process::Command::new("tar")
.args([
"xzf",
&tarball_path.to_string_lossy(),
"-C",
&tmp_dir.path().to_string_lossy(),
])
.status()
.context("failed to run tar")?;
if !status.success() {
bail!("tar extraction failed");
}
// Atomic binary replacement
let extracted_binary = tmp_dir.path().join(format!("fabro-{triple}")).join("fabro");
let backup = exe_dir.join(".fabro-upgrade-backup");
fs::rename(&current_exe, &backup).context("failed to move current binary to backup")?;
if let Err(e) = fs::rename(&extracted_binary, &current_exe) {
// Restore from backup
if let Err(restore_err) = fs::rename(&backup, &current_exe) {
bail!(
"failed to install new binary ({e}) and failed to restore backup ({restore_err})"
);
}
bail!("failed to install new binary: {e}");
}
let _ = fs::remove_file(&backup);
// Set permissions
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(&current_exe, fs::Permissions::from_mode(0o755));
}
eprintln!("Upgraded fabro to {target}");
Ok(())
}
// ── Auto version check ────────────────────────────────────────────────────
/// Spawn a background task that checks for a newer version and prints a notice
/// to stderr after the main command completes. Returns a handle that should be
/// awaited at the end of `main_inner`.
pub fn spawn_upgrade_check(
no_upgrade_check: bool,
upgrade_check_enabled: bool,
) -> Option<tokio::task::JoinHandle<()>> {
if no_upgrade_check || !upgrade_check_enabled {
return None;
}
Some(tokio::spawn(async {
if let Err(e) = check_and_print_notice().await {
debug!(%e, "Upgrade check failed (silently swallowed)");
}
}))
}
async fn check_and_print_notice() -> Result<()> {
let Some(home) = dirs::home_dir() else {
return Ok(());
};
let state_path = home.join(".fabro").join(LAST_CHECK_FILE);
let current = Version::parse(env!("CARGO_PKG_VERSION"))?;
// Check cached state first
if let Some(state) = UpgradeCheckState::load(&state_path) {
if !state.is_stale() {
if let Ok(latest) = Version::parse(&state.latest_version) {
if latest > current {
print_notice(&current, &latest);
}
}
return Ok(());
}
}
// Fetch latest version
let backend = select_backend().await;
let tag = backend.fetch_latest_release_tag().await?;
let latest = parse_version_from_tag(&tag)?;
// Save state
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let state = UpgradeCheckState {
checked_at: now,
latest_version: latest.to_string(),
};
let _ = state.save(&state_path);
if latest > current {
print_notice(&current, &latest);
}
Ok(())
}
fn print_notice(current: &Version, latest: &Version) {
eprintln!("A new version of fabro is available: {latest} (current: {current})");
eprintln!("Run `fabro upgrade` to update.");
}
// ── Tests ──────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
// -- Platform detection --
#[test]
fn detect_target_returns_known_triple() {
let result = detect_target();
// We can only assert it succeeds on known CI platforms
if cfg!(target_os = "linux") && cfg!(target_arch = "x86_64") {
assert_eq!(result.unwrap(), "x86_64-unknown-linux-gnu");
} else if cfg!(target_os = "macos") && cfg!(target_arch = "aarch64") {
assert_eq!(result.unwrap(), "aarch64-apple-darwin");
}
// On other platforms it would return an error, which is fine
}
// -- Version parsing --
#[test]
fn parse_version_from_tag_with_v_prefix() {
let v = parse_version_from_tag("v0.5.0").unwrap();
assert_eq!(v, Version::new(0, 5, 0));
}
#[test]
fn parse_version_from_tag_without_prefix() {
let v = parse_version_from_tag("0.5.0").unwrap();
assert_eq!(v, Version::new(0, 5, 0));
}
#[test]
fn parse_version_from_tag_invalid() {
assert!(parse_version_from_tag("not-a-version").is_err());
}
// -- SHA256 verification --
#[test]
fn verify_checksum_valid() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.bin");
fs::write(&path, b"hello world").unwrap();
let expected = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9";
assert!(verify_checksum(&path, expected).is_ok());
}
#[test]
fn verify_checksum_with_filename_suffix() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.bin");
fs::write(&path, b"hello world").unwrap();
let expected =
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9 fabro.tar.gz";
assert!(verify_checksum(&path, expected).is_ok());
}
#[test]
fn verify_checksum_mismatch() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.bin");
fs::write(&path, b"hello world").unwrap();
let wrong = "0000000000000000000000000000000000000000000000000000000000000000";
assert!(verify_checksum(&path, wrong).is_err());
}
// -- Upgrade check state --
#[test]
fn upgrade_check_state_roundtrip() {
let state = UpgradeCheckState {
checked_at: 1710000000,
latest_version: "0.5.0".to_string(),
};
let json = serde_json::to_string(&state).unwrap();
let parsed: UpgradeCheckState = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.checked_at, 1710000000);
assert_eq!(parsed.latest_version, "0.5.0");
}
#[test]
fn upgrade_check_state_stale() {
let old = UpgradeCheckState {
checked_at: 0, // epoch — definitely stale
latest_version: "0.1.0".to_string(),
};
assert!(old.is_stale());
}
#[test]
fn upgrade_check_state_fresh() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let fresh = UpgradeCheckState {
checked_at: now,
latest_version: "0.5.0".to_string(),
};
assert!(!fresh.is_stale());
}
#[test]
fn upgrade_check_state_save_and_load() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("state.json");
let state = UpgradeCheckState {
checked_at: 1710000000,
latest_version: "0.5.0".to_string(),
};
state.save(&path).unwrap();
let loaded = UpgradeCheckState::load(&path).unwrap();
assert_eq!(loaded.checked_at, 1710000000);
assert_eq!(loaded.latest_version, "0.5.0");
}
// -- Backend selection --
#[tokio::test]
async fn select_backend_returns_a_variant() {
// Just ensure it doesn't panic; actual variant depends on environment
let _backend = select_backend().await;
}
}