F8: distribution and GitHub-Releases self-update
- .github/workflows/release.yml: on tag v* build all 5 targets (macOS
arm64/x86_64, Linux arm64/x86_64 incl. free arm runners, Windows
x86_64) with the release-dist profile, archive kigi-<version>-<triple>
with LICENSE/NOTICE/THIRD-PARTY-NOTICES, generate SHA256SUMS, publish
the release (prerelease for tags containing '-'), with a tag↔workspace
version guard.
- install.sh / install.ps1 (repo root): platform detection, latest or
--version download from GitHub Releases, SHA-256 verification against
SHA256SUMS, install into the kigi home's downloads/ + bin/kigi symlink
(the same layout the self-updater manages), smoke test, PATH guidance.
- kigi-update rewritten onto the GitHub Releases API (documented wire
shape; stable=/latest, alpha=semver-max across the list, pinned=/tags):
SHA-256 gate before any binary swap, tar.gz/zip extraction per
platform, atomic bin/kigi symlink swap, channel/rollback semantics and
the KIGI_AUTO_UPDATE gate preserved verbatim; every x.ai/GCS/npm
endpoint deleted, npm/gh-release installers removed, legacy grok/agent
links retired on install. kigi-env owns the update base URL with a
KIGI_UPDATE_BASE_URL override (this is what the test artifact server
injects).
- .cargo/config.toml: removed the non-portable neoverse-v2 CPU pin on
Linux arm64 (fleet-specific); RELRO/NX hardening link-args now apply
to the gnu targets too, matching the release-dist profile's contract.
- THIRD-PARTY-NOTICES regenerated via cargo-about (about.toml +
template); the M0 hand-built file is dropped and README points at the
generated one. docs/RELEASE.md carries the release checklist.
- Deleted xAI-era leftovers: kigi-tui/scripts/install*.{sh,ps1} (x.ai
CDN) and the @xai-official/grok npm skeleton (PRD F8: no npm).
Gates: fmt clean; workspace check/clippy 0/0 (--locked, -D warnings);
kigi-update 58 lib + 86 integration tests green; deny ok;
release-dist build of kigi-bin succeeds and reports 'kigi 0.1.0'.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,15 @@
|
||||
//! Minimum-version enforcement.
|
||||
//!
|
||||
//! When `cli.minimum_version` is set in any config layer, Grok refuses to
|
||||
//! When `cli.minimum_version` is set in any config layer, Kigi refuses to
|
||||
//! start below that floor. With auto-update on, we install
|
||||
//! `max(latest, minimum)`; otherwise the user is asked to run `grok update`.
|
||||
//! `max(latest, minimum)`; otherwise the user is asked to run `kigi update`.
|
||||
//!
|
||||
//! Set `KIGI_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,
|
||||
UpdateConfig, fetch_latest_version, get_installed_kigi_version, write_version_cache,
|
||||
};
|
||||
use kigi_shell::util::config;
|
||||
use tracing::{info, warn};
|
||||
@@ -36,7 +36,7 @@ enum EnforcementOutcome {
|
||||
pub(crate) enum MinimumVersionError {
|
||||
/// `source` chains via `Error::source()`; omitted from `Display`.
|
||||
#[error(
|
||||
"The minimum version \"{value}\" in your Grok configuration \
|
||||
"The minimum version \"{value}\" in your Kigi configuration \
|
||||
isn't a valid version number. Update `cli.minimum_version` and try again."
|
||||
)]
|
||||
InvalidMinimum {
|
||||
@@ -45,22 +45,22 @@ pub(crate) enum MinimumVersionError {
|
||||
source: semver::Error,
|
||||
},
|
||||
#[error(
|
||||
"This version of Grok ({current}) is no longer supported. \
|
||||
Run `grok update` to install version {minimum} or later."
|
||||
"This version of Kigi ({current}) is no longer supported. \
|
||||
Run `kigi update` to install version {minimum} or later."
|
||||
)]
|
||||
AutoUpdateDisabled { current: String, minimum: String },
|
||||
/// `npm` / `gh` / `internal` GCS — none detected.
|
||||
/// No installer backend detected.
|
||||
#[error(
|
||||
"This version of Grok ({current}) is no longer supported. \
|
||||
Run `grok update` to install version {minimum} or later."
|
||||
"This version of Kigi ({current}) is no longer supported. \
|
||||
Run `kigi 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, \
|
||||
"This version of Kigi ({current}) is no longer supported, \
|
||||
and the update to version {minimum} didn't complete.\n\n\
|
||||
Run `grok update` to try again."
|
||||
Run `kigi update` to try again."
|
||||
)]
|
||||
UpgradeFailed {
|
||||
current: String,
|
||||
@@ -70,7 +70,7 @@ pub(crate) enum MinimumVersionError {
|
||||
/// 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. \
|
||||
"This version of Kigi ({current}) is no longer supported. \
|
||||
Version {minimum} or later is required, but the most recent release is {latest}. \
|
||||
Contact your administrator."
|
||||
)]
|
||||
@@ -81,15 +81,15 @@ pub(crate) enum MinimumVersionError {
|
||||
},
|
||||
/// Couldn't probe the registry — likely transient.
|
||||
#[error(
|
||||
"This version of Grok ({current}) is no longer supported. \
|
||||
"This version of Kigi ({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.
|
||||
/// `kigi 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."
|
||||
"Cannot install Kigi {target}: the configured minimum is {minimum}. \
|
||||
Run `kigi update` to install the latest allowed version."
|
||||
)]
|
||||
TargetBelowFloor { target: String, minimum: String },
|
||||
}
|
||||
@@ -133,7 +133,7 @@ fn evaluate_minimum_version(
|
||||
}
|
||||
|
||||
/// Refuse an explicit install target below the configured floor.
|
||||
/// Used by `grok update --version X`.
|
||||
/// Used by `kigi 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())
|
||||
@@ -154,7 +154,7 @@ fn check_install_target_inner(
|
||||
}
|
||||
|
||||
/// `max(target, configured_floor)`; passthrough when no floor is set.
|
||||
/// Used by `grok update` to keep the install target at or above the pin.
|
||||
/// Used by `kigi 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())
|
||||
@@ -193,7 +193,7 @@ async fn enforce_minimum_version(
|
||||
minimum_version: Option<&str>,
|
||||
update_config: &UpdateConfig,
|
||||
) -> Result<EnforcementOutcome, MinimumVersionError> {
|
||||
let current_version = get_installed_grok_version();
|
||||
let current_version = get_installed_kigi_version();
|
||||
let decision = evaluate_minimum_version(¤t_version, minimum_version)?;
|
||||
let MinimumVersionDecision::BelowMinimum { current, minimum } = decision else {
|
||||
info!(current = %current_version, "minimum_version: floor satisfied");
|
||||
@@ -214,12 +214,12 @@ async fn enforce_minimum_version(
|
||||
return Err(MinimumVersionError::NoInstaller { current, minimum });
|
||||
};
|
||||
|
||||
let latest = fetch_latest_version(installer, update_config).await.ok();
|
||||
let latest = fetch_latest_version(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. \
|
||||
"This version of Kigi ({current}) is no longer supported. \
|
||||
Updating to {target}…"
|
||||
);
|
||||
|
||||
@@ -276,12 +276,12 @@ pub async fn enforce_minimum_version_or_exit(update_config: &UpdateConfig) {
|
||||
match enforce_minimum_version(Some(&min), update_config).await {
|
||||
Ok(EnforcementOutcome::Allowed) => {}
|
||||
Ok(EnforcementOutcome::Upgraded) => {
|
||||
// TODO: restart_grok uses exec() which carries the same
|
||||
// TODO: restart_kigi 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.");
|
||||
eprintln!("Update installed. Run `kigi` to start.");
|
||||
std::process::exit(0);
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -370,7 +370,7 @@ mod tests {
|
||||
// SAFETY: #[serial] excludes other env-touching tests.
|
||||
unsafe { std::env::set_var("KIGI_TEST_VERSION", "0.1.50") };
|
||||
let decision =
|
||||
evaluate_minimum_version(&get_installed_grok_version(), Some("0.1.100")).unwrap();
|
||||
evaluate_minimum_version(&get_installed_kigi_version(), Some("0.1.100")).unwrap();
|
||||
assert!(matches!(
|
||||
decision,
|
||||
MinimumVersionDecision::BelowMinimum { .. }
|
||||
|
||||
@@ -2,28 +2,19 @@ use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use tokio::fs;
|
||||
use tokio::process::Command;
|
||||
|
||||
use kigi_shell::util::kigi_home::kigi_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];
|
||||
/// Release channel: this repo's GitHub Releases (PRD F8). The API base is
|
||||
/// resolved through [`kigi_env::update_base_url`] — production default
|
||||
/// `https://api.github.com/repos/ZacharyZhang-NY/Kigi-CLI/releases`,
|
||||
/// overridable via `KIGI_UPDATE_BASE_URL` for mirrors and tests.
|
||||
pub(crate) fn update_base_url() -> String {
|
||||
kigi_env::update_base_url()
|
||||
}
|
||||
|
||||
/// Minimal configuration the update system needs from the environment.
|
||||
///
|
||||
@@ -40,8 +31,6 @@ pub struct UpdateConfig {
|
||||
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 {
|
||||
@@ -52,20 +41,216 @@ impl UpdateConfig {
|
||||
deployment_key: None,
|
||||
alpha_test_key: None,
|
||||
channel: "stable".to_string(),
|
||||
npm_registry: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// GitHub Releases API wire shape
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// One downloadable asset attached to a GitHub release.
|
||||
///
|
||||
/// Wire shape per the GitHub REST API
|
||||
/// (<https://docs.github.com/en/rest/releases/releases#get-the-latest-release>):
|
||||
/// `GET /repos/{owner}/{repo}/releases/latest` →
|
||||
/// `{"tag_name":"v0.1.0","assets":[{"name":"...","browser_download_url":"..."}]}`.
|
||||
/// Unknown fields are ignored.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ReleaseAsset {
|
||||
pub name: String,
|
||||
pub browser_download_url: String,
|
||||
}
|
||||
|
||||
/// A GitHub release, reduced to the fields the updater consumes.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Release {
|
||||
/// Git tag, e.g. `v0.1.0`. [`Release::version`] strips the `v` prefix.
|
||||
pub tag_name: String,
|
||||
#[serde(default)]
|
||||
pub draft: bool,
|
||||
#[serde(default)]
|
||||
pub prerelease: bool,
|
||||
#[serde(default)]
|
||||
pub assets: Vec<ReleaseAsset>,
|
||||
}
|
||||
|
||||
impl Release {
|
||||
/// Semver version from `tag_name` (`v0.1.0` → `0.1.0`). Errors on a tag
|
||||
/// that is not a `v`-prefixed (or bare) semver string.
|
||||
pub fn version(&self) -> Result<String> {
|
||||
let v = self.tag_name.strip_prefix('v').unwrap_or(&self.tag_name);
|
||||
semver::Version::parse(v)
|
||||
.map_err(|e| anyhow::anyhow!("release tag '{}' is not semver: {e}", self.tag_name))?;
|
||||
Ok(v.to_string())
|
||||
}
|
||||
|
||||
/// Asset with exactly `name`, or an error listing what the release has.
|
||||
pub fn asset(&self, name: &str) -> Result<&ReleaseAsset> {
|
||||
self.assets.iter().find(|a| a.name == name).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"release {} has no asset named '{}' (available: {})",
|
||||
self.tag_name,
|
||||
name,
|
||||
self.assets
|
||||
.iter()
|
||||
.map(|a| a.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared HTTP client for GitHub API metadata requests. GitHub rejects
|
||||
/// requests without a `User-Agent`, so one is always set.
|
||||
fn github_api_client(timeout: Duration) -> Result<reqwest::Client> {
|
||||
Ok(reqwest::Client::builder()
|
||||
.user_agent(concat!("kigi/", env!("CARGO_PKG_VERSION")))
|
||||
.timeout(timeout)
|
||||
.build()?)
|
||||
}
|
||||
|
||||
/// GET `url` and decode the JSON body as `T`, retrying transient failures
|
||||
/// (network errors, HTTP 5xx) up to 3 times with 1s/2s/4s backoff.
|
||||
/// Non-5xx HTTP errors (404 missing release, 403 rate limit) fail fast.
|
||||
async fn fetch_github_json<T: serde::de::DeserializeOwned>(url: &str) -> Result<T> {
|
||||
let client = github_api_client(Duration::from_secs(15))?;
|
||||
let max_retries: u32 = 3;
|
||||
let mut last_err: Option<anyhow::Error> = 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)
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
last_err = Some(anyhow::anyhow!(
|
||||
"GitHub API request failed for {url}: {e:#}"
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let status = resp.status();
|
||||
if status.is_server_error() {
|
||||
last_err = Some(anyhow::anyhow!(
|
||||
"GitHub API returned HTTP {status} for {url}"
|
||||
));
|
||||
continue;
|
||||
}
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!(
|
||||
"GitHub API returned HTTP {} for {}: {}",
|
||||
status,
|
||||
url,
|
||||
body.chars().take(200).collect::<String>().trim()
|
||||
);
|
||||
}
|
||||
let body = match resp.text().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
last_err = Some(anyhow::anyhow!(
|
||||
"GitHub API body read failed for {url}: {e:#}"
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
return serde_json::from_str(&body)
|
||||
.map_err(|e| anyhow::anyhow!("GitHub API returned unexpected JSON for {url}: {e}"));
|
||||
}
|
||||
Err(last_err.expect("loop ran at least once"))
|
||||
}
|
||||
|
||||
/// Latest release for `channel` from a GitHub-Releases-shaped API at `base`.
|
||||
///
|
||||
/// - `stable` / `enterprise`: `GET {base}/latest` — GitHub's "latest" already
|
||||
/// excludes drafts and pre-releases.
|
||||
/// - `alpha`: `GET {base}?per_page=30` (newest first) and take the semver-max
|
||||
/// non-draft entry. The list includes both pre-releases and stable
|
||||
/// releases, so this preserves the max(alpha, stable) channel semantics —
|
||||
/// alpha users are never stuck behind a newer stable.
|
||||
pub async fn fetch_latest_release_from_base(channel: &str, base: &str) -> Result<Release> {
|
||||
let base = base.trim_end_matches('/');
|
||||
if channel == "alpha" {
|
||||
let releases: Vec<Release> = fetch_github_json(&format!("{base}?per_page=30")).await?;
|
||||
return releases
|
||||
.into_iter()
|
||||
.filter(|r| !r.draft)
|
||||
.filter_map(|r| {
|
||||
let v = semver::Version::parse(r.tag_name.strip_prefix('v').unwrap_or(&r.tag_name))
|
||||
.ok()?;
|
||||
Some((v, r))
|
||||
})
|
||||
.max_by(|a, b| a.0.cmp(&b.0))
|
||||
.map(|(_, r)| r)
|
||||
.ok_or_else(|| anyhow::anyhow!("no releases with semver tags found at {base}"));
|
||||
}
|
||||
fetch_github_json(&format!("{base}/latest")).await
|
||||
}
|
||||
|
||||
/// Release for an exact version (`GET {base}/tags/v{version}`), used by
|
||||
/// pinned installs (`kigi update --version X`) and rollbacks.
|
||||
pub async fn fetch_release_for_version_from_base(version: &str, base: &str) -> Result<Release> {
|
||||
let base = base.trim_end_matches('/');
|
||||
fetch_github_json(&format!("{base}/tags/v{version}")).await
|
||||
}
|
||||
|
||||
/// Latest release for `channel` from the production base URL
|
||||
/// ([`kigi_env::update_base_url`]).
|
||||
pub(crate) async fn fetch_latest_release(channel: &str) -> Result<Release> {
|
||||
fetch_latest_release_from_base(channel, &update_base_url()).await
|
||||
}
|
||||
|
||||
/// Fetch the latest version for the configured channel 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(config: &UpdateConfig) -> Result<String> {
|
||||
fetch_latest_release(&config.channel).await?.version()
|
||||
}
|
||||
|
||||
/// Fetch the latest version for the configured channel and cache it.
|
||||
pub async fn get_latest_version(config: &UpdateConfig) -> Result<String> {
|
||||
let version = fetch_latest_version(config).await?;
|
||||
let stable_ptr = try_fetch_stable_version().await;
|
||||
write_version_cache(&version, stable_ptr.as_deref()).await;
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
/// Fetch the latest stable version for caching alongside the version, so
|
||||
/// `channel_label()` can derive `[alpha]` vs `[stable]` without network I/O.
|
||||
///
|
||||
/// Best-effort and capped at 500 ms: the label is cosmetic, never required
|
||||
/// for correctness. On slow or unreachable networks the timeout fires and we
|
||||
/// return `None`; the label populates on the next successful TTL check
|
||||
/// (~30 min). This keeps startup and post-install paths fast.
|
||||
pub(crate) async fn try_fetch_stable_version() -> Option<String> {
|
||||
tokio::time::timeout(Duration::from_millis(500), async {
|
||||
fetch_latest_release("stable").await.ok()?.version().ok()
|
||||
})
|
||||
.await
|
||||
.unwrap_or(None)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Version cache (~/.kigi/version.json)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, serde::Serialize, Deserialize)]
|
||||
struct GrokVersion {
|
||||
struct VersionCache {
|
||||
version: String,
|
||||
#[serde(default)]
|
||||
stable_version: Option<String>,
|
||||
checked_at: String,
|
||||
}
|
||||
|
||||
impl GrokVersion {
|
||||
impl VersionCache {
|
||||
fn is_fresh(&self, now: time::OffsetDateTime, ttl: Duration) -> bool {
|
||||
if let Ok(dt) = time::OffsetDateTime::parse(
|
||||
&self.checked_at,
|
||||
@@ -93,270 +278,16 @@ impl GrokVersion {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(®istry_flag);
|
||||
}
|
||||
let mut cmd = Command::new("npm");
|
||||
cmd.args(&args).stdin(std::process::Stdio::null());
|
||||
kigi_tools::util::detach_command(&mut cmd);
|
||||
cmd.envs(kigi_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());
|
||||
kigi_tools::util::detach_command(&mut cmd);
|
||||
cmd.envs(kigi_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
|
||||
/// `stable_version` records the current stable release 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 = kigi_home().join("version.json");
|
||||
let now = time::OffsetDateTime::now_utc();
|
||||
let json = GrokVersion::new(
|
||||
let json = VersionCache::new(
|
||||
version.to_string(),
|
||||
stable_version.map(|s| s.to_string()),
|
||||
now,
|
||||
@@ -384,26 +315,12 @@ pub async fn write_version_cache(version: &str, stable_version: Option<&str>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 = kigi_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)
|
||||
&& let Ok(version) = serde_json::from_str::<VersionCache>(&version_str)
|
||||
&& version.is_fresh(now, TTL_SECONDS_BEFORE_AUTO_UPDATE)
|
||||
{
|
||||
return true;
|
||||
@@ -411,14 +328,14 @@ pub async fn is_version_cache_fresh() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub use kigi_version::installed as get_installed_grok_version;
|
||||
pub use kigi_version::installed as get_installed_kigi_version;
|
||||
|
||||
/// Version of the managed grok binary currently on disk, read from the
|
||||
/// `~/.kigi/bin/grok` symlink target (`../downloads/grok-<version>-<platform>`)
|
||||
/// Version of the managed kigi binary currently on disk, read from the
|
||||
/// `~/.kigi/bin/kigi` symlink target (`../downloads/kigi-<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
|
||||
/// explicit `kigi 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.
|
||||
///
|
||||
@@ -427,11 +344,6 @@ pub use kigi_version::installed as get_installed_grok_version;
|
||||
/// link whose target binary was deleted (e.g. manual `~/.kigi/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)]
|
||||
{
|
||||
@@ -440,7 +352,7 @@ pub fn installed_on_disk_version() -> Option<String> {
|
||||
// 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")
|
||||
version_from_versioned_binary_name(target.file_name()?.to_str()?, "kigi")
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
@@ -450,18 +362,23 @@ pub fn installed_on_disk_version() -> Option<String> {
|
||||
|
||||
/// 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.
|
||||
/// Handles the managed layout (`kigi-0.1.0-macos-aarch64`, including
|
||||
/// pre-releases: `kigi-0.1.0-alpha.1-linux-x86_64` → `0.1.0-alpha.1`) and a
|
||||
/// bare versioned name without a platform suffix (`kigi-0.1.0`): everything
|
||||
/// between the `{bin_prefix}-` prefix and the first platform-OS component is
|
||||
/// the version, validated as semver so unknown layouts (`kigi-latest`)
|
||||
/// 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"];
|
||||
// Release archives (`kigi-<v>-<triple>.tar.gz|.zip`) are downloads, not
|
||||
// binaries — and their triple would otherwise parse as a semver
|
||||
// pre-release (`0.1.0-aarch64-apple-darwin.tar.gz` is valid semver).
|
||||
if name.ends_with(".tar.gz") || name.ends_with(".zip") {
|
||||
return None;
|
||||
}
|
||||
let suffix = name.strip_prefix(bin_prefix)?.strip_prefix('-')?;
|
||||
let parts: Vec<&str> = suffix.split('-').collect();
|
||||
let platform_start = parts
|
||||
@@ -473,31 +390,6 @@ pub(crate) fn version_from_versioned_binary_name(name: &str, bin_prefix: &str) -
|
||||
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 `~/.kigi/version.json` (sync, for display).
|
||||
///
|
||||
/// Returns `None` if the file doesn't exist, can't be parsed, or has no
|
||||
@@ -505,7 +397,7 @@ pub(crate) async fn try_fetch_stable_pointer() -> Option<String> {
|
||||
pub fn cached_stable_version() -> Option<String> {
|
||||
let version_path = kigi_home().join("version.json");
|
||||
let content = std::fs::read_to_string(&version_path).ok()?;
|
||||
let gv: GrokVersion = serde_json::from_str(&content).ok()?;
|
||||
let gv: VersionCache = serde_json::from_str(&content).ok()?;
|
||||
gv.stable_version
|
||||
}
|
||||
|
||||
@@ -575,7 +467,7 @@ mod tests {
|
||||
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);
|
||||
let v = VersionCache::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)."
|
||||
@@ -583,41 +475,140 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Disk-version probe: parsing the version out of the managed install's
|
||||
/// symlink-target file name (`grok-<version>-<platform>`).
|
||||
/// symlink-target file name (`kigi-<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")),
|
||||
("kigi-0.2.46-darwin-arm64", Some("0.2.46")),
|
||||
("kigi-0.1.220-linux-x86_64", Some("0.1.220")),
|
||||
("kigi-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
|
||||
("kigi-0.1.220-alpha.4-linux-x86_64", Some("0.1.220-alpha.4")),
|
||||
("kigi-0.1.220-alpha.4", Some("0.1.220-alpha.4")), // no platform suffix
|
||||
("kigi-garbage-darwin-arm64", None), // unparseable version
|
||||
("kigi-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
|
||||
("kigi-latest", None), // symlink alias, not a version
|
||||
("kigi", None), // bare name
|
||||
("", None),
|
||||
// Release archives must never parse as versioned binaries, or
|
||||
// cleanup would treat them as installable versions.
|
||||
("kigi-0.1.0-aarch64-apple-darwin.tar.gz", None),
|
||||
("kigi-0.1.0-x86_64-pc-windows-msvc.zip", None),
|
||||
];
|
||||
for (name, expected) in cases {
|
||||
assert_eq!(
|
||||
version_from_versioned_binary_name(name, "grok").as_deref(),
|
||||
version_from_versioned_binary_name(name, "kigi").as_deref(),
|
||||
*expected,
|
||||
"version_from_versioned_binary_name({name:?})"
|
||||
);
|
||||
}
|
||||
|
||||
// bin_prefix discrimination: the pager binary parses under its own
|
||||
// prefix but not under "grok".
|
||||
// bin_prefix discrimination: a differently-prefixed binary parses
|
||||
// under its own prefix but not under "kigi".
|
||||
assert_eq!(
|
||||
version_from_versioned_binary_name("grok-pager-0.1.5-darwin-arm64", "grok-pager")
|
||||
version_from_versioned_binary_name("kigi-pager-0.1.5-darwin-arm64", "kigi-pager")
|
||||
.as_deref(),
|
||||
Some("0.1.5")
|
||||
);
|
||||
assert_eq!(
|
||||
version_from_versioned_binary_name("kigi-pager-0.1.5-darwin-arm64", "kigi"),
|
||||
None,
|
||||
"\"pager\" is not a version"
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// GitHub release JSON — wire-shape invariants
|
||||
//
|
||||
// Fixtures mirror the real GitHub REST API response for
|
||||
// GET /repos/{owner}/{repo}/releases/latest:
|
||||
// https://docs.github.com/en/rest/releases/releases#get-the-latest-release
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_release_json_parses_github_wire_shape() {
|
||||
let json = r#"{
|
||||
"url": "https://api.github.com/repos/ZacharyZhang-NY/Kigi-CLI/releases/1",
|
||||
"tag_name": "v0.1.0",
|
||||
"name": "Kigi 0.1.0",
|
||||
"draft": false,
|
||||
"prerelease": false,
|
||||
"assets": [
|
||||
{
|
||||
"name": "kigi-0.1.0-aarch64-apple-darwin.tar.gz",
|
||||
"browser_download_url": "https://github.com/ZacharyZhang-NY/Kigi-CLI/releases/download/v0.1.0/kigi-0.1.0-aarch64-apple-darwin.tar.gz",
|
||||
"size": 123,
|
||||
"content_type": "application/gzip"
|
||||
},
|
||||
{
|
||||
"name": "SHA256SUMS",
|
||||
"browser_download_url": "https://github.com/ZacharyZhang-NY/Kigi-CLI/releases/download/v0.1.0/SHA256SUMS"
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
let r: Release = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(r.tag_name, "v0.1.0");
|
||||
assert_eq!(r.version().unwrap(), "0.1.0");
|
||||
assert!(!r.draft);
|
||||
assert!(!r.prerelease);
|
||||
assert_eq!(r.assets.len(), 2);
|
||||
let asset = r.asset("kigi-0.1.0-aarch64-apple-darwin.tar.gz").unwrap();
|
||||
assert_eq!(
|
||||
asset.browser_download_url,
|
||||
"https://github.com/ZacharyZhang-NY/Kigi-CLI/releases/download/v0.1.0/kigi-0.1.0-aarch64-apple-darwin.tar.gz"
|
||||
);
|
||||
assert_eq!(
|
||||
r.asset("SHA256SUMS").unwrap().name,
|
||||
"SHA256SUMS",
|
||||
"checksum asset resolved by exact name"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_release_version_accepts_bare_and_v_prefixed_tags() {
|
||||
let mk = |tag: &str| Release {
|
||||
tag_name: tag.to_string(),
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
assets: vec![],
|
||||
};
|
||||
assert_eq!(mk("v0.1.0").version().unwrap(), "0.1.0");
|
||||
assert_eq!(mk("0.1.0").version().unwrap(), "0.1.0");
|
||||
assert_eq!(mk("v0.2.0-alpha.3").version().unwrap(), "0.2.0-alpha.3");
|
||||
assert!(mk("release-1").version().is_err());
|
||||
assert!(mk("").version().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_release_missing_asset_error_lists_available() {
|
||||
let r = Release {
|
||||
tag_name: "v0.1.0".to_string(),
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
assets: vec![ReleaseAsset {
|
||||
name: "SHA256SUMS".to_string(),
|
||||
browser_download_url: "https://example.test/SHA256SUMS".to_string(),
|
||||
}],
|
||||
};
|
||||
let err = r
|
||||
.asset("kigi-0.1.0-x86_64-unknown-linux-gnu.tar.gz")
|
||||
.unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("no asset named"), "msg: {msg}");
|
||||
assert!(msg.contains("SHA256SUMS"), "must list available: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_release_defaults_for_absent_optional_fields() {
|
||||
// Minimal object: only tag_name. draft/prerelease default false,
|
||||
// assets default empty (serde(default)).
|
||||
let r: Release = serde_json::from_str(r#"{"tag_name":"v0.1.0"}"#).unwrap();
|
||||
assert!(!r.draft && !r.prerelease && r.assets.is_empty());
|
||||
// tag_name is required.
|
||||
assert!(serde_json::from_str::<Release>(r#"{"assets":[]}"#).is_err());
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
@@ -663,59 +654,22 @@ mod tests {
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// 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
|
||||
// VersionCache 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();
|
||||
let v: VersionCache = 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 new = VersionCache::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();
|
||||
let parsed: VersionCache = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.version, "0.2.5");
|
||||
assert_eq!(parsed.stable_version.as_deref(), Some("0.2.3"));
|
||||
|
||||
@@ -730,11 +684,11 @@ mod tests {
|
||||
|
||||
// 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());
|
||||
assert!(serde_json::from_str::<VersionCache>(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());
|
||||
assert!(serde_json::from_str::<VersionCache>(missing).is_err());
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
@@ -744,7 +698,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_is_fresh_ttl_boundaries() {
|
||||
let now = time::OffsetDateTime::now_utc();
|
||||
let v = GrokVersion::new("0.1.200".to_string(), None, now);
|
||||
let v = VersionCache::new("0.1.200".to_string(), None, now);
|
||||
|
||||
// Within TTL → fresh
|
||||
assert!(v.is_fresh(now, Duration::from_secs(60)));
|
||||
@@ -760,7 +714,7 @@ mod tests {
|
||||
assert!(!v.is_fresh(now, Duration::ZERO));
|
||||
|
||||
// Malformed timestamp → not fresh
|
||||
let bad = GrokVersion {
|
||||
let bad = VersionCache {
|
||||
version: "0.1.200".to_string(),
|
||||
stable_version: None,
|
||||
checked_at: "not-rfc3339".to_string(),
|
||||
|
||||
Reference in New Issue
Block a user