Publish harness and TUI open-source

initial sync from the monorepo
This commit is contained in:
grokkybara[bot] 2026-07-16 06:46:02 +01:00
commit c68e39f604
2734 changed files with 1437016 additions and 0 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,7 @@
pub mod auto_update;
mod minimum_version;
pub mod version;
pub use auto_update::UpdateStatus;
pub use minimum_version::enforce_minimum_version_or_exit;
pub use version::{UpdateConfig, channel_label, channel_name, write_version_cache};

View file

@ -0,0 +1,384 @@
//! Minimum-version enforcement.
//!
//! When `cli.minimum_version` is set in any config layer, Grok refuses to
//! start below that floor. With auto-update on, we install
//! `max(latest, minimum)`; otherwise the user is asked to run `grok update`.
//!
//! Set `GROK_TEST_VERSION` to manually exercise either path without producing
//! a real out-of-date build.
use crate::auto_update::{get_installer, run_install_script};
use crate::version::{
UpdateConfig, fetch_latest_version, get_installed_grok_version, write_version_cache,
};
use tracing::{info, warn};
use xai_grok_shell::util::config;
/// Result of comparing the running binary against a configured floor.
#[derive(Debug, Clone, PartialEq, Eq)]
enum MinimumVersionDecision {
Allow,
BelowMinimum { current: String, minimum: String },
}
/// Outcome of a successful enforcement pass.
#[derive(Debug, Clone, PartialEq, Eq)]
enum EnforcementOutcome {
Allowed,
/// New binary on disk; caller MUST restart — running process is still old.
Upgraded,
}
/// User-facing enforcement failures; `Display` is printed to stderr.
/// `AutoUpdateDisabled` and `NoInstaller` share copy but stay separate so
/// telemetry can distinguish them.
#[derive(Debug, thiserror::Error)]
pub(crate) enum MinimumVersionError {
/// `source` chains via `Error::source()`; omitted from `Display`.
#[error(
"The minimum version \"{value}\" in your Grok configuration \
isn't a valid version number. Update `cli.minimum_version` and try again."
)]
InvalidMinimum {
value: String,
#[source]
source: semver::Error,
},
#[error(
"This version of Grok ({current}) is no longer supported. \
Run `grok update` to install version {minimum} or later."
)]
AutoUpdateDisabled { current: String, minimum: String },
/// `npm` / `gh` / `internal` GCS — none detected.
#[error(
"This version of Grok ({current}) is no longer supported. \
Run `grok update` to install version {minimum} or later."
)]
NoInstaller { current: String, minimum: String },
/// `detail` is telemetry-only; omitted from `Display` to avoid stacking
/// the installer's own action language.
#[error(
"This version of Grok ({current}) is no longer supported, \
and the update to version {minimum} didn't complete.\n\n\
Run `grok update` to try again."
)]
UpgradeFailed {
current: String,
minimum: String,
detail: String,
},
/// Latest release is known but still below the floor (vs `NoReleaseFound`,
/// which couldn't probe at all).
#[error(
"This version of Grok ({current}) is no longer supported. \
Version {minimum} or later is required, but the most recent release is {latest}. \
Contact your administrator."
)]
NoSatisfyingVersion {
current: String,
minimum: String,
latest: String,
},
/// Couldn't probe the registry — likely transient.
#[error(
"This version of Grok ({current}) is no longer supported. \
Version {minimum} or later is required, but no release was found. \
Check your network connection, or contact your administrator."
)]
NoReleaseFound { current: String, minimum: String },
/// `grok update --version X` requested a version below the floor.
#[error(
"Cannot install Grok {target}: the configured minimum is {minimum}. \
Run `grok update` to install the latest allowed version."
)]
TargetBelowFloor { target: String, minimum: String },
}
/// Pure check against the configured floor. Empty / whitespace-only
/// minimums are treated as unset.
fn evaluate_minimum_version(
current_version: &str,
minimum_version: Option<&str>,
) -> Result<MinimumVersionDecision, MinimumVersionError> {
let Some(minimum) = minimum_version.map(str::trim).filter(|s| !s.is_empty()) else {
return Ok(MinimumVersionDecision::Allow);
};
let parsed_min =
semver::Version::parse(minimum).map_err(|source| MinimumVersionError::InvalidMinimum {
value: minimum.to_string(),
source,
})?;
// Unparseable current (e.g. funky dev build): block rather than let an
// unverifiable binary through.
let parsed_cur = match semver::Version::parse(current_version) {
Ok(v) => v,
Err(_) => {
return Ok(MinimumVersionDecision::BelowMinimum {
current: current_version.to_string(),
minimum: parsed_min.to_string(),
});
}
};
if parsed_cur >= parsed_min {
Ok(MinimumVersionDecision::Allow)
} else {
Ok(MinimumVersionDecision::BelowMinimum {
current: parsed_cur.to_string(),
minimum: parsed_min.to_string(),
})
}
}
/// Refuse an explicit install target below the configured floor.
/// Used by `grok update --version X`.
pub(crate) fn check_install_target(target: &str) -> Result<(), MinimumVersionError> {
let floor = resolve_floor_or_error()?;
check_install_target_inner(target, floor.as_deref())
}
fn check_install_target_inner(
target: &str,
floor: Option<&str>,
) -> Result<(), MinimumVersionError> {
let Some(min) = floor else { return Ok(()) };
match evaluate_minimum_version(target, Some(min))? {
MinimumVersionDecision::Allow => Ok(()),
MinimumVersionDecision::BelowMinimum {
current: target,
minimum,
} => Err(MinimumVersionError::TargetBelowFloor { target, minimum }),
}
}
/// `max(target, configured_floor)`; passthrough when no floor is set.
/// Used by `grok update` to keep the install target at or above the pin.
pub(crate) fn apply_floor(target: &str) -> Result<String, MinimumVersionError> {
let floor = resolve_floor_or_error()?;
apply_floor_inner(target, floor.as_deref())
}
/// Adapts `config::resolve_minimum_version`'s error shape into ours.
fn resolve_floor_or_error() -> Result<Option<String>, MinimumVersionError> {
config::resolve_minimum_version()
.map_err(|(value, source)| MinimumVersionError::InvalidMinimum { value, source })
}
fn apply_floor_inner(target: &str, floor: Option<&str>) -> Result<String, MinimumVersionError> {
let Some(min) = floor else {
return Ok(target.to_string());
};
match evaluate_minimum_version(target, Some(min))? {
MinimumVersionDecision::Allow => Ok(target.to_string()),
MinimumVersionDecision::BelowMinimum { minimum, .. } => Ok(minimum),
}
}
/// `max(latest, minimum)`; falls back to `minimum` if `latest` is missing or unparseable.
fn pick_target_version(latest: Option<&str>, minimum: &str) -> String {
match latest.and_then(|v| semver::Version::parse(v).ok()) {
Some(latest_v) => match semver::Version::parse(minimum) {
Ok(min_v) if latest_v >= min_v => latest_v.to_string(),
_ => minimum.to_string(),
},
None => minimum.to_string(),
}
}
/// Call once at startup, before any user-facing UI. On `Ok(Upgraded)` the
/// caller MUST restart. On `Err`, print and exit non-zero.
async fn enforce_minimum_version(
minimum_version: Option<&str>,
update_config: &UpdateConfig,
) -> Result<EnforcementOutcome, MinimumVersionError> {
let current_version = get_installed_grok_version();
let decision = evaluate_minimum_version(&current_version, minimum_version)?;
let MinimumVersionDecision::BelowMinimum { current, minimum } = decision else {
info!(current = %current_version, "minimum_version: floor satisfied");
return Ok(EnforcementOutcome::Allowed);
};
info!(%current, %minimum, "minimum_version: below floor; attempting auto-update");
// `None` is "default on"; only explicit `false` opts out.
let cfg = config::load_config().await;
if cfg.cli.auto_update == Some(false) {
warn!(%current, %minimum, "minimum_version: auto-update disabled by config");
return Err(MinimumVersionError::AutoUpdateDisabled { current, minimum });
}
let Some(installer) = get_installer().await else {
warn!(%current, %minimum, "minimum_version: no installer detected");
return Err(MinimumVersionError::NoInstaller { current, minimum });
};
let latest = fetch_latest_version(installer, update_config).await.ok();
let target = pick_target_version(latest.as_deref(), &minimum);
info!(%current, %target, installer, "minimum_version: installing upgrade");
eprintln!(
"This version of Grok ({current}) is no longer supported. \
Updating to {target}"
);
if let Err(e) = run_install_script(installer, Some(&target), update_config).await {
let detail = format!("{e:#}");
warn!(%current, %target, %detail, "minimum_version: upgrade failed");
return Err(MinimumVersionError::UpgradeFailed {
current,
minimum,
detail,
});
}
// Post-install: pass None for stable_version (same rationale as run_update).
write_version_cache(&target, None).await;
// Stale channel pointer or partial install can leave us below the floor;
// surface that rather than starting an out-of-policy binary.
if let MinimumVersionDecision::BelowMinimum { .. } =
evaluate_minimum_version(&target, Some(&minimum))?
{
warn!(%target, %minimum, ?latest, "minimum_version: post-install still below floor");
return Err(match latest {
Some(latest) => MinimumVersionError::NoSatisfyingVersion {
current: target,
minimum,
latest,
},
None => MinimumVersionError::NoReleaseFound {
current: target,
minimum,
},
});
}
info!(%target, "minimum_version: upgrade installed successfully");
Ok(EnforcementOutcome::Upgraded)
}
/// Single chokepoint for the pager + tui startup paths. Re-execs after a
/// floor-driven install. Prints + exits non-zero on `Err`.
///
/// `GROK_TEST_VERSION` lets devs override the running version to skip
/// enforcement on a `cargo run` build.
pub async fn enforce_minimum_version_or_exit(update_config: &UpdateConfig) {
let min = match resolve_floor_or_error() {
Ok(None) => return,
Ok(Some(m)) => m,
Err(e) => {
eprintln!("{e}");
std::process::exit(1);
}
};
match enforce_minimum_version(Some(&min), update_config).await {
Ok(EnforcementOutcome::Allowed) => {}
Ok(EnforcementOutcome::Upgraded) => {
// TODO: restart_grok uses exec() which carries the same
// SIGABRT risk as the old piped-stderr update path if the
// child process ever writes to a broken pipe. For now this
// path is rare (only fires when the server pushes a minimum
// version bump), so print a relaunch message instead.
eprintln!("Update installed. Run `grok` to start.");
std::process::exit(0);
}
Err(e) => {
eprintln!("{e}");
std::process::exit(1);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn evaluate_minimum_version_decisions() {
use MinimumVersionDecision::{Allow, BelowMinimum};
// Allow: floor unset (None / empty / whitespace) or satisfied (equal / above).
assert_eq!(evaluate_minimum_version("0.1.100", None).unwrap(), Allow);
assert_eq!(
evaluate_minimum_version("0.1.100", Some("")).unwrap(),
Allow
);
assert_eq!(
evaluate_minimum_version("0.1.100", Some(" ")).unwrap(),
Allow
);
assert_eq!(
evaluate_minimum_version("0.1.100", Some("0.1.100")).unwrap(),
Allow
);
assert_eq!(
evaluate_minimum_version("0.2.0", Some("0.1.100")).unwrap(),
Allow
);
// BelowMinimum: current < floor.
assert!(matches!(
evaluate_minimum_version("0.1.99", Some("0.1.100")).unwrap(),
BelowMinimum { .. }
));
// InvalidMinimum: unparseable floor (admin typo).
assert!(matches!(
evaluate_minimum_version("0.1.100", Some("not-a-version")),
Err(MinimumVersionError::InvalidMinimum { .. })
));
}
#[test]
fn pick_target_returns_max_of_latest_and_minimum() {
// The `None` branch is only reachable here — apply_floor always
// passes `Some(target)`. Production hits it on fetch failure.
assert_eq!(pick_target_version(Some("0.1.200"), "0.1.150"), "0.1.200");
assert_eq!(pick_target_version(Some("0.1.140"), "0.1.150"), "0.1.150");
assert_eq!(pick_target_version(None, "0.1.150"), "0.1.150");
}
#[test]
fn install_target_helpers_consult_floor() {
// check_install_target rejects below-floor targets.
assert!(check_install_target_inner("0.1.50", None).is_ok());
assert!(check_install_target_inner("0.1.150", Some("0.1.100")).is_ok());
assert!(matches!(
check_install_target_inner("0.1.50", Some("0.1.100")).unwrap_err(),
MinimumVersionError::TargetBelowFloor { .. }
));
// apply_floor bumps below-floor targets up.
assert_eq!(apply_floor_inner("0.1.50", None).unwrap(), "0.1.50");
assert_eq!(
apply_floor_inner("0.1.200", Some("0.1.100")).unwrap(),
"0.1.200"
);
assert_eq!(
apply_floor_inner("0.1.50", Some("0.1.100")).unwrap(),
"0.1.100"
);
}
#[test]
#[serial_test::serial]
fn version_env_var_flows_through_to_decision() {
let saved = std::env::var("GROK_TEST_VERSION").ok();
// SAFETY: #[serial] excludes other env-touching tests.
unsafe { std::env::set_var("GROK_TEST_VERSION", "0.1.50") };
let decision =
evaluate_minimum_version(&get_installed_grok_version(), Some("0.1.100")).unwrap();
assert!(matches!(
decision,
MinimumVersionDecision::BelowMinimum { .. }
));
match saved {
Some(v) => unsafe { std::env::set_var("GROK_TEST_VERSION", v) },
None => unsafe { std::env::remove_var("GROK_TEST_VERSION") },
}
}
}

View file

@ -0,0 +1,784 @@
use std::time::Duration;
use anyhow::Result;
use serde::Deserialize;
use serde_json::Value;
use tokio::fs;
use tokio::process::Command;
use xai_grok_shell::env::GrokBuildEnvironment;
use xai_grok_shell::util::grok_home::grok_home;
const TTL_SECONDS_BEFORE_AUTO_UPDATE: Duration = Duration::from_secs(60 * 30);
const NPM_PACKAGE: &str = "@xai-official/grok";
pub const GH_RELEASE_REPO: &str = "xai-org-shared/grok-build";
/// Primary CLI base URL: Cloudflare-fronted x.ai endpoint with edge caching
/// for binaries and origin-respecting no-cache for channel pointers.
pub(crate) const CLI_BASE_URL_PRIMARY: &str = "https://x.ai/cli";
/// Fallback CLI base URL: direct GCS, used when the primary is unreachable
/// (Cloudflare outage, regional CF egress issue, DNS hijack, etc.).
pub(crate) const CLI_BASE_URL_FALLBACK: &str =
"https://storage.googleapis.com/grok-build-public-artifacts/cli";
/// CLI base URLs in preference order. Callers (channel-pointer fetch, binary
/// download, in-app updater) try each in turn and stop at the first success.
pub(crate) const CLI_BASE_URLS: &[&str] = &[CLI_BASE_URL_PRIMARY, CLI_BASE_URL_FALLBACK];
/// Minimal configuration the update system needs from the environment.
///
/// Constructed once from `GrokBuildEnvironment` at startup and threaded through the
/// update call chain so that `auto_update` and `version` never need to know
/// about the `GrokBuildEnvironment` enum directly.
#[derive(Debug, Clone)]
pub struct UpdateConfig {
/// Chat API proxy base URL (versioned `https://cli-chat-proxy.grok.com/v1` endpoint).
pub proxy_base_url: String,
/// Auth scope key for `~/.grok/auth.json`.
pub auth_scope: String,
/// Enterprise deployment key (GROK_DEPLOYMENT_KEY).
pub deployment_key: Option<String>,
/// Optional extra auth material forwarded with requests when present.
pub alpha_test_key: Option<String>,
/// Release channel: "stable" or "alpha". Loaded from config.
pub channel: String,
/// Custom npm registry URL. When set, passed as `--registry=` to npm CLI.
pub npm_registry: Option<String>,
}
impl UpdateConfig {
pub fn from_environment(env: &GrokBuildEnvironment) -> Self {
Self {
proxy_base_url: env.cli_chat_proxy_base_url(),
auth_scope: xai_grok_shell::auth::GrokComConfig::default().auth_scope(),
deployment_key: None,
alpha_test_key: None,
channel: "stable".to_string(),
npm_registry: None,
}
}
}
#[derive(Debug, serde::Serialize, Deserialize)]
struct GrokVersion {
version: String,
#[serde(default)]
stable_version: Option<String>,
checked_at: String,
}
impl GrokVersion {
fn is_fresh(&self, now: time::OffsetDateTime, ttl: Duration) -> bool {
if let Ok(dt) = time::OffsetDateTime::parse(
&self.checked_at,
&time::format_description::well_known::Rfc3339,
) {
// Clock-skew guard: future timestamps are never fresh.
if dt > now {
return false;
}
now - dt < ttl
} else {
false
}
}
fn new(version: String, stable_version: Option<String>, now: time::OffsetDateTime) -> Self {
let checked_at = now
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_else(|_| now.to_string());
Self {
version,
stable_version,
checked_at,
}
}
}
/// Return the semver-greater of two version strings.
fn semver_max(a: &str, b: &str) -> Result<String> {
let va = semver::Version::parse(a)?;
let vb = semver::Version::parse(b)?;
Ok(std::cmp::max(va, vb).to_string())
}
/// Fetch the latest version from npm registry using `npm view`.
/// For alpha channel, fetches both `@alpha` and `@latest` dist-tags and
/// returns the semver-greater — prevents alpha users getting stuck when a
/// newer stable ships without updating the alpha dist-tag.
async fn fetch_npm_version(channel: &str, npm_registry: Option<&str>) -> Result<String> {
if channel == "alpha" {
let (alpha_v, stable_v) = tokio::try_join!(
fetch_npm_tag("alpha", npm_registry),
fetch_npm_tag("latest", npm_registry),
)?;
return semver_max(&alpha_v, &stable_v);
}
fetch_npm_tag("latest", npm_registry).await
}
/// Test-only entry point: invokes the private [`fetch_npm_tag`] for tests
/// that swap in a fake `npm` via PATH.
#[doc(hidden)]
pub async fn fetch_npm_tag_for_test(tag: &str, npm_registry: Option<&str>) -> Result<String> {
fetch_npm_tag(tag, npm_registry).await
}
/// Test-only entry point: invokes the private [`fetch_npm_version`] for tests
/// that swap in a fake `npm` via PATH.
#[doc(hidden)]
pub async fn fetch_npm_version_for_test(
channel: &str,
npm_registry: Option<&str>,
) -> Result<String> {
fetch_npm_version(channel, npm_registry).await
}
async fn fetch_npm_tag(tag: &str, npm_registry: Option<&str>) -> Result<String> {
let pkg_spec = if tag == "latest" {
NPM_PACKAGE.to_string()
} else {
format!("{}@{}", NPM_PACKAGE, tag)
};
let mut args = vec!["view", &pkg_spec, "version", "--json"];
let registry_flag;
if let Some(registry) = npm_registry {
registry_flag = format!("--registry={}", registry);
args.push(&registry_flag);
}
let mut cmd = Command::new("npm");
cmd.args(&args).stdin(std::process::Stdio::null());
xai_grok_tools::util::detach_command(&mut cmd);
cmd.envs(xai_grok_tools::util::pager_env());
let output = cmd.output().await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("npm view @{} failed: {}", tag, stderr.trim());
}
let stdout = String::from_utf8(output.stdout)?;
let value: Value = serde_json::from_str(stdout.trim())?;
match value {
Value::String(version) => Ok(version),
Value::Array(values) => values
.iter()
.rev()
.find_map(|entry| entry.as_str().map(|item| item.to_string()))
.ok_or_else(|| anyhow::anyhow!("npm view @{} returned empty version list", tag)),
_ => anyhow::bail!("npm view @{} returned unexpected JSON", tag),
}
}
/// Fetch the latest version from GitHub Releases using `gh release list`.
/// For alpha channel, fetches both pre-release and stable-only, returns the
/// semver-greater — `gh release list --limit 1` orders by publication date,
/// not semver, so we need both to guarantee correctness.
#[doc(hidden)]
pub async fn fetch_gh_release_version(channel: &str) -> Result<String> {
if channel == "alpha" {
let (with_pre, stable_only) = tokio::try_join!(
fetch_gh_release_latest(false),
fetch_gh_release_latest(true),
)?;
return semver_max(&with_pre, &stable_only);
}
fetch_gh_release_latest(true).await
}
async fn fetch_gh_release_latest(exclude_pre: bool) -> Result<String> {
let mut args = vec![
"release",
"list",
"--repo",
GH_RELEASE_REPO,
"--limit",
"1",
"--exclude-drafts",
"--json",
"tagName",
"--jq",
".[0].tagName",
];
if exclude_pre {
args.push("--exclude-pre-releases");
}
let mut cmd = Command::new("gh");
cmd.args(&args).stdin(std::process::Stdio::null());
xai_grok_tools::util::detach_command(&mut cmd);
cmd.envs(xai_grok_tools::util::pager_env());
let output = cmd.output().await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("gh release list failed: {}", stderr.trim());
}
let tag = String::from_utf8(output.stdout)?.trim().to_string();
// Tags are formatted as "v0.1.141", strip the leading "v"
let version = tag.strip_prefix('v').unwrap_or(&tag).to_string();
if version.is_empty() {
anyhow::bail!("No releases found in {}", GH_RELEASE_REPO);
}
Ok(version)
}
/// Fetch the latest version from a public CLI channel pointer.
///
/// Reads `{base}/{channel}` which contains a plain-text semver string
/// (e.g. `0.1.181`). No auth required — the upstream bucket is public.
///
/// For the alpha channel, fetches both `alpha` and `stable` pointers and
/// returns the semver-greater, matching the behavior of the npm and
/// gh-release paths.
///
/// Tries each base URL in [`CLI_BASE_URLS`] in order and stops at the first
/// success. Each individual base also retries up to 3 times with exponential
/// backoff (1s, 2s, 4s) on transient failures before falling through to the
/// next base.
pub(crate) async fn fetch_gcs_version(channel: &str) -> Result<String> {
let mut last_err: Option<anyhow::Error> = None;
for (i, base) in CLI_BASE_URLS.iter().enumerate() {
match fetch_gcs_version_from_base(channel, base).await {
Ok(v) => return Ok(v),
Err(e) => {
if i + 1 < CLI_BASE_URLS.len() {
tracing::warn!(
"channel pointer fetch from {} failed ({:#}); trying next base URL",
base,
e
);
}
last_err = Some(e);
}
}
}
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("no CLI base URLs configured")))
}
/// Test-only entry point: same as [`fetch_gcs_version`] but reads from
/// `base_url` instead of the hardcoded GCS bucket.
#[doc(hidden)]
pub async fn fetch_gcs_version_from_base(channel: &str, base_url: &str) -> Result<String> {
if channel == "alpha" {
let (alpha_v, stable_v) = tokio::try_join!(
fetch_gcs_channel_pointer("alpha", base_url),
fetch_gcs_channel_pointer("stable", base_url),
)?;
return semver_max(&alpha_v, &stable_v);
}
fetch_gcs_channel_pointer(channel, base_url).await
}
async fn fetch_gcs_channel_pointer(channel: &str, base_url: &str) -> Result<String> {
let url = format!("{}/{}", base_url, channel);
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(15))
.build()?;
let max_retries: u32 = 3;
let mut last_err = None;
for attempt in 0..=max_retries {
if attempt > 0 {
tokio::time::sleep(Duration::from_secs(1 << (attempt - 1))).await;
}
let resp = match client.get(&url).send().await {
Ok(r) => r,
Err(e) => {
last_err = Some(anyhow::anyhow!(
"GCS channel pointer fetch failed for {}: {:#}",
url,
e
));
continue;
}
};
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
last_err = Some(anyhow::anyhow!(
"GCS channel pointer fetch failed: HTTP {} for {}: {}",
status,
url,
body.chars().take(200).collect::<String>().trim()
));
continue;
}
match resp.text().await {
Ok(body) => {
let version = body.trim().to_string();
if version.is_empty() {
last_err = Some(anyhow::anyhow!(
"empty {} channel pointer at {}",
channel,
url
));
continue;
}
if semver::Version::parse(&version).is_err() {
anyhow::bail!(
"invalid semver in {} channel pointer: '{}'",
channel,
version
);
}
return Ok(version);
}
Err(e) => {
last_err = Some(anyhow::anyhow!(
"GCS channel pointer body read failed for {}: {:#}",
url,
e
));
continue;
}
}
}
Err(last_err.unwrap())
}
/// Fetch the latest version for the given installer type without writing the
/// version cache. Use this when the caller needs to control when the cache is
/// written (e.g. auto-update should only cache after a successful install or
/// when no update is needed).
pub async fn fetch_latest_version(installer: &str, config: &UpdateConfig) -> Result<String> {
match installer {
"npm" => fetch_npm_version(&config.channel, config.npm_registry.as_deref()).await,
"gh-release" => fetch_gh_release_version(&config.channel).await,
_ => fetch_gcs_version(&config.channel).await,
}
}
/// Write the version cache to disk, recording that `version` was seen at the
/// current time. Call after confirming the version is current (no update
/// needed) or after a successful install.
///
/// `stable_version` records the current stable channel pointer so that
/// `channel_label()` can derive `[alpha]` vs `[stable]` without network I/O.
pub async fn write_version_cache(version: &str, stable_version: Option<&str>) {
let version_path = grok_home().join("version.json");
let now = time::OffsetDateTime::now_utc();
let json = GrokVersion::new(
version.to_string(),
stable_version.map(|s| s.to_string()),
now,
);
if let Some(dir) = version_path.parent()
&& let Err(e) = fs::create_dir_all(dir).await
{
tracing::warn!("failed to create version cache directory: {}", e);
return;
}
let tmp = version_path.with_extension("json.tmp");
let data = match serde_json::to_vec_pretty(&json) {
Ok(d) => d,
Err(e) => {
tracing::warn!("failed to serialize version cache: {}", e);
return;
}
};
if let Err(e) = fs::write(&tmp, data).await {
tracing::warn!("failed to write version cache tmp file: {}", e);
return;
}
if let Err(e) = fs::rename(&tmp, &version_path).await {
tracing::warn!("failed to rename version cache file: {}", e);
}
}
/// Fetch the latest version for the given installer type and cache it.
///
/// Each installer is fully independent — no cross-installer fallback.
///
/// - `"npm"` — uses `npm view` against the public registry.
/// - `"internal"` — reads the channel pointer from the public GCS bucket.
/// - `"gh-release"` — uses `gh release list` against GitHub Releases.
pub async fn get_latest_version(installer: &str, config: &UpdateConfig) -> Result<String> {
let version = fetch_latest_version(installer, config).await?;
let stable_ptr = try_fetch_stable_pointer().await;
write_version_cache(&version, stable_ptr.as_deref()).await;
Ok(version)
}
/// True if `version.json` exists and is within TTL.
pub async fn is_version_cache_fresh() -> bool {
let version_path = grok_home().join("version.json");
let now = time::OffsetDateTime::now_utc();
if let Ok(version_str) = fs::read_to_string(&version_path).await
&& let Ok(version) = serde_json::from_str::<GrokVersion>(&version_str)
&& version.is_fresh(now, TTL_SECONDS_BEFORE_AUTO_UPDATE)
{
return true;
}
false
}
pub use xai_grok_version::installed as get_installed_grok_version;
/// Version of the managed grok binary currently on disk, read from the
/// `~/.grok/bin/grok` symlink target (`../downloads/grok-<version>-<platform>`)
/// without exec'ing anything.
///
/// Concurrent updaters (TUI background download, leader hourly checker,
/// explicit `grok update`) decide staleness from this instead of their own
/// compiled-in version, so a binary another process already installed is
/// never downloaded a second time.
///
/// Returns `None` when there is no parseable managed symlink (Windows
/// copy-based installs, dev builds) or when the symlink is DANGLING — a
/// link whose target binary was deleted (e.g. manual `~/.grok/downloads`
/// cleanup) must not report an installed version, or every updater would
/// claim "already up to date" forever while no runnable binary exists.
/// NOTE: the symlink existing does not prove the *active installer*
/// maintains it — npm manages its own global install and a leftover symlink
/// from a previous internal install would lie about the npm install's
/// version. Callers must gate on the installer (see
/// `disk_version_for_installer` in `auto_update`).
pub fn installed_on_disk_version() -> Option<String> {
#[cfg(unix)]
{
let app = xai_grok_shell::util::grok_home::grok_application();
let target = std::fs::read_link(&app).ok()?;
// metadata() follows the symlink: Err means the target is gone
// (dangling link) and the version it names is not actually on disk.
std::fs::metadata(&app).ok()?;
version_from_versioned_binary_name(target.file_name()?.to_str()?, "grok")
}
#[cfg(not(unix))]
{
None
}
}
/// Extract the `<version>` portion of a versioned binary file name.
///
/// Handles the internal layout (`grok-0.1.150-macos-aarch64`, including
/// pre-releases: `grok-0.1.150-alpha.1-linux-x86_64` → `0.1.150-alpha.1`)
/// and the npm layout without a platform suffix (`grok-0.1.150`,
/// `grok-0.1.150-alpha.1`): everything between the `{bin_prefix}-` prefix
/// and the first platform-OS component is the version, validated as semver
/// so unknown layouts (`grok-latest`, `grok-pager-*` when `bin_prefix` is
/// `grok`) return `None` instead of garbage.
///
/// Shared by the disk-version probe above and `cleanup_old_downloads` in
/// `auto_update` — keep it the single place that understands this naming.
pub(crate) fn version_from_versioned_binary_name(name: &str, bin_prefix: &str) -> Option<String> {
const PLATFORM_OS: &[&str] = &["macos", "linux", "darwin", "windows"];
let suffix = name.strip_prefix(bin_prefix)?.strip_prefix('-')?;
let parts: Vec<&str> = suffix.split('-').collect();
let platform_start = parts
.iter()
.position(|p| PLATFORM_OS.contains(p))
.unwrap_or(parts.len());
let ver_str = parts[..platform_start].join("-");
semver::Version::parse(&ver_str).ok()?;
Some(ver_str)
}
/// Fetch the stable channel pointer for caching alongside the version.
///
/// Tries each base URL in [`CLI_BASE_URLS`] and returns the first success.
/// Best-effort: returns `None` on any failure (the caller will simply omit
/// the stable pointer from the cache, and `channel_label()` will return `""`
/// until the next successful fetch).
///
/// The entire operation is capped at 500 ms. The stable pointer is only used
/// to derive the `[alpha]`/`[stable]` channel label — it is never required
/// for correctness. On slow or unreachable networks the timeout fires and we
/// return `None`; the label will populate on the next successful TTL check
/// (~30 min). This keeps startup and post-install paths fast.
pub(crate) async fn try_fetch_stable_pointer() -> Option<String> {
tokio::time::timeout(Duration::from_millis(500), async {
for base in CLI_BASE_URLS {
if let Ok(v) = fetch_gcs_channel_pointer("stable", base).await {
return Some(v);
}
}
None
})
.await
.unwrap_or(None)
}
/// Read the cached stable version from `~/.grok/version.json` (sync, for display).
///
/// Returns `None` if the file doesn't exist, can't be parsed, or has no
/// `stable_version` field (e.g. written by an older binary).
pub fn cached_stable_version() -> Option<String> {
let version_path = grok_home().join("version.json");
let content = std::fs::read_to_string(&version_path).ok()?;
let gv: GrokVersion = serde_json::from_str(&content).ok()?;
gv.stable_version
}
/// Pure comparison: derive the channel name from current vs stable pointer.
///
/// Returns `Some("alpha")` when `current > stable`, `Some("stable")` when
/// `current <= stable`, or `None` when either version fails to parse.
fn derive_channel<'a>(current: &str, stable: &str) -> Option<&'a str> {
let current_v = semver::Version::parse(current).ok()?;
let stable_v = semver::Version::parse(stable).ok()?;
if current_v > stable_v {
Some("alpha")
} else {
Some("stable")
}
}
/// Machine-readable channel name derived from the cached stable pointer.
///
/// Returns `Some("alpha")` when the current version is ahead of the cached
/// stable pointer, `Some("stable")` when at or behind, or `None` when no
/// cached pointer is available (first launch, old cache format, parse error).
///
/// The result is computed once and cached for the process lifetime.
pub fn channel_name() -> Option<&'static str> {
use std::sync::OnceLock;
static NAME: OnceLock<Option<&'static str>> = OnceLock::new();
*NAME.get_or_init(|| {
let stable = cached_stable_version()?;
derive_channel(xai_grok_version::VERSION, &stable)
})
}
/// Channel label derived from the cached stable pointer.
///
/// Compares the compiled-in `VERSION` against the stable pointer stored in
/// `~/.grok/version.json` (written by the auto-updater):
/// - `" [alpha]"` when the current version is ahead of stable,
/// - `" [stable]"` when at or behind stable,
/// - `""` when no cached pointer is available (first launch, old cache format).
///
/// The result is computed once and cached for the process lifetime.
pub fn channel_label() -> &'static str {
use std::sync::OnceLock;
static LABEL: OnceLock<&'static str> = OnceLock::new();
LABEL.get_or_init(|| {
let stable = match cached_stable_version() {
Some(s) => s,
None => return "",
};
match derive_channel(xai_grok_version::VERSION, &stable) {
Some("alpha") => " [alpha]",
Some(_) => " [stable]",
None => "",
}
})
}
#[cfg(test)]
mod tests {
use super::*;
/// Verifies that a future `checked_at` timestamp (e.g. from clock skew or
/// NTP time-warp) is never considered fresh. Without the clock-skew guard
/// this would return true indefinitely, silently disabling auto-update.
#[test]
fn test_is_fresh_rejects_future_timestamp() {
let now = time::OffsetDateTime::now_utc();
let future = now + Duration::from_secs(600);
let v = GrokVersion::new("0.1.200".to_string(), None, future);
assert!(
!v.is_fresh(now, Duration::from_secs(30)),
"Future timestamp must not be considered fresh (clock-skew guard)."
);
}
/// Disk-version probe: parsing the version out of the managed install's
/// symlink-target file name (`grok-<version>-<platform>`).
#[test]
fn test_version_from_versioned_binary_name() {
let cases: &[(&str, Option<&str>)] = &[
("grok-0.2.46-darwin-arm64", Some("0.2.46")),
("grok-0.1.220-linux-x86_64", Some("0.1.220")),
("grok-0.2.5-windows-x86_64.exe", Some("0.2.5")),
// Pre-releases must round-trip whole — truncating to "0.1.220"
// would make an alpha install masquerade as the release and
// mask alpha → stable updates.
("grok-0.1.220-alpha.4-linux-x86_64", Some("0.1.220-alpha.4")),
("grok-0.1.220-alpha.4", Some("0.1.220-alpha.4")), // npm layout
("grok-pager-0.1.5-darwin-arm64", None), // "pager" is not a version
("grok-garbage-darwin-arm64", None), // unparseable version
("grok-0.2.46", Some("0.2.46")), // no platform suffix
("other-0.2.46-darwin-arm64", None), // wrong prefix
("grok-latest", None), // symlink alias, not a version
("grok", None), // bare name
("", None),
];
for (name, expected) in cases {
assert_eq!(
version_from_versioned_binary_name(name, "grok").as_deref(),
*expected,
"version_from_versioned_binary_name({name:?})"
);
}
// bin_prefix discrimination: the pager binary parses under its own
// prefix but not under "grok".
assert_eq!(
version_from_versioned_binary_name("grok-pager-0.1.5-darwin-arm64", "grok-pager")
.as_deref(),
Some("0.1.5")
);
}
// ──────────────────────────────────────────────────────────────────────
// derive_channel — invariant matrix
//
// Tests the pure comparison logic that determines [alpha] vs [stable].
// Covers current 0.1.X-alpha.N, future 0.2.X, edge cases, and errors.
// ──────────────────────────────────────────────────────────────────────
#[test]
fn test_derive_channel_matrix() {
// (current, stable_pointer, expected_channel)
let cases: &[(&str, &str, Option<&str>)] = &[
// ── Current 0.1.X workflow ──
("0.1.220-alpha.2", "0.1.219", Some("alpha")), // alpha ahead of stable
("0.1.219", "0.1.219", Some("stable")), // stable user on latest
("0.1.218", "0.1.219", Some("stable")), // stable user behind latest
("0.1.220-alpha.2", "0.1.220-alpha.2", Some("stable")), // pointer matches exactly
("0.1.220-alpha.2", "0.1.220", Some("stable")), // semver: release > pre-release
// ── Future 0.2.X workflow ──
("0.2.5", "0.2.3", Some("alpha")), // alpha ahead of stable
("0.2.5", "0.2.5", Some("stable")), // promoted to stable
("0.2.3", "0.2.5", Some("stable")), // behind stable
("0.2.0", "0.2.0", Some("stable")), // first release, both 0.2.0
// ── Cross-regime upgrade ──
("0.2.0", "0.1.219", Some("alpha")), // new regime ahead of old stable
("0.1.220-alpha.2", "0.2.0", Some("stable")), // old pre-release < new stable
// ── Error cases ──
("garbage", "0.1.219", None), // unparseable current
("0.1.219", "garbage", None), // unparseable stable
("", "0.1.219", None), // empty current
("0.1.219", "", None), // empty stable
];
for (current, stable, expected) in cases {
let result = derive_channel(current, stable);
assert_eq!(
result, *expected,
"derive_channel({:?}, {:?}) = {:?}, expected {:?}",
current, stable, result, expected,
);
}
}
// ──────────────────────────────────────────────────────────────────────
// semver_max — invariant matrix
// ──────────────────────────────────────────────────────────────────────
#[test]
fn test_semver_max_matrix() {
// (a, b, expected)
let cases: &[(&str, &str, &str)] = &[
("0.1.140", "0.1.140", "0.1.140"), // equal
("0.1.140", "0.1.141", "0.1.141"), // b higher
("0.1.141", "0.1.140", "0.1.141"), // a higher
("0.1.148-alpha.3", "0.1.148", "0.1.148"), // release > pre-release
("0.1.148", "0.1.148-alpha.3", "0.1.148"), // commutative
("0.1.148-alpha.1", "0.1.148-alpha.3", "0.1.148-alpha.3"), // pre-release ordering
("0.1.149-alpha.1", "0.1.148", "0.1.149-alpha.1"), // higher base wins
("0.0.0", "0.0.1", "0.0.1"), // zero versions
("0.99.99", "1.0.0", "1.0.0"), // major jump
];
for (a, b, expected) in cases {
assert_eq!(
semver_max(a, b).unwrap(),
*expected,
"semver_max({:?}, {:?})",
a,
b,
);
}
}
#[test]
fn test_semver_max_invalid_input_returns_err() {
assert!(semver_max("garbage", "0.1.141").is_err());
assert!(semver_max("0.1.141", "garbage").is_err());
assert!(semver_max("foo", "bar").is_err());
}
// ──────────────────────────────────────────────────────────────────────
// GrokVersion JSON shape — backward compatibility invariants
// ──────────────────────────────────────────────────────────────────────
#[test]
fn test_version_json_backward_compat() {
// Old format (no stable_version) must parse — serde(default) fills None.
let old = r#"{"version":"0.1.180","checked_at":"2026-04-22T10:30:00Z"}"#;
let v: GrokVersion = serde_json::from_str(old).unwrap();
assert_eq!(v.version, "0.1.180");
assert!(v.stable_version.is_none());
// New format with stable_version round-trips correctly.
let now = time::OffsetDateTime::now_utc();
let new = GrokVersion::new("0.2.5".to_string(), Some("0.2.3".to_string()), now);
let json = serde_json::to_string(&new).unwrap();
let parsed: GrokVersion = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.version, "0.2.5");
assert_eq!(parsed.stable_version.as_deref(), Some("0.2.3"));
// checked_at must be valid RFC3339.
assert!(
time::OffsetDateTime::parse(
&parsed.checked_at,
&time::format_description::well_known::Rfc3339,
)
.is_ok()
);
// Unknown fields are ignored (forward-compat).
let future = r#"{"version":"0.1.180","checked_at":"2026-04-22T10:30:00Z","future":"ok"}"#;
assert!(serde_json::from_str::<GrokVersion>(future).is_ok());
// Missing required field (checked_at) is rejected.
let missing = r#"{"version":"0.1.180"}"#;
assert!(serde_json::from_str::<GrokVersion>(missing).is_err());
}
// ──────────────────────────────────────────────────────────────────────
// is_fresh — TTL boundary invariants
// ──────────────────────────────────────────────────────────────────────
#[test]
fn test_is_fresh_ttl_boundaries() {
let now = time::OffsetDateTime::now_utc();
let v = GrokVersion::new("0.1.200".to_string(), None, now);
// Within TTL → fresh
assert!(v.is_fresh(now, Duration::from_secs(60)));
assert!(v.is_fresh(now + Duration::from_secs(29), Duration::from_secs(30)));
// At TTL boundary → NOT fresh (strict <)
assert!(!v.is_fresh(now + Duration::from_secs(30), Duration::from_secs(30)));
// Past TTL → not fresh
assert!(!v.is_fresh(now + Duration::from_secs(31), Duration::from_secs(30)));
// Zero TTL → never fresh
assert!(!v.is_fresh(now, Duration::ZERO));
// Malformed timestamp → not fresh
let bad = GrokVersion {
version: "0.1.200".to_string(),
stable_version: None,
checked_at: "not-rfc3339".to_string(),
};
assert!(!bad.is_fresh(now, Duration::from_secs(60)));
}
// ──────────────────────────────────────────────────────────────────────
// UpdateConfig defaults
// ──────────────────────────────────────────────────────────────────────
#[test]
fn test_update_config_default_channel_is_stable() {
use xai_grok_shell::env::GrokBuildEnvironment;
let cfg = UpdateConfig::from_environment(&GrokBuildEnvironment::Production);
assert_eq!(cfg.channel, "stable");
}
}