use crate::output::reports::update::UpdateReport;
use crate::{daemon, style};
use anyhow::Result;
use axoupdater::{AxoUpdater, UpdateRequest};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum InstallMethod {
Homebrew,
Cargo,
Npm,
Unknown,
}
fn is_npm_install(exe: &Path) -> bool {
fn normal(c: Option<std::path::Component<'_>>) -> Option<&str> {
match c {
Some(std::path::Component::Normal(s)) => s.to_str(),
_ => None,
}
}
let mut rev = exe.components().rev();
rev.next(); normal(rev.next()) == Some(".bin_real") && normal(rev.next()) == Some("node_modules")
}
fn detect_install_method(exe: &Path, cargo_home: Option<&Path>) -> InstallMethod {
if is_npm_install(exe) {
return InstallMethod::Npm;
}
let exe_str = exe.to_string_lossy();
if exe_str.contains("/Cellar/bitrouter/") || exe_str.contains("/homebrew/") {
return InstallMethod::Homebrew;
}
if cargo_home.is_some_and(|home| exe.starts_with(home.join("bin"))) {
return InstallMethod::Cargo;
}
InstallMethod::Unknown
}
fn method_label(method: InstallMethod) -> &'static str {
match method {
InstallMethod::Homebrew => "homebrew",
InstallMethod::Cargo => "cargo",
InstallMethod::Npm => "npm",
InstallMethod::Unknown => "unknown",
}
}
fn delegation_command(method: InstallMethod) -> String {
match method {
InstallMethod::Homebrew => "brew upgrade bitrouter".to_string(),
InstallMethod::Cargo => "cargo install bitrouter --force".to_string(),
InstallMethod::Npm => "npm i -g bitrouter@latest".to_string(),
InstallMethod::Unknown => {
"curl --proto '=https' --tlsv1.2 -LsSf \
https://github.com/bitrouter/bitrouter/releases/latest/download/bitrouter-installer.sh | sh"
.to_string()
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum VersionSpec {
Latest,
LatestPrerelease,
Tag(String),
}
fn choose_spec(tag: Option<&str>, stable: bool) -> VersionSpec {
match tag {
Some(t) => VersionSpec::Tag(t.to_string()),
None if stable => VersionSpec::Latest,
None => VersionSpec::LatestPrerelease,
}
}
#[derive(Debug)]
pub struct UpdateOptions {
pub check: bool,
pub tag: Option<String>,
pub stable: bool,
pub restart: bool,
pub yes: bool,
}
#[derive(Debug)]
pub struct RunOutcome {
pub report: UpdateReport,
pub restart_needed: bool,
}
impl RunOutcome {
fn done(report: UpdateReport) -> Self {
Self {
report,
restart_needed: false,
}
}
}
fn current_version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
fn cargo_home() -> Option<std::path::PathBuf> {
if let Some(h) = std::env::var_os("CARGO_HOME") {
return Some(std::path::PathBuf::from(h));
}
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".cargo"))
}
fn to_request(spec: VersionSpec) -> UpdateRequest {
match spec {
VersionSpec::Latest => UpdateRequest::Latest,
VersionSpec::LatestPrerelease => UpdateRequest::LatestMaybePrerelease,
VersionSpec::Tag(t) => UpdateRequest::SpecificTag(t),
}
}
fn build_updater() -> Option<AxoUpdater> {
let mut updater = AxoUpdater::new_for("bitrouter");
if let Err(e) = updater.load_receipt() {
tracing::debug!(error = %e, "no install receipt; delegating to package manager");
return None;
}
Some(updater)
}
pub async fn run(opts: UpdateOptions, socket: &Path) -> Result<RunOutcome> {
let current = current_version().to_string();
let Some(mut updater) = build_updater() else {
let exe = std::env::current_exe().unwrap_or_default();
let method = detect_install_method(&exe, cargo_home().as_deref());
return Ok(RunOutcome::done(UpdateReport::delegated(
current,
method_label(method),
delegation_command(method),
)));
};
let spec = choose_spec(opts.tag.as_deref(), opts.stable);
let target_label = match &spec {
VersionSpec::Tag(t) => format!("version {t}"),
_ => "the latest release".to_string(),
};
if let VersionSpec::Tag(_) = spec {
updater.always_update(true);
}
updater.configure_version_specifier(to_request(spec));
if opts.check {
let available = updater.is_update_needed().await?;
let target = if available {
updater.query_new_version().await?.map(|v| v.to_string())
} else {
None
};
return Ok(RunOutcome::done(UpdateReport::checked(
current, available, target,
)));
}
if !opts.yes && !confirm(¤t, &target_label)? {
return Ok(RunOutcome::done(UpdateReport::aborted(current)));
}
let Some(result) = updater.run().await? else {
return Ok(RunOutcome::done(UpdateReport::unchanged(current)));
};
let new_version = result.new_version.to_string();
let daemon_running = daemon::endpoint_in_use(socket);
let (daemon, restart_needed) = match (daemon_running, opts.restart) {
(true, true) => (Some("restarted"), true),
(true, false) => (Some("restart_needed"), false),
(false, _) => (None, false),
};
Ok(RunOutcome {
report: UpdateReport::updated(current, new_version, daemon),
restart_needed,
})
}
fn confirm(current: &str, target: &str) -> Result<bool> {
use std::io::Write;
let p = style::Palette::for_stderr();
eprint!(
"Update bitrouter from {bold}{current}{reset} to {target}? [y/N] ",
bold = p.bold,
reset = p.reset,
);
std::io::stderr().flush()?;
let mut line = String::new();
if std::io::stdin().read_line(&mut line)? == 0 {
return Ok(false);
}
Ok(matches!(
line.trim().to_ascii_lowercase().as_str(),
"y" | "yes"
))
}
const CACHE_FILENAME: &str = "update-check.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
struct UpdateCache {
checked_at: i64,
latest: Option<String>,
}
fn should_check(now: i64, last_checked: Option<i64>, ttl_secs: i64) -> bool {
match last_checked {
None => true,
Some(prev) => now - prev >= ttl_secs,
}
}
fn read_cache(home: &Path) -> Option<UpdateCache> {
let bytes = std::fs::read(home.join(CACHE_FILENAME)).ok()?;
serde_json::from_slice(&bytes).ok()
}
fn write_cache(home: &Path, cache: &UpdateCache) -> Result<()> {
crate::paths::ensure_home_directory(home)?;
let bytes = serde_json::to_vec(cache)?;
std::fs::write(home.join(CACHE_FILENAME), bytes)?;
Ok(())
}
const NUDGE_DISABLE_ENV: &str = "BITROUTER_NO_UPDATE_CHECK";
const NUDGE_TTL_SECS: i64 = 24 * 60 * 60;
pub async fn maybe_nudge(home: &Path, p: &style::Palette) {
if std::env::var_os(NUDGE_DISABLE_ENV).is_some() {
return;
}
let now = chrono::Utc::now().timestamp();
let cache = read_cache(home);
let last = cache.as_ref().map(|c| c.checked_at);
let latest: Option<String> = if should_check(now, last, NUDGE_TTL_SECS) {
match query_latest().await {
Some(v) => {
let _ = write_cache(
home,
&UpdateCache {
checked_at: now,
latest: Some(v.clone()),
},
);
Some(v)
}
None => {
let _ = write_cache(
home,
&UpdateCache {
checked_at: now,
latest: None,
},
);
cache.and_then(|c| c.latest)
}
}
} else {
cache.and_then(|c| c.latest)
};
if let Some(latest) = latest.filter(|l| is_newer(l, current_version())) {
eprintln!();
eprintln!(
" {dim}↑ {latest} available — run `bitrouter update`{reset}",
dim = p.dim,
reset = p.reset,
);
}
}
async fn query_latest() -> Option<String> {
let mut updater = build_updater()?;
updater.configure_version_specifier(UpdateRequest::LatestMaybePrerelease);
tokio::time::timeout(
std::time::Duration::from_secs(3),
updater.query_new_version(),
)
.await
.ok()
.and_then(|r| r.ok())
.flatten()
.map(|v| v.to_string())
}
fn is_newer(candidate: &str, current: &str) -> bool {
match (
semver::Version::parse(candidate),
semver::Version::parse(current),
) {
(Ok(c), Ok(cur)) => c > cur,
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn detects_homebrew_from_cellar_path() {
let exe = Path::new("/opt/homebrew/Cellar/bitrouter/1.0.0/bin/bitrouter");
assert_eq!(detect_install_method(exe, None), InstallMethod::Homebrew);
}
#[test]
fn detects_cargo_from_cargo_home_bin() {
let exe = Path::new("/home/me/.cargo/bin/bitrouter");
let cargo_home = Path::new("/home/me/.cargo");
assert_eq!(
detect_install_method(exe, Some(cargo_home)),
InstallMethod::Cargo
);
}
#[test]
fn detects_npm_from_bin_real_path() {
let exe =
Path::new("/usr/local/lib/node_modules/bitrouter/node_modules/.bin_real/bitrouter");
assert_eq!(detect_install_method(exe, None), InstallMethod::Npm);
}
#[test]
fn npm_under_homebrew_node_is_npm_not_homebrew() {
let exe =
Path::new("/opt/homebrew/lib/node_modules/bitrouter/node_modules/.bin_real/bitrouter");
assert_eq!(detect_install_method(exe, None), InstallMethod::Npm);
}
#[test]
fn unknown_when_no_signal_matches() {
let exe = Path::new("/usr/local/bin/bitrouter");
assert_eq!(detect_install_method(exe, None), InstallMethod::Unknown);
}
#[test]
fn delegation_command_is_method_specific() {
assert_eq!(
delegation_command(InstallMethod::Homebrew),
"brew upgrade bitrouter"
);
assert_eq!(
delegation_command(InstallMethod::Cargo),
"cargo install bitrouter --force"
);
assert_eq!(
delegation_command(InstallMethod::Npm),
"npm i -g bitrouter@latest"
);
assert!(delegation_command(InstallMethod::Unknown).contains("bitrouter-installer.sh"));
}
#[test]
fn tag_takes_precedence_over_channel() {
assert_eq!(
choose_spec(Some("1.0.0-alpha.18"), true),
VersionSpec::Tag("1.0.0-alpha.18".to_string())
);
}
#[test]
fn default_channel_includes_prereleases() {
assert_eq!(choose_spec(None, false), VersionSpec::LatestPrerelease);
}
#[test]
fn stable_flag_excludes_prereleases() {
assert_eq!(choose_spec(None, true), VersionSpec::Latest);
}
const DAY: i64 = 24 * 60 * 60;
#[test]
fn checks_when_never_checked_before() {
assert!(should_check(1_000_000, None, DAY));
}
#[test]
fn skips_when_within_ttl() {
assert!(!should_check(1_000_000 + 100, Some(1_000_000), DAY));
}
#[test]
fn checks_again_after_ttl_elapsed() {
assert!(should_check(1_000_000 + DAY + 1, Some(1_000_000), DAY));
}
#[test]
fn is_newer_compares_prerelease_semver() {
assert!(is_newer("1.0.0-alpha.20", "1.0.0-alpha.19"));
assert!(!is_newer("1.0.0-alpha.19", "1.0.0-alpha.19"));
assert!(!is_newer("1.0.0-alpha.18", "1.0.0-alpha.19"));
assert!(!is_newer("garbage", "1.0.0-alpha.19"));
}
#[test]
fn cache_round_trips_through_disk() {
let dir = std::env::temp_dir().join(format!("brupd-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let cache = UpdateCache {
checked_at: 42,
latest: Some("1.0.0-alpha.20".to_string()),
};
write_cache(&dir, &cache).unwrap();
let read = read_cache(&dir).expect("cache present");
assert_eq!(read.checked_at, 42);
assert_eq!(read.latest.as_deref(), Some("1.0.0-alpha.20"));
std::fs::remove_dir_all(&dir).ok();
}
}