use anyhow::{Context, Result}; use std::io::{self, Write}; use std::process::{Command, Stdio}; use std::time::Duration; #[cfg(unix)] use std::os::unix::process::CommandExt; use futures::StreamExt; use indicatif::{ProgressBar, ProgressStyle}; use tokio::io::AsyncWriteExt; use crate::version::{ UpdateConfig, fetch_latest_version, get_installed_grok_version, get_latest_version, is_version_cache_fresh, try_fetch_stable_pointer, write_version_cache, }; use xai_grok_shell::util::config; use xai_grok_shell::util::grok_home::{grok_application, grok_home}; #[derive(Clone, Copy, Debug)] pub enum UpdateRunMode { Blocking, NonBlocking, } const PROMPT_UPDATE_NOW: &str = "Update now? [Y/n/d]"; const MSG_AUTO_UPDATE_BACKGROUND: &str = "Auto-update running in background."; const MSG_RUN_UPDATE_MANUAL: &str = "Run `grok update` to get the latest version."; /// Manual-install one-liner for this platform's bootstrap installer. fn manual_install_cmd() -> &'static str { if cfg!(windows) { "irm https://x.ai/cli/install.ps1 | iex" } else { "curl -fsSL https://x.ai/cli/install.sh | bash" } } /// Build a reinstall hint for a known installer type. fn reinstall_hint(installer: &str) -> String { match installer { "npm" => "Please reinstall via npm:\n npm i -g @xai-official/grok".to_string(), "gh-release" => "Please reinstall via GitHub Releases:\n gh release download --repo xai-org-shared/grok-build --pattern 'grok-*' --output grok && chmod +x grok".to_string(), _ => format!("Please reinstall via:\n {}", manual_install_cmd()), } } #[derive(Debug, serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct UpdateStatus { pub current_version: String, pub latest_version: Option, pub update_available: bool, pub installer: Option, pub channel: String, pub auto_update: Option, pub error: Option, } /// Format and print an [`UpdateStatus`] to stdout. pub fn print_update_status(status: &UpdateStatus, json: bool) -> anyhow::Result<()> { if json { let payload = serde_json::to_string(status)?; println!("{payload}"); return Ok(()); } if let Some(error) = status.error.as_deref() { println!( "Grok Build - v{} [{}]", status.current_version, status.channel ); println!("Update check failed: {error}"); return Ok(()); } let channel_label = format!(" [{}]", status.channel); if status.update_available { if let Some(latest_version) = status.latest_version.as_deref() { println!( "A new version of Grok Build is available: {} -> {}{}", status.current_version, latest_version, channel_label ); } else { println!("A new version of Grok Build is available."); } return Ok(()); } if let Some(latest_version) = status.latest_version.as_deref() { println!( "Grok Build - v{} (latest: {}){}", status.current_version, latest_version, channel_label ); return Ok(()); } println!("Grok Build - v{}{}", status.current_version, channel_label); Ok(()) } pub async fn check_update_status(update_config: &UpdateConfig) -> UpdateStatus { let installer = get_installer().await.map(|value| value.to_string()); let current_version = get_installed_grok_version(); let current_config = config::load_config().await; let auto_update = current_config.cli.auto_update; let channel = update_config.channel.clone(); let Some(ref inst) = installer else { return UpdateStatus { current_version, latest_version: None, update_available: false, installer, channel, auto_update, error: None, }; }; match get_latest_version(inst, update_config).await { Ok(latest_version) => { let mut error = None; // --check reports upgrades only; a rolled-back pointer isn't a "new version" to advertise here (auto-update converges separately). let allow_downgrade = false; let update_available = match needs_update(¤t_version, &latest_version, &channel, allow_downgrade) { Some(value) => value, None => { // Distinguish parse failure from unsupported channel for clearer diagnostics. let parse_ok = semver::Version::parse(¤t_version).is_ok() && semver::Version::parse(&latest_version).is_ok(); error = Some(if parse_ok { format!( "Unsupported release channel '{}' (current={}, latest={}). \ Supported channels: stable, alpha, enterprise.", channel, current_version, latest_version ) } else { format!( "Failed to parse versions (current={}, latest={})", current_version, latest_version ) }); false } }; UpdateStatus { current_version, latest_version: Some(latest_version), update_available, installer, channel, auto_update, error, } } Err(err) => UpdateStatus { current_version, latest_version: None, update_available: false, installer, channel, auto_update, error: Some(err.to_string()), }, } } /// Installer + version the leader/background path should converge to: an /// upgrade OR an authoritative-installer rollback. `None` means stay put. Gates /// on the installer (via `installer_allows_downgrade`) so npm is never /// downgraded — the decision depends on the installer, never the caller. pub async fn auto_update_target(update_config: &UpdateConfig) -> Option<(&'static str, String)> { let installer = get_installer().await?; let current = get_installed_grok_version(); let latest = fetch_latest_version(installer, update_config).await.ok()?; needs_update( ¤t, &latest, &update_config.channel, installer_allows_downgrade(installer), ) .unwrap_or(false) .then_some((installer, latest)) } /// Outcome of [`ensure_latest_on_disk`]. #[derive(Debug)] pub struct EnsureLatestOutcome { /// Version this call downloaded and installed; `None` when the disk was /// already current (or there was no installer). pub installed: Option, /// The running process differs from what is now on disk in the channel's /// update direction — the caller should relaunch onto the on-disk binary. pub relaunch_needed: bool, } /// One leader auto-update pass: converge the on-disk install to the channel /// pointer (downloading **only** when the disk is actually behind it), then /// report whether the running process should relaunch onto the on-disk binary. /// /// Unlike [`run_update`] this never uses the compiled-in version for the /// download decision — a binary already installed by another process (TUI /// background download, explicit `grok update`) is reused as-is. This both /// removes the duplicate download in leader mode and stops the pre-fix /// hourly re-download while a busy leader keeps deferring its relaunch. /// /// When the disk version is unknowable ([`disk_version_for_installer`]: /// npm-managed installs, Windows copy-based installs, dev builds), this /// degrades to the pre-fix behavior — download when the *running* process is /// stale, relaunch only after a download this pass actually installed /// something. Note the Windows consequence: the hourly busy-leader /// re-download is NOT fixed there; only the symlink layout can prove the /// disk is current without exec'ing the binary. pub async fn ensure_latest_on_disk(update_config: &UpdateConfig) -> Result { let mut outcome = EnsureLatestOutcome { installed: None, relaunch_needed: false, }; let Some(installer) = get_installer().await else { return Ok(outcome); }; heal_managed_install(installer).await; let allow_downgrade = installer_allows_downgrade(installer); let latest = fetch_latest_version(installer, update_config).await?; let effective_current = disk_version_for_installer(installer).unwrap_or_else(get_installed_grok_version); if needs_update( &effective_current, &latest, &update_config.channel, allow_downgrade, ) .unwrap_or(false) { run_install_script(installer, Some(&latest), update_config).await?; outcome.installed = Some(latest.clone()); } // Relaunch when the running binary differs from what's on disk in the // channel's update direction — covers binaries installed by other // processes, not just the install above. let running = get_installed_grok_version(); if let Some(disk_now) = disk_version_for_installer(installer).or_else(|| outcome.installed.clone()) { outcome.relaunch_needed = needs_update(&running, &disk_now, &update_config.channel, allow_downgrade) .unwrap_or(false); } Ok(outcome) } /// Disk-version probe gated on the installer actually maintaining the /// managed `~/.grok/bin/grok` symlink. /// /// Only the internal (install.sh / CDN) and gh-release installers write that /// symlink. npm manages its own global install, so for npm a symlink left /// over from a previous internal install would LIE about the npm install's /// version — in the worst direction, a leftover symlink "newer" than the npm /// registry would make every updater report "already up to date" and /// silently suppress npm updates forever. Unknown installers are treated /// like npm (no trustworthy disk version). fn disk_version_for_installer(installer: &str) -> Option { match installer { "internal" | "gh-release" => crate::version::installed_on_disk_version(), _ => None, } } fn env_installer() -> Option<&'static str> { if let Ok(v) = std::env::var("GROK_INSTALLER") { return match v.to_ascii_lowercase().as_str() { "npm" => Some("npm"), "internal" => Some("internal"), "gh-release" | "gh" => Some("gh-release"), _ => None, }; } if std::env::var_os("GROK_MANAGED_BY_NPM").is_some() { return Some("npm"); } if std::env::var_os("GROK_MANAGED_BY_INTERNAL").is_some() { return Some("internal"); } if std::env::var_os("npm_config_user_agent").is_some() { return Some("npm"); } None } pub async fn get_installer() -> Option<&'static str> { if let Some(i) = env_installer() { return Some(i); } let cfg = config::load_config().await; match cfg.cli.installer.as_deref() { Some("npm") => Some("npm"), Some("gh-release") => Some("gh-release"), _ => Some("internal"), } } fn needs_update(current: &str, target: &str, channel: &str, allow_downgrade: bool) -> Option { let current = semver::Version::parse(current).ok()?; let target = semver::Version::parse(target).ok()?; match channel { // NOTE: With the 0.2.X versioning scheme, all versions are plain // semver (no pre-release suffix). The pre-release checks in this // match are dead code but kept as a safety net. "stable" | "enterprise" => { if !target.pre.is_empty() { tracing::warn!( %current, %target, channel = %channel, "stable/enterprise channel received pre-release candidate, rejecting" ); return Some(false); } if !current.pre.is_empty() { return Some(true); } } "alpha" => {} _ => return None, } Some(if allow_downgrade { target != current } else { target > current }) } /// Returns `true` for installer backends whose version source is authoritative /// (managed by xAI directly), meaning a pointer rollback is intentional and /// should trigger a client downgrade. Returns `false` for backends like npm /// where stale corporate registries/proxies can return arbitrarily old versions. /// /// Users who installed via `install.sh` are classified as `"internal"` by /// `get_installer()`, so they also get rollback support. fn installer_allows_downgrade(installer: &str) -> bool { match installer { "internal" | "gh-release" => true, "npm" => false, _ => false, } } /// Result of a background update availability check. #[derive(Debug, Clone)] pub struct UpdateAvailable { /// The latest version string (e.g. "0.1.200"). pub latest_version: String, } /// Outcome of [`check_update_background`]. pub struct BackgroundUpdateCheck { /// `Some` when the *running* binary is older than the channel pointer — /// drives the in-TUI restart hint regardless of who downloads the binary. pub update: Option, /// Handle to the background `grok update` child, `Some` only when a /// download was actually started (the on-disk install was behind the /// pointer). The TUI parks this and `wait()`s on it at quit-for-update /// time instead of spawning a second downloader. pub download: Option, } impl BackgroundUpdateCheck { fn none() -> Self { Self { update: None, download: None, } } } /// Check for available updates without blocking the TUI startup. /// /// Sets [`BackgroundUpdateCheck::update`] when the running binary is older /// than the channel pointer. If `auto_update` is enabled **and the on-disk /// install is also behind the pointer**, kicks off a non-blocking download /// (spawns `grok update` as a detached child process) so the new binary is /// ready when the user quits and relaunches. When another process (an earlier /// TUI, the leader's hourly checker) already put the target version on disk, /// no download is started — only the restart hint is surfaced. pub async fn check_update_background(update_config: &UpdateConfig) -> BackgroundUpdateCheck { let Some(installer) = get_installer().await else { return BackgroundUpdateCheck::none(); }; heal_managed_install(installer).await; if is_version_cache_fresh().await { return BackgroundUpdateCheck::none(); } let current_config = config::load_config().await; if current_config.cli.auto_update == Some(false) { return BackgroundUpdateCheck::none(); } let current_version = get_installed_grok_version(); let latest_version = match fetch_latest_version(installer, update_config).await { Ok(v) => v, Err(_) => return BackgroundUpdateCheck::none(), }; let allow_downgrade = installer_allows_downgrade(installer); if !needs_update( ¤t_version, &latest_version, &update_config.channel, allow_downgrade, ) .unwrap_or(false) { let stable_ptr = try_fetch_stable_pointer().await; write_version_cache(&latest_version, stable_ptr.as_deref()).await; return BackgroundUpdateCheck::none(); } // Only download when the on-disk install is behind the pointer; the // running process being stale (checked above) just means "show the // restart hint". The quit-for-update path's `grok update` child resolves // to "Already up to date" against the same disk state. Gated on the // installer maintaining the managed symlink — for npm a leftover symlink // would wrongly suppress the download (see `disk_version_for_installer`). let disk_needs_download = match disk_version_for_installer(installer) { Some(disk) => needs_update( &disk, &latest_version, &update_config.channel, allow_downgrade, ) .unwrap_or(true), None => true, }; // Kick off a non-blocking download so the binary is ready when the // user restarts (or accepts the in-TUI restart prompt). let download = if disk_needs_download { match run_update_subcommand(UpdateRunMode::NonBlocking).await { Ok(child) => child, Err(e) => { tracing::warn!("Background update download failed to start: {e}"); None } } } else { tracing::info!( latest_version = %latest_version, "Background update: target already on disk, skipping download" ); None }; BackgroundUpdateCheck { update: Some(UpdateAvailable { latest_version }), download, } } /// Returns Ok(true) if a blocking update ran; otherwise Ok(false). pub async fn run_update_if_available( run_mode: UpdateRunMode, interactive: bool, update_config: &UpdateConfig, ) -> Result { let Some(inst) = get_installer().await else { // Skip update check if no known installer. return Ok(false); }; heal_managed_install(inst).await; if is_version_cache_fresh().await { return Ok(false); } let current_config = config::load_config().await; // Skip update check if auto-update is explicitly disabled. if current_config.cli.auto_update == Some(false) { return Ok(false); } // Resolve effective auto_update: None defaults to true (first-run). let auto_update = current_config.cli.auto_update.unwrap_or(true); if current_config.cli.auto_update.is_none() && let Err(e) = config::update_config(|st| { if st.cli.auto_update.is_none() { st.cli.auto_update = Some(true); } }) .await { tracing::warn!("Failed to save auto-update setting: {}", e); } let current_version = get_installed_grok_version(); // Fetch without writing version.json — we only cache after confirming the // update is not needed or after a successful blocking install. This prevents // a failed background download from suppressing retries for the TTL window. let latest_version = match fetch_latest_version(inst, update_config).await { Ok(v) => v, Err(_) => return Ok(false), }; if !needs_update( ¤t_version, &latest_version, &update_config.channel, installer_allows_downgrade(inst), ) .unwrap_or(false) { let stable_ptr = try_fetch_stable_pointer().await; write_version_cache(&latest_version, stable_ptr.as_deref()).await; return Ok(false); } let channel_label = format!(" [{}]", update_config.channel); if auto_update { eprintln!( "A new version of Grok Build is available: {} -> {}{}", current_version, latest_version, channel_label ); if interactive { if let Err(e) = run_update_subcommand(run_mode).await { eprintln!("Update failed: {}", e); } else if matches!(run_mode, UpdateRunMode::Blocking) { return Ok(true); } else { eprintln!("{}", MSG_AUTO_UPDATE_BACKGROUND); return Ok(false); } } else if let Err(e) = run_update_subcommand(run_mode).await { eprintln!("Update failed: {}", e); } else if matches!(run_mode, UpdateRunMode::Blocking) { return Ok(true); } return Ok(false); } else { if current_config .cli .dismissed_version .as_deref() .is_some_and(|v| v == latest_version) { return Ok(false); } eprintln!( "A new version of Grok Build is available: {} -> {}{}", current_version, latest_version, channel_label ); if interactive { eprintln!("{}", PROMPT_UPDATE_NOW); let mut line = String::new(); if io::stdin().read_line(&mut line).is_ok() { let ans = line.trim().to_ascii_lowercase(); if ans.is_empty() || ans == "y" || ans == "yes" { if let Err(e) = run_update_subcommand(run_mode).await { eprintln!("Update failed: {}", e); } else if matches!(run_mode, UpdateRunMode::Blocking) { return Ok(true); } else { eprintln!("{}", MSG_AUTO_UPDATE_BACKGROUND); return Ok(false); } } else if ans == "d" || ans == "dismiss" { let dismissed = latest_version.clone(); if let Err(e) = config::update_config(|st| { st.cli.dismissed_version = Some(dismissed); }) .await { tracing::warn!("Failed to save dismissed version: {}", e); } } } } else { eprintln!("{}", MSG_RUN_UPDATE_MANUAL); } } Ok(false) } /// Launch "grok update" in blocking or non-blocking mode. /// /// In `NonBlocking` mode the spawned child's handle is returned so the caller /// can later `wait()` on the in-flight download (e.g. the TUI's /// quit-for-update path) instead of blind-spawning a second downloader. /// Dropping the handle does not kill the child (`kill_on_drop` is off), so /// callers that don't care can ignore it. `Blocking` mode returns `None`. async fn run_update_subcommand(run_mode: UpdateRunMode) -> Result> { let exe = std::env::current_exe()?; let mut cmd = tokio::process::Command::new(exe); cmd.arg("update"); match run_mode { UpdateRunMode::Blocking => { // stderr must be null, not piped: `.status()` does not drain // pipes, so if the child writes more than the OS pipe buffer // (~16 KB macOS / ~64 KB Linux) to stderr (e.g. download // progress bars), the child blocks on the write while the // parent blocks on waitpid — deadlocking both processes. // With `panic = "abort"`, the blocked child eventually // receives SIGABRT. cmd.stdin(Stdio::null()) .stdout(Stdio::null()) // inherit, not piped: the TUI is already restored so the // parent's stderr fd is a normal terminal. inherit lets // the child's diagnostic output reach the user. piped + // status() would immediately close the read end → EPIPE // → panic → SIGABRT (signal 6) under panic=abort. .stderr(Stdio::inherit()); // No detach: the child must stay in the foreground process group so Ctrl+C cancels it with the parent; the atomic install protocol makes mid-download kills safe. let status = cmd.status().await?; if !status.success() { anyhow::bail!("grok update failed with {}", status); } Ok(None) } UpdateRunMode::NonBlocking => { cmd.stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()); // Detach = new session (Ctrl+C isolation), not handle abandonment: // the child is still ours to wait() on. xai_grok_tools::util::detach_command(&mut cmd); let child = cmd.spawn()?; Ok(Some(child)) } } } /// Resolve the grok binary path for re-execution after an update. /// /// `current_exe()` resolves symlinks via `/proc/self/exe` (see proc(5)), /// so it returns the old versioned target after a symlink swap. /// Prefer `~/.grok/bin/grok` which always points to the latest version. fn resolve_restart_exe() -> Result { let canonical = grok_application(); if canonical.exists() { return Ok(canonical); } Ok(std::env::current_exe()?) } /// Restart grok with the original command-line arguments to pick up the update. pub fn restart_grok() -> Result<()> { let exe = resolve_restart_exe()?; let mut cmd = Command::new(exe); for arg in std::env::args_os().skip(1) { cmd.arg(arg); } cmd.env_clear(); cmd.envs(std::env::vars_os().filter(|(k, _)| k != "GROK_AUTO_UPDATE")); eprintln!("Restarting Grok..."); // Use exec on Unix to replace the current process, avoiding stdio issues // when the parent exits. On Windows, fall back to spawn + exit. #[cfg(unix)] { // Flush output before exec to ensure messages are visible let _ = io::stdout().flush(); let _ = io::stderr().flush(); let err = cmd.exec(); // exec only returns if there was an error anyhow::bail!("Failed to exec: {}", err); } #[cfg(not(unix))] { // Flush output before exit to ensure messages are visible let _ = io::stdout().flush(); let _ = io::stderr().flush(); cmd.stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()); let _ = cmd.spawn()?; std::process::exit(0); } } pub async fn run_install_script( installer: &str, target: Option<&str>, update_config: &UpdateConfig, ) -> Result<()> { let result = match installer { "npm" => install_npm( target, &update_config.channel, update_config.npm_registry.as_deref(), ), "gh-release" => install_gh_release(target).await, _ => install_internal(target, update_config).await, }; if result.is_ok() { remove_stale_models_cache().await; } result.map_err(|e| { anyhow::anyhow!( "Auto-update failed: {:#}\n\n{}", e, reinstall_hint(installer) ) }) } /// Detect the current platform (os, arch) for binary downloads. pub(crate) fn detect_platform() -> Result<(&'static str, &'static str)> { let os = if cfg!(target_os = "macos") { "macos" } else if cfg!(target_os = "linux") { "linux" } else if cfg!(target_os = "windows") { "windows" } else { anyhow::bail!("Unsupported OS"); }; let arch = if cfg!(target_arch = "x86_64") { "x86_64" } else if cfg!(target_arch = "aarch64") { "aarch64" } else { anyhow::bail!("Unsupported architecture"); }; Ok((os, arch)) } /// Age past which a leftover `.tmp` download file (or a freshly-renamed /// versioned binary) is considered abandoned (crashed/killed updater) and /// safe for `cleanup_old_downloads` to sweep. Generous compared to the /// longest plausible download (per-request budget is /// [`DOWNLOAD_REQUEST_TIMEOUT`]; the leader check+download pass matches) so /// a concurrent updater's in-flight or just-landed file is never deleted /// out from under it. const STALE_TMP_AGE: Duration = Duration::from_secs(60 * 60); /// Total timeout for a CLI artifact download request (including body). /// Previously 5 minutes, which was too tight on slow links and caused the /// transfer to abort and restart from zero repeatedly. const DOWNLOAD_REQUEST_TIMEOUT: Duration = Duration::from_secs(20 * 60); /// Unique temp path for an in-flight download of `dest`. /// /// Appends `.{pid}-{seq}.tmp` to the FULL file name instead of using /// `Path::with_extension`, which treats everything after the last dot of the /// versioned name as the extension (`grok-0.1.181-linux-x86_64` → /// `grok-0.1.tmp`) and therefore collides for every `0.1.x` version. The PID /// plus a per-process counter makes the name unique per download attempt — /// across processes (two updaters racing in the same instant, the accepted /// lock-free residual race) and within one process — so no racer can ever /// rename another's half-written temp file into place. Leftovers older than /// [`STALE_TMP_AGE`] are swept by `cleanup_old_downloads`. fn tmp_download_path(dest: &std::path::Path) -> std::path::PathBuf { unique_temp_sibling(dest, "tmp") } /// Unique temp path `.{pid}-{seq}.{ext}`, appended to the full name so a /// versioned base like `grok-0.1.181` doesn't collide via `with_extension`. /// PID + per-process counter keep racing updaters from clobbering each other. fn unique_temp_sibling(base: &std::path::Path, ext: &str) -> std::path::PathBuf { use std::sync::atomic::{AtomicU64, Ordering}; static SEQ: AtomicU64 = AtomicU64::new(0); let mut name = base .file_name() .map(|n| n.to_os_string()) .unwrap_or_default(); name.push(format!( ".{}-{}.{ext}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed) )); base.with_file_name(name) } /// Set `+x` on the temp file before renaming onto `dest`, so a concurrent /// same-version installer never execs `dest` while it is still 0644. async fn publish_downloaded_artifact(tmp: &std::path::Path, dest: &std::path::Path) -> Result<()> { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; tokio::fs::set_permissions(tmp, std::fs::Permissions::from_mode(0o755)).await?; } tokio::fs::rename(tmp, dest).await?; Ok(()) } /// Files smaller than this are not worth fragmenting across parallel chunks. const PARALLEL_DOWNLOAD_MIN_BYTES: u64 = 16 * 1024 * 1024; /// Pick chunk count from file size: 1 chunk per 16 MiB, capped at 8. fn parallel_chunk_count(size: u64) -> u64 { let size_mb = size / (1024 * 1024); (size_mb / 16).clamp(1, 8) } /// Try a parallel byte-range download to `dest`. Returns Err if the server /// doesn't advertise a Content-Length, the file is too small to be worth /// splitting, the range request is rejected, or any chunk transfer fails. /// The caller is expected to fall back to a single-connection download on Err. async fn try_parallel_download( url: &str, dest: &std::path::Path, with_progress: bool, ) -> Result<()> { let client = reqwest::Client::builder() .timeout(DOWNLOAD_REQUEST_TIMEOUT) .build()?; let head = client.head(url).send().await?; if !head.status().is_success() { anyhow::bail!("HEAD failed: HTTP {}", head.status()); } let size = head .content_length() .ok_or_else(|| anyhow::anyhow!("response missing Content-Length"))?; if size < PARALLEL_DOWNLOAD_MIN_BYTES { anyhow::bail!("file too small for parallel download ({} bytes)", size); } let n_chunks = parallel_chunk_count(size); if n_chunks < 2 { anyhow::bail!( "file size yields {} chunk(s); not worth parallelizing", n_chunks ); } let chunk_size = size.div_ceil(n_chunks); let pb = if with_progress { let pb = ProgressBar::new(size); pb.set_style( ProgressStyle::default_bar() .template(" {bar:30.cyan/dim} {bytes}/{total_bytes} ({eta})") .unwrap() .progress_chars("━╸─"), ); Some(pb) } else { None }; let tmp = tmp_download_path(dest); // Pre-allocate so each task can seek+write to its own range concurrently. // One blocking-pool hop instead of two per tokio::fs call. let tmp_for_alloc = tmp.clone(); tokio::task::spawn_blocking(move || -> std::io::Result<()> { let f = std::fs::File::create(&tmp_for_alloc)?; f.set_len(size)?; Ok(()) }) .await .map_err(|e| anyhow::anyhow!("blocking pre-allocate task panicked: {e}"))??; let tasks = (0..n_chunks).map(|i| { let start = i * chunk_size; let end = std::cmp::min(start + chunk_size, size) - 1; let url = url.to_string(); let tmp = tmp.clone(); let client = client.clone(); let pb = pb.clone(); async move { download_range(&client, &url, &tmp, start, end, pb.as_ref()).await } }); let result = futures::future::try_join_all(tasks).await; if let Some(pb) = &pb { pb.finish_and_clear(); } match result { Ok(_) => { publish_downloaded_artifact(&tmp, dest).await?; Ok(()) } Err(e) => { let _ = tokio::fs::remove_file(&tmp).await; Err(e) } } } /// Fetch bytes `[start, end]` (inclusive) of `url` and write them at `start` /// in `dest`. Errors if the server doesn't return `206 Partial Content`. /// /// Streams from the network into a `Vec` (so progress ticks smoothly as /// bytes arrive), then issues a single `spawn_blocking` per chunk to do the /// open + seek + write_all in `std::fs`. This avoids the per-write hop into /// tokio's blocking pool that `tokio::fs::File::write_all` performs on every /// ~8 KiB Bytes item from `bytes_stream()`. async fn download_range( client: &reqwest::Client, url: &str, dest: &std::path::Path, start: u64, end: u64, progress: Option<&ProgressBar>, ) -> Result<()> { let resp = client .get(url) .header("Range", format!("bytes={}-{}", start, end)) .send() .await?; if resp.status() != reqwest::StatusCode::PARTIAL_CONTENT { anyhow::bail!("range request rejected: HTTP {}", resp.status()); } let mut buf = Vec::with_capacity((end - start + 1) as usize); let mut stream = resp.bytes_stream(); while let Some(chunk) = stream.next().await { let chunk = chunk?; if let Some(pb) = progress { pb.inc(chunk.len() as u64); } buf.extend_from_slice(&chunk); } let dest = dest.to_owned(); tokio::task::spawn_blocking(move || -> std::io::Result<()> { use std::io::{Seek, SeekFrom, Write}; let mut f = std::fs::OpenOptions::new().write(true).open(&dest)?; f.seek(SeekFrom::Start(start))?; f.write_all(&buf)?; Ok(()) }) .await .map_err(|e| anyhow::anyhow!("blocking write task panicked: {e}"))??; Ok(()) } /// Download a file from `url` to `dest` with a terminal progress bar. /// /// If the server provides a `Content-Length` header, a determinate bar is shown /// with bytes downloaded, total size, and ETA. Otherwise a spinner with a byte /// counter is used as a fallback. #[doc(hidden)] pub async fn download_with_progress(url: &str, dest: &std::path::Path) -> Result<()> { // Try parallel byte-range first. Falls through to single-connection on any // failure (HEAD missing Content-Length, ranges rejected, partial-fetch error). match try_parallel_download(url, dest, true).await { Ok(()) => return Ok(()), Err(e) => { tracing::debug!("parallel download failed, falling back to single connection: {e}") } } let client = reqwest::Client::builder() .timeout(DOWNLOAD_REQUEST_TIMEOUT) .build()?; let resp = client.get(url).send().await?; if !resp.status().is_success() { anyhow::bail!("Download failed: HTTP {}", resp.status()); } let total_size = resp.content_length(); let pb = if let Some(size) = total_size { let pb = ProgressBar::new(size); pb.set_style( ProgressStyle::default_bar() .template(" {bar:30.cyan/dim} {bytes}/{total_bytes} ({eta})") .unwrap() .progress_chars("━╸─"), ); pb } else { let pb = ProgressBar::new_spinner(); pb.set_style( ProgressStyle::default_spinner() .template(" {spinner:.cyan} {bytes} downloaded") .unwrap(), ); pb.enable_steady_tick(Duration::from_millis(100)); pb }; // Stream to a temp file, then rename atomically let tmp = tmp_download_path(dest); let mut file = tokio::fs::File::create(&tmp).await?; let mut stream = resp.bytes_stream(); while let Some(chunk) = stream.next().await { let chunk = chunk?; file.write_all(&chunk).await?; pb.inc(chunk.len() as u64); } file.flush().await?; drop(file); pb.finish_and_clear(); publish_downloaded_artifact(&tmp, dest).await?; Ok(()) } /// Download a file silently (no progress bar). #[doc(hidden)] pub async fn download_silent(url: &str, dest: &std::path::Path) -> Result<()> { match try_parallel_download(url, dest, false).await { Ok(()) => return Ok(()), Err(e) => { tracing::debug!("parallel download failed, falling back to single connection: {e}") } } let client = reqwest::Client::builder() .timeout(DOWNLOAD_REQUEST_TIMEOUT) .build()?; let resp = client.get(url).send().await?; if !resp.status().is_success() { anyhow::bail!("Download failed: HTTP {}", resp.status()); } let tmp = tmp_download_path(dest); let mut file = tokio::fs::File::create(&tmp).await?; let mut stream = resp.bytes_stream(); while let Some(chunk) = stream.next().await { let chunk = chunk?; file.write_all(&chunk).await?; } file.flush().await?; drop(file); publish_downloaded_artifact(&tmp, dest).await?; Ok(()) } /// Delete `~/.grok/models_cache.json` after a successful update. /// /// The cache embeds the binary version and will be treated as a miss by the /// new binary anyway, but removing it eagerly avoids a wasted disk read + /// deserialize on first launch. async fn remove_stale_models_cache() { let cache = grok_home().join("models_cache.json"); match tokio::fs::remove_file(&cache).await { Ok(()) => tracing::debug!("removed stale models_cache.json after update"), Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => tracing::debug!("failed to remove stale models cache: {e}"), } } /// Remove the stale `grok-pager` symlink/binary from `~/.grok/bin/` left by /// older installations that shipped a separate pager binary. async fn remove_stale_pager(bin_dir: &std::path::Path) { let name = if cfg!(windows) { "grok-pager.exe" } else { "grok-pager" }; let link = bin_dir.join(name); if link.exists() || link.is_symlink() { let _ = tokio::fs::remove_file(&link).await; } } /// Fetch a CLI object from GCS. On Windows the public bucket may use a `.exe` /// suffix; try that first, then the extensionless name used on macOS/Linux. async fn download_cli_artifact_from_gcs( gcs_base_url: &str, object_name: &str, dest: &std::path::Path, with_progress: bool, ) -> Result<()> { let base = gcs_base_url.trim_end_matches('/'); #[cfg(windows)] { let with_exe = format!("{}/{}.exe", base, object_name); let r = if with_progress { download_with_progress(&with_exe, dest).await } else { download_silent(&with_exe, dest).await }; match r { Ok(()) => return Ok(()), Err(e) => tracing::debug!("{with_exe} not found, trying extensionless: {e}"), } } let url = format!("{}/{}", base, object_name); if with_progress { download_with_progress(&url, dest).await } else { download_silent(&url, dest).await } } async fn install_internal(target: Option<&str>, update_config: &UpdateConfig) -> Result<()> { install_internal_from_bases(target, update_config, crate::version::CLI_BASE_URLS).await } /// Try the base-dependent install phase ([`download_verified_from_base`]: /// version resolution, download, smoke test) against each base URL in turn, /// falling through to the next on any failure. Used to keep installs working /// when the primary CDN endpoint (Cloudflare) is unreachable but the fallback /// (direct GCS) still resolves. /// /// Download-phase side effects (download dir creation, binary fetch) are /// idempotent, so retrying with a different base after a partial failure is /// safe. Local activation ([`activate_verified_download`]: link swap, /// cleanup, config persist) runs once after the first successful download — /// its failures are not base-dependent, so they abort the install instead of /// triggering a pointless re-download from the next base. #[doc(hidden)] pub async fn install_internal_from_bases( target: Option<&str>, update_config: &UpdateConfig, bases: &[&str], ) -> Result<()> { let mut last_err: Option = None; for (i, base) in bases.iter().enumerate() { match download_verified_from_base(target, update_config, base).await { Ok(download) => return activate_verified_download(&download).await, Err(e) => { if i + 1 < bases.len() { tracing::warn!( "install via {} failed ({:#}); trying next base URL", base, e ); } last_err = Some(e); } } } Err(last_err.unwrap_or_else(|| anyhow::anyhow!("no CLI base URLs to try"))) } const SMOKE_TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); async fn smoke_test_binary(binary_path: &std::path::Path) -> bool { let mut cmd = tokio::process::Command::new(binary_path); cmd.arg("--version") .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()); xai_grok_tools::util::detach_command(&mut cmd); match tokio::time::timeout(SMOKE_TEST_TIMEOUT, cmd.status()).await { Ok(Ok(status)) => status.success(), _ => false, } } /// Test-only entry point: same as [`install_internal`] but reads from /// `gcs_base_url` instead of the hardcoded GCS bucket. Persists installer /// config and writes to `~/.grok/bin/`, so callers must isolate /// `GROK_HOME`. #[doc(hidden)] pub async fn install_internal_from_base( target: Option<&str>, update_config: &UpdateConfig, gcs_base_url: &str, ) -> Result<()> { let download = download_verified_from_base(target, update_config, gcs_base_url).await?; activate_verified_download(&download).await } /// A downloaded and smoke-tested binary in `~/.grok/downloads/`, not yet /// activated as the managed `grok`/`agent`. struct VerifiedDownload { version: String, binary_path: std::path::PathBuf, } /// Base-dependent install phase: resolve the version (per base when no /// target is pinned), download the binary, and smoke-test it. Failures here /// are worth retrying against another base URL. async fn download_verified_from_base( target: Option<&str>, update_config: &UpdateConfig, gcs_base_url: &str, ) -> Result { let (os, arch) = detect_platform()?; let platform = format!("{}-{}", os, arch); let version = match target { Some(v) => { semver::Version::parse(v) .map_err(|_| anyhow::anyhow!("invalid version format: '{}'", v))?; v.to_string() } None => { crate::version::fetch_gcs_version_from_base(&update_config.channel, gcs_base_url) .await? } }; let grok_home = grok_home(); let download_dir = grok_home.join("downloads"); tokio::fs::create_dir_all(&download_dir).await?; let binary_name = format!("grok-{}-{}", version, platform); let binary_path = download_dir.join(&binary_name); eprintln!(" Downloading grok v{} ({})...", version, platform); // Published already +x (see `publish_downloaded_artifact`). download_cli_artifact_from_gcs(gcs_base_url, &binary_name, &binary_path, true).await?; // Smoke-test: run the binary before activating it. A truncated or // corrupt download is caught here and never becomes the active grok. if !smoke_test_binary(&binary_path).await { let _ = tokio::fs::remove_file(&binary_path).await; // No prefix: run_install_script's wrap adds "Auto-update failed:". anyhow::bail!( "downloaded binary failed to run.\n\ Your current version is unchanged.\n\ To update manually: {}", manual_install_cmd() ); } Ok(VerifiedDownload { version, binary_path, }) } /// Local activation phase: swap the managed bin links to the downloaded /// binary and finish bookkeeping. Nothing here depends on which base URL /// served the download, so callers must not retry another base on failure. async fn activate_verified_download(download: &VerifiedDownload) -> Result<()> { let grok_home = grok_home(); let download_dir = grok_home.join("downloads"); let bin_dir = grok_home.join("bin"); tokio::fs::create_dir_all(&bin_dir).await?; // Atomic swap of ~/.grok/bin/{grok,agent} -> downloaded binary. let link_path = swap_managed_bin_links(&download.binary_path, &bin_dir).await?; remove_stale_pager(&bin_dir).await; eprintln!(); // Clean up old versioned binaries (keeps current + 1 previous). cleanup_old_downloads(&download_dir, "grok", &download.version).await; cleanup_old_downloads(&download_dir, "grok-pager", &download.version).await; // Persist installer to config.toml so future runs auto-detect internal. let _ = config::update_config(|st| { st.cli.installer = Some("internal".to_string()); }) .await; // Regenerate shell completions so they reflect the new binary's CLI surface. // Best-effort: failures are silently ignored (same as the installer). regenerate_completions(&link_path, &grok_home).await; Ok(()) } /// Regenerate shell completions after a binary update (best-effort). /// /// Spawns the newly-installed binary with `completions ` for each /// supported shell and writes the output to the standard completion paths. /// Failures are silently ignored — completions are a nice-to-have, not a /// requirement for a successful update. async fn regenerate_completions(binary: &std::path::Path, grok_home: &std::path::Path) { // Derive $HOME independently — grok_home may be overridden via GROK_HOME // env var, so grok_home.parent() isn't necessarily the user's home dir. #[allow(deprecated)] let user_home = std::env::home_dir().unwrap_or_default(); let completions: &[(&str, std::path::PathBuf)] = &[ ("bash", grok_home.join("completions/bash/grok.bash")), ("zsh", grok_home.join("completions/zsh/_grok")), ("fish", user_home.join(".config/fish/completions/grok.fish")), ]; for (shell, dest) in completions { if let Some(parent) = dest.parent() { let _ = tokio::fs::create_dir_all(parent).await; } let mut cmd = tokio::process::Command::new(binary); cmd.args(["completions", shell]) .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::null()); xai_grok_tools::util::detach_command(&mut cmd); let Ok(output) = cmd.output().await else { continue; }; if output.status.success() && !output.stdout.is_empty() { let _ = tokio::fs::write(dest, &output.stdout).await; } } } /// Compute a relative symlink target from `link` to `target`. /// /// When both paths share a grandparent (e.g. `~/.grok/bin/grok` and /// `~/.grok/downloads/grok-0.1.203-linux-x86_64`), returns a relative path /// like `../downloads/grok-0.1.203-linux-x86_64`. When they share the same /// parent directory, returns just the filename. Falls back to the absolute /// `target` path for any other layout. /// /// Relative symlinks survive Docker bind-mounts where `~/.grok/` is mapped /// into a container with a different `$HOME` (and thus a different absolute /// prefix). #[cfg(unix)] fn relative_symlink_target(target: &std::path::Path, link: &std::path::Path) -> std::path::PathBuf { let (Some(target_parent), Some(link_parent)) = (target.parent(), link.parent()) else { return target.to_path_buf(); }; // Same directory — just the filename (e.g. grok-latest -> grok-0.1.203-…) if target_parent == link_parent && let Some(name) = target.file_name() { return std::path::PathBuf::from(name); } // Sibling directories — ../target_dir/filename (e.g. bin/grok -> ../downloads/grok-…) if let (Some(tp), Some(lp)) = (target_parent.parent(), link_parent.parent()) && tp == lp && let (Some(dir_name), Some(file_name)) = (target_parent.file_name(), target.file_name()) { return std::path::Path::new("..").join(dir_name).join(file_name); } target.to_path_buf() } /// Swap `~/.grok/bin/{grok,agent}` to point at `binary_path`. Returns the /// `grok` link path (for [`regenerate_completions`]). /// /// `grok` and `agent` are first-class entry points that the bootstrap /// installers (`install.sh`, `install.ps1`, `install-enterprise.sh`) /// maintain in lockstep, and so must the updater — otherwise `grok update` /// leaves `agent` pinned at the previous version. /// /// Unix: atomic symlink swap with relative target (survives Docker /// bind-mounts of `~/.grok/`). Windows: [`windows_replace_exe`]. /// /// **All-or-nothing.** Each link's prior state is captured (Unix: prior /// symlink target; Windows: `.rollback.bak`; or `Absent` marker via /// `symlink_metadata`) before the swap, and any earlier successful swaps /// are rolled back if a later one fails — including *removing* a link that /// didn't exist before. Restore failures go to `tracing::warn!`; the swap /// error itself propagates unwrapped so the caller's `reinstall_hint` wrap /// stays the user-visible message. async fn swap_managed_bin_links( binary_path: &std::path::Path, bin_dir: &std::path::Path, ) -> Result { let grok_name = if cfg!(windows) { "grok.exe" } else { "grok" }; let agent_name = if cfg!(windows) { "agent.exe" } else { "agent" }; let grok_link = bin_dir.join(grok_name); let agent_link = bin_dir.join(agent_name); let link_paths: [std::path::PathBuf; 2] = [grok_link.clone(), agent_link]; // Capture every link up-front so a 2nd-link capture failure can't // strand the 1st mid-swap. let mut captured: Vec = Vec::with_capacity(link_paths.len()); for path in &link_paths { match LinkRollback::capture(path).await { Ok(rb) => captured.push(rb), Err(e) => { // Nothing swapped yet; drop any Windows .rollback.bak files. for prior in &captured { prior.cleanup().await; } return Err(e) .with_context(|| format!("capturing rollback state for {}", path.display())); } } } let mut completed: Vec<&LinkRollback> = Vec::with_capacity(captured.len()); for (i, (link_path, rollback)) in link_paths.iter().zip(captured.iter()).enumerate() { #[cfg(unix)] let swap_result = { let rel_target = relative_symlink_target(binary_path, link_path); atomic_symlink_swap(&rel_target, link_path).await }; #[cfg(windows)] let swap_result = windows_replace_exe(binary_path, link_path).await; #[cfg(not(any(unix, windows)))] let swap_result: Result<()> = { // No managed bin layout on this target; no-op. let _ = (binary_path, link_path); Ok(()) }; match swap_result { Ok(()) => completed.push(rollback), Err(e) => { // Restore each successful swap in reverse. On restore // failure keep the .rollback.bak as a recovery artifact // (Windows only) and warn!; the swap error propagates so // `reinstall_hint` is the user-visible message. for prior in completed.iter().rev() { if let Err(restore_err) = prior.restore().await { let backup_note = prior.backup_path().map_or(String::new(), |p| { format!(" (prior binary preserved at {})", p.display()) }); tracing::warn!( "failed to roll back managed bin link {}: {restore_err:#}{backup_note}", prior.link_path().display(), ); continue; } prior.cleanup().await; } // Failed swap had no active state to restore; drop its backup. rollback.cleanup().await; // Drop backups for never-attempted later captures (Windows orphans). for later in &captured[i + 1..] { later.cleanup().await; } return Err(e); } } } for cap in &captured { cap.cleanup().await; } Ok(grok_link) } /// Snapshot of a managed-bin link's prior state for rollback in /// [`swap_managed_bin_links`]. `Absent` vs `Present` is discriminated up /// front via `symlink_metadata` so capture errors never get misread as /// "link was absent". enum LinkRollback { /// Link was absent before the swap; rollback removes the one we created. Absent { link_path: std::path::PathBuf }, /// Link existed before the swap; rollback restores its prior contents. Present { link_path: std::path::PathBuf, /// Unix: prior symlink target (relative or absolute). #[cfg(unix)] prior_target: std::path::PathBuf, /// Windows: `.rollback.bak` copy of the previous binary. #[cfg(windows)] backup_path: std::path::PathBuf, }, } impl LinkRollback { async fn capture(link_path: &std::path::Path) -> Result { let lp = link_path.to_path_buf(); // `symlink_metadata` (lstat) handles valid symlinks, broken // symlinks, and regular files alike. Any IO error other than // NotFound aborts the swap before mutation. match tokio::fs::symlink_metadata(&lp).await { Ok(_) => {} Err(e) if e.kind() == std::io::ErrorKind::NotFound => { return Ok(LinkRollback::Absent { link_path: lp }); } Err(e) => { return Err(e).with_context(|| format!("stat {} before swap", lp.display())); } } #[cfg(unix)] { let prior_target = tokio::fs::read_link(&lp) .await .with_context(|| format!("reading prior symlink target {}", lp.display()))?; Ok(LinkRollback::Present { link_path: lp, prior_target, }) } #[cfg(windows)] { // Per-process+sequence backup name via `unique_temp_sibling` // so concurrent updaters can't clobber each other's backups. let backup_path = unique_temp_sibling(&lp, "rollback.bak"); tokio::fs::copy(&lp, &backup_path).await.with_context(|| { format!( "backing up {} to {} before swap", lp.display(), backup_path.display(), ) })?; Ok(LinkRollback::Present { link_path: lp, backup_path, }) } } fn link_path(&self) -> &std::path::Path { match self { LinkRollback::Absent { link_path } => link_path, LinkRollback::Present { link_path, .. } => link_path, } } /// Path to the on-disk backup (Windows only — Unix is in-memory). #[cfg(windows)] fn backup_path(&self) -> Option<&std::path::Path> { match self { LinkRollback::Present { backup_path, .. } => Some(backup_path), LinkRollback::Absent { .. } => None, } } #[cfg(unix)] fn backup_path(&self) -> Option<&std::path::Path> { None } async fn restore(&self) -> Result<()> { match self { LinkRollback::Absent { link_path } => { // Remove the link we created. NotFound (someone else // cleaned up) is fine; anything else is a real failure. match tokio::fs::remove_file(link_path).await { Ok(()) => Ok(()), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(e) => Err(e).with_context(|| { format!("removing rolled-back link {}", link_path.display()) }), } } #[cfg(unix)] LinkRollback::Present { link_path, prior_target, } => atomic_symlink_swap(prior_target, link_path) .await .with_context(|| { format!("restoring prior symlink target for {}", link_path.display()) }), #[cfg(windows)] LinkRollback::Present { link_path, backup_path, } => { // Route through `windows_replace_exe` so rollback inherits // the same ERROR_SHARING_VIOLATION rename-aside fallback // as the forward path. windows_replace_exe(backup_path, link_path) .await .with_context(|| { format!( "restoring {} from {}", link_path.display(), backup_path.display() ) }) } } } async fn cleanup(&self) { #[cfg(windows)] if let LinkRollback::Present { backup_path, .. } = self { let _ = tokio::fs::remove_file(backup_path).await; } #[cfg(unix)] let _ = self; // no on-disk backup on Unix } } /// Atomically swap a symlink to point to a new target. /// /// Creates a temporary symlink next to `link_path`, then renames it over the /// old symlink. This avoids the remove-then-create race where the path /// briefly doesn't exist, and — crucially — never deletes the old target /// file. On macOS (especially Apple Silicon), deleting a binary that a /// running process has mmap'd causes SIGKILL because the kernel can no longer /// verify the code signature of the executable pages. #[cfg(unix)] async fn atomic_symlink_swap(target: &std::path::Path, link_path: &std::path::Path) -> Result<()> { // Per-racer temp name: a shared one makes remove_file → symlink racy // (EEXIST, or ENOENT when another racer renames the link away). sweep_stale_tmp_links(link_path, STALE_TMP_AGE).await; let tmp_link = unique_temp_sibling(link_path, "tmp-link"); let _ = tokio::fs::remove_file(&tmp_link).await; tokio::fs::symlink(target, &tmp_link).await?; tokio::fs::rename(&tmp_link, link_path).await?; Ok(()) } /// Remove `.*.tmp-link` siblings left by a swap that crashed between /// symlink and rename. Only those older than `max_age` are removed, so a /// concurrent racer's in-flight link is never deleted out from under it. #[cfg(unix)] async fn sweep_stale_tmp_links(link_path: &std::path::Path, max_age: Duration) { let (Some(dir), Some(name)) = ( link_path.parent(), link_path.file_name().and_then(|n| n.to_str()), ) else { return; }; let prefix = format!("{name}."); let Ok(mut entries) = tokio::fs::read_dir(dir).await else { return; }; while let Ok(Some(entry)) = entries.next_entry().await { let fname = entry.file_name(); let Some(fname) = fname.to_str() else { continue; }; if !fname.starts_with(&prefix) || !fname.ends_with(".tmp-link") { continue; } let stale = tokio::fs::symlink_metadata(entry.path()) .await .ok() .and_then(|m| m.modified().ok()) .and_then(|t| std::time::SystemTime::now().duration_since(t).ok()) .is_some_and(|age| age > max_age); if stale { let _ = tokio::fs::remove_file(entry.path()).await; } } } /// Replace an executable that may be locked by a running process (Windows). /// /// On Windows the kernel prevents writes to a running executable but allows /// renames. If a direct copy fails with a sharing violation, this renames /// `dest` aside and copies `src` into the freed path. If the copy then /// fails, the rename is rolled back to avoid a broken install. /// /// The aside target is normally `.old`, but a leftover `.old` can /// itself still be a running image (the session that was live during the /// previous update keeps executing the renamed-aside file), and a running /// image can neither be deleted nor rename-replaced. In that case `dest` is /// renamed to a unique `.old.{pid}-{seq}.old` sibling instead, so a /// locked leftover can never block the update. All `.old` leftovers are /// swept best-effort at the start of each cycle; still-locked ones survive /// until a later update runs after those processes exit. #[cfg(windows)] async fn windows_replace_exe(src: &std::path::Path, dest: &std::path::Path) -> Result<()> { let file_name = dest .file_name() .ok_or_else(|| anyhow::anyhow!("destination has no filename: {}", dest.display()))? .to_string_lossy(); let old = dest.with_file_name(format!("{file_name}.old")); sweep_old_exe_backups(&old).await; match tokio::fs::copy(src, dest).await { Ok(_) => return Ok(()), // ERROR_SHARING_VIOLATION (32) / ERROR_ACCESS_DENIED (5): exe is // locked by a running process. Fall through to rename-and-replace. Err(e) if matches!(e.raw_os_error(), Some(32) | Some(5)) => { tracing::debug!("exe locked, falling back to rename: {e}"); } Err(e) => return Err(e.into()), } // A .old that survived the sweep is locked; renaming onto it would need // to delete-replace it and fail, so divert to a guaranteed-free name. let old_is_free = matches!( tokio::fs::symlink_metadata(&old).await, Err(e) if e.kind() == std::io::ErrorKind::NotFound ); let mut aside = if old_is_free { old.clone() } else { let diverted = unique_temp_sibling(&old, "old"); tracing::debug!( "stale {} is locked; diverting aside to {}", old.display(), diverted.display() ); diverted }; // Move the locked file aside, then copy the new binary into place. let mut rename_result = tokio::fs::rename(dest, &aside).await; // Pid reuse can collide a diverted name with a dead updater's // still-locked leftover, and a racer can occupy a just-checked-free // .old; a fresh unique sibling clears both tails (3 attempts total). for _ in 0..2 { match &rename_result { Err(e) if matches!(e.raw_os_error(), Some(32) | Some(5)) => { tracing::debug!( "rename aside to {} failed; retrying with a fresh name: {e}", aside.display() ); aside = unique_temp_sibling(&old, "old"); rename_result = tokio::fs::rename(dest, &aside).await; } _ => break, } } rename_result.map_err(|e| { anyhow::anyhow!( "cannot rename locked executable {}: {e}\n\ Close all running grok sessions and retry.", dest.display(), ) })?; match tokio::fs::copy(src, dest).await { Ok(_) => Ok(()), Err(e) => { // Rollback: restore the old binary so the install isn't broken. let _ = tokio::fs::rename(&aside, dest).await; Err(e.into()) } } } /// Best-effort removal of `.old` plus the unique /// `.old.{pid}-{seq}.old` asides accumulated by prior update cycles. /// Locked ones (still-running images) survive and are collected by a later /// update once those processes exit. The `.old` prefix keeps the sweep /// away from `` itself, other executables' leftovers, and the /// `.rollback.bak` / `.tmp` sibling shapes. /// /// Unlike `sweep_stale_tmp_links` there is deliberately no `max_age` gate: /// rename preserves mtime, so a racer's seconds-old aside already looks /// days old and age cannot distinguish it; in-use asides survive deletion /// by being locked; and deleting a racer's fresh unlocked aside (its /// rollback source while both racers converge on the same dest) is the /// accepted lock-free residual race (see `tmp_download_path`). #[cfg(windows)] async fn sweep_old_exe_backups(old: &std::path::Path) { let _ = tokio::fs::remove_file(old).await; let (Some(dir), Some(old_name)) = (old.parent(), old.file_name().and_then(|n| n.to_str())) else { return; }; let prefix = format!("{old_name}."); let Ok(mut entries) = tokio::fs::read_dir(dir).await else { return; }; while let Ok(Some(entry)) = entries.next_entry().await { let name = entry.file_name(); let Some(name) = name.to_str() else { continue; }; if name.starts_with(&prefix) && name.ends_with(".old") { let _ = tokio::fs::remove_file(entry.path()).await; } } } /// Best-effort cleanup of old versioned binaries for a given binary name. /// /// Mirrors the npm `cleanupOldVersions()` policy: keeps the current version /// plus one previous version (in case a process is still running the old binary /// and hasn't fully loaded all pages yet — deleting it on macOS causes SIGKILL /// because the kernel can no longer verify the code signature). /// /// `bin_prefix` is the binary name prefix, e.g. `"grok"` or `"grok-pager"`. /// Files must match `{bin_prefix}-{digit}*` to be considered versioned binaries /// (this avoids `grok-*` matching `grok-pager-*` or `grok-latest`). /// /// Temporary/partial files (containing `.tmp`) are deleted only once they /// are **stale** (mtime older than [`STALE_TMP_AGE`]). A fresh `.tmp` may be /// a concurrent updater's in-flight download — the same-instant race the /// lock-free design accepts — and deleting it out from under that updater /// would make its atomic rename fail. async fn cleanup_old_downloads(dir: &std::path::Path, bin_prefix: &str, current_version: &str) { let prefix = format!("{}-", bin_prefix); let current_semver = match semver::Version::parse(current_version) { Ok(v) => v, Err(e) => { tracing::warn!( "cleanup_old_downloads: invalid current version '{}': {}", current_version, e ); return; } }; let mut entries = match tokio::fs::read_dir(dir).await { Ok(rd) => rd, Err(e) => { tracing::warn!( "cleanup_old_downloads: failed to read {}: {}", dir.display(), e ); return; } }; let mut versioned: Vec<(semver::Version, String)> = Vec::new(); while let Ok(Some(entry)) = entries.next_entry().await { let name = entry.file_name().to_string_lossy().to_string(); if !name.starts_with(&prefix) { continue; } // Temp/partial files: sweep only STALE ones. A fresh `.tmp` may be a // concurrent updater's in-flight download — deleting it would make // that updater's atomic rename fail with ENOENT. if name.contains(".tmp") { let stale = match entry.metadata().await.and_then(|m| m.modified()) { Ok(modified) => std::time::SystemTime::now() .duration_since(modified) .map(|age| age > STALE_TMP_AGE) // Future mtime (clock skew): can't tell — leave it. .unwrap_or(false), // Unknown mtime: leave it; it is swept once readable+old. Err(_) => false, }; if stale && let Err(e) = tokio::fs::remove_file(entry.path()).await { tracing::warn!("failed to remove stale temp file {}: {}", name, e); } continue; } // Skip symlinks (e.g. grok-latest). if let Ok(ft) = entry.file_type().await && ft.is_symlink() { continue; } // The suffix after the prefix must start with a digit to be a versioned // binary (avoids `grok-latest`, `grok-pager-*` when prefix is `grok`). let suffix = &name[prefix.len()..]; if !suffix.starts_with(|c: char| c.is_ascii_digit()) { continue; } // Extract the version portion via the shared parser (handles the // internal `grok-0.1.150-macos-aarch64`, pre-release, and npm // `grok-0.1.150` layouts — see `version_from_versioned_binary_name`). let Some(ver_str) = crate::version::version_from_versioned_binary_name(&name, bin_prefix) else { continue; }; if let Ok(v) = semver::Version::parse(&ver_str) { // Skip the current version — never delete it. if v == current_semver { continue; } versioned.push((v, name)); } } // Sort descending by version so the newest is first. versioned.sort_by(|a, b| b.0.cmp(&a.0)); // Keep the most recent old version (index 0), delete the rest (index 1+). // This matches the npm policy: current + 1 previous. for (_, name) in versioned.iter().skip(1) { let path = dir.join(name); // Same freshness guard as the `.tmp` sweep: a versioned binary // written moments ago is likely a concurrent installer's // just-renamed download (its symlink swap hasn't happened yet) — // deleting it would leave that installer's swap pointing at // nothing. Old binaries from previous releases are days old. let fresh = tokio::fs::metadata(&path) .await .and_then(|m| m.modified()) .ok() .and_then(|modified| std::time::SystemTime::now().duration_since(modified).ok()) .is_some_and(|age| age <= STALE_TMP_AGE); if fresh { continue; } if let Err(e) = tokio::fs::remove_file(&path).await { tracing::warn!("failed to remove old binary {}: {}", name, e); } } } fn installer_manages_bin_entrypoints(installer: &str) -> bool { matches!(installer, "internal" | "gh-release") } #[cfg_attr(not(any(unix, windows)), allow(clippy::unused_async))] async fn heal_managed_install(installer: &str) { if !installer_manages_bin_entrypoints(installer) { return; } #[cfg(any(unix, windows))] { let bin_dir = grok_home().join("bin"); #[cfg(unix)] reconcile_agent_to_grok(&bin_dir).await; #[cfg(windows)] reconcile_agent_exe_to_grok(&bin_dir).await; } } #[cfg(unix)] async fn reconcile_agent_to_grok(bin_dir: &std::path::Path) { let grok_link = bin_dir.join("grok"); let agent_link = bin_dir.join("agent"); let Ok(grok_target) = tokio::fs::read_link(&grok_link).await else { return; }; if tokio::fs::metadata(&grok_link).await.is_err() { return; } if let Ok(agent_target) = tokio::fs::read_link(&agent_link).await && agent_target == grok_target { return; } match atomic_symlink_swap(&grok_target, &agent_link).await { Ok(()) => tracing::info!( grok_target = %grok_target.display(), "reconciled agent bin symlink to grok target" ), Err(e) => tracing::warn!("failed to reconcile agent bin symlink: {e:#}"), } } #[cfg(windows)] async fn reconcile_agent_exe_to_grok(bin_dir: &std::path::Path) { let grok_exe = bin_dir.join("grok.exe"); let agent_exe = bin_dir.join("agent.exe"); if tokio::fs::metadata(&grok_exe).await.is_err() { return; } match agent_exe_differs(&grok_exe, &agent_exe).await { Ok(true) => {} Ok(false) => return, Err(e) => { tracing::debug!("agent.exe reconcile: compare failed: {e:#}"); return; } } match windows_replace_exe(&grok_exe, &agent_exe).await { Ok(()) => tracing::info!("reconciled agent.exe to grok.exe"), Err(e) => tracing::warn!("failed to reconcile agent.exe to grok.exe: {e:#}"), } } #[cfg(windows)] async fn agent_exe_differs( grok: &std::path::Path, agent: &std::path::Path, ) -> std::io::Result { use tokio::io::{AsyncReadExt, BufReader}; let grok_len = tokio::fs::metadata(grok).await?.len(); match tokio::fs::metadata(agent).await { Ok(m) if m.len() != grok_len => return Ok(true), Ok(_) => {} Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(true), Err(e) => return Err(e), } let mut rg = BufReader::new(tokio::fs::File::open(grok).await?); let mut ra = BufReader::new(tokio::fs::File::open(agent).await?); let mut bg = [0u8; 64 * 1024]; let mut ba = [0u8; 64 * 1024]; loop { let n = rg.read(&mut bg).await?; if n == 0 { return Ok(false); } ra.read_exact(&mut ba[..n]).await?; if bg[..n] != ba[..n] { return Ok(true); } } } /// Download a single asset from a GitHub release via `gh release download`. async fn gh_release_download(tag: &str, pattern: &str, dest: &std::path::Path) -> Result<()> { let pb = ProgressBar::new_spinner(); pb.set_style( ProgressStyle::default_spinner() .template(" {spinner:.cyan} Downloading from GitHub Releases...") .unwrap(), ); pb.enable_steady_tick(Duration::from_millis(100)); let mut cmd = tokio::process::Command::new("gh"); cmd.args([ "release", "download", tag, "--repo", crate::version::GH_RELEASE_REPO, "--pattern", pattern, "--output", &dest.to_string_lossy(), "--clobber", ]) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::piped()); xai_grok_tools::util::detach_command(&mut cmd); cmd.envs(xai_grok_tools::util::pager_env()); let output = cmd.output().await?; pb.finish_and_clear(); if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); anyhow::bail!( "gh release download failed for {} tag {} from {}: {}", pattern, tag, crate::version::GH_RELEASE_REPO, stderr.trim() ); } Ok(()) } /// Download and install grok from GitHub Releases (xai-org-shared/grok-build). /// /// Uses `gh release download` to fetch the binary matching the current platform. /// This works anywhere the `gh` CLI is authenticated, without needing npm or /// internal network access. async fn install_gh_release(target: Option<&str>) -> Result<()> { let (os, arch) = detect_platform()?; let platform = format!("{}-{}", os, arch); let version = match target { Some(v) => v.to_string(), None => crate::version::fetch_gh_release_version("stable").await?, }; let grok_home = grok_home(); let download_dir = grok_home.join("downloads"); let bin_dir = grok_home.join("bin"); tokio::fs::create_dir_all(&download_dir).await?; tokio::fs::create_dir_all(&bin_dir).await?; let binary_name = format!("grok-{}-{}", version, platform); let binary_path = download_dir.join(&binary_name); let tag = format!("v{}", version); eprintln!( " Downloading grok v{} ({}) from GitHub Releases...", version, platform ); gh_release_download(&tag, &binary_name, &binary_path).await?; // chmod +x #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; tokio::fs::set_permissions(&binary_path, std::fs::Permissions::from_mode(0o755)).await?; } // Atomic swap of ~/.grok/bin/{grok,agent} -> downloaded binary. swap_managed_bin_links(&binary_path, &bin_dir).await?; // Update grok-latest -> versioned binary so any existing symlinks that route // through it (e.g. /usr/local/bin/grok -> ~/.grok/downloads/grok-latest) // resolve to the newly installed version. #[cfg(unix)] { let latest_path = download_dir.join("grok-latest"); let rel_target = relative_symlink_target(&binary_path, &latest_path); if let Err(e) = atomic_symlink_swap(&rel_target, &latest_path).await { tracing::warn!("Failed to update grok-latest symlink: {e}"); } } // Also update /usr/local/bin/{grok,agent} if either points directly into // ~/.grok/downloads/ (legacy layout — skips the grok-latest indirection). // Permission errors ignored. #[cfg(unix)] for name in ["grok", "agent"] { let system_link = std::path::PathBuf::from(format!("/usr/local/bin/{name}")); if let Ok(existing_target) = tokio::fs::read_link(&system_link).await { let target_str = existing_target.to_string_lossy(); if target_str.contains(".grok/downloads/") && !target_str.ends_with("grok-latest") { // Try to update; ignore permission errors let _ = atomic_symlink_swap(&binary_path, &system_link).await; } } } remove_stale_pager(&bin_dir).await; eprintln!(); // Clean up old versioned binaries (keeps current + 1 previous). cleanup_old_downloads(&download_dir, "grok", &version).await; cleanup_old_downloads(&download_dir, "grok-pager", &version).await; // Persist installer to config.toml so future runs auto-detect gh-release. let _ = config::update_config(|st| { st.cli.installer = Some("gh-release".to_string()); }) .await; Ok(()) } /// Creates a temporary .npmrc file with the NPM token if present. /// Returns the path to the created file, or None if no token was set. fn create_temp_npmrc(npm_registry: Option<&str>) -> Result> { if let Ok(token) = std::env::var("NPM_TOKEN") { let token = token.trim(); if !token.is_empty() { let dir = std::env::temp_dir(); let npmrc_path = dir.join(format!(".npmrc-{}-install", std::process::id())); let registry_host = npm_registry .and_then(|r| reqwest::Url::parse(r).ok()) .map(|u| { let host = u.host_str().unwrap_or("registry.npmjs.org"); let port_suffix = u.port().map(|p| format!(":{}", p)).unwrap_or_default(); format!("{}{}{}", host, port_suffix, u.path().trim_end_matches('/')) }) .unwrap_or_else(|| "registry.npmjs.org".to_string()); let npmrc_content = format!("//{}/:_authToken={}\n", registry_host, token); std::fs::write(&npmrc_path, npmrc_content)?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&npmrc_path, std::fs::Permissions::from_mode(0o600))?; } return Ok(Some(npmrc_path)); } } Ok(None) } /// Check if other grok processes are running (macOS only). /// /// On macOS, `npm i -g` replaces the vendored binary in node_modules in-place. /// Any grok process running from that vendored path will be SIGKILL'd by the /// kernel because macOS (Apple Silicon in particular) can no longer verify /// the code signature of the mmap'd executable pages once the backing file /// inode is unlinked. /// /// While our postinstall.js now uses versioned binaries under ~/.grok/bin/ /// (so processes launched from there are safe), older installations or npx /// invocations may still be running the vendored binary directly. #[cfg(target_os = "macos")] fn warn_if_other_grok_processes_running() { let my_pid = std::process::id().to_string(); let mut cmd = Command::new("pgrep"); cmd.args(["-f", "grok"]) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::null()); xai_grok_tools::util::detach_std_command(&mut cmd); if let Ok(output) = cmd.output() { let stdout = String::from_utf8_lossy(&output.stdout); let other_pids: Vec<&str> = stdout .lines() .map(|l| l.trim()) .filter(|pid| !pid.is_empty() && *pid != my_pid) .collect(); if !other_pids.is_empty() { eprintln!( " ⚠ Warning: {} other grok process(es) detected.", other_pids.len() ); eprintln!(" Processes running from the npm vendored binary path may be"); eprintln!(" killed by macOS when npm replaces the package files."); eprintln!(" Consider closing other grok sessions before updating."); eprintln!(); } } } /// Test-only entry point: invokes the private [`install_npm`] for tests /// that swap in a fake `npm` via PATH. #[doc(hidden)] pub fn install_npm_for_test( target: Option<&str>, channel: &str, npm_registry: Option<&str>, ) -> Result<()> { install_npm(target, channel, npm_registry) } fn install_npm(target: Option<&str>, channel: &str, npm_registry: Option<&str>) -> Result<()> { // Warn on macOS about potential impact on other running processes. #[cfg(target_os = "macos")] warn_if_other_grok_processes_running(); let version_arg = match target { Some(ver) => format!("@xai-official/grok@{ver}"), None => { // All current callers resolve the version via get_latest_version // (which applies max(stable, alpha) for the alpha channel) before // reaching here. Falling back to a raw dist-tag would bypass that // logic, so warn loudly if this path is ever hit. tracing::warn!( channel, "install_npm called without a resolved version, falling back to dist-tag" ); format!( "@xai-official/grok@{}", if channel == "alpha" { "alpha" } else { "latest" } ) } }; let pb = ProgressBar::new_spinner(); pb.set_style( ProgressStyle::default_spinner() .template(" {spinner:.cyan} Installing via npm...") .unwrap(), ); pb.enable_steady_tick(Duration::from_millis(100)); let mut cmd = Command::new("npm"); cmd.args(["i", "-g", &version_arg]); if let Some(registry) = npm_registry { cmd.arg(format!("--registry={}", registry)); } // Use a temporary .npmrc to avoid exposing the token in process lists or shell history. let temp_npmrc = create_temp_npmrc(npm_registry)?; if let Some(ref npmrc_path) = temp_npmrc { cmd.arg(format!("--userconfig={}", npmrc_path.display())); } cmd.stdin(Stdio::null()) .stdout(Stdio::null()) // inherit, not piped — same rationale as run_update_subcommand. .stderr(Stdio::inherit()); xai_grok_tools::util::detach_std_command(&mut cmd); let status = cmd.status()?; if let Some(path) = temp_npmrc && let Err(e) = std::fs::remove_file(&path) { tracing::warn!("Failed to remove temp .npmrc file: {}", e); } pb.finish_and_clear(); if !status.success() { anyhow::bail!("npm install failed. Please try again."); } eprintln!(); Ok(()) } pub async fn apply_channel_switch(channel_switch: Option<&str>, update_config: &mut UpdateConfig) { if let Some(ch) = channel_switch && update_config.channel != ch { let _ = config::update_config(|st| { st.cli.channel = Some(ch.to_string()); }) .await; update_config.channel = ch.to_string(); eprintln!("Switched to {} channel.", ch); } } /// Run the `grok update` command. Returns `Ok(Some(version))` when the target /// version is present on disk afterwards — either installed by this call or /// found already installed (e.g. by a concurrent background download); returns /// `Ok(None)` when there is no installer or no applicable target. Callers use /// the returned version to signal a running leader to relaunch onto the new /// binary (see the pager's post-update leader relaunch) — that signal must /// fire even when the download itself was skipped, so a stale leader still /// picks up a binary someone else installed. pub async fn run_update( force: bool, pinned_version: Option<&str>, channel_switch: Option<&str>, update_config: &mut UpdateConfig, ) -> Result> { apply_channel_switch(channel_switch, update_config).await; let installer = match get_installer().await { Some(i) => i, None => { eprintln!("Auto-update is not available for manual installations."); return Ok(None); } }; // Persist installer if not already saved let cfg = config::load_config().await; if cfg.cli.installer.is_none() { let _ = config::update_config(|st| { st.cli.installer = Some(installer.to_string()); }) .await; } heal_managed_install(installer).await; let current_version = get_installed_grok_version(); // When --version is given, skip the latest-version check and install directly if let Some(version) = pinned_version { if let Err(e) = crate::minimum_version::check_install_target(version) { anyhow::bail!("{e}"); } eprintln!( "Installing Grok {} (current: {})...", version, current_version ); eprintln!(); run_install_script(installer, Some(version), update_config).await?; refresh_deployment_config().await; if let Err(e) = config::update_config(|st| { st.cli.auto_update = Some(false); }) .await { tracing::warn!("Failed to persist auto_update=false for pinned install: {e}"); } eprintln!(" ✓ grok v{} installed successfully!", version); eprintln!(" Please restart Grok."); return Ok(Some(version.to_string())); } let pb = ProgressBar::new_spinner(); pb.set_style( ProgressStyle::default_spinner() .template(" {spinner:.cyan} Checking for updates...") .unwrap(), ); pb.enable_steady_tick(Duration::from_millis(100)); let latest_version = fetch_latest_version(installer, update_config).await?; pb.finish_and_clear(); let install_target = match crate::minimum_version::apply_floor(&latest_version) { Ok(t) => t, Err(e) => anyhow::bail!("{e}"), }; if install_target != latest_version { eprintln!( "Latest available is {} but the configured minimum is higher; \ installing {} instead.", latest_version, install_target ); } // What's on disk wins over this process's compiled-in version: a // concurrent or earlier updater (TUI background download, leader hourly // checker) may already have installed the target, in which case there is // nothing to download. Gated on the installer maintaining the managed // symlink — for npm a leftover symlink would lie (see // `disk_version_for_installer`). let effective_current = disk_version_for_installer(installer).unwrap_or_else(|| current_version.clone()); if !force { match needs_update( &effective_current, &install_target, &update_config.channel, installer_allows_downgrade(installer), ) { Some(true) => {} Some(false) => { // Explicit channel switch (--stable / --alpha) with a // different target version: install even though the current // version is "newer" by semver. This handles switching from // alpha 0.2.X back to stable 0.1.220 where 0.2.X > 0.1.220. if channel_switch.is_some() && effective_current != install_target { // Fall through to install } else { let stable_ptr = try_fetch_stable_pointer().await; write_version_cache(&install_target, stable_ptr.as_deref()).await; eprintln!("Already up to date ({}).", effective_current); // Retry if a prior sync failed. refresh_deployment_config().await; // The target is on disk even though this call installed // nothing — report it so the caller still signals stale // leaders to relaunch onto it (signalling is directional // and skips leaders already at/after this version). return Ok(Some(install_target)); } } None => { // Distinguish parse failure from unsupported channel. let parse_ok = semver::Version::parse(&effective_current).is_ok() && semver::Version::parse(&install_target).is_ok(); if parse_ok { anyhow::bail!( "Unsupported release channel '{}' (current={}, target={}). \ Supported channels: stable, alpha, enterprise. \ Use --stable or --alpha to override, or set [cli] channel in config.toml.", update_config.channel, effective_current, install_target ); } else { anyhow::bail!( "Failed to parse versions (current={}, target={})", effective_current, install_target ); } } } } let target_version = if force && !needs_update( &effective_current, &install_target, &update_config.channel, installer_allows_downgrade(installer), ) .unwrap_or(true) { eprintln!( "Forcing reinstall of Grok {} (already up to date)", effective_current ); &effective_current } else { eprintln!("Updating Grok {} → {}", effective_current, install_target); &install_target }; eprintln!(); run_install_script(installer, Some(target_version), update_config).await?; // Fetch the stable pointer now so the new binary has it immediately // for channel_label() display, rather than waiting for the next // TTL-gated update check (~30 min). let stable_ptr = try_fetch_stable_pointer().await; write_version_cache(target_version, stable_ptr.as_deref()).await; refresh_deployment_config().await; eprintln!(" ✓ grok v{} installed successfully!", target_version); if !force && std::env::var_os("GROK_AUTO_UPDATE").is_none() { eprintln!(" Please restart Grok."); } Ok(Some(target_version.to_string())) } /// Refresh managed config post-update (best-effort, staleness-gated), for /// deployment-key and team principals alike. async fn refresh_deployment_config() { if !xai_grok_shell::managed_config::has_principal() { return; } if !xai_grok_shell::managed_config::is_fetch_enabled() { return; } // Clear a logged-out team's files before deciding to fetch (mirrors the loop). xai_grok_shell::managed_config::clear_orphan(); if !xai_grok_shell::config::is_managed_config_stale_for( &xai_grok_shell::managed_config::current_serving_identity(), ) { return; } match xai_grok_shell::managed_config::sync().await { Ok(true) => eprintln!(" Applied managed configuration."), Ok(false) => tracing::debug!("no managed configuration to apply"), // Auth issues aren't actionable mid-update: quiet here, loud on `grok setup`. Err(e) if e.is_auth_rejection() => tracing::debug!("managed config not applied: {e}"), Err(e) if e.is_retryable() => { tracing::debug!("managed config refresh failed: {e}"); eprintln!(" Couldn't apply managed configuration. Run `grok setup` to retry."); } Err(e) => eprintln!(" Couldn't apply managed configuration. {e}"), } } #[cfg(test)] mod tests { use super::*; #[test] fn test_tmp_download_path_is_unique_per_version_and_per_attempt() { // The old `with_extension("tmp")` collapsed every 0.1.x versioned // name onto a single `grok-0.1.tmp`; the helper must keep distinct // versions distinct AND make repeated attempts (same process, e.g. // concurrent tokio tasks) unique. let dest_181 = std::path::Path::new("/home/u/.grok/downloads/grok-0.1.181-linux-x86_64"); let dest_182 = std::path::Path::new("/home/u/.grok/downloads/grok-0.1.182-linux-x86_64"); let a = tmp_download_path(dest_181); let b = tmp_download_path(dest_182); assert_ne!(a, b, "different versions must not share a temp file"); let a2 = tmp_download_path(dest_181); assert_ne!( a, a2, "two attempts for the same dest must not share a temp file" ); let name = a.file_name().unwrap().to_string_lossy().to_string(); assert!( name.starts_with("grok-0.1.181-linux-x86_64."), "full versioned name must be preserved: {name}" ); assert!( name.ends_with(".tmp") && name.contains(&std::process::id().to_string()), "temp name must embed the PID and end in .tmp (cleanup sweeps *.tmp*): {name}" ); assert_eq!( a.parent(), std::path::Path::new("/home/u/.grok/downloads").into(), "temp file must stay in the destination directory for atomic rename" ); } #[test] fn test_needs_update_same_version() { assert_eq!( needs_update("0.1.141", "0.1.141", "stable", false), Some(false) ); } #[test] fn test_needs_update_invalid_versions() { assert_eq!( needs_update("not-a-version", "0.1.141", "stable", false), None ); assert_eq!(needs_update("0.1.141", "garbage", "stable", false), None); } #[test] fn test_needs_update_unknown_channel() { assert_eq!(needs_update("0.1.140", "0.1.141", "beta", false), None); } #[test] fn test_needs_update_enterprise_channel_behaves_like_stable() { // Enterprise uses the same conservative pre-release rules as stable. // Same version: no update. assert_eq!( needs_update("0.1.206", "0.1.206", "enterprise", false), Some(false) ); // Newer stable: update. assert_eq!( needs_update("0.1.205", "0.1.206", "enterprise", false), Some(true) ); // Older stable: no downgrade (allow_downgrade=false). assert_eq!( needs_update("0.1.207", "0.1.206", "enterprise", false), Some(false) ); // Pre-release candidate rejected on enterprise channel. assert_eq!( needs_update("0.1.205", "0.1.206-alpha.1", "enterprise", false), Some(false) ); // Current pre-release on enterprise forces upgrade (even to equal base). assert_eq!( needs_update("0.1.206-alpha.3", "0.1.206", "enterprise", false), Some(true) ); } #[cfg(unix)] #[tokio::test] async fn test_atomic_symlink_swap_creates_new_symlink() { let dir = tempfile::tempdir().unwrap(); let target = dir.path().join("binary-v1"); std::fs::write(&target, "v1").unwrap(); let link = dir.path().join("grok"); // No existing symlink — should create one. atomic_symlink_swap(&target, &link).await.unwrap(); assert!(link.is_symlink()); assert_eq!(std::fs::read_link(&link).unwrap(), target); assert_eq!(std::fs::read_to_string(&link).unwrap(), "v1"); } #[cfg(unix)] #[tokio::test] async fn test_atomic_symlink_swap_replaces_existing() { let dir = tempfile::tempdir().unwrap(); let target_v1 = dir.path().join("binary-v1"); std::fs::write(&target_v1, "v1").unwrap(); let target_v2 = dir.path().join("binary-v2"); std::fs::write(&target_v2, "v2").unwrap(); let link = dir.path().join("grok"); // Set up initial symlink to v1. std::os::unix::fs::symlink(&target_v1, &link).unwrap(); assert_eq!(std::fs::read_to_string(&link).unwrap(), "v1"); // Swap to v2. atomic_symlink_swap(&target_v2, &link).await.unwrap(); assert!(link.is_symlink()); assert_eq!(std::fs::read_link(&link).unwrap(), target_v2); assert_eq!(std::fs::read_to_string(&link).unwrap(), "v2"); } #[cfg(unix)] #[tokio::test] async fn test_atomic_symlink_swap_preserves_old_target() { let dir = tempfile::tempdir().unwrap(); let target_v1 = dir.path().join("binary-v1"); std::fs::write(&target_v1, "v1-content").unwrap(); let target_v2 = dir.path().join("binary-v2"); std::fs::write(&target_v2, "v2-content").unwrap(); let link = dir.path().join("grok"); std::os::unix::fs::symlink(&target_v1, &link).unwrap(); // Swap to v2. atomic_symlink_swap(&target_v2, &link).await.unwrap(); // The old target file must still exist on disk — this is the key // property that prevents SIGKILL on macOS. Running processes that // have binary-v1 mmap'd can continue to page-fault from it. assert!(target_v1.exists(), "old binary must not be deleted"); assert_eq!(std::fs::read_to_string(&target_v1).unwrap(), "v1-content"); } #[cfg(unix)] #[tokio::test] async fn test_atomic_symlink_swap_no_intermediate_missing_state() { // Verify that the link path always exists (is never absent) during // the swap. We can't truly test atomicity without threads, but we // can at least verify the path exists before and after. let dir = tempfile::tempdir().unwrap(); let target_v1 = dir.path().join("binary-v1"); std::fs::write(&target_v1, "v1").unwrap(); let target_v2 = dir.path().join("binary-v2"); std::fs::write(&target_v2, "v2").unwrap(); let link = dir.path().join("grok"); std::os::unix::fs::symlink(&target_v1, &link).unwrap(); assert!(link.exists(), "link should exist before swap"); atomic_symlink_swap(&target_v2, &link).await.unwrap(); assert!(link.exists(), "link should exist after swap"); // No tmp-link file should be left behind. let tmp_link = link.with_extension("tmp-link"); assert!(!tmp_link.exists(), "temp link should be cleaned up"); } #[cfg(unix)] #[tokio::test] async fn test_atomic_symlink_swap_replaces_regular_file() { // If the canonical path is a regular file (from an old non-symlink // installation), the swap should still work by replacing it. let dir = tempfile::tempdir().unwrap(); let target = dir.path().join("binary-v2"); std::fs::write(&target, "v2").unwrap(); let link = dir.path().join("grok"); // Simulate an old installation where grok is a regular file. std::fs::write(&link, "old-binary").unwrap(); atomic_symlink_swap(&target, &link).await.unwrap(); assert!(link.is_symlink()); assert_eq!(std::fs::read_to_string(&link).unwrap(), "v2"); } #[cfg(unix)] #[tokio::test] async fn test_atomic_symlink_swap_succeeds_despite_leftover_tmp_link() { // A leftover .tmp-link from a crashed swap must not block a new swap: // unique per-racer temp names mean no collision. let dir = tempfile::tempdir().unwrap(); let target_v1 = dir.path().join("binary-v1"); std::fs::write(&target_v1, "v1").unwrap(); let target_v2 = dir.path().join("binary-v2"); std::fs::write(&target_v2, "v2").unwrap(); let link = dir.path().join("grok"); std::os::unix::fs::symlink(&target_v1, &link).unwrap(); std::os::unix::fs::symlink(&target_v1, link.with_extension("tmp-link")).unwrap(); atomic_symlink_swap(&target_v2, &link).await.unwrap(); assert_eq!(std::fs::read_to_string(&link).unwrap(), "v2"); } #[cfg(unix)] fn managed_layout() -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) { let dir = tempfile::tempdir().unwrap(); let bin = dir.path().join("bin"); let downloads = dir.path().join("downloads"); std::fs::create_dir_all(&bin).unwrap(); std::fs::create_dir_all(&downloads).unwrap(); (dir, bin, downloads) } #[test] fn test_installer_manages_bin_entrypoints_gate() { assert!(installer_manages_bin_entrypoints("internal")); assert!(installer_manages_bin_entrypoints("gh-release")); assert!(!installer_manages_bin_entrypoints("npm")); assert!(!installer_manages_bin_entrypoints("unknown")); } #[cfg(unix)] #[tokio::test] async fn test_reconcile_agent_repoints_diverged_agent() { let (_dir, bin, downloads) = managed_layout(); std::fs::write(downloads.join("grok-0.2.101-macos-aarch64"), "new").unwrap(); std::fs::write(downloads.join("grok-0.1.199-macos-aarch64"), "old").unwrap(); std::os::unix::fs::symlink("../downloads/grok-0.2.101-macos-aarch64", bin.join("grok")) .unwrap(); std::os::unix::fs::symlink("../downloads/grok-0.1.199-macos-aarch64", bin.join("agent")) .unwrap(); reconcile_agent_to_grok(&bin).await; assert_eq!( std::fs::read_link(bin.join("agent")).unwrap(), std::path::PathBuf::from("../downloads/grok-0.2.101-macos-aarch64"), ); assert_eq!(std::fs::read_to_string(bin.join("agent")).unwrap(), "new"); assert!(downloads.join("grok-0.1.199-macos-aarch64").exists()); } #[cfg(unix)] #[tokio::test] async fn test_reconcile_agent_heals_legacy_unversioned_agent() { let (_dir, bin, downloads) = managed_layout(); std::fs::write(downloads.join("grok-0.2.101-macos-aarch64"), "new").unwrap(); std::fs::write(downloads.join("grok-macos-aarch64"), "legacy").unwrap(); std::os::unix::fs::symlink("../downloads/grok-0.2.101-macos-aarch64", bin.join("grok")) .unwrap(); std::os::unix::fs::symlink("../downloads/grok-macos-aarch64", bin.join("agent")).unwrap(); reconcile_agent_to_grok(&bin).await; assert_eq!( std::fs::read_link(bin.join("agent")).unwrap(), std::path::PathBuf::from("../downloads/grok-0.2.101-macos-aarch64"), ); assert_eq!(std::fs::read_to_string(bin.join("agent")).unwrap(), "new"); } #[cfg(unix)] #[tokio::test] async fn test_reconcile_agent_creates_missing_agent() { let (_dir, bin, downloads) = managed_layout(); std::fs::write(downloads.join("grok-0.2.101-macos-aarch64"), "new").unwrap(); std::os::unix::fs::symlink("../downloads/grok-0.2.101-macos-aarch64", bin.join("grok")) .unwrap(); reconcile_agent_to_grok(&bin).await; assert!(bin.join("agent").is_symlink()); assert_eq!(std::fs::read_to_string(bin.join("agent")).unwrap(), "new"); } #[cfg(unix)] #[tokio::test] async fn test_reconcile_agent_noop_when_consistent() { let (_dir, bin, downloads) = managed_layout(); std::fs::write(downloads.join("grok-0.2.101-macos-aarch64"), "new").unwrap(); let target = "../downloads/grok-0.2.101-macos-aarch64"; std::os::unix::fs::symlink(target, bin.join("grok")).unwrap(); std::os::unix::fs::symlink(target, bin.join("agent")).unwrap(); reconcile_agent_to_grok(&bin).await; assert_eq!( std::fs::read_link(bin.join("agent")).unwrap(), std::path::PathBuf::from(target), ); let leftovers = std::fs::read_dir(&bin) .unwrap() .filter_map(|e| e.ok()) .filter(|e| e.file_name().to_string_lossy().contains(".tmp-link")) .count(); assert_eq!(leftovers, 0, "no temp links from a no-op reconcile"); } #[cfg(unix)] #[tokio::test] async fn test_reconcile_agent_skips_when_grok_dangling() { let (_dir, bin, downloads) = managed_layout(); std::os::unix::fs::symlink("../downloads/grok-0.2.101-macos-aarch64", bin.join("grok")) .unwrap(); std::fs::write(downloads.join("grok-0.1.199-macos-aarch64"), "old").unwrap(); std::os::unix::fs::symlink("../downloads/grok-0.1.199-macos-aarch64", bin.join("agent")) .unwrap(); reconcile_agent_to_grok(&bin).await; assert_eq!( std::fs::read_link(bin.join("agent")).unwrap(), std::path::PathBuf::from("../downloads/grok-0.1.199-macos-aarch64"), ); } #[cfg(unix)] #[tokio::test] async fn test_reconcile_agent_skips_when_grok_not_symlink() { let (_dir, bin, downloads) = managed_layout(); std::fs::write(bin.join("grok"), "copy-binary").unwrap(); std::fs::write(downloads.join("grok-0.1.199-macos-aarch64"), "old").unwrap(); std::os::unix::fs::symlink("../downloads/grok-0.1.199-macos-aarch64", bin.join("agent")) .unwrap(); reconcile_agent_to_grok(&bin).await; assert_eq!( std::fs::read_link(bin.join("agent")).unwrap(), std::path::PathBuf::from("../downloads/grok-0.1.199-macos-aarch64"), ); } #[cfg(unix)] #[tokio::test] async fn test_sweep_stale_tmp_links_removes_stale_keeps_fresh_and_active() { let dir = tempfile::tempdir().unwrap(); let target = dir.path().join("binary-v1"); std::fs::write(&target, "v1").unwrap(); let link = dir.path().join("grok"); std::os::unix::fs::symlink(&target, &link).unwrap(); // Old- and new-style leftover temp links. let leftover_old = dir.path().join("grok.tmp-link"); let leftover_new = dir.path().join("grok.123-0.tmp-link"); std::os::unix::fs::symlink(&target, &leftover_old).unwrap(); std::os::unix::fs::symlink(&target, &leftover_new).unwrap(); // max_age = ZERO: every leftover is stale and removed; the active // `grok` link (no `.tmp-link` suffix) is untouched. sweep_stale_tmp_links(&link, Duration::ZERO).await; assert!(!leftover_old.exists() && !leftover_new.exists()); assert!(link.is_symlink(), "active link must be preserved"); // A fresh leftover under a real max_age is preserved — it could be a // concurrent racer's in-flight link. let fresh = dir.path().join("grok.999-9.tmp-link"); std::os::unix::fs::symlink(&target, &fresh).unwrap(); sweep_stale_tmp_links(&link, Duration::from_secs(3600)).await; assert!(fresh.exists(), "fresh tmp-link must be preserved"); } #[cfg(unix)] #[tokio::test] async fn test_atomic_symlink_swap_multiple_sequential_swaps() { // Simulate v1 -> v2 -> v3 -> v4 sequential swaps. let dir = tempfile::tempdir().unwrap(); let link = dir.path().join("grok"); for i in 1..=4 { let target = dir.path().join(format!("binary-v{}", i)); std::fs::write(&target, format!("content-v{}", i)).unwrap(); atomic_symlink_swap(&target, &link).await.unwrap(); assert!(link.is_symlink()); assert_eq!( std::fs::read_to_string(&link).unwrap(), format!("content-v{}", i) ); } // All old binaries should still be on disk. for i in 1..=4 { let target = dir.path().join(format!("binary-v{}", i)); assert!(target.exists(), "binary-v{} should still exist", i); } // No temp files should remain. let tmp_link = link.with_extension("tmp-link"); assert!(!tmp_link.exists(), "no temp link should remain"); } #[cfg(unix)] #[tokio::test] async fn test_atomic_symlink_swap_with_absolute_target() { // atomic_symlink_swap stores whatever path is given — if absolute, // readlink returns the absolute path. let dir = tempfile::tempdir().unwrap(); let binary = dir.path().join("grok-0.1.141"); std::fs::write(&binary, "v141").unwrap(); let link = dir.path().join("grok"); atomic_symlink_swap(&binary, &link).await.unwrap(); assert!(link.is_symlink()); // readlink returns the absolute path we passed. assert_eq!(std::fs::read_link(&link).unwrap(), binary); assert_eq!(std::fs::read_to_string(&link).unwrap(), "v141"); } #[cfg(unix)] #[tokio::test] async fn test_atomic_symlink_swap_with_relative_target() { // When given a relative path, the symlink stores a relative target. let dir = tempfile::tempdir().unwrap(); let downloads = dir.path().join("downloads"); let bin = dir.path().join("bin"); std::fs::create_dir_all(&downloads).unwrap(); std::fs::create_dir_all(&bin).unwrap(); std::fs::write(downloads.join("grok-0.1.203"), "v203").unwrap(); let rel_target = std::path::Path::new("../downloads/grok-0.1.203"); let link = bin.join("grok"); atomic_symlink_swap(rel_target, &link).await.unwrap(); assert!(link.is_symlink()); assert_eq!( std::fs::read_link(&link).unwrap(), std::path::PathBuf::from("../downloads/grok-0.1.203") ); assert_eq!(std::fs::read_to_string(&link).unwrap(), "v203"); } #[cfg(unix)] #[test] fn test_relative_symlink_target_sibling_dirs() { // bin/grok -> ../downloads/grok-0.1.203 let target = std::path::Path::new("/home/alice/.grok/downloads/grok-0.1.203"); let link = std::path::Path::new("/home/alice/.grok/bin/grok"); let result = relative_symlink_target(target, link); assert_eq!( result, std::path::PathBuf::from("../downloads/grok-0.1.203") ); } #[cfg(unix)] #[test] fn test_relative_symlink_target_same_dir() { // downloads/grok-latest -> grok-0.1.203 (same directory) let target = std::path::Path::new("/home/alice/.grok/downloads/grok-0.1.203"); let link = std::path::Path::new("/home/alice/.grok/downloads/grok-latest"); let result = relative_symlink_target(target, link); assert_eq!(result, std::path::PathBuf::from("grok-0.1.203")); } #[cfg(unix)] #[test] fn test_relative_symlink_target_cross_tree_stays_absolute() { // /usr/local/bin/grok -> /home/alice/.grok/downloads/grok-0.1.203 // Different grandparents — should stay absolute. let target = std::path::Path::new("/home/alice/.grok/downloads/grok-0.1.203"); let link = std::path::Path::new("/usr/local/bin/grok"); let result = relative_symlink_target(target, link); assert_eq!( result, std::path::PathBuf::from("/home/alice/.grok/downloads/grok-0.1.203") ); } #[cfg(unix)] #[tokio::test] async fn test_relative_symlink_survives_directory_move() { // Simulates Docker bind-mount: create ~/.grok/ layout at path A, // then move it to path B and verify the symlink still resolves. let dir = tempfile::tempdir().unwrap(); // Create alice's layout let alice = dir.path().join("alice").join(".grok"); let alice_downloads = alice.join("downloads"); let alice_bin = alice.join("bin"); std::fs::create_dir_all(&alice_downloads).unwrap(); std::fs::create_dir_all(&alice_bin).unwrap(); std::fs::write(alice_downloads.join("grok-0.1.203"), "binary-content").unwrap(); // Create a relative symlink (what the fix produces) let rel_target = std::path::Path::new("../downloads/grok-0.1.203"); let link = alice_bin.join("grok"); atomic_symlink_swap(rel_target, &link).await.unwrap(); // Verify it works at the original location assert_eq!(std::fs::read_to_string(&link).unwrap(), "binary-content"); // "Bind-mount" to bob: copy the entire .grok tree let bob_home = dir.path().join("bob"); std::fs::create_dir_all(&bob_home).unwrap(); let bob = bob_home.join(".grok"); let copy_status = std::process::Command::new("cp") .args(["-a", alice.to_str().unwrap(), bob.to_str().unwrap()]) .status() .unwrap(); assert!(copy_status.success()); // Verify the symlink resolves at bob's path too let bob_link = bob.join("bin").join("grok"); assert!(bob_link.is_symlink()); assert_eq!( std::fs::read_link(&bob_link).unwrap(), std::path::PathBuf::from("../downloads/grok-0.1.203"), "symlink target should be relative" ); assert_eq!( std::fs::read_to_string(&bob_link).unwrap(), "binary-content", "relative symlink should resolve at the new path" ); } #[cfg(unix)] #[tokio::test] async fn test_atomic_symlink_swap_broken_symlink_target() { // If the current symlink is broken (target deleted externally), // the swap should still succeed. let dir = tempfile::tempdir().unwrap(); let link = dir.path().join("grok"); // Create a broken symlink — points to a file that doesn't exist. std::os::unix::fs::symlink(dir.path().join("deleted-binary"), &link).unwrap(); assert!(link.is_symlink()); assert!(!link.exists(), "broken symlink should not 'exist'"); // New target to swap to. let target = dir.path().join("binary-v2"); std::fs::write(&target, "v2").unwrap(); atomic_symlink_swap(&target, &link).await.unwrap(); assert!(link.is_symlink()); assert!(link.exists(), "symlink should now resolve"); assert_eq!(std::fs::read_to_string(&link).unwrap(), "v2"); } #[test] fn test_needs_update_prerelease_to_stable_forces_install() { // Inadmissible current (pre-release on stable channel) → install even // if the candidate is semver-lower. assert_eq!( needs_update("0.1.149-alpha.1", "0.1.148", "stable", false), Some(true) ); assert_eq!( needs_update("0.1.148-alpha.3", "0.1.148", "stable", false), Some(true) ); } #[test] fn test_needs_update_stable_to_alpha_no_install_when_candidate_equal() { // Server returns max(stable, alpha) for alpha channel. When the user's // stable version already IS the candidate, no install needed. assert_eq!( needs_update("0.1.148", "0.1.148", "alpha", false), Some(false) ); } #[test] fn test_needs_update_stable_channel_never_gets_prerelease() { assert_eq!( needs_update("0.1.139", "0.1.140-alpha.1", "stable", false), Some(false) ); assert_eq!( needs_update("0.1.0", "0.1.1-beta.1", "stable", false), Some(false) ); } #[test] fn test_needs_update_valid_current_only_upgrades() { // Admissible current on the target channel → pure semver (allow_downgrade=false). assert_eq!( needs_update("0.1.140", "0.1.141", "stable", false), Some(true) ); assert_eq!( needs_update("0.1.141", "0.1.140", "stable", false), Some(false) ); assert_eq!( needs_update("0.1.140-alpha.8", "0.1.140", "alpha", false), Some(true) ); assert_eq!( needs_update("0.1.140", "0.1.139-alpha.5", "alpha", false), Some(false) ); // Alpha → newer alpha: upgrade. assert_eq!( needs_update("0.1.148-alpha.1", "0.1.148-alpha.3", "alpha", false), Some(true) ); // Alpha → older alpha: no downgrade (allow_downgrade=false). assert_eq!( needs_update("0.1.148-alpha.3", "0.1.148-alpha.2", "alpha", false), Some(false) ); } #[test] fn test_needs_update_large_version_numbers() { // Ensure no overflow on realistic version numbers assert_eq!( needs_update("0.1.140", "0.1.999", "stable", false), Some(true) ); assert_eq!( needs_update("0.1.999", "0.2.0", "stable", false), Some(true) ); assert_eq!( needs_update("99.99.99", "100.0.0", "stable", false), Some(true) ); } #[tokio::test] async fn test_cleanup_old_downloads_keeps_current_plus_one() { let dir = tempfile::tempdir().unwrap(); let d = dir.path(); // Simulate 5 old grok binaries in downloads dir. for v in ["0.1.140", "0.1.141", "0.1.142", "0.1.143", "0.1.144"] { std::fs::write(d.join(format!("grok-{}-macos-aarch64", v)), v).unwrap(); } // Current version. std::fs::write(d.join("grok-0.1.145-macos-aarch64"), "current").unwrap(); make_all_stale(d); cleanup_old_downloads(d, "grok", "0.1.145").await; // Current must survive. assert!(d.join("grok-0.1.145-macos-aarch64").exists(), "current"); // Newest old version (0.1.144) must survive. assert!(d.join("grok-0.1.144-macos-aarch64").exists(), "N-1"); // Everything else should be deleted. assert!( !d.join("grok-0.1.143-macos-aarch64").exists(), "0.1.143 should be deleted" ); assert!( !d.join("grok-0.1.142-macos-aarch64").exists(), "0.1.142 should be deleted" ); assert!( !d.join("grok-0.1.141-macos-aarch64").exists(), "0.1.141 should be deleted" ); assert!( !d.join("grok-0.1.140-macos-aarch64").exists(), "0.1.140 should be deleted" ); } #[tokio::test] async fn test_cleanup_old_downloads_does_not_touch_other_binaries() { let dir = tempfile::tempdir().unwrap(); let d = dir.path(); // grok and grok-pager should not interfere with each other. std::fs::write(d.join("grok-0.1.140-macos-aarch64"), "old-grok").unwrap(); std::fs::write(d.join("grok-0.1.141-macos-aarch64"), "current-grok").unwrap(); std::fs::write(d.join("grok-pager-0.1.140-macos-aarch64"), "old-pager").unwrap(); std::fs::write(d.join("grok-pager-0.1.141-macos-aarch64"), "current-pager").unwrap(); // Cleanup only grok — pager files must be untouched. make_all_stale(d); cleanup_old_downloads(d, "grok", "0.1.141").await; assert!(d.join("grok-0.1.141-macos-aarch64").exists()); assert!(d.join("grok-0.1.140-macos-aarch64").exists()); // only old, kept as N-1 assert!( d.join("grok-pager-0.1.140-macos-aarch64").exists(), "pager untouched" ); assert!( d.join("grok-pager-0.1.141-macos-aarch64").exists(), "pager untouched" ); } /// Backdate a file's mtime past [`STALE_TMP_AGE`] so cleanup treats it /// as an abandoned download / genuinely old binary. fn make_stale(path: &std::path::Path) { let old = std::time::SystemTime::now() - (STALE_TMP_AGE + Duration::from_secs(60)); let f = std::fs::File::options().write(true).open(path).unwrap(); f.set_times(std::fs::FileTimes::new().set_modified(old)) .unwrap(); } /// Backdate every file in `dir`. Cleanup deliberately never deletes a /// freshly-written binary or temp file (it may belong to a concurrent /// in-flight install), so retention-policy tests must age their fixtures /// to look like real leftovers from previous releases. fn make_all_stale(dir: &std::path::Path) { for entry in std::fs::read_dir(dir).unwrap() { let p = entry.unwrap().path(); if p.is_file() { make_stale(&p); } } } #[tokio::test] async fn test_cleanup_old_downloads_removes_stale_tmp_keeps_fresh_tmp() { let dir = tempfile::tempdir().unwrap(); let d = dir.path(); // Stale tmp: abandoned by a crashed updater — swept. std::fs::write(d.join("grok-0.1.140-macos-aarch64.tmp"), "partial").unwrap(); make_stale(&d.join("grok-0.1.140-macos-aarch64.tmp")); // Fresh tmp: a concurrent updater's in-flight download — kept, or // its atomic rename would fail with ENOENT. std::fs::write(d.join("grok-0.1.142-macos-aarch64.77-0.tmp"), "inflight").unwrap(); std::fs::write(d.join("grok-0.1.141-macos-aarch64"), "current").unwrap(); cleanup_old_downloads(d, "grok", "0.1.141").await; assert!( !d.join("grok-0.1.140-macos-aarch64.tmp").exists(), "stale tmp cleaned up" ); assert!( d.join("grok-0.1.142-macos-aarch64.77-0.tmp").exists(), "fresh in-flight tmp must NOT be swept" ); assert!( d.join("grok-0.1.141-macos-aarch64").exists(), "current kept" ); } #[tokio::test] async fn test_cleanup_old_downloads_keeps_fresh_versioned_binary() { // A versioned binary written moments ago may be a concurrent // installer's just-renamed download whose symlink swap hasn't // happened yet — even when the retention policy would otherwise // delete it, it must survive until it ages. let dir = tempfile::tempdir().unwrap(); let d = dir.path(); // Three old versions + current: policy would delete .138 and .139. for v in ["0.1.138", "0.1.139", "0.1.140"] { std::fs::write(d.join(format!("grok-{v}-macos-aarch64")), v).unwrap(); } std::fs::write(d.join("grok-0.1.141-macos-aarch64"), "current").unwrap(); make_all_stale(d); // .138 is re-written NOW — simulating a racer that just renamed its // download into place (e.g. a rollback install racing an upgrade). std::fs::write(d.join("grok-0.1.138-macos-aarch64"), "in-flight").unwrap(); cleanup_old_downloads(d, "grok", "0.1.141").await; assert!(d.join("grok-0.1.141-macos-aarch64").exists(), "current"); assert!(d.join("grok-0.1.140-macos-aarch64").exists(), "N-1 kept"); assert!( d.join("grok-0.1.138-macos-aarch64").exists(), "fresh just-renamed binary must NOT be deleted" ); assert!( !d.join("grok-0.1.139-macos-aarch64").exists(), "genuinely old binary still swept" ); } #[cfg(unix)] #[tokio::test] async fn test_cleanup_old_downloads_skips_symlinks() { let dir = tempfile::tempdir().unwrap(); let d = dir.path(); // grok-latest is a symlink — must be skipped. let target = d.join("grok-0.1.141-macos-aarch64"); std::fs::write(&target, "current").unwrap(); std::os::unix::fs::symlink(&target, d.join("grok-latest")).unwrap(); make_all_stale(d); cleanup_old_downloads(d, "grok", "0.1.141").await; assert!( d.join("grok-latest").exists(), "symlink must not be deleted" ); assert!(target.exists(), "current must not be deleted"); } #[tokio::test] async fn test_cleanup_old_downloads_empty_dir() { let dir = tempfile::tempdir().unwrap(); // Should not panic or error on empty directory. make_all_stale(dir.path()); cleanup_old_downloads(dir.path(), "grok", "0.1.141").await; } #[tokio::test] async fn test_cleanup_old_downloads_version_prefix_collision() { // Regression test: version "0.1.14" must not protect "0.1.140", "0.1.141", etc. let dir = tempfile::tempdir().unwrap(); let d = dir.path(); std::fs::write(d.join("grok-0.1.14-macos-aarch64"), "current").unwrap(); std::fs::write(d.join("grok-0.1.140-macos-aarch64"), "old-140").unwrap(); std::fs::write(d.join("grok-0.1.141-macos-aarch64"), "old-141").unwrap(); std::fs::write(d.join("grok-0.1.13-macos-aarch64"), "old-13").unwrap(); make_all_stale(d); cleanup_old_downloads(d, "grok", "0.1.14").await; // Current must survive. assert!( d.join("grok-0.1.14-macos-aarch64").exists(), "current 0.1.14" ); // Newest old version (0.1.141) must survive as N-1. assert!( d.join("grok-0.1.141-macos-aarch64").exists(), "N-1 is 0.1.141" ); // 0.1.140 and 0.1.13 should be deleted. assert!( !d.join("grok-0.1.140-macos-aarch64").exists(), "0.1.140 should be deleted" ); assert!( !d.join("grok-0.1.13-macos-aarch64").exists(), "0.1.13 should be deleted" ); } #[tokio::test] async fn test_cleanup_old_downloads_pager_multi_version() { // Verify cleanup works for grok-pager with multiple old versions. let dir = tempfile::tempdir().unwrap(); let d = dir.path(); for v in ["0.1.148", "0.1.149", "0.1.150"] { std::fs::write(d.join(format!("grok-pager-{}-linux-x64", v)), v).unwrap(); } std::fs::write(d.join("grok-pager-0.1.151-linux-x64"), "current").unwrap(); make_all_stale(d); cleanup_old_downloads(d, "grok-pager", "0.1.151").await; assert!(d.join("grok-pager-0.1.151-linux-x64").exists(), "current"); assert!(d.join("grok-pager-0.1.150-linux-x64").exists(), "N-1 kept"); assert!( !d.join("grok-pager-0.1.149-linux-x64").exists(), "0.1.149 deleted" ); assert!( !d.join("grok-pager-0.1.148-linux-x64").exists(), "0.1.148 deleted" ); } #[tokio::test] async fn test_cleanup_old_downloads_npm_layout() { // npm layout: files are just `grok-{version}` (no platform suffix). let dir = tempfile::tempdir().unwrap(); let d = dir.path(); for v in ["0.1.138", "0.1.139", "0.1.140"] { std::fs::write(d.join(format!("grok-{}", v)), v).unwrap(); } std::fs::write(d.join("grok-0.1.141"), "current").unwrap(); make_all_stale(d); cleanup_old_downloads(d, "grok", "0.1.141").await; assert!(d.join("grok-0.1.141").exists(), "current"); assert!(d.join("grok-0.1.140").exists(), "N-1 kept"); assert!(!d.join("grok-0.1.139").exists(), "0.1.139 deleted"); assert!(!d.join("grok-0.1.138").exists(), "0.1.138 deleted"); } #[tokio::test] async fn test_cleanup_old_downloads_alpha_versions() { // Alpha version filenames include pre-release tags: // grok-0.1.150-alpha.1-macos-aarch64 let dir = tempfile::tempdir().unwrap(); let d = dir.path(); std::fs::write(d.join("grok-0.1.148-alpha.1-macos-aarch64"), "alpha-148-1").unwrap(); std::fs::write(d.join("grok-0.1.148-alpha.2-macos-aarch64"), "alpha-148-2").unwrap(); std::fs::write(d.join("grok-0.1.149-alpha.1-macos-aarch64"), "alpha-149-1").unwrap(); // Current version is the newest alpha. std::fs::write(d.join("grok-0.1.150-alpha.1-macos-aarch64"), "current").unwrap(); make_all_stale(d); cleanup_old_downloads(d, "grok", "0.1.150-alpha.1").await; // Current must survive. assert!( d.join("grok-0.1.150-alpha.1-macos-aarch64").exists(), "current alpha" ); // Newest old (0.1.149-alpha.1) kept as N-1. assert!( d.join("grok-0.1.149-alpha.1-macos-aarch64").exists(), "N-1 alpha" ); // Older alphas deleted. assert!( !d.join("grok-0.1.148-alpha.2-macos-aarch64").exists(), "0.1.148-alpha.2 deleted" ); assert!( !d.join("grok-0.1.148-alpha.1-macos-aarch64").exists(), "0.1.148-alpha.1 deleted" ); } #[tokio::test] async fn test_cleanup_old_downloads_mixed_stable_and_alpha() { // Mix of stable and alpha binaries in the same directory. let dir = tempfile::tempdir().unwrap(); let d = dir.path(); std::fs::write(d.join("grok-0.1.148-macos-aarch64"), "stable-148").unwrap(); std::fs::write(d.join("grok-0.1.149-alpha.1-macos-aarch64"), "alpha-149").unwrap(); std::fs::write(d.join("grok-0.1.149-macos-aarch64"), "stable-149").unwrap(); // Current is a stable release. std::fs::write(d.join("grok-0.1.150-macos-aarch64"), "current").unwrap(); make_all_stale(d); cleanup_old_downloads(d, "grok", "0.1.150").await; // Current must survive. assert!(d.join("grok-0.1.150-macos-aarch64").exists(), "current"); // Newest old is 0.1.149 stable (semver: 0.1.149 > 0.1.149-alpha.1). assert!( d.join("grok-0.1.149-macos-aarch64").exists(), "N-1 is stable 0.1.149" ); // The rest should be deleted. assert!( !d.join("grok-0.1.149-alpha.1-macos-aarch64").exists(), "alpha 0.1.149-alpha.1 deleted" ); assert!( !d.join("grok-0.1.148-macos-aarch64").exists(), "stable 0.1.148 deleted" ); } // ────────────────────────────────────────────────────────────────────── // reinstall_hint // ────────────────────────────────────────────────────────────────────── #[test] fn test_reinstall_hint_npm_mentions_npm_command() { let hint = reinstall_hint("npm"); assert!(hint.contains("npm i -g"), "should suggest npm i -g: {hint}"); assert!( hint.contains("@xai-official/grok"), "should name the package: {hint}" ); } #[test] fn test_reinstall_hint_gh_release_mentions_gh_command() { let hint = reinstall_hint("gh-release"); assert!( hint.contains("gh release download"), "should suggest gh release download: {hint}" ); assert!( hint.contains("xai-org-shared/grok-build"), "should name the repo: {hint}" ); } #[test] fn test_reinstall_hint_internal_mentions_platform_installer() { let hint = reinstall_hint("internal"); if cfg!(windows) { assert!(hint.contains("irm"), "should suggest irm install: {hint}"); assert!( hint.contains("install.ps1"), "should reference install.ps1: {hint}" ); } else { assert!(hint.contains("curl"), "should suggest curl install: {hint}"); assert!( hint.contains("install.sh"), "should reference install.sh: {hint}" ); } } #[test] fn test_reinstall_hint_unknown_falls_back_to_internal() { // Unknown installer falls back to the same hint as "internal". let unknown = reinstall_hint("homebrew"); let internal = reinstall_hint("internal"); assert_eq!(unknown, internal); } #[test] fn test_reinstall_hint_empty_falls_back_to_internal() { let hint = reinstall_hint(""); assert_eq!(hint, reinstall_hint("internal")); } // ────────────────────────────────────────────────────────────────────── // UpdateStatus serialization (camelCase contract for --json clients) // ────────────────────────────────────────────────────────────────────── fn make_status() -> UpdateStatus { UpdateStatus { current_version: "0.1.150".to_string(), latest_version: Some("0.1.151".to_string()), update_available: true, installer: Some("npm".to_string()), channel: "stable".to_string(), auto_update: Some(true), error: None, } } #[test] fn test_update_status_serializes_camel_case_keys() { let s = make_status(); let v = serde_json::to_value(&s).unwrap(); let obj = v.as_object().unwrap(); assert!(obj.contains_key("currentVersion")); assert!(obj.contains_key("latestVersion")); assert!(obj.contains_key("updateAvailable")); assert!(obj.contains_key("installer")); assert!(obj.contains_key("channel")); assert!(obj.contains_key("autoUpdate")); assert!(obj.contains_key("error")); // Snake-case names must NOT leak. assert!(!obj.contains_key("current_version")); assert!(!obj.contains_key("latest_version")); assert!(!obj.contains_key("update_available")); assert!(!obj.contains_key("auto_update")); } #[test] fn test_update_status_field_values_round_trip_through_json() { let s = make_status(); let v = serde_json::to_value(&s).unwrap(); assert_eq!(v["currentVersion"], "0.1.150"); assert_eq!(v["latestVersion"], "0.1.151"); assert_eq!(v["updateAvailable"], true); assert_eq!(v["installer"], "npm"); assert_eq!(v["channel"], "stable"); assert_eq!(v["autoUpdate"], true); assert!(v["error"].is_null()); } #[test] fn test_update_status_optional_none_serializes_to_null() { let s = UpdateStatus { current_version: "0.1.150".to_string(), latest_version: None, update_available: false, installer: None, channel: "stable".to_string(), auto_update: None, error: None, }; let v = serde_json::to_value(&s).unwrap(); assert!(v["latestVersion"].is_null()); assert!(v["installer"].is_null()); assert!(v["autoUpdate"].is_null()); assert!(v["error"].is_null()); assert_eq!(v["updateAvailable"], false); } #[test] fn test_update_status_with_error_field_serialized() { let s = UpdateStatus { current_version: "0.1.150".to_string(), latest_version: None, update_available: false, installer: Some("npm".to_string()), channel: "stable".to_string(), auto_update: Some(true), error: Some("npm view failed: ENETUNREACH".to_string()), }; let v = serde_json::to_value(&s).unwrap(); assert_eq!(v["error"], "npm view failed: ENETUNREACH"); } #[test] fn test_update_status_alpha_channel_serialized() { let s = UpdateStatus { current_version: "0.1.150-alpha.1".to_string(), latest_version: Some("0.1.150-alpha.2".to_string()), update_available: true, installer: Some("npm".to_string()), channel: "alpha".to_string(), auto_update: Some(true), error: None, }; let v = serde_json::to_value(&s).unwrap(); assert_eq!(v["channel"], "alpha"); assert_eq!(v["currentVersion"], "0.1.150-alpha.1"); assert_eq!(v["latestVersion"], "0.1.150-alpha.2"); } #[test] fn test_update_status_json_is_valid_single_object() { // Whatever we add to UpdateStatus in the future, the serialization // must remain a single JSON object (not an array, primitive, etc.). let s = make_status(); let json = serde_json::to_string(&s).unwrap(); assert!(json.starts_with('{'), "must be a JSON object: {json}"); assert!(json.ends_with('}'), "must be a JSON object: {json}"); // Single line: no embedded newlines (the wire format is one line). assert!(!json.contains('\n'), "must be single line: {json}"); } // ────────────────────────────────────────────────────────────────────── // print_update_status — exercise both code paths via JSON serialization // (the human path writes to stdout/stderr which is hard to capture // without altering the function signature). // ────────────────────────────────────────────────────────────────────── #[test] fn test_print_update_status_json_returns_ok() { let s = make_status(); // We can't easily capture stdout, but we can confirm the function // doesn't panic or return Err on a well-formed status. print_update_status(&s, true).unwrap(); } #[test] fn test_print_update_status_human_returns_ok_when_update_available() { let s = make_status(); print_update_status(&s, false).unwrap(); } #[test] fn test_print_update_status_human_returns_ok_when_no_installer() { let s = UpdateStatus { current_version: "0.1.150".to_string(), latest_version: None, update_available: false, installer: None, channel: "stable".to_string(), auto_update: None, error: None, }; print_update_status(&s, false).unwrap(); } #[test] fn test_print_update_status_human_returns_ok_with_error() { let s = UpdateStatus { current_version: "0.1.150".to_string(), latest_version: None, update_available: false, installer: Some("npm".to_string()), channel: "stable".to_string(), auto_update: Some(true), error: Some("network down".to_string()), }; print_update_status(&s, false).unwrap(); } #[test] fn test_print_update_status_human_returns_ok_when_up_to_date() { let s = UpdateStatus { current_version: "0.1.150".to_string(), latest_version: Some("0.1.150".to_string()), update_available: false, installer: Some("npm".to_string()), channel: "stable".to_string(), auto_update: Some(true), error: None, }; print_update_status(&s, false).unwrap(); } // ────────────────────────────────────────────────────────────────────── // needs_update — additional edge cases // ────────────────────────────────────────────────────────────────────── #[test] fn test_needs_update_empty_current_returns_none() { assert_eq!(needs_update("", "0.1.141", "stable", false), None); } #[test] fn test_needs_update_empty_latest_returns_none() { assert_eq!(needs_update("0.1.141", "", "stable", false), None); } #[test] fn test_needs_update_whitespace_returns_none() { // Leading/trailing whitespace is not stripped — semver::parse rejects. assert_eq!(needs_update(" 0.1.141", "0.1.142", "stable", false), None); assert_eq!(needs_update("0.1.141", "0.1.142 ", "stable", false), None); } #[test] fn test_needs_update_channel_is_case_sensitive() { // "STABLE", "Stable", "ENTERPRISE" etc. are not recognized — must be exact lowercase. assert_eq!(needs_update("0.1.140", "0.1.141", "STABLE", false), None); assert_eq!(needs_update("0.1.140", "0.1.141", "Stable", false), None); assert_eq!(needs_update("0.1.140", "0.1.141", "ALPHA", false), None); assert_eq!( needs_update("0.1.140", "0.1.141", "ENTERPRISE", false), None ); } #[test] fn test_needs_update_unknown_channels_return_none() { // Unknown channels (not stable/alpha/enterprise) return None. assert_eq!(needs_update("0.1.140", "0.1.141", "beta", false), None); assert_eq!(needs_update("0.1.140", "0.1.141", "nightly", false), None); assert_eq!(needs_update("0.1.140", "0.1.141", "", false), None); assert_eq!(needs_update("0.1.140", "0.1.141", "rc", false), None); // Enterprise is explicitly supported (behaves like stable). assert_eq!( needs_update("0.1.140", "0.1.141", "enterprise", false), Some(true) ); // Unknown channels return None regardless of allow_downgrade. assert_eq!(needs_update("0.1.140", "0.1.141", "beta", true), None); assert_eq!(needs_update("0.1.140", "0.1.141", "", true), None); } #[test] fn test_needs_update_zero_versions() { assert_eq!(needs_update("0.0.0", "0.0.1", "stable", false), Some(true)); assert_eq!(needs_update("0.0.0", "0.0.0", "stable", false), Some(false)); } #[test] fn test_needs_update_major_version_jump() { assert_eq!(needs_update("0.9.99", "1.0.0", "stable", false), Some(true)); assert_eq!( needs_update("1.99.99", "2.0.0", "stable", false), Some(true) ); // Major downgrade: not an upgrade (allow_downgrade=false). assert_eq!( needs_update("2.0.0", "1.99.99", "stable", false), Some(false) ); } #[test] fn test_needs_update_alpha_to_alpha_same_version_not_upgrade() { assert_eq!( needs_update("0.1.150-alpha.5", "0.1.150-alpha.5", "alpha", false), Some(false) ); } #[test] fn test_needs_update_alpha_to_beta_same_base_is_upgrade_per_semver() { // semver: alpha.5 < beta.1 (lexicographic on identifiers per spec) assert_eq!( needs_update("0.1.150-alpha.5", "0.1.150-beta.1", "alpha", false), Some(true) ); } #[test] fn test_needs_update_with_build_metadata_uses_semver_crate_ordering() { // SUBTLE: per the semver SPEC, build metadata (after `+`) MUST be // ignored when determining version precedence. However the `semver` // crate's `PartialOrd` impl compares build metadata lexicographically // for differing values. So `0.1.141+xyz > 0.1.141+abc` returns true // here even though spec-wise they are equal. // // This means CI publishers MUST NOT publish multiple builds of the // same version differing only in build metadata, or auto-update will // bounce users between them. Today our pipeline doesn't, so this is // latent — but the test locks in the surprising behavior so it can't // change silently. assert_eq!( needs_update("0.1.141+abc", "0.1.141+xyz", "stable", false), Some(true), "semver crate orders by build metadata lexicographically (contra spec)" ); // No build metadata vs with build metadata: semver crate treats // a version with build > the same version without it. assert_eq!( needs_update("0.1.141", "0.1.141+abc", "stable", false), Some(true) ); } #[test] fn test_needs_update_partial_versions_rejected() { assert_eq!(needs_update("0.1", "0.1.141", "stable", false), None); assert_eq!(needs_update("0", "0.1.141", "stable", false), None); assert_eq!(needs_update("0.1.141", "1", "stable", false), None); } #[test] fn test_needs_update_alpha_channel_with_invalid_versions_returns_none() { // Same parse-failure behavior on alpha as stable. assert_eq!(needs_update("garbage", "0.1.141", "alpha", false), None); assert_eq!(needs_update("0.1.141", "garbage", "alpha", false), None); } #[test] fn test_needs_update_alpha_channel_treats_release_as_higher_than_prerelease() { // On alpha channel, a release version is semver-higher than its // matching pre-release: 0.1.150 > 0.1.150-alpha.99. assert_eq!( needs_update("0.1.150-alpha.99", "0.1.150", "alpha", false), Some(true) ); } #[test] fn test_needs_update_stable_does_not_install_when_pre_and_pre() { // current is pre-release, latest is also pre-release on stable channel: // latest is rejected as pre-release, so no install. assert_eq!( needs_update("0.1.150-alpha.1", "0.1.151-alpha.1", "stable", false), Some(false) ); } // ────────────────────────────────────────────────────────────────────── // needs_update — allow_downgrade=true (rollback support) // ────────────────────────────────────────────────────────────────────── #[test] fn test_needs_update_downgrade_stable_when_allowed() { // Rollback scenario: stable pointer moved from 0.2.7 → 0.2.5. // GCS/internal installer: allow_downgrade=true → triggers update. assert_eq!(needs_update("0.2.7", "0.2.5", "stable", true), Some(true)); } #[test] fn test_needs_update_downgrade_stable_blocked_when_disallowed() { // Same rollback scenario but npm installer: allow_downgrade=false → no update. assert_eq!(needs_update("0.2.7", "0.2.5", "stable", false), Some(false)); } #[test] fn test_needs_update_downgrade_alpha_when_allowed() { // Alpha rollback: pointer moved backward. assert_eq!(needs_update("0.2.7", "0.2.5", "alpha", true), Some(true)); // Alpha pre-release downgrade. assert_eq!( needs_update("0.1.148-alpha.3", "0.1.148-alpha.2", "alpha", true), Some(true) ); } #[test] fn test_needs_update_downgrade_enterprise_when_allowed() { assert_eq!( needs_update("0.1.207", "0.1.206", "enterprise", true), Some(true) ); } #[test] fn test_needs_update_same_version_unaffected_by_allow_downgrade() { // Same version → no update regardless of allow_downgrade setting. assert_eq!(needs_update("0.2.5", "0.2.5", "stable", true), Some(false)); assert_eq!(needs_update("0.2.5", "0.2.5", "stable", false), Some(false)); assert_eq!(needs_update("0.2.5", "0.2.5", "alpha", true), Some(false)); } #[test] fn test_needs_update_upgrade_unaffected_by_allow_downgrade() { // Upgrade works regardless of allow_downgrade setting. assert_eq!(needs_update("0.2.5", "0.2.7", "stable", true), Some(true)); assert_eq!(needs_update("0.2.5", "0.2.7", "stable", false), Some(true)); assert_eq!(needs_update("0.2.5", "0.2.7", "alpha", true), Some(true)); assert_eq!(needs_update("0.2.5", "0.2.7", "alpha", false), Some(true)); } #[test] fn test_needs_update_downgrade_major_version_when_allowed() { // Major version downgrade (e.g. v2 → v1 rollback). assert_eq!(needs_update("2.0.0", "1.99.99", "stable", true), Some(true)); } #[test] fn test_needs_update_downgrade_prerelease_still_rejected_on_stable() { // Even with allow_downgrade=true, pre-release targets are rejected on // stable/enterprise channels (safety net). assert_eq!( needs_update("0.2.7", "0.2.5-alpha.1", "stable", true), Some(false) ); assert_eq!( needs_update("0.2.7", "0.2.5-alpha.1", "enterprise", true), Some(false) ); } #[test] fn test_needs_update_prerelease_current_forces_install_regardless_of_allow_downgrade() { // Pre-release current on stable channel → force-install, independent // of allow_downgrade. assert_eq!( needs_update("0.1.149-alpha.1", "0.1.148", "stable", true), Some(true) ); assert_eq!( needs_update("0.1.149-alpha.1", "0.1.148", "stable", false), Some(true) ); } // ────────────────────────────────────────────────────────────────────── // installer_allows_downgrade // ────────────────────────────────────────────────────────────────────── #[test] fn test_installer_allows_downgrade_internal() { assert!(installer_allows_downgrade("internal")); } #[test] fn test_installer_allows_downgrade_gh_release() { assert!(installer_allows_downgrade("gh-release")); } #[test] fn test_installer_allows_downgrade_npm_blocked() { // npm registries can return stale/misconfigured versions — no downgrade. assert!(!installer_allows_downgrade("npm")); } #[test] fn test_installer_allows_downgrade_unknown_blocked() { assert!(!installer_allows_downgrade("unknown")); assert!(!installer_allows_downgrade("")); assert!(!installer_allows_downgrade("homebrew")); } // ────────────────────────────────────────────────────────────────────── // detect_platform // ────────────────────────────────────────────────────────────────────── #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] #[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))] #[test] fn test_detect_platform_returns_known_os() { let (os, arch) = detect_platform().unwrap(); assert!( os == "macos" || os == "linux" || os == "windows", "got os={os}" ); assert!(arch == "x86_64" || arch == "aarch64", "got arch={arch}"); } #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] #[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))] #[test] fn test_detect_platform_matches_compile_time_cfg() { let (os, arch) = detect_platform().unwrap(); if cfg!(target_os = "macos") { assert_eq!(os, "macos"); } if cfg!(target_os = "linux") { assert_eq!(os, "linux"); } if cfg!(target_os = "windows") { assert_eq!(os, "windows"); } if cfg!(target_arch = "x86_64") { assert_eq!(arch, "x86_64"); } if cfg!(target_arch = "aarch64") { assert_eq!(arch, "aarch64"); } } // ────────────────────────────────────────────────────────────────────── // cleanup_old_downloads — additional edge cases // ────────────────────────────────────────────────────────────────────── #[tokio::test] async fn test_cleanup_old_downloads_invalid_current_version_is_no_op() { let dir = tempfile::tempdir().unwrap(); let d = dir.path(); std::fs::write(d.join("grok-0.1.140-macos-aarch64"), "v140").unwrap(); std::fs::write(d.join("grok-0.1.141-macos-aarch64"), "v141").unwrap(); // Invalid version string → cleanup must early-return without deleting. make_all_stale(d); cleanup_old_downloads(d, "grok", "not-a-version").await; assert!(d.join("grok-0.1.140-macos-aarch64").exists()); assert!(d.join("grok-0.1.141-macos-aarch64").exists()); } #[tokio::test] async fn test_cleanup_old_downloads_missing_dir_no_panic() { let dir = tempfile::tempdir().unwrap(); let missing = dir.path().join("does-not-exist"); // Must not panic when the directory doesn't exist. cleanup_old_downloads(&missing, "grok", "0.1.141").await; } #[tokio::test] async fn test_cleanup_old_downloads_files_with_non_digit_suffix_skipped() { let dir = tempfile::tempdir().unwrap(); let d = dir.path(); // Files matching prefix but with a non-digit-leading suffix must be // ignored (e.g. grok-latest, grok-pager-* when prefix is grok). std::fs::write(d.join("grok-latest"), "alias").unwrap(); std::fs::write(d.join("grok-pager-0.1.141-macos-aarch64"), "pager").unwrap(); std::fs::write(d.join("grok-0.1.140-macos-aarch64"), "v140").unwrap(); std::fs::write(d.join("grok-0.1.141-macos-aarch64"), "current").unwrap(); make_all_stale(d); cleanup_old_downloads(d, "grok", "0.1.141").await; // grok-latest and grok-pager-* must be untouched. assert!(d.join("grok-latest").exists()); assert!(d.join("grok-pager-0.1.141-macos-aarch64").exists()); } #[tokio::test] async fn test_cleanup_old_downloads_unparseable_version_skipped() { let dir = tempfile::tempdir().unwrap(); let d = dir.path(); // Files with prefix + digit but unparseable as semver are ignored // (not deleted, not counted). std::fs::write(d.join("grok-9garbage-macos-aarch64"), "junk").unwrap(); std::fs::write(d.join("grok-0.1.141-macos-aarch64"), "current").unwrap(); make_all_stale(d); cleanup_old_downloads(d, "grok", "0.1.141").await; assert!( d.join("grok-9garbage-macos-aarch64").exists(), "unparseable file must be ignored, not deleted" ); assert!(d.join("grok-0.1.141-macos-aarch64").exists()); } #[tokio::test] async fn test_cleanup_old_downloads_only_current_present_no_op() { let dir = tempfile::tempdir().unwrap(); let d = dir.path(); std::fs::write(d.join("grok-0.1.141-macos-aarch64"), "current").unwrap(); make_all_stale(d); cleanup_old_downloads(d, "grok", "0.1.141").await; assert!(d.join("grok-0.1.141-macos-aarch64").exists()); } #[tokio::test] async fn test_cleanup_old_downloads_only_one_old_keeps_it() { let dir = tempfile::tempdir().unwrap(); let d = dir.path(); std::fs::write(d.join("grok-0.1.140-macos-aarch64"), "v140").unwrap(); std::fs::write(d.join("grok-0.1.141-macos-aarch64"), "current").unwrap(); make_all_stale(d); cleanup_old_downloads(d, "grok", "0.1.141").await; // Only one old version → keep it as N-1. assert!(d.join("grok-0.1.140-macos-aarch64").exists(), "N-1 kept"); assert!(d.join("grok-0.1.141-macos-aarch64").exists(), "current"); } #[tokio::test] async fn test_cleanup_old_downloads_unrelated_files_untouched() { let dir = tempfile::tempdir().unwrap(); let d = dir.path(); // Files that don't start with the prefix must never be touched. std::fs::write(d.join("README.md"), "readme").unwrap(); std::fs::write(d.join("config.toml"), "config").unwrap(); std::fs::write(d.join("other-tool-0.1.0"), "other").unwrap(); std::fs::write(d.join("grok-0.1.140-macos-aarch64"), "v140").unwrap(); std::fs::write(d.join("grok-0.1.141-macos-aarch64"), "current").unwrap(); make_all_stale(d); cleanup_old_downloads(d, "grok", "0.1.141").await; assert!(d.join("README.md").exists()); assert!(d.join("config.toml").exists()); assert!(d.join("other-tool-0.1.0").exists()); } #[tokio::test] async fn test_cleanup_old_downloads_multiplatform_in_same_dir() { let dir = tempfile::tempdir().unwrap(); let d = dir.path(); // Same version, multiple platforms (uncommon, but possible). // Both should be considered "current" via the version equality check. std::fs::write(d.join("grok-0.1.141-macos-aarch64"), "mac").unwrap(); std::fs::write(d.join("grok-0.1.141-linux-x86_64"), "linux").unwrap(); std::fs::write(d.join("grok-0.1.140-macos-aarch64"), "old-mac").unwrap(); std::fs::write(d.join("grok-0.1.139-macos-aarch64"), "older-mac").unwrap(); make_all_stale(d); cleanup_old_downloads(d, "grok", "0.1.141").await; // Both platform variants of current must survive. assert!(d.join("grok-0.1.141-macos-aarch64").exists()); assert!(d.join("grok-0.1.141-linux-x86_64").exists()); // N-1 (0.1.140) kept, older deleted. assert!(d.join("grok-0.1.140-macos-aarch64").exists()); assert!(!d.join("grok-0.1.139-macos-aarch64").exists()); } #[tokio::test] async fn test_cleanup_old_downloads_tmp_files_deleted_even_when_unparseable() { let dir = tempfile::tempdir().unwrap(); let d = dir.path(); // Stale tmp files are deleted regardless of version-parseability. std::fs::write(d.join("grok-junk.tmp"), "partial").unwrap(); make_stale(&d.join("grok-junk.tmp")); std::fs::write(d.join("grok-0.1.140-macos-aarch64.tmp"), "partial2").unwrap(); make_stale(&d.join("grok-0.1.140-macos-aarch64.tmp")); std::fs::write(d.join("grok-0.1.141-macos-aarch64"), "current").unwrap(); make_all_stale(d); cleanup_old_downloads(d, "grok", "0.1.141").await; assert!(!d.join("grok-junk.tmp").exists(), "junk tmp deleted"); assert!( !d.join("grok-0.1.140-macos-aarch64.tmp").exists(), "versioned tmp deleted" ); assert!(d.join("grok-0.1.141-macos-aarch64").exists()); } #[tokio::test] async fn test_cleanup_old_downloads_three_olds_keeps_only_newest() { // Regression: keep exactly N-1, not N-2 or older. let dir = tempfile::tempdir().unwrap(); let d = dir.path(); for v in ["0.1.138", "0.1.139", "0.1.140"] { std::fs::write(d.join(format!("grok-{}-macos-aarch64", v)), v).unwrap(); } std::fs::write(d.join("grok-0.1.141-macos-aarch64"), "current").unwrap(); make_all_stale(d); cleanup_old_downloads(d, "grok", "0.1.141").await; assert!(d.join("grok-0.1.141-macos-aarch64").exists(), "current"); assert!(d.join("grok-0.1.140-macos-aarch64").exists(), "N-1 only"); assert!(!d.join("grok-0.1.139-macos-aarch64").exists()); assert!(!d.join("grok-0.1.138-macos-aarch64").exists()); } #[tokio::test] async fn test_cleanup_old_downloads_darwin_platform_recognized() { // The `darwin` alias for macOS is in PLATFORM_OS — versions on // grok-X.Y.Z-darwin-* layouts must split correctly. let dir = tempfile::tempdir().unwrap(); let d = dir.path(); std::fs::write(d.join("grok-0.1.140-darwin-arm64"), "v140").unwrap(); std::fs::write(d.join("grok-0.1.141-darwin-arm64"), "current").unwrap(); make_all_stale(d); cleanup_old_downloads(d, "grok", "0.1.141").await; assert!(d.join("grok-0.1.141-darwin-arm64").exists(), "current"); assert!(d.join("grok-0.1.140-darwin-arm64").exists(), "N-1"); } // ────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────── // UpdateRunMode // ────────────────────────────────────────────────────────────────────── #[test] fn test_update_run_mode_is_copy_clone_debug() { // The ergonomic Copy/Clone/Debug derives must not regress: we pass // `run_mode` by value through several layers. let m1 = UpdateRunMode::Blocking; let m2 = m1; // Copy let m3 = m1; // Copy again, m1 not moved assert!(matches!(m1, UpdateRunMode::Blocking)); assert!(matches!(m2, UpdateRunMode::Blocking)); assert!(matches!(m3, UpdateRunMode::Blocking)); // Debug exists. let _ = format!("{:?}", UpdateRunMode::NonBlocking); } // ────────────────────────────────────────────────────────────────────── // Constants — lock them in so silent renames are caught. // ────────────────────────────────────────────────────────────────────── #[test] fn test_user_facing_constants_are_stable() { assert_eq!(PROMPT_UPDATE_NOW, "Update now? [Y/n/d]"); assert_eq!( MSG_AUTO_UPDATE_BACKGROUND, "Auto-update running in background." ); assert_eq!( MSG_RUN_UPDATE_MANUAL, "Run `grok update` to get the latest version." ); } // ────────────────────────────────────────────────────────────────────── // env_installer — env-var based, must run serially. // // Resolution order (matches function body): // 1. GROK_INSTALLER (npm | internal | gh-release | gh) // 2. GROK_MANAGED_BY_NPM → npm // 3. GROK_MANAGED_BY_INTERNAL → internal // 4. npm_config_user_agent → npm // 5. None // ────────────────────────────────────────────────────────────────────── /// Snapshot every installer-related env var so the test can clear them /// at start and restore them at end. Without this, a parent shell that /// sets e.g. `npm_config_user_agent` (which happens whenever you run via /// `npm run`) silently makes every "no env vars" test misbehave. struct InstallerEnvGuard { prev: Vec<(&'static str, Option)>, } impl InstallerEnvGuard { fn isolate() -> Self { const VARS: &[&str] = &[ "GROK_INSTALLER", "GROK_MANAGED_BY_NPM", "GROK_MANAGED_BY_INTERNAL", "npm_config_user_agent", "NPM_TOKEN", ]; let prev: Vec<_> = VARS.iter().map(|k| (*k, std::env::var_os(k))).collect(); unsafe { for k in VARS { std::env::remove_var(k); } } Self { prev } } } impl Drop for InstallerEnvGuard { fn drop(&mut self) { unsafe { for (k, v) in &self.prev { match v { Some(val) => std::env::set_var(k, val), None => std::env::remove_var(k), } } } } } #[test] #[serial_test::serial] fn test_env_installer_no_vars_returns_none() { let _g = InstallerEnvGuard::isolate(); assert_eq!(env_installer(), None); } #[test] #[serial_test::serial] fn test_env_installer_explicit_npm() { let _g = InstallerEnvGuard::isolate(); unsafe { std::env::set_var("GROK_INSTALLER", "npm") }; assert_eq!(env_installer(), Some("npm")); } #[test] #[serial_test::serial] fn test_env_installer_explicit_internal() { let _g = InstallerEnvGuard::isolate(); unsafe { std::env::set_var("GROK_INSTALLER", "internal") }; assert_eq!(env_installer(), Some("internal")); } #[test] #[serial_test::serial] fn test_env_installer_explicit_gh_release() { let _g = InstallerEnvGuard::isolate(); unsafe { std::env::set_var("GROK_INSTALLER", "gh-release") }; assert_eq!(env_installer(), Some("gh-release")); } #[test] #[serial_test::serial] fn test_env_installer_explicit_gh_alias() { // `gh` is shorthand for `gh-release`. let _g = InstallerEnvGuard::isolate(); unsafe { std::env::set_var("GROK_INSTALLER", "gh") }; assert_eq!(env_installer(), Some("gh-release")); } #[test] #[serial_test::serial] fn test_env_installer_explicit_uppercase_normalized() { let _g = InstallerEnvGuard::isolate(); unsafe { std::env::set_var("GROK_INSTALLER", "NPM") }; assert_eq!(env_installer(), Some("npm")); unsafe { std::env::set_var("GROK_INSTALLER", "Gh-Release") }; assert_eq!(env_installer(), Some("gh-release")); } #[test] #[serial_test::serial] fn test_env_installer_explicit_unknown_value_returns_none() { // CRITICAL: when the explicit env var is set to something we don't // recognize, we early-return None. This means we do NOT fall through // to the other env vars or to config. So `GROK_INSTALLER=brew` // disables the env-installer detection entirely. let _g = InstallerEnvGuard::isolate(); unsafe { std::env::set_var("GROK_INSTALLER", "brew") }; // Even if MANAGED_BY_NPM is also set, the explicit var wins (and rejects). unsafe { std::env::set_var("GROK_MANAGED_BY_NPM", "1") }; assert_eq!( env_installer(), None, "explicit GROK_INSTALLER=brew must early-return None, not fall through" ); } #[test] #[serial_test::serial] fn test_env_installer_explicit_empty_returns_none() { let _g = InstallerEnvGuard::isolate(); unsafe { std::env::set_var("GROK_INSTALLER", "") }; assert_eq!(env_installer(), None); } #[test] #[serial_test::serial] fn test_env_installer_managed_by_npm() { let _g = InstallerEnvGuard::isolate(); unsafe { std::env::set_var("GROK_MANAGED_BY_NPM", "1") }; assert_eq!(env_installer(), Some("npm")); } #[test] #[serial_test::serial] fn test_env_installer_managed_by_npm_any_value() { // The check is `is_some` — any value (including empty) wins. let _g = InstallerEnvGuard::isolate(); unsafe { std::env::set_var("GROK_MANAGED_BY_NPM", "") }; assert_eq!(env_installer(), Some("npm")); } #[test] #[serial_test::serial] fn test_env_installer_managed_by_internal() { let _g = InstallerEnvGuard::isolate(); unsafe { std::env::set_var("GROK_MANAGED_BY_INTERNAL", "1") }; assert_eq!(env_installer(), Some("internal")); } #[test] #[serial_test::serial] fn test_env_installer_npm_config_user_agent_implies_npm() { // npm sets npm_config_user_agent in the env of any process it spawns. // The trampoline relies on this fallback when MANAGED_BY_NPM was lost. let _g = InstallerEnvGuard::isolate(); unsafe { std::env::set_var( "npm_config_user_agent", "npm/10.2.0 node/v20.11.0 darwin arm64 workspaces/false", ) }; assert_eq!(env_installer(), Some("npm")); } #[test] #[serial_test::serial] fn test_env_installer_managed_by_npm_wins_over_npm_config_user_agent() { // Both set: the order in env_installer is MANAGED_BY_NPM checked first, // so MANAGED_BY_NPM wins. (Result is the same — both → npm — but the // resolution path matters for future maintainers.) let _g = InstallerEnvGuard::isolate(); unsafe { std::env::set_var("GROK_MANAGED_BY_NPM", "1"); std::env::set_var("npm_config_user_agent", "npm/10"); } assert_eq!(env_installer(), Some("npm")); } #[test] #[serial_test::serial] fn test_env_installer_explicit_internal_wins_over_npm_managed() { // GROK_INSTALLER=internal must override an inherited MANAGED_BY_NPM. let _g = InstallerEnvGuard::isolate(); unsafe { std::env::set_var("GROK_INSTALLER", "internal"); std::env::set_var("GROK_MANAGED_BY_NPM", "1"); } assert_eq!(env_installer(), Some("internal")); } // ────────────────────────────────────────────────────────────────────── // create_temp_npmrc — also env-var based (NPM_TOKEN), must run serially. // ────────────────────────────────────────────────────────────────────── #[test] #[serial_test::serial] fn test_create_temp_npmrc_no_token_returns_none() { let _g = InstallerEnvGuard::isolate(); let result = create_temp_npmrc(None).unwrap(); assert!(result.is_none(), "no NPM_TOKEN must yield None"); } #[test] #[serial_test::serial] fn test_create_temp_npmrc_empty_token_returns_none() { // An empty token is not a real token — must not write a file. let _g = InstallerEnvGuard::isolate(); unsafe { std::env::set_var("NPM_TOKEN", "") }; let result = create_temp_npmrc(None).unwrap(); assert!(result.is_none(), "empty NPM_TOKEN must yield None"); } #[test] #[serial_test::serial] fn test_create_temp_npmrc_whitespace_only_token_returns_none() { // Whitespace-only is treated as empty after trim. let _g = InstallerEnvGuard::isolate(); unsafe { std::env::set_var("NPM_TOKEN", " \t\n ") }; let result = create_temp_npmrc(None).unwrap(); assert!(result.is_none(), "whitespace NPM_TOKEN must yield None"); } #[test] #[serial_test::serial] fn test_create_temp_npmrc_default_registry() { let _g = InstallerEnvGuard::isolate(); unsafe { std::env::set_var("NPM_TOKEN", "secret123") }; let path = create_temp_npmrc(None).unwrap().expect("file written"); let body = std::fs::read_to_string(&path).unwrap(); assert!( body.contains("registry.npmjs.org"), "default registry: {body}" ); assert!(body.contains("_authToken=secret123"), "token: {body}"); assert!(body.starts_with("//"), "must be // prefix: {body}"); assert!(body.ends_with('\n'), "must end with newline: {body}"); let _ = std::fs::remove_file(&path); } #[test] #[serial_test::serial] fn test_create_temp_npmrc_token_trimmed() { let _g = InstallerEnvGuard::isolate(); unsafe { std::env::set_var("NPM_TOKEN", " padded-token ") }; let path = create_temp_npmrc(None).unwrap().expect("file written"); let body = std::fs::read_to_string(&path).unwrap(); assert!( body.contains("_authToken=padded-token"), "token must be trimmed: {body}" ); assert!( !body.contains("padded-token "), "trailing whitespace must be stripped: {body}" ); let _ = std::fs::remove_file(&path); } #[test] #[serial_test::serial] fn test_create_temp_npmrc_custom_registry_extracts_host_and_path() { let _g = InstallerEnvGuard::isolate(); unsafe { std::env::set_var("NPM_TOKEN", "tok") }; let path = create_temp_npmrc(Some("https://npm.example.com/repository/npm/")) .unwrap() .expect("file written"); let body = std::fs::read_to_string(&path).unwrap(); // Host + path must be preserved (trailing slash stripped per impl). assert!( body.contains("npm.example.com/repository/npm"), "registry host+path: {body}" ); assert!(body.contains("_authToken=tok")); let _ = std::fs::remove_file(&path); } #[test] #[serial_test::serial] fn test_create_temp_npmrc_custom_registry_with_port() { let _g = InstallerEnvGuard::isolate(); unsafe { std::env::set_var("NPM_TOKEN", "tok") }; let path = create_temp_npmrc(Some("https://npm.example.com:8443/")) .unwrap() .expect("file written"); let body = std::fs::read_to_string(&path).unwrap(); assert!( body.contains("npm.example.com:8443"), "port must be preserved: {body}" ); let _ = std::fs::remove_file(&path); } #[test] #[serial_test::serial] fn test_create_temp_npmrc_invalid_registry_url_falls_back_to_default() { // If the registry string doesn't parse as a URL, fall back to the // public npm host so the auth token isn't silently lost. let _g = InstallerEnvGuard::isolate(); unsafe { std::env::set_var("NPM_TOKEN", "tok") }; let path = create_temp_npmrc(Some("not a url")) .unwrap() .expect("file written"); let body = std::fs::read_to_string(&path).unwrap(); assert!( body.contains("registry.npmjs.org"), "invalid URL falls back: {body}" ); let _ = std::fs::remove_file(&path); } #[cfg(unix)] #[test] #[serial_test::serial] fn test_create_temp_npmrc_file_perms_are_0600() { // The file contains an auth token — must be readable only by owner. use std::os::unix::fs::PermissionsExt; let _g = InstallerEnvGuard::isolate(); unsafe { std::env::set_var("NPM_TOKEN", "secret") }; let path = create_temp_npmrc(None).unwrap().expect("file written"); let perms = std::fs::metadata(&path).unwrap().permissions(); let mode = perms.mode() & 0o777; assert_eq!( mode, 0o600, "npmrc must be 0600 to protect the auth token, got {mode:o}" ); let _ = std::fs::remove_file(&path); } #[test] #[serial_test::serial] fn test_create_temp_npmrc_unique_path_per_pid() { // Two parallel installs would clobber each other if the path didn't // include the PID. Verify the filename includes the current PID. let _g = InstallerEnvGuard::isolate(); unsafe { std::env::set_var("NPM_TOKEN", "tok") }; let path = create_temp_npmrc(None).unwrap().expect("file written"); let pid = std::process::id().to_string(); let name = path.file_name().unwrap().to_string_lossy().to_string(); assert!( name.contains(&pid), "filename should include PID: {name} (pid={pid})" ); let _ = std::fs::remove_file(&path); } // ────────────────────────────────────────────────────────────────────── // windows_replace_exe — runs only on Windows CI // ────────────────────────────────────────────────────────────────────── #[cfg(windows)] #[tokio::test] async fn test_windows_replace_exe_creates_dest_when_missing() { let dir = tempfile::tempdir().unwrap(); let src = dir.path().join("new-binary.exe"); std::fs::write(&src, "new content").unwrap(); let dest = dir.path().join("grok.exe"); windows_replace_exe(&src, &dest).await.unwrap(); assert!(dest.exists()); assert_eq!(std::fs::read(&dest).unwrap(), b"new content"); } #[cfg(windows)] #[tokio::test] async fn test_windows_replace_exe_overwrites_unlocked_dest() { let dir = tempfile::tempdir().unwrap(); let src = dir.path().join("new-binary.exe"); std::fs::write(&src, "new content").unwrap(); let dest = dir.path().join("grok.exe"); std::fs::write(&dest, "old content").unwrap(); windows_replace_exe(&src, &dest).await.unwrap(); assert_eq!(std::fs::read(&dest).unwrap(), b"new content"); } #[cfg(windows)] #[tokio::test] async fn test_windows_replace_exe_preserves_binary_bytes() { let dir = tempfile::tempdir().unwrap(); let body: Vec = (0u8..=255).cycle().take(4096).collect(); let src = dir.path().join("binary.exe"); std::fs::write(&src, &body).unwrap(); let dest = dir.path().join("grok.exe"); windows_replace_exe(&src, &dest).await.unwrap(); assert_eq!(std::fs::read(&dest).unwrap(), body); } #[cfg(windows)] #[tokio::test] async fn test_windows_replace_exe_cleans_stale_old_backup() { let dir = tempfile::tempdir().unwrap(); let src = dir.path().join("new.exe"); std::fs::write(&src, "new").unwrap(); let dest = dir.path().join("grok.exe"); std::fs::write(&dest, "current").unwrap(); let old = dir.path().join("grok.exe.old"); std::fs::write(&old, "stale-from-prior-update").unwrap(); windows_replace_exe(&src, &dest).await.unwrap(); assert_eq!(std::fs::read_to_string(&dest).unwrap(), "new"); assert!(!old.exists(), "stale .old must be removed"); } #[cfg(windows)] #[tokio::test] async fn test_windows_replace_exe_no_filename_returns_err() { let dir = tempfile::tempdir().unwrap(); let src = dir.path().join("src.exe"); std::fs::write(&src, "data").unwrap(); let bad_dest = dir.path().join(".."); let err = windows_replace_exe(&src, &bad_dest).await.unwrap_err(); assert!(format!("{err:#}").contains("no filename"), "error: {err:#}"); } #[cfg(windows)] #[tokio::test] async fn test_windows_replace_exe_locked_file_renames_aside() { // Simulate a running .exe: blocks writes but allows rename. use std::os::windows::fs::OpenOptionsExt; const FILE_SHARE_READ: u32 = 0x00000001; const FILE_SHARE_DELETE: u32 = 0x00000004; let dir = tempfile::tempdir().unwrap(); let src = dir.path().join("new.exe"); std::fs::write(&src, "updated binary").unwrap(); let dest = dir.path().join("grok.exe"); std::fs::write(&dest, "running binary").unwrap(); let _lock = std::fs::OpenOptions::new() .read(true) .share_mode(FILE_SHARE_READ | FILE_SHARE_DELETE) .open(&dest) .unwrap(); windows_replace_exe(&src, &dest).await.unwrap(); assert_eq!(std::fs::read_to_string(&dest).unwrap(), "updated binary"); let old = dir.path().join("grok.exe.old"); assert!(old.exists(), ".old must exist after rename fallback"); drop(_lock); assert_eq!(std::fs::read_to_string(&old).unwrap(), "running binary"); } #[cfg(windows)] #[tokio::test] async fn test_windows_replace_exe_rollback_on_copy_failure() { // No stale .old: the aside IS grok.exe.old, so this pins the // non-diverted rollback branch (rename .old back onto dest). use std::os::windows::fs::OpenOptionsExt; const FILE_SHARE_READ: u32 = 0x00000001; const FILE_SHARE_DELETE: u32 = 0x00000004; let dir = tempfile::tempdir().unwrap(); let src = dir.path().join("new.exe"); std::fs::write(&src, "updated binary").unwrap(); let dest = dir.path().join("grok.exe"); std::fs::write(&dest, "original").unwrap(); // Dest locked like a running exe: blocks writes but allows rename. let _dest_lock = std::fs::OpenOptions::new() .read(true) .share_mode(FILE_SHARE_READ | FILE_SHARE_DELETE) .open(&dest) .unwrap(); // Exclusive src lock: both copies fail with a sharing violation, so // the rename runs and the second copy triggers the rollback. let _src_lock = std::fs::OpenOptions::new() .read(true) .share_mode(0) .open(&src) .unwrap(); let result = windows_replace_exe(&src, &dest).await; drop(_src_lock); drop(_dest_lock); assert!(result.is_err()); assert_eq!( std::fs::read_to_string(&dest).unwrap(), "original", "rollback must restore the original binary" ); let old = dir.path().join("grok.exe.old"); assert!(!old.exists(), "rollback must consume the .old aside"); } #[cfg(windows)] #[tokio::test] async fn test_windows_replace_exe_idempotent_same_content() { let dir = tempfile::tempdir().unwrap(); let src = dir.path().join("binary.exe"); std::fs::write(&src, "same content").unwrap(); let dest = dir.path().join("grok.exe"); std::fs::write(&dest, "same content").unwrap(); windows_replace_exe(&src, &dest).await.unwrap(); assert_eq!(std::fs::read_to_string(&dest).unwrap(), "same content"); } #[cfg(windows)] #[tokio::test] async fn test_windows_replace_exe_empty_binary() { let dir = tempfile::tempdir().unwrap(); let src = dir.path().join("empty.exe"); std::fs::write(&src, b"").unwrap(); let dest = dir.path().join("grok.exe"); std::fs::write(&dest, "non-empty").unwrap(); windows_replace_exe(&src, &dest).await.unwrap(); assert_eq!(std::fs::metadata(&dest).unwrap().len(), 0); } #[cfg(windows)] #[tokio::test] async fn test_windows_replace_exe_locked_stale_old_does_not_block_update() { // A leftover .old can still be a running image (the session live // during the previous update): undeletable, so the rename must // divert to a unique aside instead of failing on the locked name. use std::os::windows::fs::OpenOptionsExt; const FILE_SHARE_READ: u32 = 0x00000001; const FILE_SHARE_DELETE: u32 = 0x00000004; let dir = tempfile::tempdir().unwrap(); let src = dir.path().join("new.exe"); std::fs::write(&src, "updated binary").unwrap(); let dest = dir.path().join("grok.exe"); std::fs::write(&dest, "running binary").unwrap(); let old = dir.path().join("grok.exe.old"); std::fs::write(&old, "previous binary").unwrap(); // No FILE_SHARE_DELETE: .old cannot be deleted or rename-replaced. let _old_lock = std::fs::OpenOptions::new() .read(true) .share_mode(FILE_SHARE_READ) .open(&old) .unwrap(); // Dest locked like a running exe: blocks writes but allows rename. let _dest_lock = std::fs::OpenOptions::new() .read(true) .share_mode(FILE_SHARE_READ | FILE_SHARE_DELETE) .open(&dest) .unwrap(); windows_replace_exe(&src, &dest).await.unwrap(); assert_eq!(std::fs::read_to_string(&dest).unwrap(), "updated binary"); assert_eq!( std::fs::read_to_string(&old).unwrap(), "previous binary", "locked .old must be left in place" ); let asides: Vec = std::fs::read_dir(dir.path()) .unwrap() .filter_map(|e| e.ok().map(|e| e.path())) .filter(|p| { p.file_name() .and_then(|n| n.to_str()) .is_some_and(|n| n.starts_with("grok.exe.old.") && n.ends_with(".old")) }) .collect(); assert_eq!( asides.len(), 1, "dest must be renamed to a unique aside: {asides:?}" ); assert_eq!( std::fs::read_to_string(&asides[0]).unwrap(), "running binary" ); } #[cfg(windows)] #[tokio::test] async fn test_windows_replace_exe_rollback_restores_from_diverted_aside() { // Copy failure after a divert must roll dest back from the unique // aside, not the hardcoded .old (which still holds the locked image). use std::os::windows::fs::OpenOptionsExt; const FILE_SHARE_READ: u32 = 0x00000001; const FILE_SHARE_DELETE: u32 = 0x00000004; let dir = tempfile::tempdir().unwrap(); let src = dir.path().join("new.exe"); std::fs::write(&src, "updated binary").unwrap(); let dest = dir.path().join("grok.exe"); std::fs::write(&dest, "running binary").unwrap(); let old = dir.path().join("grok.exe.old"); std::fs::write(&old, "previous binary").unwrap(); // No FILE_SHARE_DELETE: .old survives the sweep and forces a divert. let _old_lock = std::fs::OpenOptions::new() .read(true) .share_mode(FILE_SHARE_READ) .open(&old) .unwrap(); // Dest locked like a running exe: blocks writes but allows rename. let _dest_lock = std::fs::OpenOptions::new() .read(true) .share_mode(FILE_SHARE_READ | FILE_SHARE_DELETE) .open(&dest) .unwrap(); // Exclusive src lock: both copies fail with a sharing violation, so // the rename dance runs and the second copy triggers the rollback. let _src_lock = std::fs::OpenOptions::new() .read(true) .share_mode(0) .open(&src) .unwrap(); let result = windows_replace_exe(&src, &dest).await; assert!(result.is_err()); assert_eq!( std::fs::read_to_string(&dest).unwrap(), "running binary", "rollback must restore dest from the diverted aside" ); assert_eq!(std::fs::read_to_string(&old).unwrap(), "previous binary"); let leftover_asides = std::fs::read_dir(dir.path()) .unwrap() .filter_map(|e| e.ok()) .filter(|e| { let name = e.file_name(); let name = name.to_string_lossy(); name.starts_with("grok.exe.old.") && name.ends_with(".old") }) .count(); assert_eq!(leftover_asides, 0, "rollback must consume the aside"); } #[cfg(windows)] #[tokio::test] async fn test_windows_replace_exe_sweeps_accumulated_asides() { // Asides pile up while superseded sessions keep running; a later // update must collect the no-longer-locked ones — but never another // executable's leftovers. let dir = tempfile::tempdir().unwrap(); let src = dir.path().join("new.exe"); std::fs::write(&src, "new").unwrap(); let dest = dir.path().join("grok.exe"); std::fs::write(&dest, "current").unwrap(); let old = dir.path().join("grok.exe.old"); std::fs::write(&old, "stale").unwrap(); let aside_a = dir.path().join("grok.exe.old.1234-0.old"); let aside_b = dir.path().join("grok.exe.old.1234-1.old"); std::fs::write(&aside_a, "aside-a").unwrap(); std::fs::write(&aside_b, "aside-b").unwrap(); let agent_old = dir.path().join("agent.exe.old"); std::fs::write(&agent_old, "agent-old").unwrap(); windows_replace_exe(&src, &dest).await.unwrap(); assert_eq!(std::fs::read_to_string(&dest).unwrap(), "new"); assert!(!old.exists(), "legacy .old must be swept"); assert!(!aside_a.exists(), "aside must be swept"); assert!(!aside_b.exists(), "aside must be swept"); assert!( agent_old.exists(), "other executables' leftovers must be untouched" ); } }