diff --git a/CHANGELOG.md b/CHANGELOG.md index dd433af..7b88d17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [0.4.0] - 2026-03-01 + +### Added +- Self-update feature (`tmuxido --update`) to update binary from latest GitHub release +- New `self_update` module with version comparison and atomic binary replacement +- `--update` CLI flag for in-place binary updates +- Backup and rollback mechanism if update fails + ## [0.3.0] - 2026-03-01 ### Added diff --git a/Cargo.lock b/Cargo.lock index 5dd42bd..e467d1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -542,7 +542,7 @@ dependencies = [ [[package]] name = "tmuxido" -version = "0.2.4" +version = "0.4.0" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index 1e0b411..27e5018 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tmuxido" -version = "0.2.4" +version = "0.4.0" edition = "2024" [dev-dependencies] diff --git a/README.md b/README.md index 7deb5e5..99f6686 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ A Rust-based tool to quickly find and open projects in tmux using fzf. No extern - TOML-based configuration - Smart caching system for fast subsequent runs - Configurable cache TTL +- Self-update capability (`tmuxido --update`) - Zero external dependencies (except tmux and fzf) ## Installation @@ -97,6 +98,11 @@ Check cache status: tmuxido --cache-status ``` +Update tmuxido to the latest version: +```bash +tmuxido --update +``` + View help: ```bash tmuxido --help diff --git a/src/lib.rs b/src/lib.rs index 368812a..a07ede4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ pub mod cache; pub mod config; pub mod deps; +pub mod self_update; pub mod session; use anyhow::Result; diff --git a/src/main.rs b/src/main.rs index aab60aa..ef22052 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ use std::path::PathBuf; use std::process::{Command, Stdio}; use tmuxido::config::Config; use tmuxido::deps::ensure_dependencies; +use tmuxido::self_update; use tmuxido::{get_projects, launch_tmux_session, show_cache_status}; #[derive(Parser, Debug)] @@ -24,11 +25,20 @@ struct Args { /// Show cache status and exit #[arg(long)] cache_status: bool, + + /// Update tmuxido to the latest version + #[arg(long)] + update: bool, } fn main() -> Result<()> { let args = Args::parse(); + // Handle self-update before anything else + if args.update { + return self_update::self_update(); + } + // Check that fzf and tmux are installed; offer to install if missing ensure_dependencies()?; diff --git a/src/self_update.rs b/src/self_update.rs new file mode 100644 index 0000000..a9bd1ab --- /dev/null +++ b/src/self_update.rs @@ -0,0 +1,218 @@ +use anyhow::{Context, Result}; +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; +use std::process::Command; + +const REPO: &str = "cinco/Tmuxido"; +const BASE_URL: &str = "https://git.cincoeuzebio.com"; + +/// Check if running from cargo (development mode) +fn is_dev_build() -> bool { + option_env!("CARGO_PKG_NAME").is_none() +} + +/// Get current version from cargo +pub fn current_version() -> &'static str { + env!("CARGO_PKG_VERSION") +} + +/// Detect system architecture +fn detect_arch() -> Result<&'static str> { + let arch = std::env::consts::ARCH; + match arch { + "x86_64" => Ok("x86_64-linux"), + "aarch64" => Ok("aarch64-linux"), + _ => Err(anyhow::anyhow!("Unsupported architecture: {}", arch)), + } +} + +/// Fetch latest release tag from Gitea API +fn fetch_latest_tag() -> Result { + let url = format!("{}/api/v1/repos/{}/releases?limit=1&page=1", BASE_URL, REPO); + + let output = Command::new("curl") + .args(["-fsSL", &url]) + .output() + .context("Failed to execute curl. Make sure curl is installed.")?; + + if !output.status.success() { + return Err(anyhow::anyhow!( + "Failed to fetch latest release: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + + let response = String::from_utf8_lossy(&output.stdout); + + // Parse JSON response to extract tag_name + let tag: serde_json::Value = + serde_json::from_str(&response).context("Failed to parse release API response")?; + + tag.get(0) + .and_then(|r| r.get("tag_name")) + .and_then(|t| t.as_str()) + .map(|t| t.to_string()) + .ok_or_else(|| anyhow::anyhow!("Could not extract tag_name from release")) +} + +/// Get path to current executable +fn get_current_exe() -> Result { + std::env::current_exe().context("Failed to get current executable path") +} + +/// Download binary to a temporary location +fn download_binary(tag: &str, arch: &str, temp_path: &std::path::Path) -> Result<()> { + let url = format!("{}/{}/releases/download/{}/{}", BASE_URL, REPO, tag, arch); + + println!("Downloading {}...", url); + + let output = Command::new("curl") + .args(["-fsSL", &url, "-o", &temp_path.to_string_lossy()]) + .output() + .context("Failed to execute curl for download")?; + + if !output.status.success() { + return Err(anyhow::anyhow!( + "Failed to download binary: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + + // Make executable + let mut perms = std::fs::metadata(temp_path)?.permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(temp_path, perms)?; + + Ok(()) +} + +/// Perform self-update +pub fn self_update() -> Result<()> { + if is_dev_build() { + println!("Development build detected. Skipping self-update."); + return Ok(()); + } + + let current = current_version(); + println!("Current version: {}", current); + + let latest = fetch_latest_tag()?; + let latest_clean = latest.trim_start_matches('v'); + println!("Latest version: {}", latest); + + // Compare versions (simple string comparison for semver without 'v' prefix) + if latest_clean == current { + println!("Already up to date!"); + return Ok(()); + } + + // Check if latest is actually newer + match version_compare(latest_clean, current) { + std::cmp::Ordering::Less => { + println!("Current version is newer than release. Skipping update."); + return Ok(()); + } + std::cmp::Ordering::Equal => { + println!("Already up to date!"); + return Ok(()); + } + _ => {} + } + + let arch = detect_arch()?; + let exe_path = get_current_exe()?; + + // Create temporary file in same directory as target (for atomic rename) + let exe_dir = exe_path + .parent() + .ok_or_else(|| anyhow::anyhow!("Could not determine executable directory"))?; + let temp_path = exe_dir.join(".tmuxido.new"); + + println!("Downloading update..."); + download_binary(&latest, arch, &temp_path)?; + + // Verify the downloaded binary works + let verify = Command::new(&temp_path).arg("--version").output(); + if let Err(e) = verify { + let _ = std::fs::remove_file(&temp_path); + return Err(anyhow::anyhow!( + "Downloaded binary verification failed: {}", + e + )); + } + + // Atomic replace: rename old to .old, rename new to target + let backup_path = exe_path.with_extension("old"); + + // Remove old backup if exists + let _ = std::fs::remove_file(&backup_path); + + // Rename current to backup + std::fs::rename(&exe_path, &backup_path) + .context("Failed to backup current binary (is tmuxido running?)")?; + + // Move new to current location + if let Err(e) = std::fs::rename(&temp_path, &exe_path) { + // Restore backup on failure + let _ = std::fs::rename(&backup_path, &exe_path); + return Err(anyhow::anyhow!("Failed to install new binary: {}", e)); + } + + // Remove backup on success + let _ = std::fs::remove_file(&backup_path); + + println!("Successfully updated to {}!", latest); + Ok(()) +} + +/// Compare two semver versions +fn version_compare(a: &str, b: &str) -> std::cmp::Ordering { + let parse = |s: &str| { + s.split('.') + .filter_map(|n| n.parse::().ok()) + .collect::>() + }; + + let a_parts = parse(a); + let b_parts = parse(b); + + for (a_part, b_part) in a_parts.iter().zip(b_parts.iter()) { + match a_part.cmp(b_part) { + std::cmp::Ordering::Equal => continue, + other => return other, + } + } + + a_parts.len().cmp(&b_parts.len()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_detect_current_version() { + let version = current_version(); + // Version should be non-empty and contain dots + assert!(!version.is_empty()); + assert!(version.contains('.')); + } + + #[test] + fn should_compare_versions_correctly() { + assert_eq!( + version_compare("0.3.0", "0.2.4"), + std::cmp::Ordering::Greater + ); + assert_eq!(version_compare("0.2.4", "0.3.0"), std::cmp::Ordering::Less); + assert_eq!(version_compare("0.3.0", "0.3.0"), std::cmp::Ordering::Equal); + assert_eq!( + version_compare("1.0.0", "0.9.9"), + std::cmp::Ordering::Greater + ); + assert_eq!( + version_compare("0.10.0", "0.9.0"), + std::cmp::Ordering::Greater + ); + } +}