feat: add self-update capability

Add `tmuxido --update` command to update binary from latest release.
- New `self_update` module with version comparison
- Atomic binary replacement with backup/rollback
- Fetches latest release from Gitea API
- Downloads correct binary for system architecture
This commit is contained in:
Cinco Euzebio 2026-03-01 00:34:42 -03:00
parent fcb7c7f6a6
commit ba3f923781
7 changed files with 245 additions and 2 deletions

View File

@ -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

2
Cargo.lock generated
View File

@ -542,7 +542,7 @@ dependencies = [
[[package]]
name = "tmuxido"
version = "0.2.4"
version = "0.4.0"
dependencies = [
"anyhow",
"clap",

View File

@ -1,6 +1,6 @@
[package]
name = "tmuxido"
version = "0.2.4"
version = "0.4.0"
edition = "2024"
[dev-dependencies]

View File

@ -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

View File

@ -1,6 +1,7 @@
pub mod cache;
pub mod config;
pub mod deps;
pub mod self_update;
pub mod session;
use anyhow::Result;

View File

@ -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()?;

218
src/self_update.rs Normal file
View File

@ -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<String> {
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<PathBuf> {
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::<u32>().ok())
.collect::<Vec<_>>()
};
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
);
}
}