diff --git a/Cargo.lock b/Cargo.lock index 271c51f86..d8942db13 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1278,6 +1278,7 @@ dependencies = [ "semver", "serde", "serde_json", + "sha2", "tempfile", "tokio", "toml", diff --git a/lib/crates/fabro-cli/Cargo.toml b/lib/crates/fabro-cli/Cargo.toml index a855fd69e..03d5597f7 100644 --- a/lib/crates/fabro-cli/Cargo.toml +++ b/lib/crates/fabro-cli/Cargo.toml @@ -55,6 +55,8 @@ dialoguer.workspace = true axum = "0.8" open = "5" serde_json.workspace = true +tempfile = "3" +sha2.workspace = true [target.'cfg(unix)'.dependencies] libc = "0.2" @@ -66,7 +68,6 @@ chrono = { workspace = true } assert_cmd = "2" insta = { workspace = true } predicates = "3" -tempfile = "3" serde_json.workspace = true httpmock = "0.8" trycmd = "0.15" diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index 8fff95800..6d209ca17 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -4,6 +4,7 @@ mod init; mod install; mod logging; mod skill; +mod upgrade; use std::path::PathBuf; @@ -27,6 +28,10 @@ struct Cli { #[arg(long, global = true)] debug: bool, + /// Disable automatic upgrade check + #[arg(long, global = true)] + no_upgrade_check: bool, + /// Execution mode: standalone (in-process) or server (delegate to API) #[cfg(feature = "server")] #[arg(long, global = true, value_parser = parse_execution_mode)] @@ -136,6 +141,8 @@ enum Command { #[command(subcommand)] command: WorkflowCommand, }, + /// Upgrade fabro to the latest version + Upgrade(upgrade::UpgradeArgs), /// System maintenance commands System { #[command(subcommand)] @@ -436,6 +443,7 @@ async fn main_inner() -> (String, Result<()>) { Command::Skill { command } => match command { SkillCommand::Install(_) => "skill install", }, + Command::Upgrade(_) => "upgrade", Command::System { command } => match command { SystemCommand::Prune(_) => "system prune", SystemCommand::Df(_) => "system df", @@ -445,17 +453,17 @@ async fn main_inner() -> (String, Result<()>) { let command_name = command_name.to_string(); - let config_log_level = { + let (config_log_level, upgrade_check_enabled) = { #[cfg(feature = "server")] { if let Command::Serve(ref args) = cli.command { match fabro_config::server::load_server_config(args.config.as_deref()) { - Ok(server_config) => server_config.log.level, + Ok(server_config) => (server_config.log.level, false), Err(err) => return (command_name, Err(err)), } } else { match fabro_config::cli::load_cli_config(None) { - Ok(cli_config) => cli_config.log.level, + Ok(cli_config) => (cli_config.log.level, cli_config.upgrade_check), Err(err) => return (command_name, Err(err)), } } @@ -463,7 +471,7 @@ async fn main_inner() -> (String, Result<()>) { #[cfg(not(feature = "server"))] { match fabro_config::cli::load_cli_config(None) { - Ok(cli_config) => cli_config.log.level, + Ok(cli_config) => (cli_config.log.level, cli_config.upgrade_check), Err(err) => return (command_name, Err(err)), } } @@ -480,6 +488,15 @@ async fn main_inner() -> (String, Result<()>) { debug!(command = %command_name, "CLI command started"); + let upgrade_handle = if matches!( + cli.command, + Command::Run(_) | Command::Exec(_) | Command::Init | Command::Install + ) { + upgrade::spawn_upgrade_check(cli.no_upgrade_check, upgrade_check_enabled) + } else { + None + }; + let result = async { match cli.command { Command::Llm { command } => { @@ -776,6 +793,9 @@ async fn main_inner() -> (String, Result<()>) { skill::run_skill_install(&args)?; } }, + Command::Upgrade(args) => { + upgrade::run_upgrade(args).await?; + } Command::System { command } => match command { SystemCommand::Prune(args) => { fabro_workflows::cli::runs::prune_command(&args)?; @@ -800,5 +820,10 @@ async fn main_inner() -> (String, Result<()>) { } .await; + // Print upgrade notice after command completes (non-blocking during execution) + if let Some(handle) = upgrade_handle { + let _ = handle.await; + } + (command_name, result) } diff --git a/lib/crates/fabro-cli/src/upgrade.rs b/lib/crates/fabro-cli/src/upgrade.rs new file mode 100644 index 000000000..f3c9e98bb --- /dev/null +++ b/lib/crates/fabro-cli/src/upgrade.rs @@ -0,0 +1,551 @@ +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, + + /// 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::builder() + .user_agent("fabro-cli") + .build() + .context("failed to build HTTP client") +} + +impl Backend { + async fn fetch_latest_release_tag(&self) -> Result { + 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 { + 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 { + 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 { + 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(¤t) { + 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("fabro"); + let backup = exe_dir.join(".fabro-upgrade-backup"); + fs::rename(¤t_exe, &backup).context("failed to move current binary to backup")?; + if let Err(e) = fs::rename(&extracted_binary, ¤t_exe) { + // Restore from backup + if let Err(restore_err) = fs::rename(&backup, ¤t_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(¤t_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> { + 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(¤t, &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(¤t, &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; + } +} diff --git a/lib/crates/fabro-config/src/cli.rs b/lib/crates/fabro-config/src/cli.rs index ecfd1e954..eb864a8b4 100644 --- a/lib/crates/fabro-config/src/cli.rs +++ b/lib/crates/fabro-config/src/cli.rs @@ -43,7 +43,11 @@ pub struct CliGitConfig { pub author: crate::server::GitAuthorConfig, } -#[derive(Clone, Debug, Default, Deserialize, PartialEq)] +fn default_upgrade_check() -> bool { + true +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] pub struct CliConfig { pub mode: Option, pub server: Option, @@ -53,12 +57,30 @@ pub struct CliConfig { pub prevent_idle_sleep: bool, #[serde(default)] pub verbose: bool, + #[serde(default = "default_upgrade_check")] + pub upgrade_check: bool, #[serde(default)] pub log: crate::server::LogConfig, #[serde(flatten)] pub run_defaults: RunDefaults, } +impl Default for CliConfig { + fn default() -> Self { + Self { + mode: Default::default(), + server: Default::default(), + exec: Default::default(), + git: Default::default(), + prevent_idle_sleep: false, + verbose: false, + upgrade_check: true, + log: Default::default(), + run_defaults: Default::default(), + } + } +} + impl CliConfig { pub fn app_id(&self) -> Option<&str> { self.git.as_ref().and_then(|g| g.app_id.as_deref()) @@ -459,4 +481,16 @@ command = ["echo"] assert_eq!(config.startup_timeout_secs, 15); assert_eq!(config.tool_timeout_secs, 90); } + + #[test] + fn parse_upgrade_check_false() { + let config: CliConfig = toml::from_str("upgrade_check = false").unwrap(); + assert!(!config.upgrade_check); + } + + #[test] + fn parse_upgrade_check_default_true() { + let config: CliConfig = toml::from_str("").unwrap(); + assert!(config.upgrade_check); + } }