use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
const GITHUB_REPO_OWNER: &str = "yetidevworks";
const GITHUB_REPO_NAME: &str = "bosun";
fn parse_version(v: &str) -> (u64, u64, u64) {
let parts: Vec<u64> = v.split('.').filter_map(|p| p.parse().ok()).collect();
(
parts.first().copied().unwrap_or(0),
parts.get(1).copied().unwrap_or(0),
parts.get(2).copied().unwrap_or(0),
)
}
fn is_newer(current: &str, latest: &str) -> bool {
parse_version(latest) > parse_version(current)
}
#[derive(serde::Serialize, serde::Deserialize)]
struct UpdateCache {
latest_version: String,
checked_at: u64,
}
fn cache_path() -> Option<PathBuf> {
let dirs = directories::ProjectDirs::from("dev", "yetidevworks", "bosun")?;
Some(dirs.data_dir().join("update-check.json"))
}
fn write_cache(cache: &UpdateCache) -> Result<()> {
let path = cache_path().context("could not determine cache path")?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, serde_json::to_string(cache)?)?;
Ok(())
}
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn fetch_latest_version() -> Result<String> {
let url = format!(
"https://api.github.com/repos/{}/{}/releases/latest",
GITHUB_REPO_OWNER, GITHUB_REPO_NAME
);
let resp = ureq::get(&url)
.set(
"User-Agent",
&format!("bosun/{}", env!("CARGO_PKG_VERSION")),
)
.set("Accept", "application/vnd.github.v3+json")
.call()
.context("failed to reach GitHub API")?;
let body: serde_json::Value = resp
.into_json()
.context("failed to parse GitHub response")?;
let tag = body["tag_name"]
.as_str()
.context("no tag_name in release")?;
Ok(tag.strip_prefix('v').unwrap_or(tag).to_string())
}
fn platform_target() -> Option<&'static str> {
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
return Some("darwin-aarch64");
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
return Some("darwin-x86_64");
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
return Some("linux-x86_64");
#[cfg(all(target_os = "linux", target_arch = "aarch64"))]
return Some("linux-aarch64");
#[allow(unreachable_code)]
None
}
enum InstallMethod {
Homebrew,
Cargo,
Binary(PathBuf),
}
fn detect_install_method() -> InstallMethod {
let exe = std::env::current_exe().unwrap_or_default();
let s = exe.to_string_lossy();
if s.contains("/Cellar/") || s.contains("/homebrew/") {
InstallMethod::Homebrew
} else if s.contains("/.cargo/bin/") {
InstallMethod::Cargo
} else {
InstallMethod::Binary(exe)
}
}
pub fn run(check_only: bool) -> Result<()> {
let current = env!("CARGO_PKG_VERSION");
eprintln!("Checking for updates…");
let latest = fetch_latest_version()?;
let _ = write_cache(&UpdateCache {
latest_version: latest.clone(),
checked_at: now_secs(),
});
if !is_newer(current, &latest) {
eprintln!("bosun v{} is already the latest version.", current);
return Ok(());
}
eprintln!("Update available: v{} → v{}", current, latest);
if check_only {
eprintln!("\nRun `bosun update` to install.");
return Ok(());
}
match detect_install_method() {
InstallMethod::Homebrew => {
eprintln!("\nbosun was installed via Homebrew. Run:");
eprintln!(" brew upgrade bosun");
}
InstallMethod::Cargo => {
eprintln!("\nbosun was installed via cargo. Run:");
eprintln!(" cargo install --force bosun-tmux");
}
InstallMethod::Binary(exe_path) => {
perform_update(&exe_path, &latest)?;
}
}
Ok(())
}
fn perform_update(exe_path: &Path, version: &str) -> Result<()> {
let target = platform_target().context(
"unsupported platform for self-update — install via cargo or download a release \
binary from https://github.com/yetidevworks/bosun/releases",
)?;
let asset_name = format!("bosun-{}.tar.gz", target);
let download_url = format!(
"https://github.com/{}/{}/releases/download/v{}/{}",
GITHUB_REPO_OWNER, GITHUB_REPO_NAME, version, asset_name
);
eprintln!("Downloading {}…", asset_name);
let tmp = std::env::temp_dir().join(format!("bosun-update-{}", std::process::id()));
std::fs::create_dir_all(&tmp)?;
let _cleanup = TempDirGuard(tmp.clone());
let archive_path = tmp.join(&asset_name);
let resp = ureq::get(&download_url)
.set(
"User-Agent",
&format!("bosun/{}", env!("CARGO_PKG_VERSION")),
)
.call()
.context("failed to download release")?;
let mut body = resp.into_reader();
let mut file = std::fs::File::create(&archive_path)?;
std::io::copy(&mut body, &mut file)?;
drop(file);
extract_tar_gz(&archive_path, &tmp)?;
let new_bin = tmp.join("bosun");
if !new_bin.exists() {
anyhow::bail!("binary not found in archive");
}
replace_binary(&new_bin, exe_path)?;
eprintln!("Updated bosun to v{}.", version);
eprintln!("Quit and relaunch bosun to pick up the new binary.");
Ok(())
}
fn extract_tar_gz(archive: &Path, dest: &Path) -> Result<()> {
let status = std::process::Command::new("tar")
.args([
"xzf",
&archive.to_string_lossy(),
"-C",
&dest.to_string_lossy(),
])
.status()
.context("failed to run tar")?;
if !status.success() {
anyhow::bail!("tar extraction failed");
}
Ok(())
}
fn replace_binary(new_bin: &Path, exe_path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(new_bin, std::fs::Permissions::from_mode(0o755))?;
if std::fs::rename(new_bin, exe_path).is_err() {
std::fs::copy(new_bin, exe_path)?;
}
Ok(())
}
struct TempDirGuard(PathBuf);
impl Drop for TempDirGuard {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_version_works() {
assert_eq!(parse_version("0.3.3"), (0, 3, 3));
assert_eq!(parse_version("1.0.0"), (1, 0, 0));
assert_eq!(parse_version("10.20.30"), (10, 20, 30));
}
#[test]
fn is_newer_works() {
assert!(is_newer("0.3.3", "0.3.4"));
assert!(is_newer("0.3.9", "0.4.0"));
assert!(is_newer("0.9.9", "1.0.0"));
assert!(!is_newer("0.3.3", "0.3.3"));
assert!(!is_newer("0.3.4", "0.3.3"));
assert!(!is_newer("1.0.0", "0.99.99"));
}
#[test]
fn platform_target_resolves_on_supported_hosts() {
assert!(platform_target().is_some());
}
}