From 36b6a6d37082d2a6507a9bec29dc105267b3ff18 Mon Sep 17 00:00:00 2001 From: pat-s Date: Fri, 20 Feb 2026 09:09:01 +0100 Subject: [PATCH 1/3] feat: install to ~/.local/bin by default for non-root users The install script now defaults to ~/.local/bin instead of /usr/local/bin when running as a non-privileged user, avoiding the need for sudo. The sudo fallback has been removed; if the target directory is not writable, the script errors with a clear message instead. The PATH check and warning are now shown on all platforms, not just Windows. When RICOCHET_INSTALL_DIR is set, that value is still respected. When running as root, /usr/local/bin is still the default. Closes #86 --- install.sh | 51 ++++++++++++++++++++++++++------------------------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/install.sh b/install.sh index a199103..0244c32 100755 --- a/install.sh +++ b/install.sh @@ -5,7 +5,6 @@ set -e VERSION="${RICOCHET_VERSION:-0.3.0}" -INSTALL_DIR="${RICOCHET_INSTALL_DIR:-/usr/local/bin}" GITHUB_RELEASES_BASE="https://github.com/ricochet-rs/cli/releases/download/v${VERSION}" S3_BASE_URL="https://hel1.your-objectstorage.com/ricochet-cli/v${VERSION}" @@ -24,6 +23,17 @@ case "${OS}" in ;; esac +# Determine default install directory +if [ -n "${RICOCHET_INSTALL_DIR:-}" ]; then + INSTALL_DIR="${RICOCHET_INSTALL_DIR}" +elif [ "$(id -u)" = "0" ]; then + INSTALL_DIR="/usr/local/bin" +elif [ "${IS_WINDOWS}" = "1" ]; then + INSTALL_DIR="$HOME/bin" +else + INSTALL_DIR="$HOME/.local/bin" +fi + case "${OS}" in Darwin*) case "${ARCH}" in @@ -67,11 +77,6 @@ case "${OS}" in TARBALL="ricochet-${VERSION}-windows-x86_64.exe.tar.gz" BINARY_NAME="ricochet-${VERSION}-windows-x86_64.exe" BASE_URL="${GITHUB_RELEASES_BASE}" - # On Windows, use ~/bin (C:\Users\\bin) if not specified - if [ -z "${RICOCHET_INSTALL_DIR:-}" ]; then - INSTALL_DIR="$HOME/bin" - mkdir -p "$INSTALL_DIR" 2>/dev/null || true - fi ;; *) echo "Unsupported Windows architecture: ${ARCH}" @@ -110,18 +115,16 @@ tar -xzf "${TMP_DIR}/${TARBALL}" -C "${TMP_DIR}" # Determine final binary name FINAL_NAME="ricochet" +# Ensure install directory exists +mkdir -p "${INSTALL_DIR}" 2>/dev/null || true + # Move the binary if [ -w "${INSTALL_DIR}" ]; then mv "${TMP_DIR}/${BINARY_NAME}" "${INSTALL_DIR}/${FINAL_NAME}" else - if [ "${IS_WINDOWS}" = "1" ]; then - # On Windows, if we can't write to the dir, just fail (no sudo) - echo "Error: Cannot write to ${INSTALL_DIR}" - echo "Please run this script with appropriate permissions or set RICOCHET_INSTALL_DIR to a writable location." - exit 1 - else - sudo mv "${TMP_DIR}/${BINARY_NAME}" "${INSTALL_DIR}/${FINAL_NAME}" - fi + echo "Error: Cannot write to ${INSTALL_DIR}" + echo "Set RICOCHET_INSTALL_DIR to a writable location, or run as root." + exit 1 fi chmod +x "${INSTALL_DIR}/${FINAL_NAME}" @@ -130,21 +133,19 @@ echo "✓ Ricochet CLI installed successfully!" echo "Binary installed to: ${INSTALL_DIR}/${FINAL_NAME}" echo "" -if [ "${IS_WINDOWS}" = "1" ]; then - # Check if directory is in PATH - if echo "$PATH" | grep -q "${INSTALL_DIR}"; then +# Check if directory is in PATH +case ":${PATH}:" in + *":${INSTALL_DIR}:"*) echo "Run 'ricochet --help' to get started." - else + ;; + *) echo "⚠️ Note: ${INSTALL_DIR} is not in your PATH." echo "" - echo "To add it to your PATH, run:" + echo "To add it, run:" echo " export PATH=\"\$PATH:${INSTALL_DIR}\"" echo "" - echo "Or add this line to your ~/.bashrc or ~/.bash_profile:" - echo " export PATH=\"\$PATH:${INSTALL_DIR}\"" + echo "To make it permanent, add that line to your shell profile (~/.bashrc, ~/.zshrc, etc.)." echo "" echo "For now, you can run: ${INSTALL_DIR}/${FINAL_NAME} --help" - fi -else - echo "Run 'ricochet --help' to get started." -fi + ;; +esac From 8f655bf48458b8accf29fb636c68cad7f43aa48d Mon Sep 17 00:00:00 2001 From: pat-s Date: Fri, 20 Feb 2026 13:23:27 +0100 Subject: [PATCH 2/3] feat: add background update check and self-update command Add a periodic background update check that queries the GitHub Releases API every 24 hours and notifies the user via stderr when a new version is available. Add a `ricochet self-update` command that downloads and replaces the running binary with the latest release. Key behaviors: - Background check runs on all platforms; skipped for Homebrew installs (/opt/homebrew) and when RICOCHET_NO_UPDATE_CHECK is set - After 3 consecutive GitHub API failures, update checks are automatically disabled via skip_update_check in config.toml, with a stderr notice explaining the change - `ricochet self-update` re-enables checks if they were auto-disabled - Uses self-replace crate for cross-platform atomic binary replacement (handles Windows file locking) - macOS downloads from S3 (Homebrew bottle format), Linux/Windows from GitHub Releases - Release notes link shown after updating --- Cargo.lock | 12 ++ Cargo.toml | 3 +- src/commands/auth/auth_unit_test.rs | 1 + src/commands/mod.rs | 1 + src/commands/update.rs | 241 +++++++++++++++++++++++ src/config.rs | 14 ++ src/lib.rs | 1 + src/main.rs | 21 +- src/update.rs | 290 ++++++++++++++++++++++++++++ tests/deploy_test.rs | 1 + tests/servers_test.rs | 5 + 11 files changed, 588 insertions(+), 2 deletions(-) create mode 100644 src/commands/update.rs create mode 100644 src/update.rs diff --git a/Cargo.lock b/Cargo.lock index a021f17..40786d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2086,6 +2086,7 @@ dependencies = [ "regex", "reqwest", "ricochet-core", + "self-replace", "serde", "serde_json", "serde_yaml", @@ -2308,6 +2309,17 @@ dependencies = [ "libc", ] +[[package]] +name = "self-replace" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ec815b5eab420ab893f63393878d89c90fdd94c0bcc44c07abb8ad95552fb7" +dependencies = [ + "fastrand", + "tempfile", + "windows-sys 0.52.0", +] + [[package]] name = "semver" version = "1.0.27" diff --git a/Cargo.toml b/Cargo.toml index 524e29c..3a8afb5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,13 +49,14 @@ ulid = "1.1" unicode-icons = "2.0" url = { version = "2.5", features = ["serde"] } urlencoding = "2.1" +self-replace = "1" +tempfile = "3.8" walkdir = "2.5" webbrowser = "1.0" [dev-dependencies] mockito = "1.5" serial_test = "3" -tempfile = "3.8" tokio-test = "0.4" # Optimize dependencies even in dev mode for better test performance diff --git a/src/commands/auth/auth_unit_test.rs b/src/commands/auth/auth_unit_test.rs index 60ea180..c548821 100644 --- a/src/commands/auth/auth_unit_test.rs +++ b/src/commands/auth/auth_unit_test.rs @@ -287,6 +287,7 @@ mod tests { servers, default_server: Some("prod".to_string()), default_format: Some("table".to_string()), + skip_update_check: None, } } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 2381b77..c6581d0 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -6,3 +6,4 @@ pub mod init; pub mod invoke; pub mod list; pub mod servers; +pub mod update; diff --git a/src/commands/update.rs b/src/commands/update.rs new file mode 100644 index 0000000..e2a6f41 --- /dev/null +++ b/src/commands/update.rs @@ -0,0 +1,241 @@ +use crate::update; +use anyhow::{Context, Result}; +use colored::Colorize; +use flate2::read::GzDecoder; +use indicatif::{ProgressBar, ProgressStyle}; +use std::io::Read; + +const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Determine the download URL for the current platform. +fn download_url(version: &str) -> Result { + #[cfg(any( + all(target_os = "linux", target_arch = "x86_64"), + all(target_os = "linux", target_arch = "aarch64"), + all(target_os = "windows", target_arch = "x86_64"), + ))] + let base = format!( + "https://github.com/ricochet-rs/cli/releases/download/v{}", + version + ); + + #[cfg(target_os = "macos")] + let base = format!( + "https://hel1.your-objectstorage.com/ricochet-cli/v{}", + version + ); + + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] + return Ok(format!( + "{}/ricochet-{}-linux-x86_64.tar.gz", + base, version + )); + + #[cfg(all(target_os = "linux", target_arch = "aarch64"))] + return Ok(format!( + "{}/ricochet-{}-linux-aarch64.tar.gz", + base, version + )); + + #[cfg(all(target_os = "windows", target_arch = "x86_64"))] + return Ok(format!( + "{}/ricochet-{}-windows-x86_64.exe.tar.gz", + base, version + )); + + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + return Ok(format!( + "{}/ricochet-{}-darwin-arm64.tar.gz", + base, version + )); + + #[cfg(all(target_os = "macos", target_arch = "x86_64"))] + return Ok(format!( + "{}/ricochet-{}-darwin-x86_64.tar.gz", + base, version + )); + + #[cfg(not(any( + all(target_os = "linux", target_arch = "x86_64"), + all(target_os = "linux", target_arch = "aarch64"), + all(target_os = "windows", target_arch = "x86_64"), + all(target_os = "macos", target_arch = "aarch64"), + all(target_os = "macos", target_arch = "x86_64"), + )))] + anyhow::bail!( + "self-update is not supported on this platform.\n Install manually: https://github.com/ricochet-rs/cli/releases" + ); +} + +/// The binary name inside the tarball for the current platform. +/// macOS bottles have a different structure: ricochet/{version}/bin/ricochet +fn binary_name_in_tarball(version: &str) -> Result { + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] + return Ok(format!("ricochet-{}-linux-x86_64", version)); + + #[cfg(all(target_os = "linux", target_arch = "aarch64"))] + return Ok(format!("ricochet-{}-linux-aarch64", version)); + + #[cfg(all(target_os = "windows", target_arch = "x86_64"))] + return Ok(format!("ricochet-{}-windows-x86_64.exe", version)); + + // macOS bottles: the binary is at ricochet/{version}/bin/ricochet inside the tarball + #[cfg(target_os = "macos")] + return Ok(format!("ricochet/{}/bin/ricochet", version)); + + #[cfg(not(any( + all(target_os = "linux", target_arch = "x86_64"), + all(target_os = "linux", target_arch = "aarch64"), + all(target_os = "windows", target_arch = "x86_64"), + target_os = "macos", + )))] + { + let _ = version; + anyhow::bail!("self-update is not supported on this platform"); + } +} + +pub async fn self_update(force: bool) -> Result<()> { + println!("Checking for updates..."); + + let latest = update::fetch_latest_version() + .await + .context("Failed to fetch latest version from GitHub")?; + + if !update::is_newer(CURRENT_VERSION, &latest) && !force { + println!( + "{} Already on the latest version: {}", + "✓".green().bold(), + CURRENT_VERSION.bright_cyan() + ); + return Ok(()); + } + + if !update::is_newer(CURRENT_VERSION, &latest) { + println!( + "Reinstalling current version {} (--force)", + latest.bright_cyan() + ); + } else { + println!( + "Updating {} -> {}", + CURRENT_VERSION.dimmed(), + latest.green().bold() + ); + } + + let url = download_url(&latest)?; + + let client = reqwest::Client::builder() + .user_agent(concat!("ricochet-cli/", env!("CARGO_PKG_VERSION"))) + .timeout(std::time::Duration::from_secs(120)) + .build()?; + + let spinner = ProgressBar::new_spinner(); + spinner.set_style( + ProgressStyle::default_spinner() + .template("{spinner:.green} {msg}") + .unwrap(), + ); + spinner.set_message(format!("Downloading v{}...", latest)); + spinner.enable_steady_tick(std::time::Duration::from_millis(80)); + + let tarball_bytes = client + .get(&url) + .send() + .await + .context("Failed to download release tarball")? + .error_for_status() + .context("Download failed (server returned error)")? + .bytes() + .await + .context("Failed to read download response")?; + + spinner.finish_and_clear(); + println!( + "{} Downloaded ({} bytes)", + "✓".green(), + tarball_bytes.len() + ); + + let binary_path = binary_name_in_tarball(&latest)?; + let extracted_bytes = extract_binary_from_tarball(&tarball_bytes, &binary_path) + .with_context(|| format!("Failed to extract '{}' from tarball", binary_path))?; + + // Write extracted binary to a temp file, then use self_replace to atomically swap it in. + // self_replace handles platform quirks like Windows locking the running executable. + let tmp_dir = tempfile::tempdir().context("Failed to create temp directory")?; + let tmp_path = tmp_dir.path().join("ricochet-new"); + std::fs::write(&tmp_path, &extracted_bytes) + .context("Failed to write new binary to temp file")?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o755)) + .context("Failed to set executable permissions")?; + } + + self_replace::self_replace(&tmp_path) + .context("Failed to replace the ricochet binary. You may need elevated permissions.")?; + + // Update the cache: reset failure counter and record the new version + let _ = update::save_cache(&update::UpdateCache { + last_checked: chrono::Utc::now(), + latest_version: latest.clone(), + consecutive_failures: 0, + }); + + // Re-enable update checks if they were auto-disabled due to previous failures + if let Ok(mut config) = crate::config::Config::load() + && config.skip_update_check == Some(true) + { + config.skip_update_check = None; + let _ = config.save(); + } + + println!( + "\n{} Successfully updated to v{}", + "✓".green().bold(), + latest.bright_cyan() + ); + println!( + "Release notes: {}", + update::release_notes_url(&latest).bright_cyan() + ); + + Ok(()) +} + +fn extract_binary_from_tarball(tarball: &[u8], binary_path: &str) -> Result> { + use std::io::Cursor; + + let cursor = Cursor::new(tarball); + let gz = GzDecoder::new(cursor); + let mut archive = tar::Archive::new(gz); + + for entry in archive.entries().context("Failed to read tar entries")? { + let mut entry = entry.context("Failed to read tar entry")?; + let path = entry.path().context("Failed to read entry path")?; + let path_str = path.to_string_lossy(); + + // Match against full path (for macOS bottles: ricochet/0.4.0/bin/ricochet) + // or just the filename (for Linux/Windows flat tarballs) + let matches = path_str == binary_path + || path + .file_name() + .and_then(|n| n.to_str()) + .map(|n| n == binary_path) + .unwrap_or(false); + + if matches { + let mut bytes = Vec::new(); + entry + .read_to_end(&mut bytes) + .context("Failed to read binary from tar")?; + return Ok(bytes); + } + } + + anyhow::bail!("Binary '{}' not found in tarball", binary_path) +} diff --git a/src/config.rs b/src/config.rs index c68c333..0b8597e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -38,6 +38,11 @@ pub struct Config { pub default_server: Option, pub default_format: Option, + + /// When set to true, disables the periodic background update check. + /// This is set automatically when the GitHub API is repeatedly unreachable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub skip_update_check: Option, } impl Default for Config { @@ -56,6 +61,7 @@ impl Default for Config { servers, default_server: Some("default".to_string()), default_format: Some("table".to_string()), + skip_update_check: None, } } } @@ -78,6 +84,7 @@ impl Config { servers, default_server: Some("default".to_string()), default_format: Some("table".to_string()), + skip_update_check: None, } } } @@ -307,6 +314,7 @@ mod tests { servers, default_server: Some("prod".to_string()), default_format: Some("table".to_string()), + skip_update_check: None, } } @@ -365,6 +373,7 @@ mod tests { servers: HashMap::new(), default_server: None, default_format: Some("table".to_string()), + skip_update_check: None, }; let url = Url::parse("https://first.server.com").unwrap(); @@ -485,6 +494,7 @@ mod tests { servers: HashMap::new(), default_server: None, default_format: Some("table".to_string()), + skip_update_check: None, }; let servers = config.list_servers(); @@ -528,6 +538,7 @@ mod tests { servers: HashMap::new(), default_server: None, default_format: Some("table".to_string()), + skip_update_check: None, }; let result = config.resolve_server(None); @@ -617,6 +628,7 @@ mod tests { servers: HashMap::new(), default_server: None, default_format: Some("table".to_string()), + skip_update_check: None, }; config.migrate_v1_config(); @@ -639,6 +651,7 @@ mod tests { servers: HashMap::new(), default_server: None, default_format: Some("table".to_string()), + skip_update_check: None, }; config.migrate_v1_config(); @@ -717,6 +730,7 @@ mod tests { servers: HashMap::new(), default_server: None, default_format: Some("table".to_string()), + skip_update_check: None, }; assert_eq!(config.default_server(), None); diff --git a/src/lib.rs b/src/lib.rs index e8d78f6..f55f927 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ pub mod client; pub mod commands; pub mod config; +pub mod update; pub mod utils; #[derive(clap::ValueEnum, Clone, Debug, Copy)] diff --git a/src/main.rs b/src/main.rs index 2733146..7564910 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,7 @@ use anyhow::Result; use clap::{Parser, Subcommand}; use ricochet_cli::{ - OutputFormat, commands, + OutputFormat, commands, update, config::Config, }; @@ -114,6 +114,12 @@ enum Commands { #[command(subcommand)] command: ServersCommands, }, + /// Update the ricochet CLI to the latest version + SelfUpdate { + /// Force reinstall even if already on the latest version + #[arg(short = 'f', long)] + force: bool, + }, /// Generate markdown documentation (hidden command) #[command(hide = true)] GenerateDocs, @@ -224,6 +230,9 @@ async fn main() -> Result<()> { commands::servers::set_default(&mut config, name)?; } }, + Some(Commands::SelfUpdate { force }) => { + commands::update::self_update(force).await?; + } Some(Commands::GenerateDocs) => { let markdown = clap_markdown::help_markdown::(); println!("{}", markdown); @@ -235,5 +244,15 @@ async fn main() -> Result<()> { } } + // Background update check and notification. + // Skipped for Homebrew installs, when RICOCHET_NO_UPDATE_CHECK is set, + // or when skip_update_check is set in config (auto-set after repeated failures). + update::maybe_notify_update(&config); + if let Some(handle) = update::trigger_background_check(&config) { + // Give the background check a short window to complete before exiting. + // If it doesn't finish in time, the cache will be written on the next run. + let _ = tokio::time::timeout(std::time::Duration::from_secs(5), handle).await; + } + Ok(()) } diff --git a/src/update.rs b/src/update.rs new file mode 100644 index 0000000..5094fd3 --- /dev/null +++ b/src/update.rs @@ -0,0 +1,290 @@ +//! Background update checking and notification. +//! +//! Checks for new CLI versions via the GitHub Releases API every 24 hours and +//! notifies the user via stderr. Skipped when the binary lives in /opt/homebrew/bin +//! (Homebrew manages updates there), when RICOCHET_NO_UPDATE_CHECK is set, or when +//! skip_update_check is set in the config (auto-set after repeated GitHub API failures). + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +use crate::config::Config; + +const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); +const GITHUB_API_URL: &str = "https://api.github.com/repos/ricochet-rs/cli/releases/latest"; +const RELEASE_NOTES_BASE: &str = "https://github.com/ricochet-rs/cli/releases/tag"; +const CHECK_INTERVAL_SECS: u64 = 60 * 60 * 24; // 24 hours +const MAX_CONSECUTIVE_FAILURES: u32 = 3; + +#[derive(Debug, Serialize, Deserialize)] +pub struct UpdateCache { + pub last_checked: chrono::DateTime, + pub latest_version: String, + #[serde(default)] + pub consecutive_failures: u32, +} + +#[derive(Debug, Deserialize)] +struct GitHubRelease { + tag_name: String, +} + +/// Returns true if update checks should be suppressed. +fn should_suppress_checks(config: &Config) -> bool { + if config.skip_update_check == Some(true) { + return true; + } + if std::env::var("RICOCHET_NO_UPDATE_CHECK") + .map(|v| !v.is_empty()) + .unwrap_or(false) + { + return true; + } + if std::env::var("CI").is_ok() { + return true; + } + if let Ok(exe) = std::env::current_exe() + && let Some(parent) = exe.parent() + && parent.starts_with("/opt/homebrew") + { + return true; + } + false +} + +pub fn cache_path() -> Result { + let cache_dir = dirs::cache_dir().context("Failed to get cache directory")?; + Ok(cache_dir.join("ricochet").join("update-check.json")) +} + +pub fn load_cache() -> Option { + let path = cache_path().ok()?; + let content = std::fs::read_to_string(path).ok()?; + serde_json::from_str(&content).ok() +} + +pub fn save_cache(cache: &UpdateCache) -> Result<()> { + let path = cache_path()?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).context("Failed to create cache directory")?; + } + let content = + serde_json::to_string_pretty(cache).context("Failed to serialize update cache")?; + std::fs::write(&path, content).context("Failed to write update cache")?; + Ok(()) +} + +pub fn release_notes_url(version: &str) -> String { + format!("{}/v{}", RELEASE_NOTES_BASE, version) +} + +/// Fetch the latest release version string (without leading 'v') from GitHub. +pub async fn fetch_latest_version() -> Result { + let client = reqwest::Client::builder() + .user_agent(concat!("ricochet-cli/", env!("CARGO_PKG_VERSION"))) + .timeout(std::time::Duration::from_secs(10)) + .build()?; + + let release: GitHubRelease = client + .get(GITHUB_API_URL) + .send() + .await + .context("Failed to contact GitHub API")? + .error_for_status() + .context("GitHub API returned error")? + .json() + .await + .context("Failed to parse GitHub API response")?; + + Ok(release.tag_name.trim_start_matches('v').to_string()) +} + +/// Returns true if `candidate` is a newer version than `current`. +pub fn is_newer(current: &str, candidate: &str) -> bool { + fn parse(v: &str) -> Option<(u64, u64, u64)> { + let v = v.split('-').next()?; + let parts: Vec = v.split('.').filter_map(|p| p.parse().ok()).collect(); + if parts.len() >= 3 { + Some((parts[0], parts[1], parts[2])) + } else { + None + } + } + match (parse(current), parse(candidate)) { + (Some(c), Some(n)) => n > c, + _ => false, + } +} + +/// Background task: fetch latest version and save to cache. +/// On success, resets the failure counter. On failure, increments it. +/// After MAX_CONSECUTIVE_FAILURES, auto-disables update checks in the config +/// and notifies the user via stderr. +pub async fn check_for_update() -> Option { + let previous_failures = load_cache() + .map(|c| c.consecutive_failures) + .unwrap_or(0); + + match fetch_latest_version().await { + Ok(latest) => { + let cache = UpdateCache { + last_checked: chrono::Utc::now(), + latest_version: latest.clone(), + consecutive_failures: 0, + }; + let _ = save_cache(&cache); + if is_newer(CURRENT_VERSION, &latest) { + Some(latest) + } else { + None + } + } + Err(_) => { + let failures = previous_failures + 1; + let cache = UpdateCache { + last_checked: chrono::Utc::now(), + latest_version: load_cache() + .map(|c| c.latest_version) + .unwrap_or_else(|| CURRENT_VERSION.to_string()), + consecutive_failures: failures, + }; + let _ = save_cache(&cache); + + if failures >= MAX_CONSECUTIVE_FAILURES { + disable_update_checks(); + } + + None + } + } +} + +/// Disable update checks by setting skip_update_check in the config file, +/// and inform the user via stderr. +fn disable_update_checks() { + use colored::Colorize; + + if let Ok(mut config) = Config::load() { + config.skip_update_check = Some(true); + let _ = config.save(); + } + + eprintln!( + "\n{} Automatic update checks have been disabled after {} consecutive failures reaching GitHub.\n To re-enable, set {} in your config file or run:\n {}", + "notice:".yellow().bold(), + MAX_CONSECUTIVE_FAILURES, + "skip_update_check = false".bright_cyan(), + "ricochet self-update".bright_cyan(), + ); +} + +/// Print a one-line stderr notice if a newer version is recorded in the cache. +/// Reads the on-disk cache synchronously — no network call. +pub fn maybe_notify_update(config: &Config) { + if should_suppress_checks(config) { + return; + } + use colored::Colorize; + let Some(cache) = load_cache() else { return }; + if is_newer(CURRENT_VERSION, &cache.latest_version) { + eprintln!( + "\n{} A new version of ricochet is available: {} -> {}\n Update with: {}\n Release notes: {}", + "notice:".yellow().bold(), + CURRENT_VERSION.dimmed(), + cache.latest_version.green().bold(), + "ricochet self-update".bright_cyan(), + release_notes_url(&cache.latest_version).dimmed(), + ); + } +} + +/// If the last update check was more than 24h ago (or never), spawn a background +/// tokio task to fetch the latest version and refresh the cache. +/// Returns the JoinHandle so the caller can await it with a timeout. +pub fn trigger_background_check(config: &Config) -> Option> { + if should_suppress_checks(config) { + return None; + } + + let should_check = match load_cache() { + None => true, + Some(cache) => { + let age = chrono::Utc::now() + .signed_duration_since(cache.last_checked) + .num_seconds() + .unsigned_abs(); + age >= CHECK_INTERVAL_SECS + } + }; + + if should_check { + Some(tokio::spawn(async { + let _ = check_for_update().await; + })) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_newer_basic() { + assert!(is_newer("0.3.0", "0.4.0")); + assert!(is_newer("0.3.0", "0.3.1")); + assert!(is_newer("0.3.0", "1.0.0")); + assert!(!is_newer("0.3.0", "0.3.0")); + assert!(!is_newer("0.4.0", "0.3.0")); + } + + #[test] + fn test_is_newer_double_digit() { + assert!(is_newer("0.9.0", "0.10.0")); + assert!(!is_newer("0.10.0", "0.9.0")); + } + + #[test] + fn test_is_newer_with_prerelease() { + assert!(is_newer("0.3.0-abc1234", "0.4.0")); + assert!(!is_newer("0.4.0", "0.3.0-abc1234")); + } + + #[test] + fn test_is_newer_unparseable_returns_false() { + assert!(!is_newer("0.3.0", "garbage")); + assert!(!is_newer("garbage", "0.4.0")); + assert!(!is_newer("garbage", "also-garbage")); + } + + #[test] + fn test_release_notes_url() { + assert_eq!( + release_notes_url("0.4.0"), + "https://github.com/ricochet-rs/cli/releases/tag/v0.4.0" + ); + } + + #[test] + fn test_cache_roundtrip_with_failures() { + let cache = UpdateCache { + last_checked: chrono::Utc::now(), + latest_version: "0.4.0".to_string(), + consecutive_failures: 2, + }; + let json = serde_json::to_string(&cache).unwrap(); + let loaded: UpdateCache = serde_json::from_str(&json).unwrap(); + assert_eq!(loaded.consecutive_failures, 2); + assert_eq!(loaded.latest_version, "0.4.0"); + } + + #[test] + fn test_cache_deserializes_without_failures_field() { + // Backward compat: old cache files won't have consecutive_failures + let json = r#"{"last_checked":"2026-02-20T00:00:00Z","latest_version":"0.3.0"}"#; + let loaded: UpdateCache = serde_json::from_str(json).unwrap(); + assert_eq!(loaded.consecutive_failures, 0); + } +} diff --git a/tests/deploy_test.rs b/tests/deploy_test.rs index 427da79..b5a3268 100644 --- a/tests/deploy_test.rs +++ b/tests/deploy_test.rs @@ -37,6 +37,7 @@ mod deploy_tests { servers, default_server: Some("prod".to_string()), default_format: Some("table".to_string()), + skip_update_check: None, } } diff --git a/tests/servers_test.rs b/tests/servers_test.rs index f82574a..4b69ea5 100644 --- a/tests/servers_test.rs +++ b/tests/servers_test.rs @@ -71,6 +71,7 @@ fn create_multi_server_config() -> Config { servers, default_server: Some("prod".to_string()), default_format: Some("table".to_string()), + skip_update_check: None, } } @@ -91,6 +92,7 @@ mod servers_tests { servers: HashMap::new(), default_server: None, default_format: Some("table".to_string()), + skip_update_check: None, }; let url = Url::parse("https://new.server.com").unwrap(); @@ -140,6 +142,7 @@ mod servers_tests { servers: HashMap::new(), default_server: None, default_format: Some("table".to_string()), + skip_update_check: None, }; // Add first server @@ -416,6 +419,7 @@ mod servers_tests { servers: HashMap::new(), default_server: None, default_format: Some("table".to_string()), + skip_update_check: None, }; config.add_server( @@ -494,6 +498,7 @@ mod servers_tests { servers: HashMap::new(), default_server: None, default_format: Some("table".to_string()), + skip_update_check: None, }; let result = config.resolve_server(None); From 0fca588fa4887df607932751e81a29d80e7478f4 Mon Sep 17 00:00:00 2001 From: pat-s Date: Fri, 20 Feb 2026 13:41:08 +0100 Subject: [PATCH 3/3] docs: regenerate CLI command documentation for self-update --- .pre-commit-config.yaml | 1 + docs/cli-commands.md | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4c013ea..e24cd6d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,6 +8,7 @@ repos: rev: v6.0.0 hooks: - id: end-of-file-fixer + exclude: ^docs/cli-commands\.md$ - id: trailing-whitespace args: - --markdown-linebreak-ext=md diff --git a/docs/cli-commands.md b/docs/cli-commands.md index e048a61..0373371 100644 --- a/docs/cli-commands.md +++ b/docs/cli-commands.md @@ -18,6 +18,7 @@ This document contains the help content for the `ricochet` command-line program. * [`ricochet servers add`↴](#ricochet-servers-add) * [`ricochet servers remove`↴](#ricochet-servers-remove) * [`ricochet servers set-default`↴](#ricochet-servers-set-default) +* [`ricochet self-update`↴](#ricochet-self-update) ## `ricochet` @@ -36,6 +37,7 @@ Ricochet CLI * `config` — Show configuration * `init` — Initialize a new Ricochet deployment * `servers` — Manage configured Ricochet servers +* `self-update` — Update the ricochet CLI to the latest version ###### **Options:** @@ -231,6 +233,18 @@ Set the default server +## `ricochet self-update` + +Update the ricochet CLI to the latest version + +**Usage:** `ricochet self-update [OPTIONS]` + +###### **Options:** + +* `-f`, `--force` — Force reinstall even if already on the latest version + + +