diff --git a/Cargo.lock b/Cargo.lock index e50fd81..fdf0959 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -153,6 +153,12 @@ version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.1" @@ -308,6 +314,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + [[package]] name = "crc32fast" version = "1.5.0" @@ -661,8 +682,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -672,9 +695,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -744,12 +769,14 @@ dependencies = [ "flate2", "indicatif", "inquire", + "lzma-rs", "mockito", "nix", "open", "rand 0.8.5", "rayon", "reqwest", + "self_update", "semver", "serde", "serde_json", @@ -761,6 +788,7 @@ dependencies = [ "tar", "tempfile", "tiny_http", + "toml", ] [[package]] @@ -845,6 +873,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots", ] [[package]] @@ -1155,6 +1184,22 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "lzma-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" +dependencies = [ + "byteorder", + "crc", +] + [[package]] name = "memchr" version = "2.8.0" @@ -1502,6 +1547,70 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.44" @@ -1686,6 +1795,8 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", "rustls-pki-types", "serde", "serde_json", @@ -1693,6 +1804,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", + "tokio-rustls", "tower", "tower-http", "tower-service", @@ -1700,6 +1812,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots", ] [[package]] @@ -1716,6 +1829,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustc_version" version = "0.4.1" @@ -1758,6 +1877,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -1770,6 +1890,7 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ + "web-time", "zeroize", ] @@ -1834,6 +1955,36 @@ dependencies = [ "libc", ] +[[package]] +name = "self-replace" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ec815b5eab420ab893f63393878d89c90fdd94c0bcc44c07abb8ad95552fb7" +dependencies = [ + "fastrand", + "tempfile", + "windows-sys 0.52.0", +] + +[[package]] +name = "self_update" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d832c086ece0dacc29fb2947bb4219b8f6e12fe9e40b7108f9e57c4224e47b5c" +dependencies = [ + "hyper", + "indicatif", + "log", + "quick-xml", + "regex", + "reqwest", + "self-replace", + "semver", + "serde_json", + "tempfile", + "urlencoding", +] + [[package]] name = "semver" version = "1.0.27" @@ -1883,6 +2034,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2195,6 +2355,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.50.0" @@ -2243,6 +2418,47 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tower" version = "0.5.3" @@ -2373,6 +2589,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -2553,6 +2775,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index 09ef74a..5eeab83 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,10 +37,23 @@ tar = "0.4" semver = "1" sqlformat = "0.5.0" sysinfo = { version = "0.38.4", default-features = false, features = ["system"] } +self_update = { version = "0.42", default-features = false, features = ["rustls"] } +lzma-rs = "0.3" +tempfile = "3" [dev-dependencies] mockito = "1" -tempfile = "3" + +[build-dependencies] +toml = "0.8" + +# Project distribution config read by build.rs and exposed as compile-time +# env vars (HOTDATA_HOMEBREW_FORMULA, ...) so source files don't hardcode +# values that also live in the README / dist-workspace.toml. +[package.metadata.hotdata] +# Fully-qualified Homebrew install target. Matches the `brew install` line +# in README.md; the trailing segment must match `formula` in dist-workspace.toml. +homebrew_formula = "hotdata-dev/tap/cli" [package.metadata.release] pre-release-hook = ["git-cliff", "-o", "CHANGELOG.md", "--tag", "v{{version}}" ] diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..cb39ef7 --- /dev/null +++ b/build.rs @@ -0,0 +1,30 @@ +// Reads `[package.metadata.hotdata]` from Cargo.toml and re-exports the +// values as compile-time environment variables, so source files can read +// distribution config (e.g. the Homebrew formula) via `env!()` without +// duplicating strings that also live in README.md / dist-workspace.toml. + +use std::path::PathBuf; + +fn main() { + let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); + let manifest_path = manifest_dir.join("Cargo.toml"); + println!("cargo:rerun-if-changed={}", manifest_path.display()); + + let raw = std::fs::read_to_string(&manifest_path) + .unwrap_or_else(|e| panic!("could not read {}: {e}", manifest_path.display())); + let parsed: toml::Value = toml::from_str(&raw) + .unwrap_or_else(|e| panic!("could not parse {}: {e}", manifest_path.display())); + + let meta = parsed + .get("package") + .and_then(|p| p.get("metadata")) + .and_then(|m| m.get("hotdata")) + .unwrap_or_else(|| panic!("missing [package.metadata.hotdata] in Cargo.toml")); + + let homebrew_formula = meta + .get("homebrew_formula") + .and_then(|v| v.as_str()) + .unwrap_or_else(|| panic!("missing package.metadata.hotdata.homebrew_formula")); + + println!("cargo:rustc-env=HOTDATA_HOMEBREW_FORMULA={homebrew_formula}"); +} diff --git a/src/command.rs b/src/command.rs index 3013c70..d4483b9 100644 --- a/src/command.rs +++ b/src/command.rs @@ -223,6 +223,9 @@ pub enum Commands { #[arg(value_enum)] shell: ShellChoice, }, + + /// Update the hotdata CLI to the latest release + Update, } #[derive(Clone, clap::ValueEnum)] diff --git a/src/main.rs b/src/main.rs index 3b2b72d..04ee4ba 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,6 +18,7 @@ mod sandbox_session; mod skill; mod table; mod tables; +mod update; mod util; mod workspace; @@ -148,6 +149,12 @@ fn main() { skill::maybe_auto_update_after_cli_upgrade(); } + // Quiet update-available notice. Skip during `hotdata update` itself so + // we don't talk over the updater's own output. + if !matches!(&cli.command, Some(Commands::Update)) { + update::maybe_print_update_notice(); + } + match cli.command { None => { use clap::CommandFactory; @@ -781,6 +788,7 @@ fn main() { let mut cmd = Cli::command(); generate(shell, &mut cmd, "hotdata", &mut std::io::stdout()); } + Commands::Update => update::run_update(), }, } } diff --git a/src/update.rs b/src/update.rs new file mode 100644 index 0000000..3aa8ba2 --- /dev/null +++ b/src/update.rs @@ -0,0 +1,275 @@ +use crate::util; +use crossterm::style::Stylize; +use semver::Version; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +const REPO_OWNER: &str = "hotdata-dev"; +const REPO_NAME: &str = "hotdata-cli"; +const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); +/// Fully-qualified Homebrew formula (e.g. `hotdata-dev/tap/cli`). Pulled from +/// `[package.metadata.hotdata]` in Cargo.toml via build.rs so the README, +/// dist-workspace.toml, and this binary all agree on the same target. +const HOMEBREW_FORMULA: &str = env!("HOTDATA_HOMEBREW_FORMULA"); +const CHECK_INTERVAL_SECS: u64 = 86_400; +const NETWORK_TIMEOUT_SECS: u64 = 5; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InstallMethod { + Homebrew, + Other, +} + +pub fn detect_install_method() -> InstallMethod { + let Ok(exe) = std::env::current_exe() else { + return InstallMethod::Other; + }; + let path = fs::canonicalize(&exe).unwrap_or(exe); + let s = path.to_string_lossy(); + // Homebrew installs land under .../Cellar///bin on every + // platform (`/opt/homebrew/Cellar`, `/usr/local/Cellar`, `/home/linuxbrew/...`). + if s.contains("/Cellar/") { + return InstallMethod::Homebrew; + } + InstallMethod::Other +} + +#[derive(Serialize, Deserialize)] +struct UpdateCheckCache { + checked_at: u64, + latest_version: String, +} + +fn cache_path() -> Option { + crate::config::config_dir().ok().map(|d| d.join(".update_check.json")) +} + +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn read_cache() -> Option { + let s = fs::read_to_string(cache_path()?).ok()?; + serde_json::from_str(&s).ok() +} + +fn write_cache(cache: &UpdateCheckCache) { + let Some(path) = cache_path() else { return }; + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + if let Ok(s) = serde_json::to_string(cache) { + let _ = fs::write(path, s); + } +} + +fn fetch_latest_version() -> Result { + let url = format!("https://api.github.com/repos/{REPO_OWNER}/{REPO_NAME}/releases/latest"); + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(NETWORK_TIMEOUT_SECS)) + .build() + .map_err(|e| e.to_string())?; + let resp = client + .get(&url) + .header("User-Agent", concat!("hotdata-cli/", env!("CARGO_PKG_VERSION"))) + .header("Accept", "application/vnd.github+json") + .send() + .map_err(|e| e.to_string())?; + if !resp.status().is_success() { + return Err(format!("HTTP {}", resp.status())); + } + let json: serde_json::Value = resp.json().map_err(|e| e.to_string())?; + let tag = json + .get("tag_name") + .and_then(|v| v.as_str()) + .ok_or("no tag_name in response")?; + Version::parse(tag.trim_start_matches('v')).map_err(|e| e.to_string()) +} + +/// Returns Some(latest) if a newer version is available, using the cached +/// value when fresh and refreshing it (best-effort) otherwise. Silent on errors. +fn cached_latest_if_newer() -> Option { + let current = Version::parse(CURRENT_VERSION).ok()?; + let cache = read_cache(); + let fresh = cache + .as_ref() + .map(|c| now_secs().saturating_sub(c.checked_at) < CHECK_INTERVAL_SECS) + .unwrap_or(false); + + let latest = if fresh { + Version::parse(&cache.as_ref()?.latest_version).ok()? + } else { + let v = fetch_latest_version().ok()?; + write_cache(&UpdateCheckCache { + checked_at: now_secs(), + latest_version: v.to_string(), + }); + v + }; + + (latest > current).then_some(latest) +} + +fn stderr_is_tty() -> bool { + use std::io::IsTerminal; + std::io::stderr().is_terminal() +} + +/// Print a one-line notice if a newer release exists. No-op when stderr +/// isn't a TTY, when --no-input is set, or when the cache says we're up +/// to date. Best-effort: network/cache errors are swallowed silently so +/// commands never fail because of the update check. +pub fn maybe_print_update_notice() { + if !stderr_is_tty() { + return; + } + if !util::is_interactive() { + return; + } + if std::env::var_os("HOTDATA_NO_UPDATE_CHECK").is_some() { + return; + } + let Some(latest) = cached_latest_if_newer() else { + return; + }; + let how = match detect_install_method() { + InstallMethod::Homebrew => format!("Run: brew upgrade {HOMEBREW_FORMULA}"), + InstallMethod::Other => "Run: hotdata update".to_string(), + }; + eprintln!( + "{}", + format!( + "A new version of hotdata is available (v{CURRENT_VERSION} → v{latest}). {how}" + ) + .yellow() + ); +} + +pub fn run_update() { + let current = Version::parse(CURRENT_VERSION).expect("invalid package version"); + + if detect_install_method() == InstallMethod::Homebrew { + println!("hotdata was installed via Homebrew. Update with:"); + println!(" {}", format!("brew upgrade {HOMEBREW_FORMULA}").cyan()); + return; + } + + println!("Checking for updates..."); + let latest = match fetch_latest_version() { + Ok(v) => v, + Err(e) => { + eprintln!("{}", format!("error: could not check for updates: {e}").red()); + std::process::exit(1); + } + }; + + if latest <= current { + println!("Already up to date (v{current})."); + // Refresh cache so the notice goes away. + write_cache(&UpdateCheckCache { + checked_at: now_secs(), + latest_version: latest.to_string(), + }); + return; + } + + println!("Updating from v{current} to v{latest}..."); + if let Err(e) = perform_update(&latest) { + eprintln!("{}", format!("error: update failed: {e}").red()); + std::process::exit(1); + } + println!("{}", format!("Updated to v{latest}.").green()); + + // Bust the cache so the notice clears on the next run. + write_cache(&UpdateCheckCache { + checked_at: now_secs(), + latest_version: latest.to_string(), + }); +} + +/// Download the cargo-dist tar.xz asset for the running target, unpack it, +/// and atomically swap the running binary with the new one. +fn perform_update(version: &Version) -> Result<(), String> { + let target = self_update::get_target(); + let asset_stem = format!("{REPO_NAME}-{target}"); + let asset_name = format!("{asset_stem}.tar.xz"); + let url = format!( + "https://github.com/{REPO_OWNER}/{REPO_NAME}/releases/download/v{version}/{asset_name}" + ); + + util::debug_request("GET", &url, &[], None); + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(120)) + .build() + .map_err(|e| format!("http client: {e}"))?; + let resp = client + .get(&url) + .header( + "User-Agent", + concat!("hotdata-cli/", env!("CARGO_PKG_VERSION")), + ) + .send() + .map_err(|e| format!("download: {e}"))?; + if !resp.status().is_success() { + return Err(format!("HTTP {} downloading {asset_name}", resp.status())); + } + let xz_bytes = resp + .bytes() + .map_err(|e| format!("reading download: {e}"))?; + + let mut tar_bytes: Vec = Vec::with_capacity(xz_bytes.len() * 4); + lzma_rs::xz_decompress(&mut std::io::Cursor::new(&xz_bytes[..]), &mut tar_bytes) + .map_err(|e| format!("xz decompress: {e}"))?; + + // `tempfile::TempDir` creates a randomly-named directory with 0700 + // permissions and removes it on drop. The random suffix prevents a + // local attacker on a shared system from pre-planting a symlink at a + // predictable path and redirecting the extraction to a directory + // they control. + let tmp_dir = tempfile::TempDir::new().map_err(|e| format!("creating temp dir: {e}"))?; + + let mut archive = tar::Archive::new(std::io::Cursor::new(&tar_bytes[..])); + archive + .unpack(tmp_dir.path()) + .map_err(|e| format!("extract tar: {e}"))?; + + // cargo-dist lays out the tarball as `/hotdata` (the binary + // sits at the top of a single directory matching the asset name without + // its extension). + let new_binary = tmp_dir.path().join(&asset_stem).join("hotdata"); + if !new_binary.exists() { + return Err(format!( + "binary not found in archive at {}", + new_binary.display() + )); + } + + let current_exe = std::env::current_exe().map_err(|e| format!("current_exe: {e}"))?; + let current_exe = fs::canonicalize(¤t_exe).unwrap_or(current_exe); + + // Reserve a sibling temp file on the same filesystem as the destination + // so `Move::to_dest` can do an atomic rename. + let backup = current_exe.with_extension("old"); + let _ = fs::remove_file(&backup); + + self_update::Move::from_source(&new_binary) + .replace_using_temp(&backup) + .to_dest(¤t_exe) + .map_err(|e| format!("replacing binary: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detect_install_method_returns_one_of_the_variants() { + let m = detect_install_method(); + assert!(matches!(m, InstallMethod::Homebrew | InstallMethod::Other)); + } +}