use std::time::{Duration, SystemTime, UNIX_EPOCH};
#[cfg_attr(windows, allow(unused_imports))]
use anyhow::{Context, Result};
const CURRENT: &str = env!("CARGO_PKG_VERSION");
fn repo() -> &'static str {
env!("CARGO_PKG_REPOSITORY")
.trim_end_matches('/')
.trim_start_matches("https://github.com/")
}
#[derive(PartialEq, Eq)]
pub(crate) enum Channel {
Binary,
Cargo,
Homebrew,
Npm,
}
pub(crate) fn channel() -> Channel {
channel_of(
&std::env::current_exe()
.map(|e| e.to_string_lossy().into_owned())
.unwrap_or_default(),
)
}
fn channel_of(p: &str) -> Channel {
if p.contains("node_modules") || p.contains("/_npx/") || p.contains("\\_npx\\") {
Channel::Npm
} else if p.contains("/.cargo/") || p.contains("\\.cargo\\") {
Channel::Cargo
} else if p.contains("/Cellar/") || p.contains("/homebrew/") || p.contains("/linuxbrew/") {
Channel::Homebrew
} else {
Channel::Binary
}
}
fn semver(s: &str) -> (u64, u64, u64) {
let mut it = s.trim_start_matches('v').split(['.', '-', '+']);
let n = |x: Option<&str>| x.and_then(|v| v.parse().ok()).unwrap_or(0);
(n(it.next()), n(it.next()), n(it.next()))
}
fn fetch_latest() -> Option<String> {
let url = format!("https://api.github.com/repos/{}/releases/latest", repo());
let mut req = ureq::get(&url)
.config()
.timeout_global(Some(Duration::from_secs(3)))
.http_status_as_error(false)
.build();
req = req.header("User-Agent", "llmtrim-update-check"); let body = req.call().ok()?.body_mut().read_to_string().ok()?;
let v: serde_json::Value = serde_json::from_str(&body).ok()?;
Some(
v.get("tag_name")?
.as_str()?
.trim_start_matches('v')
.to_string(),
)
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn cache_path() -> Option<std::path::PathBuf> {
crate::daemon::home_dir()
.ok()
.map(|h| h.join("update-check.json"))
}
fn write_cache(latest: &str) {
if let Some(p) = cache_path() {
if let Some(dir) = p.parent() {
let _ = std::fs::create_dir_all(dir);
}
let _ = std::fs::write(
&p,
serde_json::json!({ "checked_at": now_secs(), "latest": latest }).to_string(),
);
}
}
pub fn check(force: bool) -> Option<String> {
if llmtrim_core::config::RuntimeConfig::get().no_update_check {
return None;
}
if !force
&& let Some(txt) = cache_path().and_then(|p| std::fs::read_to_string(p).ok())
&& let Ok(c) = serde_json::from_str::<serde_json::Value>(&txt)
{
let at = c.get("checked_at").and_then(|x| x.as_u64()).unwrap_or(0);
if now_secs().saturating_sub(at) < 86_400 {
let latest = c.get("latest").and_then(|x| x.as_str()).unwrap_or("");
return newer(latest);
}
}
let latest = fetch_latest().unwrap_or_default();
write_cache(&latest);
newer(&latest)
}
fn newer(latest: &str) -> Option<String> {
(!latest.is_empty() && semver(latest) > semver(CURRENT)).then(|| latest.to_string())
}
pub fn run() -> Result<()> {
let color = crate::ui::color_stdout();
println!(
"{} {}",
crate::ui::wordmark(color),
crate::ui::paint(
color,
crate::ui::Tone::Dim,
&format!("v{CURRENT} · checking the latest release…")
)
);
let latest = fetch_latest();
match &latest {
Some(v) if semver(v) <= semver(CURRENT) => {
println!(
"{}",
crate::ui::ok(color, &format!("Already up to date (v{CURRENT})."))
);
return Ok(());
}
Some(v) => println!(
"{} v{v} available {}",
crate::ui::paint(color, crate::ui::Tone::Accent, "→"),
crate::ui::paint(
color,
crate::ui::Tone::Dim,
&format!("(you have v{CURRENT})")
)
),
None => println!(
"{}",
crate::ui::note(
color,
"Couldn't reach GitHub to confirm the version — proceeding anyway."
)
),
}
let instructions = |title: &str, cmds: &[&str]| {
let lines: Vec<String> = cmds.iter().map(|c| c.to_string()).collect();
print!("\n{}", crate::ui::panel(color, title, &lines));
};
match channel() {
Channel::Cargo => instructions(
"update via cargo",
&[
"cargo install --locked llmtrim --force",
"llmtrim setup # restart the daemon on the new binary",
],
),
Channel::Homebrew => instructions(
"update via Homebrew",
&[
"brew tap fkiene/tap",
"brew upgrade fkiene/tap/llmtrim",
"llmtrim setup # restart the daemon on the new binary",
],
),
Channel::Npm => instructions(
"update via npm",
&[
"npm install -g @llmtrim/cli@latest",
"llmtrim setup # restart the daemon on the new binary",
],
),
Channel::Binary => {
let tag = latest
.as_deref()
.map(|v| format!("v{v}"))
.unwrap_or_else(|| "main".to_string());
#[cfg(windows)]
instructions(
"update via the installer",
&[
&format!(
"iwr -useb https://raw.githubusercontent.com/{}/{tag}/install.ps1 | iex",
repo()
),
"llmtrim setup # restart the daemon on the new binary",
],
);
#[cfg(not(windows))]
{
let url = format!(
"https://raw.githubusercontent.com/{}/{tag}/install.sh",
repo()
);
println!(
"Updating via the installer (downloads the latest release; `setup` restarts the daemon)…"
);
let mut cmd = std::process::Command::new("sh");
cmd.args(["-c", &format!("curl -fsSL {url} | sh")]);
if tag != "main" {
cmd.env("LLMTRIM_VERSION", &tag);
}
let status = cmd
.status()
.context("failed to launch the installer (curl + sh required)")?;
if !status.success() {
anyhow::bail!("installer exited non-zero");
}
if let Some(v) = latest {
write_cache(&v); }
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn channel_detection_by_path() {
assert!(matches!(
channel_of("/home/u/.cargo/bin/llmtrim"),
Channel::Cargo
));
assert!(matches!(
channel_of("C:\\Users\\u\\.cargo\\bin\\llmtrim.exe"),
Channel::Cargo
));
assert!(matches!(
channel_of("/opt/homebrew/Cellar/llmtrim/0.1.1/bin/llmtrim"),
Channel::Homebrew
));
assert!(matches!(
channel_of("/home/linuxbrew/.linuxbrew/bin/llmtrim"),
Channel::Homebrew
));
assert!(matches!(
channel_of("/usr/lib/node_modules/llmtrim-linux-x64/bin/llmtrim"),
Channel::Npm
));
assert!(matches!(
channel_of("/home/u/.npm/_npx/abc123/node_modules/.bin/llmtrim"),
Channel::Npm
));
assert!(matches!(
channel_of("C:\\Users\\u\\AppData\\npm-cache\\_npx\\x\\llmtrim.exe"),
Channel::Npm
));
assert!(matches!(
channel_of("/home/u/.local/bin/llmtrim"),
Channel::Binary
));
assert!(matches!(
channel_of("C:\\Users\\u\\AppData\\Local\\llmtrim\\bin\\llmtrim.exe"),
Channel::Binary
));
assert!(matches!(channel_of(""), Channel::Binary));
}
#[test]
fn semver_compares() {
assert!(semver("0.2.0") > semver("0.1.9"));
assert!(semver("v1.0.0") > semver("0.9.9"));
assert_eq!(semver("0.1.0"), semver("0.1.0-rc1")); }
#[test]
fn newer_only_when_ahead() {
assert!(newer("999.0.0").is_some());
assert!(newer(CURRENT).is_none());
assert!(newer("0.0.1").is_none());
assert!(newer("").is_none());
}
#[test]
fn repo_is_owner_slash_name() {
assert!(!repo().starts_with("http"));
assert_eq!(repo().matches('/').count(), 1, "owner/name");
}
}