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:
2026-07-18 00:54:53 -04:00
parent 5e4e24db99
commit 86e3724310
57 changed files with 27431 additions and 27787 deletions
+19 -2
View File
@@ -32,10 +32,27 @@ rustflags = [
] ]
[target.x86_64-unknown-linux-gnu] [target.x86_64-unknown-linux-gnu]
rustflags = ["-C", "force-unwind-tables=yes"] rustflags = [
"-C", "force-unwind-tables=yes",
# Binary hardening (SECURITY): Full RELRO + non-executable stack
# These flags prevent: GOT overwrite attacks, lazy binding exploits, stack shellcode
# IMPORTANT: If changing build system (e.g. from cargo to Bazel-only),
# ensure equivalent linker hardening is applied to production binaries.
"-C", "link-arg=-Wl,-z,relro,-z,now,-z,noexecstack",
]
# NOTE: no target-cpu pin. Release artifacts (PRD F8) must run on ordinary
# aarch64 hardware; the default baseline for the target is the portable
# choice. (The upstream grok-build tree pinned target-cpu=neoverse-v2 here —
# a server-CPU tune from xAI's build fleet that would emit instructions
# unavailable on common Cortex-A cores.)
[target.aarch64-unknown-linux-gnu] [target.aarch64-unknown-linux-gnu]
rustflags = ["-C", "target-cpu=neoverse-v2", "-C", "force-unwind-tables=yes"] rustflags = [
"-C", "force-unwind-tables=yes",
# Binary hardening (SECURITY): Full RELRO + non-executable stack — see
# x86_64-unknown-linux-gnu above.
"-C", "link-arg=-Wl,-z,relro,-z,now,-z,noexecstack",
]
[target.x86_64-unknown-linux-musl] [target.x86_64-unknown-linux-musl]
rustflags = [ rustflags = [
+134
View File
@@ -0,0 +1,134 @@
name: Release
# PRD F8: on tag push v*, build the single-file kigi binary for the five
# supported targets, package per-target archives named
# kigi-<version>-<target-triple>.{tar.gz|zip} (binary + LICENSE + NOTICE +
# THIRD-PARTY-NOTICES), generate SHA256SUMS, and publish everything as a
# GitHub Release. install.sh / install.ps1 and the in-app self-updater
# (kigi-update) both resolve these exact asset names — keep the naming in
# lockstep with auto_update::release_asset_name().
on:
push:
tags: ["v*"]
permissions:
contents: write
env:
CARGO_TERM_COLOR: always
jobs:
build:
name: build (${{ matrix.target }})
strategy:
fail-fast: false
matrix:
include:
- target: aarch64-apple-darwin
os: macos-14
# macos-14 runners are arm64; the x86_64 slice is a cross-compile
# against the same SDK (macos-13 Intel runners are deprecated).
- target: x86_64-apple-darwin
os: macos-14
- target: x86_64-unknown-linux-gnu
os: ubuntu-24.04
- target: aarch64-unknown-linux-gnu
os: ubuntu-24.04-arm
- target: x86_64-pc-windows-msvc
os: windows-2022
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Check tag matches workspace version
shell: bash
run: |
tag_version="${GITHUB_REF_NAME#v}"
cargo_version="$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -n 1)"
if [ "$tag_version" != "$cargo_version" ]; then
echo "Tag $GITHUB_REF_NAME does not match workspace version $cargo_version" >&2
exit 1
fi
- name: Install toolchain (rust-toolchain.toml)
run: rustup show
- name: Add build target
run: rustup target add ${{ matrix.target }}
- name: Install dotslash (protoc launcher)
run: cargo install dotslash --locked
- uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.target }}
- name: Build kigi (release-dist)
run: cargo build --profile release-dist -p kigi-bin --locked --target ${{ matrix.target }}
- name: Package archive (tar.gz)
if: runner.os != 'Windows'
shell: bash
run: |
version="${GITHUB_REF_NAME#v}"
archive="kigi-${version}-${{ matrix.target }}.tar.gz"
staging="$(mktemp -d)"
cp "target/${{ matrix.target }}/release-dist/kigi" "$staging/kigi"
cp LICENSE "$staging/"
[ -f NOTICE ] && cp NOTICE "$staging/"
for f in THIRD-PARTY-NOTICES THIRD-PARTY-NOTICES.md; do
[ -f "$f" ] && cp "$f" "$staging/"
done
tar -C "$staging" -czf "$archive" .
shasum -a 256 "$archive" || sha256sum "$archive"
echo "ARCHIVE=$archive" >> "$GITHUB_ENV"
- name: Package archive (zip)
if: runner.os == 'Windows'
shell: pwsh
run: |
$version = $env:GITHUB_REF_NAME.TrimStart("v")
$archive = "kigi-$version-${{ matrix.target }}.zip"
$staging = New-Item -ItemType Directory -Path (Join-Path $env:RUNNER_TEMP "staging")
Copy-Item "target/${{ matrix.target }}/release-dist/kigi.exe" (Join-Path $staging "kigi.exe")
Copy-Item LICENSE $staging
if (Test-Path NOTICE) { Copy-Item NOTICE $staging }
foreach ($f in @("THIRD-PARTY-NOTICES", "THIRD-PARTY-NOTICES.md")) {
if (Test-Path $f) { Copy-Item $f $staging }
}
Compress-Archive -Path (Join-Path $staging "*") -DestinationPath $archive
Get-FileHash -Algorithm SHA256 $archive
Add-Content -Path $env:GITHUB_ENV -Value "ARCHIVE=$archive"
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ${{ env.ARCHIVE }}
path: ${{ env.ARCHIVE }}
if-no-files-found: error
release:
name: publish GitHub Release
needs: build
runs-on: ubuntu-24.04
steps:
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: Generate SHA256SUMS
working-directory: dist
run: |
ls -l
sha256sum kigi-* > SHA256SUMS
cat SHA256SUMS
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: dist/*
prerelease: ${{ contains(github.ref_name, '-') }}
generate_release_notes: true
fail_on_unmatched_files: true
Generated
+4
View File
@@ -6693,6 +6693,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"dunce", "dunce",
"flate2",
"futures", "futures",
"indicatif", "indicatif",
"kigi-env", "kigi-env",
@@ -6704,12 +6705,15 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"serial_test", "serial_test",
"sha2 0.10.9",
"tar",
"tempfile", "tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
"time", "time",
"tokio", "tokio",
"tracing", "tracing",
"wiremock", "wiremock",
"zip 8.6.0",
] ]
[[package]] [[package]]
+1
View File
@@ -295,6 +295,7 @@ kigi-tool-runtime = { path = "crates/common/kigi-tool-runtime" }
kigi-tool-types = { path = "crates/common/kigi-tool-types" } kigi-tool-types = { path = "crates/common/kigi-tool-types" }
kigi-tty-utils = { path = "crates/codegen/kigi-tty-utils" } kigi-tty-utils = { path = "crates/codegen/kigi-tty-utils" }
zbus = { version = "5" } zbus = { version = "5" }
zip = { version = "8", default-features = false, features = ["deflate"] }
zstd = "0.13" zstd = "0.13"
[profile.release] [profile.release]
+1 -1
View File
@@ -87,6 +87,6 @@ launcher at `bin/protoc`; install dotslash (`brew install dotslash` or
## License ## License
Apache-2.0. See [LICENSE](LICENSE), [NOTICE](NOTICE), and Apache-2.0. See [LICENSE](LICENSE), [NOTICE](NOTICE), and
[THIRD-PARTY-NOTICES](THIRD-PARTY-NOTICES). Code ported from [THIRD-PARTY-NOTICES](THIRD-PARTY-NOTICES.md). Code ported from
openai/codex and sst/opencode is documented in openai/codex and sst/opencode is documented in
[crates/codegen/kigi-tools/THIRD_PARTY_NOTICES.md](crates/codegen/kigi-tools/THIRD_PARTY_NOTICES.md). [crates/codegen/kigi-tools/THIRD_PARTY_NOTICES.md](crates/codegen/kigi-tools/THIRD_PARTY_NOTICES.md).
-18898
View File
File diff suppressed because it is too large Load Diff
+23906
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
# Third-Party Notices
This file lists the third-party Rust crates distributed as part of the
`kigi` binary, grouped by license, with the full license texts and the
crates each text applies to. Generated by
[cargo-about](https://github.com/EmbarkStudios/cargo-about) from
`about.toml`; regenerate with:
```
cargo about generate about.hbs -o THIRD-PARTY-NOTICES.md
```
## Overview
{{#each overview}}
- {{{name}}} ({{count}} crate{{#unless (eq count 1)}}s{{/unless}})
{{/each}}
## Licenses
{{#each licenses}}
### {{{name}}}
Applies to:
{{#each used_by}}
- [{{{crate.name}}} {{{crate.version}}}]({{#if crate.repository}}{{{crate.repository}}}{{else}}https://crates.io/crates/{{{crate.name}}}{{/if}})
{{/each}}
```
{{{text}}}
```
{{/each}}
+45
View File
@@ -0,0 +1,45 @@
# cargo-about configuration (PRD F8 / release packaging).
#
# Generates THIRD-PARTY-NOTICES.md at the repo root:
#
# cargo about generate about.hbs -o THIRD-PARTY-NOTICES.md
#
# Regeneration is a release-checklist step (docs/RELEASE.md), not a CI gate.
# The accepted list mirrors deny.toml's [licenses].allow — keep them in sync.
accepted = [
"Apache-2.0",
"Apache-2.0 WITH LLVM-exception",
"BSD-2-Clause",
"BSD-3-Clause",
"BSL-1.0",
"CC0-1.0",
"CDLA-Permissive-2.0",
# In the dependency graph but not in deny.toml's allow list (deny only
# gates advisories in CI today): colored_json (EPL-2.0), finl_unicode /
# wezterm-bidi (Unicode-DFS-2016), terminfo (WTFPL).
"EPL-2.0",
"ISC",
"MIT",
"MIT-0",
"MPL-2.0",
"OpenSSL",
"Unicode-3.0",
"Unicode-DFS-2016",
"WTFPL",
"Zlib",
]
# Notices cover what we ship: the kigi binary's dependency graph.
targets = [
"aarch64-apple-darwin",
"x86_64-apple-darwin",
"aarch64-unknown-linux-gnu",
"x86_64-unknown-linux-gnu",
"x86_64-pc-windows-msvc",
]
# ring bundles ISC/MIT/OpenSSL-derived texts in a single LICENSE file that
# SPDX expression parsing cannot classify on its own.
[ring]
accepted = ["ISC", "MIT", "OpenSSL"]
-3
View File
@@ -1655,9 +1655,6 @@ fn build_update_config() -> UpdateConfig {
kigi_shell::agent::config::EndpointsConfig::default().deployment_key; kigi_shell::agent::config::EndpointsConfig::default().deployment_key;
} }
}); });
config.npm_registry = std::env::var(obfstr::obfstr!("KIGI_NPM_REGISTRY"))
.ok()
.or_else(kigi_shell::util::config::load_npm_registry_sync);
if let Ok(root) = kigi_shell::config::load_effective_config_disk_only() if let Ok(root) = kigi_shell::config::load_effective_config_disk_only()
&& let Some(ch) = kigi_shell::util::config::channel_from_toml_opt(&root) && let Some(ch) = kigi_shell::util::config::channel_from_toml_opt(&root)
{ {
+14 -4
View File
@@ -29,6 +29,9 @@ pub const PRODUCTION_ENDPOINTS: KigiEndpoints = KigiEndpoints {
pub const CODE_BASE_URL_ENV: &str = "KIGI_CODE_BASE_URL"; pub const CODE_BASE_URL_ENV: &str = "KIGI_CODE_BASE_URL";
/// Env var overriding [`oauth_host`] (PRD F1). /// Env var overriding [`oauth_host`] (PRD F1).
pub const OAUTH_HOST_ENV: &str = "KIGI_OAUTH_HOST"; pub const OAUTH_HOST_ENV: &str = "KIGI_OAUTH_HOST";
/// Env var overriding [`update_base_url`] (PRD F8). Points the self-updater
/// at an alternate GitHub-Releases-shaped API (mirrors, tests).
pub const UPDATE_BASE_URL_ENV: &str = "KIGI_UPDATE_BASE_URL";
fn resolve(var: &str, compiled: &'static str) -> String { fn resolve(var: &str, compiled: &'static str) -> String {
match std::env::var(var) { match std::env::var(var) {
@@ -49,10 +52,11 @@ pub fn oauth_host() -> String {
resolve(OAUTH_HOST_ENV, PRODUCTION_ENDPOINTS.oauth_host) resolve(OAUTH_HOST_ENV, PRODUCTION_ENDPOINTS.oauth_host)
} }
/// GitHub Releases API endpoint for the self-updater. Compile-time constant; /// GitHub Releases API endpoint for the self-updater:
/// the updater's channel/rollback semantics layer on top of it. /// `KIGI_UPDATE_BASE_URL` override when set, else the compiled production
pub fn update_base_url() -> &'static str { /// endpoint. The updater's channel/rollback semantics layer on top of it.
PRODUCTION_ENDPOINTS.update_base_url pub fn update_base_url() -> String {
resolve(UPDATE_BASE_URL_ENV, PRODUCTION_ENDPOINTS.update_base_url)
} }
/// Subscription upgrade page shown in rate-limit and upsell surfaces. /// Subscription upgrade page shown in rate-limit and upsell surfaces.
@@ -144,6 +148,12 @@ mod tests {
drop(_g); drop(_g);
let _g2 = EnvVarGuard::set(OAUTH_HOST_ENV, "https://auth.example.test"); let _g2 = EnvVarGuard::set(OAUTH_HOST_ENV, "https://auth.example.test");
assert_eq!(oauth_host(), "https://auth.example.test"); assert_eq!(oauth_host(), "https://auth.example.test");
drop(_g2);
let _g3 = EnvVarGuard::set(UPDATE_BASE_URL_ENV, "http://127.0.0.1:1/releases");
assert_eq!(update_base_url(), "http://127.0.0.1:1/releases");
drop(_g3);
let _unset = EnvVarGuard::remove(UPDATE_BASE_URL_ENV);
assert_eq!(update_base_url(), PRODUCTION_ENDPOINTS.update_base_url);
} }
#[test] #[test]
@@ -1,4 +0,0 @@
bin/*.br
bin/grok
bin/grok.exe
THIRD_PARTY_NOTICES.md
@@ -1,11 +0,0 @@
# @xai-official/grok-darwin-arm64
Platform-specific binary for [`@xai-official/grok`](https://www.npmjs.com/package/@xai-official/grok) on darwin-arm64.
Do not install this package directly. Install the main package instead:
```sh
npm install -g @xai-official/grok
```
The main package will automatically pull the correct binary for your platform via `optionalDependencies`.
@@ -1,19 +0,0 @@
{
"name": "@xai-official/grok-darwin-arm64",
"version": "0.2.0-dev",
"description": "darwin-arm64 binary for @xai-official/grok. Do not install directly; install @xai-official/grok instead.",
"license": "Apache-2.0",
"files": [
"bin/",
"THIRD_PARTY_NOTICES.md"
],
"os": [
"darwin"
],
"cpu": [
"arm64"
],
"publishConfig": {
"access": "public"
}
}
@@ -1,4 +0,0 @@
bin/*.br
bin/grok
bin/grok.exe
THIRD_PARTY_NOTICES.md
@@ -1,11 +0,0 @@
# @xai-official/grok-darwin-x64
Platform-specific binary for [`@xai-official/grok`](https://www.npmjs.com/package/@xai-official/grok) on darwin-x64.
Do not install this package directly. Install the main package instead:
```sh
npm install -g @xai-official/grok
```
The main package will automatically pull the correct binary for your platform via `optionalDependencies`.
@@ -1,19 +0,0 @@
{
"name": "@xai-official/grok-darwin-x64",
"version": "0.2.0-dev",
"description": "darwin-x64 binary for @xai-official/grok. Do not install directly; install @xai-official/grok instead.",
"license": "Apache-2.0",
"files": [
"bin/",
"THIRD_PARTY_NOTICES.md"
],
"os": [
"darwin"
],
"cpu": [
"x64"
],
"publishConfig": {
"access": "public"
}
}
@@ -1,4 +0,0 @@
bin/*.br
bin/grok
bin/grok.exe
THIRD_PARTY_NOTICES.md
@@ -1,11 +0,0 @@
# @xai-official/grok-linux-arm64
Platform-specific binary for [`@xai-official/grok`](https://www.npmjs.com/package/@xai-official/grok) on linux-arm64.
Do not install this package directly. Install the main package instead:
```sh
npm install -g @xai-official/grok
```
The main package will automatically pull the correct binary for your platform via `optionalDependencies`.
@@ -1,19 +0,0 @@
{
"name": "@xai-official/grok-linux-arm64",
"version": "0.2.0-dev",
"description": "linux-arm64 binary for @xai-official/grok. Do not install directly; install @xai-official/grok instead.",
"license": "Apache-2.0",
"files": [
"bin/",
"THIRD_PARTY_NOTICES.md"
],
"os": [
"linux"
],
"cpu": [
"arm64"
],
"publishConfig": {
"access": "public"
}
}
@@ -1,4 +0,0 @@
bin/*.br
bin/grok
bin/grok.exe
THIRD_PARTY_NOTICES.md
@@ -1,11 +0,0 @@
# @xai-official/grok-linux-x64
Platform-specific binary for [`@xai-official/grok`](https://www.npmjs.com/package/@xai-official/grok) on linux-x64.
Do not install this package directly. Install the main package instead:
```sh
npm install -g @xai-official/grok
```
The main package will automatically pull the correct binary for your platform via `optionalDependencies`.
@@ -1,19 +0,0 @@
{
"name": "@xai-official/grok-linux-x64",
"version": "0.2.0-dev",
"description": "linux-x64 binary for @xai-official/grok. Do not install directly; install @xai-official/grok instead.",
"license": "Apache-2.0",
"files": [
"bin/",
"THIRD_PARTY_NOTICES.md"
],
"os": [
"linux"
],
"cpu": [
"x64"
],
"publishConfig": {
"access": "public"
}
}
@@ -1,4 +0,0 @@
bin/*.br
bin/grok
bin/grok.exe
THIRD_PARTY_NOTICES.md
@@ -1,11 +0,0 @@
# @xai-official/grok-win32-arm64
Platform-specific binary for [`@xai-official/grok`](https://www.npmjs.com/package/@xai-official/grok) on win32-arm64.
Do not install this package directly. Install the main package instead:
```sh
npm install -g @xai-official/grok
```
The main package will automatically pull the correct binary for your platform via `optionalDependencies`.
@@ -1,19 +0,0 @@
{
"name": "@xai-official/grok-win32-arm64",
"version": "0.2.0-dev",
"description": "win32-arm64 binary for @xai-official/grok. Do not install directly; install @xai-official/grok instead.",
"license": "Apache-2.0",
"files": [
"bin/",
"THIRD_PARTY_NOTICES.md"
],
"os": [
"win32"
],
"cpu": [
"arm64"
],
"publishConfig": {
"access": "public"
}
}
@@ -1,4 +0,0 @@
bin/*.br
bin/grok
bin/grok.exe
THIRD_PARTY_NOTICES.md
@@ -1,11 +0,0 @@
# @xai-official/grok-win32-x64
Platform-specific binary for [`@xai-official/grok`](https://www.npmjs.com/package/@xai-official/grok) on win32-x64.
Do not install this package directly. Install the main package instead:
```sh
npm install -g @xai-official/grok
```
The main package will automatically pull the correct binary for your platform via `optionalDependencies`.
@@ -1,19 +0,0 @@
{
"name": "@xai-official/grok-win32-x64",
"version": "0.2.0-dev",
"description": "win32-x64 binary for @xai-official/grok. Do not install directly; install @xai-official/grok instead.",
"license": "Apache-2.0",
"files": [
"bin/",
"THIRD_PARTY_NOTICES.md"
],
"os": [
"win32"
],
"cpu": [
"x64"
],
"publishConfig": {
"access": "public"
}
}
@@ -1,61 +0,0 @@
# Grok
Bring Grok into your terminal. Fast, flicker-free CLI built for plans, subagents, and parallel work.
**[Homepage](https://x.ai/cli)** | **[Documentation](https://docs.x.ai/build/overview)**
## Install
```bash
curl -fsSL https://x.ai/cli/install.sh | bash
```
Or install with npm:
```bash
npm i -g @xai-official/grok
```
## Get Started
```bash
# Launch the interactive TUI
grok
# Run a single task
grok -p "Explain this codebase"
```
On first launch, Grok opens your browser to authenticate. For CI or headless environments, use an API key from [console.x.ai](https://console.x.ai):
```bash
export XAI_API_KEY="xai-..."
```
## Update
```bash
grok update
```
Or if installed via npm:
```bash
npm i -g @xai-official/grok@latest
```
## Supported Platforms
| Platform | Architecture |
|---|---|
| macOS | Apple Silicon (arm64) |
| Linux | x86_64, arm64 |
| Windows | x86_64 |
## Documentation
For full documentation including configuration, MCP servers, custom models, headless mode, agent mode, and more, visit [docs.x.ai/build/overview](https://docs.x.ai/build/overview).
## Feedback
Run `/feedback` inside Grok to report issues or send feedback directly.
-136
View File
@@ -1,136 +0,0 @@
#!/usr/bin/env node
// Thin trampoline: resolves the grok binary from the matching per-platform
// optional dependency package and execs it.
//
// Falls back to bootstrapping the canonical ~/.grok/bin/grok-<version> symlink
// layout if postinstall hasn't run (e.g. npx, or postinstall failure).
//
// Binary location strategy (in priority order):
// 1. ~/.grok/bin/grok — canonical versioned symlink (postinstall.js)
// 2. @xai-official/grok-<platform>/bin/grok[.exe] — decompressed sibling
// 3. @xai-official/grok-<platform>/bin/grok[.exe].br — brotli-compressed
//
// Per-platform binaries are shipped brotli-compressed to stay well under
// npm's ~200 MB tarball ceiling. See sibling packages @xai-official/grok-*.
const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs');
const os = require('os');
const zlib = require('zlib');
const pkgName = '@xai-official/grok';
const IS_WINDOWS = process.platform === 'win32';
const EXE = IS_WINDOWS ? '.exe' : '';
const BIN_NAME = `grok${EXE}`;
const CANONICAL_DIR = path.join(os.homedir(), '.grok', 'bin');
const CANONICAL_PATH = path.join(CANONICAL_DIR, BIN_NAME);
function readLocalVersion() {
try { return require('../package.json').version; } catch { return undefined; }
}
// Resolve the per-platform sibling package's directory. Returns null if the
// matching optional dependency wasn't installed (unsupported platform, or
// npm refused to install optionalDependencies).
function resolvePlatformPackageDir() {
const platformPkg = `@xai-official/grok-${process.platform}-${process.arch}`;
try {
return path.dirname(require.resolve(`${platformPkg}/package.json`));
} catch {
return null;
}
}
// Decompress a brotli-compressed binary to a sibling path. Atomic via tmp+rename.
function decompressBrotli(brPath, outPath) {
const compressed = fs.readFileSync(brPath);
const decompressed = zlib.brotliDecompressSync(compressed);
const tmp = outPath + `.tmp.${process.pid}`;
fs.writeFileSync(tmp, decompressed);
if (!IS_WINDOWS) fs.chmodSync(tmp, 0o755);
try { fs.renameSync(tmp, outPath); } catch {}
}
// Bootstrap the canonical versioned-symlink layout from a source binary.
// Returns the canonical path on success, or the source path on failure.
function bootstrapCanonical(sourceBinPath, version) {
try {
fs.mkdirSync(CANONICAL_DIR, { recursive: true });
const versionedName = `grok-${version}${EXE}`;
const versionedPath = path.join(CANONICAL_DIR, versionedName);
if (!fs.existsSync(versionedPath)) {
const tmpPath = versionedPath + `.tmp.${process.pid}`;
fs.copyFileSync(sourceBinPath, tmpPath);
if (!IS_WINDOWS) fs.chmodSync(tmpPath, 0o755);
fs.renameSync(tmpPath, versionedPath);
}
if (IS_WINDOWS) {
const oldPath = CANONICAL_PATH + '.old';
try { fs.unlinkSync(oldPath); } catch {}
try {
try { fs.unlinkSync(CANONICAL_PATH); } catch {}
fs.copyFileSync(versionedPath, CANONICAL_PATH);
} catch {
fs.renameSync(CANONICAL_PATH, oldPath);
try {
fs.copyFileSync(versionedPath, CANONICAL_PATH);
} catch {
try { fs.renameSync(oldPath, CANONICAL_PATH); } catch {}
throw new Error('locked');
}
}
} else {
const tmpLink = CANONICAL_PATH + `.link.${process.pid}`;
try { fs.unlinkSync(tmpLink); } catch {}
fs.symlinkSync(versionedName, tmpLink);
fs.renameSync(tmpLink, CANONICAL_PATH);
}
return CANONICAL_PATH;
} catch {
return sourceBinPath;
}
}
function resolveBinary() {
if (fs.existsSync(CANONICAL_PATH)) return CANONICAL_PATH;
const platformDir = resolvePlatformPackageDir();
if (!platformDir) {
console.error(`${pkgName}: no platform binary installed for ${process.platform}-${process.arch}.`);
console.error(` Expected sibling package @xai-official/grok-${process.platform}-${process.arch}.`);
console.error(` This usually means npm skipped optionalDependencies (e.g. --no-optional)`);
console.error(` or the platform is not supported.`);
process.exit(1);
}
const rawPath = path.join(platformDir, 'bin', BIN_NAME);
const brPath = rawPath + '.br';
// Decompress on first use if needed (atomic via tmp+rename).
if (!fs.existsSync(rawPath)) {
if (fs.existsSync(brPath)) {
decompressBrotli(brPath, rawPath);
}
}
if (!fs.existsSync(rawPath)) {
console.error(`${pkgName}: missing binary at ${rawPath}`);
process.exit(1);
}
const version = readLocalVersion();
if (version) {
return bootstrapCanonical(rawPath, version);
}
return rawPath;
}
const execPath = resolveBinary();
const childEnv = { ...process.env, KIGI_MANAGED_BY_NPM: '1' };
const child = spawn(execPath, process.argv.slice(2), { stdio: 'inherit', env: childEnv });
child.on('exit', (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
} else {
process.exit(code ?? 0);
}
});
@@ -1,225 +0,0 @@
#!/usr/bin/env node
// Runs once after npm install/update. Reads the grok binary from the
// matching per-platform optional dependency (@xai-official/grok-<platform>)
// and installs it to ~/.grok/bin/ using versioned filenames:
//
// Unix: grok-<version> + grok (symlink)
// Windows: grok-<version>.exe + grok.exe (copy)
//
// Versioned files ensure running processes are never disrupted on macOS
// (replacing a binary that a running process has mmap'd causes SIGKILL
// because the kernel can no longer verify the code signature).
const path = require('path');
const fs = require('fs');
const os = require('os');
const zlib = require('zlib');
const { execSync } = require('child_process');
const TOML = require('@iarna/toml');
const CANONICAL_DIR = path.join(os.homedir(), '.grok', 'bin');
const key = `${process.platform}-${process.arch}`;
const SUPPORTED = new Set([
'darwin-arm64',
'darwin-x64',
'linux-x64',
'linux-arm64',
'win32-x64',
'win32-arm64',
]);
if (!SUPPORTED.has(key)) {
console.error(`@xai-official/grok: unsupported platform ${key}`);
process.exit(0);
}
// Resolve the per-platform sibling package's directory. The matching
// optionalDependency is installed by npm based on `os`/`cpu` filters; the
// other five are silently skipped. If the matching one is missing, npm was
// likely invoked with --no-optional or the platform is unsupported.
function resolvePlatformPackageDir() {
const platformPkg = `@xai-official/grok-${key}`;
try {
return path.dirname(require.resolve(`${platformPkg}/package.json`));
} catch {
return null;
}
}
let version;
try { version = require('../package.json').version; } catch {}
if (!version) {
console.error('@xai-official/grok: unable to determine version');
process.exit(0);
}
const IS_WINDOWS = process.platform === 'win32';
const EXE = IS_WINDOWS ? '.exe' : '';
fs.mkdirSync(CANONICAL_DIR, { recursive: true });
// Install a vendored binary: versioned filename + symlink (Unix) or copy (Windows).
// Binaries are shipped brotli-compressed in the per-platform npm tarball to keep
// each sub-package well under npm's ~200 MB tarball limit. This function
// decompresses them before installing into the canonical layout.
function installBinary(binName, sourceDir, vendorSubpath) {
const brPath = path.join(sourceDir, 'bin', vendorSubpath + '.br');
const rawPath = path.join(sourceDir, 'bin', vendorSubpath);
let vendoredBinPath;
if (fs.existsSync(brPath)) {
const compressed = fs.readFileSync(brPath);
const decompressed = zlib.brotliDecompressSync(compressed);
vendoredBinPath = rawPath;
fs.writeFileSync(vendoredBinPath, decompressed);
if (!IS_WINDOWS) fs.chmodSync(vendoredBinPath, 0o755);
try { fs.unlinkSync(brPath); } catch {}
} else if (fs.existsSync(rawPath)) {
vendoredBinPath = rawPath;
} else {
console.error(`@xai-official/grok: missing binary at ${brPath}`);
return false;
}
const versionedName = `${binName}-${version}${EXE}`;
const versionedPath = path.join(CANONICAL_DIR, versionedName);
const canonicalName = `${binName}${EXE}`;
const canonicalPath = path.join(CANONICAL_DIR, canonicalName);
// Only copy if this exact version isn't already installed.
if (!fs.existsSync(versionedPath)) {
const tmpPath = versionedPath + `.tmp.${process.pid}`;
try {
fs.copyFileSync(vendoredBinPath, tmpPath);
if (!IS_WINDOWS) fs.chmodSync(tmpPath, 0o755);
fs.renameSync(tmpPath, versionedPath);
} finally {
try { fs.unlinkSync(tmpPath); } catch {}
}
}
if (IS_WINDOWS) {
// Symlinks need elevation on Windows; copy instead. If the exe is
// locked by a running process, rename it aside then retry.
const oldPath = canonicalPath + '.old';
try { fs.unlinkSync(oldPath); } catch {} // stale backup from prior update
try {
try { fs.unlinkSync(canonicalPath); } catch {}
fs.copyFileSync(versionedPath, canonicalPath);
} catch (e) {
try {
fs.renameSync(canonicalPath, oldPath);
try {
fs.copyFileSync(versionedPath, canonicalPath);
} catch (copyErr) {
// Rollback: restore the old binary so the install isn't broken.
try { fs.renameSync(oldPath, canonicalPath); } catch {}
throw copyErr;
}
} catch (e2) {
console.error(`@xai-official/grok: failed to update ${canonicalPath}: ${e2.message}`);
console.error('Close all running grok processes and try again.');
return false;
}
}
} else {
// Atomic symlink swap.
const tmpLink = canonicalPath + `.link.${process.pid}`;
try { fs.unlinkSync(tmpLink); } catch {}
fs.symlinkSync(versionedName, tmpLink);
fs.renameSync(tmpLink, canonicalPath);
}
console.log(`${binName} ${version} installed to ${canonicalPath} -> ${versionedName}`);
return true;
}
// Best-effort cleanup of old versioned binaries for a given binary name.
// Keeps the current version and the previous one (in case a process is still
// running the old binary and hasn't fully loaded all pages yet).
// Uses an exact prefix match + hyphen + digit to avoid grok-* matching grok-pager-*.
function cleanupOldVersions(binName) {
try {
const prefix = `${binName}-`;
const currentVersioned = `${binName}-${version}${EXE}`;
const entries = fs.readdirSync(CANONICAL_DIR);
const versionedBinaries = entries
.filter(e => {
if (!e.startsWith(prefix)) return false;
if (e.includes('.tmp.') || e.includes('.link.')) return false;
if (e === currentVersioned) return false;
const suffix = e.slice(prefix.length);
return /^\d/.test(suffix);
})
.sort((a, b) => {
const pa = a.slice(prefix.length).split('.').map(Number);
const pb = b.slice(prefix.length).split('.').map(Number);
for (let i = 0; i < 3; i++) {
if ((pa[i] || 0) !== (pb[i] || 0)) return (pb[i] || 0) - (pa[i] || 0);
}
return 0;
});
for (const old of versionedBinaries.slice(1)) {
try { fs.unlinkSync(path.join(CANONICAL_DIR, old)); } catch {}
}
} catch {}
}
const platformDir = resolvePlatformPackageDir();
if (!platformDir) {
console.error(`@xai-official/grok: platform package @xai-official/grok-${key} not installed.`);
console.error(' This usually means npm was invoked with --no-optional, or the install failed.');
console.error(' Try: npm install -g @xai-official/grok');
process.exit(0);
}
installBinary('grok', platformDir, `grok${EXE}`);
cleanupOldVersions('grok');
cleanupOldVersions('grok-pager');
// Write installer config
const configDir = path.join(os.homedir(), '.grok');
const configPath = path.join(configDir, 'config.toml');
let obj = {};
try { obj = TOML.parse(fs.readFileSync(configPath, 'utf8')); } catch { }
obj.cli ??= {};
obj.cli.installer = 'npm';
// Persist the npm registry so `grok update` and the trampoline use the same one.
const npmRegistry = process.env.KIGI_NPM_REGISTRY
|| (() => {
try {
const resolved = execSync(
'npm config get @xai-official:registry',
{ encoding: 'utf8', timeout: 5000 }
).trim();
if (resolved && resolved !== 'undefined') return resolved;
} catch {}
return null;
})();
if (npmRegistry) {
obj.cli.npm_registry = npmRegistry;
}
fs.writeFileSync(configPath, TOML.stringify(obj), 'utf8');
// Shell completions: print setup hints (no silent shell config mutation).
// Set KIGI_INSTALL_COMPLETIONS=1 to auto-generate to ~/.grok/completions.
const KIGI_PATH = path.join(CANONICAL_DIR, `grok${EXE}`);
if (process.env.KIGI_INSTALL_COMPLETIONS === '1' && !IS_WINDOWS) {
try {
const { spawnSync } = require('child_process');
const completionsDir = path.join(os.homedir(), '.grok', 'completions');
const bashPath = path.join(completionsDir, 'bash', 'grok.bash');
const zshPath = path.join(completionsDir, 'zsh', '_grok');
fs.mkdirSync(path.dirname(bashPath), { recursive: true });
fs.mkdirSync(path.dirname(zshPath), { recursive: true });
const bashRes = spawnSync(KIGI_PATH, ['completions', 'bash'], { encoding: 'utf8' });
if (bashRes.status === 0) fs.writeFileSync(bashPath, bashRes.stdout);
const zshRes = spawnSync(KIGI_PATH, ['completions', 'zsh'], { encoding: 'utf8' });
if (zshRes.status === 0) fs.writeFileSync(zshPath, zshRes.stdout);
console.log('Completions generated to ~/.grok/completions (bash/zsh)');
} catch {}
} else if (!IS_WINDOWS) {
console.log('Tip: grok completions bash > ~/.local/share/bash-completion/completions/grok');
console.log(' grok completions zsh > ~/.zsh/completions/_grok');
}
@@ -1,41 +0,0 @@
{
"name": "@xai-official/grok",
"version": "0.1.220-alpha.4",
"description": "Bring Grok into your terminal",
"license": "Apache-2.0",
"bin": {
"grok": "bin/grok"
},
"files": [
"bin/"
],
"os": [
"darwin",
"linux",
"win32"
],
"cpu": [
"arm64",
"x64"
],
"scripts": {
"postinstall": "node bin/postinstall.js"
},
"publishConfig": {
"access": "public"
},
"engines": {
"node": ">=20"
},
"dependencies": {
"@iarna/toml": "^3.0.0"
},
"optionalDependencies": {
"@xai-official/grok-darwin-arm64": "0.1.220-alpha.4",
"@xai-official/grok-darwin-x64": "0.1.220-alpha.4",
"@xai-official/grok-linux-arm64": "0.1.220-alpha.4",
"@xai-official/grok-linux-x64": "0.1.220-alpha.4",
"@xai-official/grok-win32-arm64": "0.1.220-alpha.4",
"@xai-official/grok-win32-x64": "0.1.220-alpha.4"
}
}
@@ -1,134 +0,0 @@
#!/usr/bin/env node
// Assemble the six per-platform npm packages prior to `npm publish`.
//
// For each supported (platform, arch) target this:
// 1. Brotli-compresses the built binary into `../grok-<platform>/bin/<bin>.br`
// 2. Stamps the sub-package's version to match the meta package
//
// Each per-platform package is its own npm publish target. The meta package
// (`@xai-official/grok`) lists all six as `optionalDependencies` pinned to
// the same version; npm installs only the one matching the host's
// `os` + `cpu` filters.
//
// Why brotli? npm's tarball ceiling is ~200 MB and the raw grok binary is
// 100150 MB per platform. Brotli at max quality cuts that to 3040 MB,
// leaves plenty of headroom for binary growth, and is decoded by Node's
// built-in zlib.brotliDecompressSync (no native deps required).
//
// Source paths come from environment variables (set in CI) and fall back to
// the default cargo target dirs for local testing.
const fs = require('fs');
const path = require('path');
const { promisify } = require('util');
const zlib = require('zlib');
const brotliCompress = promisify(zlib.brotliCompress);
const xaiRoot = process.env.XAI_ROOT || path.resolve(__dirname, '..', '..', '..', '..', '..');
const npmRoot = path.resolve(__dirname, '..', '..');
const NOTICES_SOURCE = path.resolve(
npmRoot, '..', '..', 'kigi-tools', 'THIRD_PARTY_NOTICES.md');
const NOTICES_NAME = 'THIRD_PARTY_NOTICES.md';
const META_PKG_JSON = path.resolve(__dirname, '..', 'package.json');
const meta = JSON.parse(fs.readFileSync(META_PKG_JSON, 'utf8'));
const VERSION = meta.version;
function ensureDir(p) { fs.mkdirSync(path.dirname(p), { recursive: true }); }
async function packPlatform({ platform, arch, envVar, defaultSource, binName }) {
const pkgDir = path.join(npmRoot, `grok-${platform}-${arch}`);
const pkgJsonPath = path.join(pkgDir, 'package.json');
if (!fs.existsSync(pkgJsonPath)) {
console.error(`[assemble] Missing per-platform package at ${pkgDir}`);
return false;
}
const source = process.env[envVar] || defaultSource;
if (!fs.existsSync(source)) {
console.error(`[assemble] Missing binary for ${platform}-${arch}: ${source}`);
console.error(` Set ${envVar} or build to the default location.`);
return false;
}
// Stamp the sub-package's version to match the meta package.
const subPkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
subPkg.version = VERSION;
fs.writeFileSync(pkgJsonPath, JSON.stringify(subPkg, null, 4) + '\n');
if (!fs.existsSync(NOTICES_SOURCE)) {
console.error(`[assemble] Missing third-party notices file: ${NOTICES_SOURCE}`);
return false;
}
fs.copyFileSync(NOTICES_SOURCE, path.join(pkgDir, NOTICES_NAME));
// Brotli-compress into the sub-package's bin/.
const outBr = path.join(pkgDir, 'bin', `${binName}.br`);
ensureDir(outBr);
const raw = fs.readFileSync(source);
const compressed = await brotliCompress(raw, {
params: { [zlib.constants.BROTLI_PARAM_QUALITY]: zlib.constants.BROTLI_MAX_QUALITY },
});
fs.writeFileSync(outBr, compressed);
console.log(
`[assemble] grok-${platform}-${arch}@${VERSION}: ` +
`${(raw.length / 1048576).toFixed(1)} MB -> ${(compressed.length / 1048576).toFixed(1)} MB ` +
`(${path.relative(npmRoot, outBr)})`
);
return true;
}
async function main() {
const targets = [
{
platform: 'darwin', arch: 'arm64', binName: 'grok',
envVar: 'KIGI_DARWIN_ARM64',
defaultSource: path.join(xaiRoot, 'target', 'release', 'kigi-tui'),
},
{
platform: 'darwin', arch: 'x64', binName: 'grok',
envVar: 'KIGI_DARWIN_X64',
defaultSource: path.join(xaiRoot, 'target', 'x86_64-apple-darwin', 'release', 'kigi-tui'),
},
{
platform: 'linux', arch: 'x64', binName: 'grok',
envVar: 'KIGI_LINUX_X64',
defaultSource: path.join(xaiRoot, 'target',
'explorer_cross_x86_64-unknown-linux-gnu',
'x86_64-unknown-linux-gnu', 'release', 'kigi-tui'),
},
{
platform: 'linux', arch: 'arm64', binName: 'grok',
envVar: 'KIGI_LINUX_ARM64',
defaultSource: path.join(xaiRoot, 'target',
'explorer_cross_aarch64-unknown-linux-gnu',
'aarch64-unknown-linux-gnu', 'release', 'kigi-tui'),
},
{
platform: 'win32', arch: 'x64', binName: 'grok.exe',
envVar: 'KIGI_WIN32_X64',
defaultSource: path.join(xaiRoot, 'target', 'x86_64-pc-windows-msvc', 'release', 'kigi-tui.exe'),
},
{
platform: 'win32', arch: 'arm64', binName: 'grok.exe',
envVar: 'KIGI_WIN32_ARM64',
defaultSource: path.join(xaiRoot, 'target', 'aarch64-pc-windows-msvc', 'release', 'kigi-tui.exe'),
},
];
// Compress in parallel — brotliCompress runs on the libuv thread pool so
// calls genuinely overlap (set UV_THREADPOOL_SIZE>=6 in CI for full
// parallelism; Node's default pool size is 4).
const results = await Promise.all(targets.map(packPlatform));
const failed = results.filter(r => !r).length;
if (failed > 0) {
console.error(`[assemble] ${failed} target(s) failed.`);
process.exit(1);
}
console.log(`[assemble] All 6 per-platform packages assembled at version ${VERSION}.`);
}
main().catch((err) => { console.error(err); process.exit(1); });
@@ -1,983 +0,0 @@
#!/usr/bin/env node
// Tests for the versioned-binary + symlink installation logic used by
// postinstall.js and the bin/grok trampoline.
//
// Run with: node scripts/test-postinstall.js
//
// Uses only Node.js built-in modules (no test framework needed).
const fs = require('fs');
const path = require('path');
const os = require('os');
const assert = require('assert');
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(`${name}`);
passed++;
} catch (e) {
console.error(`${name}`);
console.error(` ${e.message}`);
failed++;
}
}
function makeTmpDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'grok-test-'));
}
function cleanup(dir) {
fs.rmSync(dir, { recursive: true, force: true });
}
// ─── Extracted logic (mirrors postinstall.js and bin/grok exactly) ─────
/** Semver-aware descending sort for "grok-X.Y.Z" filenames. */
function semverSortDescending(a, b) {
const pa = a.slice(5).split('.').map(Number);
const pb = b.slice(5).split('.').map(Number);
for (let i = 0; i < 3; i++) {
if ((pa[i] || 0) !== (pb[i] || 0)) return (pb[i] || 0) - (pa[i] || 0);
}
return 0;
}
/** Install a versioned binary + atomic symlink (same as postinstall.js). */
function installVersionedBinary(vendoredBinPath, version, canonicalDir) {
const canonicalPath = path.join(canonicalDir, 'grok');
fs.mkdirSync(canonicalDir, { recursive: true });
const versionedName = `grok-${version}`;
const versionedPath = path.join(canonicalDir, versionedName);
if (!fs.existsSync(versionedPath)) {
const tmpPath = versionedPath + `.tmp.${process.pid}`;
try {
fs.copyFileSync(vendoredBinPath, tmpPath);
fs.chmodSync(tmpPath, 0o755);
fs.renameSync(tmpPath, versionedPath);
} finally {
try { fs.unlinkSync(tmpPath); } catch {}
}
}
const tmpLink = canonicalPath + `.link.${process.pid}`;
try { fs.unlinkSync(tmpLink); } catch {}
fs.symlinkSync(versionedName, tmpLink);
fs.renameSync(tmpLink, canonicalPath);
return { canonicalPath, versionedPath, versionedName };
}
/** Cleanup old versioned binaries (same as postinstall.js). */
function cleanupOldVersions(canonicalDir, currentVersionedName) {
const entries = fs.readdirSync(canonicalDir);
const versionedBinaries = entries
.filter(e => e.startsWith('grok-') && !e.includes('.tmp.') && !e.includes('.link.') && e !== currentVersionedName)
.sort(semverSortDescending);
// Keep the most recent old version, remove anything older.
for (const old of versionedBinaries.slice(1)) {
try { fs.unlinkSync(path.join(canonicalDir, old)); } catch {}
}
return versionedBinaries;
}
/** Bootstrap canonical from vendored (same as bin/grok trampoline). */
function bootstrapCanonical(vendoredBinPath, version, canonicalDir) {
const canonicalPath = path.join(canonicalDir, 'grok');
try {
fs.mkdirSync(canonicalDir, { recursive: true });
const versionedName = `grok-${version}`;
const versionedPath = path.join(canonicalDir, versionedName);
if (!fs.existsSync(versionedPath)) {
const tmpPath = versionedPath + `.tmp.${process.pid}`;
fs.copyFileSync(vendoredBinPath, tmpPath);
fs.chmodSync(tmpPath, 0o755);
fs.renameSync(tmpPath, versionedPath);
}
const tmpLink = canonicalPath + `.link.${process.pid}`;
try { fs.unlinkSync(tmpLink); } catch {}
fs.symlinkSync(versionedName, tmpLink);
fs.renameSync(tmpLink, canonicalPath);
return canonicalPath;
} catch {
return vendoredBinPath;
}
}
// ═══════════════════════════════════════════════════════════════════════
// Install + Symlink Tests
// ═══════════════════════════════════════════════════════════════════════
console.log('install + symlink tests\n');
test('creates versioned binary and symlink on fresh install', () => {
const dir = makeTmpDir();
try {
const vendored = path.join(dir, 'vendored-grok');
fs.writeFileSync(vendored, 'binary-content-v1');
const binDir = path.join(dir, 'bin');
const result = installVersionedBinary(vendored, '0.1.140', binDir);
// Versioned file should exist
assert.ok(fs.existsSync(result.versionedPath), 'versioned binary should exist');
assert.strictEqual(fs.readFileSync(result.versionedPath, 'utf8'), 'binary-content-v1');
// Canonical path should be a symlink
const stat = fs.lstatSync(result.canonicalPath);
assert.ok(stat.isSymbolicLink(), 'canonical path should be a symlink');
// Symlink should point to the versioned name (relative)
const target = fs.readlinkSync(result.canonicalPath);
assert.strictEqual(target, 'grok-0.1.140');
// Reading through the symlink should return the binary content
assert.strictEqual(fs.readFileSync(result.canonicalPath, 'utf8'), 'binary-content-v1');
} finally {
cleanup(dir);
}
});
test('upgrade swaps symlink and preserves old binary', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
// Install v1
const vendored_v1 = path.join(dir, 'vendored-v1');
fs.writeFileSync(vendored_v1, 'v1-content');
installVersionedBinary(vendored_v1, '0.1.140', binDir);
// Install v2
const vendored_v2 = path.join(dir, 'vendored-v2');
fs.writeFileSync(vendored_v2, 'v2-content');
const result = installVersionedBinary(vendored_v2, '0.1.141', binDir);
// Symlink now points to v2
assert.strictEqual(fs.readlinkSync(result.canonicalPath), 'grok-0.1.141');
assert.strictEqual(fs.readFileSync(result.canonicalPath, 'utf8'), 'v2-content');
// Old v1 binary MUST still exist on disk (this is the key safety property)
const oldBinary = path.join(binDir, 'grok-0.1.140');
assert.ok(fs.existsSync(oldBinary), 'old versioned binary must not be deleted');
assert.strictEqual(fs.readFileSync(oldBinary, 'utf8'), 'v1-content');
} finally {
cleanup(dir);
}
});
test('idempotent: reinstalling same version does not re-copy', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
const vendored = path.join(dir, 'vendored');
fs.writeFileSync(vendored, 'original');
installVersionedBinary(vendored, '0.1.140', binDir);
// Modify vendored source (simulate npm replacing it)
fs.writeFileSync(vendored, 'replaced-by-npm');
// Re-run postinstall with same version
installVersionedBinary(vendored, '0.1.140', binDir);
// Versioned binary should NOT have been replaced (existsSync guard)
const versionedPath = path.join(binDir, 'grok-0.1.140');
assert.strictEqual(fs.readFileSync(versionedPath, 'utf8'), 'original');
} finally {
cleanup(dir);
}
});
test('symlink swap is atomic (no intermediate missing state)', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
const canonicalPath = path.join(binDir, 'grok');
const vendored = path.join(dir, 'vendored');
fs.writeFileSync(vendored, 'v1');
installVersionedBinary(vendored, '0.1.140', binDir);
assert.ok(fs.existsSync(canonicalPath), 'should exist after first install');
// Upgrade
fs.writeFileSync(vendored, 'v2');
installVersionedBinary(vendored, '0.1.141', binDir);
assert.ok(fs.existsSync(canonicalPath), 'should exist after upgrade');
// No temp files left behind
const entries = fs.readdirSync(binDir);
const tempFiles = entries.filter(e => e.includes('.tmp.') || e.includes('.link.'));
assert.strictEqual(tempFiles.length, 0, `temp files should be cleaned up, found: ${tempFiles}`);
} finally {
cleanup(dir);
}
});
test('handles upgrade from old-style regular file to versioned symlink', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
const canonicalPath = path.join(binDir, 'grok');
fs.mkdirSync(binDir, { recursive: true });
// Simulate old installation: grok is a regular file
fs.writeFileSync(canonicalPath, 'old-style-binary');
assert.ok(!fs.lstatSync(canonicalPath).isSymbolicLink(), 'should be regular file initially');
// Run new-style install
const vendored = path.join(dir, 'vendored');
fs.writeFileSync(vendored, 'v2-content');
installVersionedBinary(vendored, '0.1.141', binDir);
// Should now be a symlink
assert.ok(fs.lstatSync(canonicalPath).isSymbolicLink(), 'should be symlink after install');
assert.strictEqual(fs.readFileSync(canonicalPath, 'utf8'), 'v2-content');
} finally {
cleanup(dir);
}
});
test('handles broken symlink (target deleted externally)', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
fs.mkdirSync(binDir, { recursive: true });
// Create a broken symlink (points to a file that doesn't exist)
const canonicalPath = path.join(binDir, 'grok');
fs.symlinkSync('grok-0.1.99', canonicalPath);
assert.ok(!fs.existsSync(canonicalPath), 'broken symlink should not "exist"');
// Install should work and fix the broken symlink
const vendored = path.join(dir, 'vendored');
fs.writeFileSync(vendored, 'fixed-content');
const result = installVersionedBinary(vendored, '0.1.141', binDir);
assert.ok(fs.existsSync(result.canonicalPath), 'symlink should now resolve');
assert.strictEqual(fs.readFileSync(result.canonicalPath, 'utf8'), 'fixed-content');
} finally {
cleanup(dir);
}
});
test('three sequential upgrades: v1 -> v2 -> v3 all coexist', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
const vendored = path.join(dir, 'vendored');
fs.writeFileSync(vendored, 'content-v1');
installVersionedBinary(vendored, '0.1.1', binDir);
fs.writeFileSync(vendored, 'content-v2');
installVersionedBinary(vendored, '0.1.2', binDir);
fs.writeFileSync(vendored, 'content-v3');
installVersionedBinary(vendored, '0.1.3', binDir);
// Symlink points to latest
assert.strictEqual(fs.readlinkSync(path.join(binDir, 'grok')), 'grok-0.1.3');
// All three versioned binaries still exist (no cleanup yet)
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.1')));
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.2')));
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.3')));
} finally {
cleanup(dir);
}
});
test('file permissions are preserved (0o755)', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
const vendored = path.join(dir, 'vendored');
fs.writeFileSync(vendored, 'binary');
const result = installVersionedBinary(vendored, '0.1.140', binDir);
const mode = fs.statSync(result.versionedPath).mode & 0o777;
assert.strictEqual(mode, 0o755, `expected 0755, got ${mode.toString(8)}`);
} finally {
cleanup(dir);
}
});
// ═══════════════════════════════════════════════════════════════════════
// Cleanup / Semver Sort Tests
// ═══════════════════════════════════════════════════════════════════════
console.log('\ncleanup + semver sort tests\n');
test('cleanup keeps N-1 version and removes older ones', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
fs.mkdirSync(binDir, { recursive: true });
// Create three old versioned binaries
fs.writeFileSync(path.join(binDir, 'grok-0.1.138'), 'v138');
fs.writeFileSync(path.join(binDir, 'grok-0.1.139'), 'v139');
fs.writeFileSync(path.join(binDir, 'grok-0.1.140'), 'v140');
// grok-0.1.141 is the current version (excluded from cleanup)
fs.writeFileSync(path.join(binDir, 'grok-0.1.141'), 'v141');
cleanupOldVersions(binDir, 'grok-0.1.141');
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.141')), 'current should exist');
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.140')), 'N-1 should be kept');
assert.ok(!fs.existsSync(path.join(binDir, 'grok-0.1.139')), 'N-2 should be removed');
assert.ok(!fs.existsSync(path.join(binDir, 'grok-0.1.138')), 'N-3 should be removed');
} finally {
cleanup(dir);
}
});
test('cleanup with only one old version keeps it', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
fs.mkdirSync(binDir, { recursive: true });
fs.writeFileSync(path.join(binDir, 'grok-0.1.140'), 'v140');
fs.writeFileSync(path.join(binDir, 'grok-0.1.141'), 'v141');
cleanupOldVersions(binDir, 'grok-0.1.141');
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.140')), 'single old version should be kept');
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.141')), 'current should exist');
} finally {
cleanup(dir);
}
});
test('cleanup with no old versions is a no-op', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
fs.mkdirSync(binDir, { recursive: true });
// Only the current version exists
fs.writeFileSync(path.join(binDir, 'grok-0.1.141'), 'v141');
cleanupOldVersions(binDir, 'grok-0.1.141');
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.141')), 'current should still exist');
const entries = fs.readdirSync(binDir).filter(e => e.startsWith('grok-'));
assert.strictEqual(entries.length, 1, 'should only have current version');
} finally {
cleanup(dir);
}
});
test('cleanup ignores .tmp. and .link. files', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
fs.mkdirSync(binDir, { recursive: true });
fs.writeFileSync(path.join(binDir, 'grok-0.1.141'), 'current');
// Leftover temp files from a crashed install
fs.writeFileSync(path.join(binDir, 'grok-0.1.140.tmp.12345'), 'crashed-tmp');
fs.writeFileSync(path.join(binDir, 'grok.link.12345'), 'crashed-link');
cleanupOldVersions(binDir, 'grok-0.1.141');
// Temp files should not be touched by cleanup (they're filtered out)
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.140.tmp.12345')), 'tmp file should not be touched');
assert.ok(fs.existsSync(path.join(binDir, 'grok.link.12345')), 'link file should not be touched');
} finally {
cleanup(dir);
}
});
test('semver sort: 0.1.9 vs 0.1.10 (digit boundary)', () => {
// Regression test: lexical sort puts '0.1.9' after '0.1.10' because '9' > '1'
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
fs.mkdirSync(binDir, { recursive: true });
fs.writeFileSync(path.join(binDir, 'grok-0.1.8'), 'v8');
fs.writeFileSync(path.join(binDir, 'grok-0.1.9'), 'v9');
fs.writeFileSync(path.join(binDir, 'grok-0.1.10'), 'v10');
fs.writeFileSync(path.join(binDir, 'grok-0.1.11'), 'v11');
cleanupOldVersions(binDir, 'grok-0.1.11');
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.11')), 'current should exist');
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.10')), '0.1.10 should be kept (N-1)');
assert.ok(!fs.existsSync(path.join(binDir, 'grok-0.1.9')), '0.1.9 should be removed');
assert.ok(!fs.existsSync(path.join(binDir, 'grok-0.1.8')), '0.1.8 should be removed');
} finally {
cleanup(dir);
}
});
test('semver sort: major version boundary (0.x vs 1.x)', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
fs.mkdirSync(binDir, { recursive: true });
fs.writeFileSync(path.join(binDir, 'grok-0.9.99'), 'old');
fs.writeFileSync(path.join(binDir, 'grok-1.0.0'), 'v1');
fs.writeFileSync(path.join(binDir, 'grok-1.0.1'), 'current');
cleanupOldVersions(binDir, 'grok-1.0.1');
assert.ok(fs.existsSync(path.join(binDir, 'grok-1.0.0')), '1.0.0 should be kept (N-1)');
assert.ok(!fs.existsSync(path.join(binDir, 'grok-0.9.99')), '0.9.99 should be removed');
} finally {
cleanup(dir);
}
});
test('semver sort: minor version boundary (0.1.x vs 0.2.x)', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
fs.mkdirSync(binDir, { recursive: true });
fs.writeFileSync(path.join(binDir, 'grok-0.1.999'), 'old');
fs.writeFileSync(path.join(binDir, 'grok-0.2.0'), 'v2');
fs.writeFileSync(path.join(binDir, 'grok-0.2.1'), 'current');
cleanupOldVersions(binDir, 'grok-0.2.1');
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.2.0')), '0.2.0 should be kept (N-1)');
assert.ok(!fs.existsSync(path.join(binDir, 'grok-0.1.999')), '0.1.999 should be removed');
} finally {
cleanup(dir);
}
});
test('semverSortDescending: unit test comparator directly', () => {
const input = ['grok-0.1.9', 'grok-0.1.10', 'grok-0.1.2', 'grok-1.0.0', 'grok-0.2.0'];
const sorted = [...input].sort(semverSortDescending);
assert.deepStrictEqual(sorted, [
'grok-1.0.0',
'grok-0.2.0',
'grok-0.1.10',
'grok-0.1.9',
'grok-0.1.2',
]);
});
// ═══════════════════════════════════════════════════════════════════════
// Bootstrap (trampoline) Tests
// ═══════════════════════════════════════════════════════════════════════
console.log('\nbootstrap (trampoline) tests\n');
test('bootstrapCanonical creates versioned binary from vendored', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
const vendored = path.join(dir, 'vendored-grok');
fs.writeFileSync(vendored, 'vendored-content');
const result = bootstrapCanonical(vendored, '0.1.140', binDir);
assert.strictEqual(result, path.join(binDir, 'grok'));
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.140')), 'versioned binary should exist');
assert.ok(fs.lstatSync(result).isSymbolicLink(), 'canonical should be symlink');
assert.strictEqual(fs.readFileSync(result, 'utf8'), 'vendored-content');
} finally {
cleanup(dir);
}
});
test('bootstrapCanonical is idempotent', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
const vendored = path.join(dir, 'vendored-grok');
fs.writeFileSync(vendored, 'original-content');
bootstrapCanonical(vendored, '0.1.140', binDir);
// Change vendored content (simulating npm update)
fs.writeFileSync(vendored, 'npm-replaced-content');
// Second bootstrap should not overwrite existing versioned binary
const result = bootstrapCanonical(vendored, '0.1.140', binDir);
assert.strictEqual(
fs.readFileSync(path.join(binDir, 'grok-0.1.140'), 'utf8'),
'original-content',
'should keep original, not npm-replaced version'
);
} finally {
cleanup(dir);
}
});
test('bootstrapCanonical returns vendored path on failure', () => {
// If the canonical dir can't be created (e.g. permission denied),
// bootstrap should gracefully fall back to the vendored binary.
const dir = makeTmpDir();
try {
const vendored = path.join(dir, 'vendored');
fs.writeFileSync(vendored, 'fallback');
// Create a regular file where the dir should be — mkdirSync will fail.
const blockerFile = path.join(dir, 'blocked');
fs.writeFileSync(blockerFile, 'I am a file, not a directory');
const impossibleDir = path.join(blockerFile, 'subdir');
const result = bootstrapCanonical(vendored, '0.1.140', impossibleDir);
assert.strictEqual(result, vendored, 'should fall back to vendored path');
} finally {
cleanup(dir);
}
});
test('bootstrapCanonical works when canonical already exists (different version)', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
// Install v1 via postinstall
const vendored1 = path.join(dir, 'vendored-v1');
fs.writeFileSync(vendored1, 'v1');
installVersionedBinary(vendored1, '0.1.140', binDir);
// Bootstrap with v2 (simulates trampoline running a newer vendored binary)
const vendored2 = path.join(dir, 'vendored-v2');
fs.writeFileSync(vendored2, 'v2');
const result = bootstrapCanonical(vendored2, '0.1.141', binDir);
assert.strictEqual(result, path.join(binDir, 'grok'));
// Symlink should now point to v2
assert.strictEqual(fs.readlinkSync(result), 'grok-0.1.141');
// v1 should still exist
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.140')), 'old version should still exist');
} finally {
cleanup(dir);
}
});
// ═══════════════════════════════════════════════════════════════════════
// End-to-end Scenario Tests
// ═══════════════════════════════════════════════════════════════════════
console.log('\nend-to-end scenario tests\n');
test('full lifecycle: install, upgrade, cleanup', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
const vendored = path.join(dir, 'vendored');
// v1: fresh install
fs.writeFileSync(vendored, 'v1');
installVersionedBinary(vendored, '0.1.140', binDir);
// v2: upgrade
fs.writeFileSync(vendored, 'v2');
installVersionedBinary(vendored, '0.1.141', binDir);
// v3: another upgrade
fs.writeFileSync(vendored, 'v3');
installVersionedBinary(vendored, '0.1.142', binDir);
cleanupOldVersions(binDir, 'grok-0.1.142');
// Current (v3) + N-1 (v2) should exist; v1 removed
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.142')), 'v3 should exist');
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.141')), 'v2 should be kept (N-1)');
assert.ok(!fs.existsSync(path.join(binDir, 'grok-0.1.140')), 'v1 should be removed');
// Canonical symlink points to v3
assert.strictEqual(fs.readlinkSync(path.join(binDir, 'grok')), 'grok-0.1.142');
assert.strictEqual(fs.readFileSync(path.join(binDir, 'grok'), 'utf8'), 'v3');
} finally {
cleanup(dir);
}
});
test('downgrade: installing older version than current', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
const vendored = path.join(dir, 'vendored');
// Install v2 first
fs.writeFileSync(vendored, 'v2');
installVersionedBinary(vendored, '0.1.141', binDir);
// Downgrade to v1
fs.writeFileSync(vendored, 'v1');
installVersionedBinary(vendored, '0.1.140', binDir);
// Symlink should now point to v1
assert.strictEqual(fs.readlinkSync(path.join(binDir, 'grok')), 'grok-0.1.140');
assert.strictEqual(fs.readFileSync(path.join(binDir, 'grok'), 'utf8'), 'v1');
// v2 should still exist (never delete old binaries during install)
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.141')), 'v2 should still exist');
} finally {
cleanup(dir);
}
});
test('non-grok files in bin dir are not touched by cleanup', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
fs.mkdirSync(binDir, { recursive: true });
// Non-grok files
fs.writeFileSync(path.join(binDir, 'other-tool'), 'should-stay');
fs.writeFileSync(path.join(binDir, 'README.md'), 'should-stay');
// Grok versions
fs.writeFileSync(path.join(binDir, 'grok-0.1.138'), 'old1');
fs.writeFileSync(path.join(binDir, 'grok-0.1.139'), 'old2');
fs.writeFileSync(path.join(binDir, 'grok-0.1.140'), 'current');
cleanupOldVersions(binDir, 'grok-0.1.140');
assert.ok(fs.existsSync(path.join(binDir, 'other-tool')), 'non-grok file should not be touched');
assert.ok(fs.existsSync(path.join(binDir, 'README.md')), 'non-grok file should not be touched');
} finally {
cleanup(dir);
}
});
// ═══════════════════════════════════════════════════════════════════════
// grok vs grok-pager Isolation Tests
// ═══════════════════════════════════════════════════════════════════════
console.log('\ngrok vs grok-pager isolation tests\n');
/**
* Cleanup for a named binary (mirrors postinstall.js cleanupOldVersions).
* Uses prefix + leading digit to avoid grok-* matching grok-pager-*.
*/
function cleanupOldVersionsNamed(canonicalDir, binName, version) {
const prefix = `${binName}-`;
const currentVersioned = `${binName}-${version}`;
const entries = fs.readdirSync(canonicalDir);
const versionedBinaries = entries
.filter(e => {
if (!e.startsWith(prefix)) return false;
if (e.includes('.tmp.') || e.includes('.link.')) return false;
if (e === currentVersioned) return false;
const suffix = e.slice(prefix.length);
return /^\d/.test(suffix);
})
.sort((a, b) => {
const pa = a.slice(prefix.length).split('.').map(Number);
const pb = b.slice(prefix.length).split('.').map(Number);
for (let i = 0; i < 3; i++) {
if ((pa[i] || 0) !== (pb[i] || 0)) return (pb[i] || 0) - (pa[i] || 0);
}
return 0;
});
for (const old of versionedBinaries.slice(1)) {
try { fs.unlinkSync(path.join(canonicalDir, old)); } catch {}
}
return versionedBinaries;
}
/** Install a named binary (mirrors postinstall.js installBinary). */
function installNamedBinary(vendoredBinPath, binName, version, canonicalDir) {
fs.mkdirSync(canonicalDir, { recursive: true });
const versionedName = `${binName}-${version}`;
const versionedPath = path.join(canonicalDir, versionedName);
const canonicalPath = path.join(canonicalDir, binName);
if (!fs.existsSync(versionedPath)) {
const tmpPath = versionedPath + `.tmp.${process.pid}`;
try {
fs.copyFileSync(vendoredBinPath, tmpPath);
fs.chmodSync(tmpPath, 0o755);
fs.renameSync(tmpPath, versionedPath);
} finally {
try { fs.unlinkSync(tmpPath); } catch {}
}
}
const tmpLink = canonicalPath + `.link.${process.pid}`;
try { fs.unlinkSync(tmpLink); } catch {}
fs.symlinkSync(versionedName, tmpLink);
fs.renameSync(tmpLink, canonicalPath);
return { canonicalPath, versionedPath, versionedName };
}
test('installing both grok and grok-pager creates independent symlinks', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
const vendored = path.join(dir, 'vendored');
fs.writeFileSync(vendored, 'grok-binary');
const vendoredPager = path.join(dir, 'vendored-pager');
fs.writeFileSync(vendoredPager, 'pager-binary');
installNamedBinary(vendored, 'grok', '0.1.141', binDir);
installNamedBinary(vendoredPager, 'grok-pager', '0.1.141', binDir);
// Both symlinks exist and point to correct targets
assert.strictEqual(fs.readlinkSync(path.join(binDir, 'grok')), 'grok-0.1.141');
assert.strictEqual(fs.readlinkSync(path.join(binDir, 'grok-pager')), 'grok-pager-0.1.141');
// Both versioned files exist with correct content
assert.strictEqual(fs.readFileSync(path.join(binDir, 'grok-0.1.141'), 'utf8'), 'grok-binary');
assert.strictEqual(fs.readFileSync(path.join(binDir, 'grok-pager-0.1.141'), 'utf8'), 'pager-binary');
} finally {
cleanup(dir);
}
});
test('cleanup of grok-* does not remove grok-pager-*', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
fs.mkdirSync(binDir, { recursive: true });
// Old grok versions
fs.writeFileSync(path.join(binDir, 'grok-0.1.138'), 'old-grok-1');
fs.writeFileSync(path.join(binDir, 'grok-0.1.139'), 'old-grok-2');
fs.writeFileSync(path.join(binDir, 'grok-0.1.140'), 'old-grok-3');
// Current grok
fs.writeFileSync(path.join(binDir, 'grok-0.1.141'), 'current-grok');
// grok-pager versions (should not be touched)
fs.writeFileSync(path.join(binDir, 'grok-pager-0.1.138'), 'old-pager-1');
fs.writeFileSync(path.join(binDir, 'grok-pager-0.1.139'), 'old-pager-2');
fs.writeFileSync(path.join(binDir, 'grok-pager-0.1.141'), 'current-pager');
cleanupOldVersionsNamed(binDir, 'grok', '0.1.141');
// grok cleanup: current + N-1 kept, older removed
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.141')), 'current grok should exist');
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.140')), 'N-1 grok should be kept');
assert.ok(!fs.existsSync(path.join(binDir, 'grok-0.1.139')), 'N-2 grok should be removed');
assert.ok(!fs.existsSync(path.join(binDir, 'grok-0.1.138')), 'N-3 grok should be removed');
// ALL grok-pager versions must be untouched
assert.ok(fs.existsSync(path.join(binDir, 'grok-pager-0.1.138')), 'grok-pager-0.1.138 must survive grok cleanup');
assert.ok(fs.existsSync(path.join(binDir, 'grok-pager-0.1.139')), 'grok-pager-0.1.139 must survive grok cleanup');
assert.ok(fs.existsSync(path.join(binDir, 'grok-pager-0.1.141')), 'grok-pager-0.1.141 must survive grok cleanup');
} finally {
cleanup(dir);
}
});
test('cleanup of grok-pager-* does not remove grok-*', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
fs.mkdirSync(binDir, { recursive: true });
// grok versions (should not be touched)
fs.writeFileSync(path.join(binDir, 'grok-0.1.138'), 'old-grok-1');
fs.writeFileSync(path.join(binDir, 'grok-0.1.139'), 'old-grok-2');
fs.writeFileSync(path.join(binDir, 'grok-0.1.141'), 'current-grok');
// Old grok-pager versions
fs.writeFileSync(path.join(binDir, 'grok-pager-0.1.138'), 'old-pager-1');
fs.writeFileSync(path.join(binDir, 'grok-pager-0.1.139'), 'old-pager-2');
fs.writeFileSync(path.join(binDir, 'grok-pager-0.1.140'), 'old-pager-3');
// Current pager
fs.writeFileSync(path.join(binDir, 'grok-pager-0.1.141'), 'current-pager');
cleanupOldVersionsNamed(binDir, 'grok-pager', '0.1.141');
// grok-pager cleanup: current + N-1 kept, older removed
assert.ok(fs.existsSync(path.join(binDir, 'grok-pager-0.1.141')), 'current pager should exist');
assert.ok(fs.existsSync(path.join(binDir, 'grok-pager-0.1.140')), 'N-1 pager should be kept');
assert.ok(!fs.existsSync(path.join(binDir, 'grok-pager-0.1.139')), 'N-2 pager should be removed');
assert.ok(!fs.existsSync(path.join(binDir, 'grok-pager-0.1.138')), 'N-3 pager should be removed');
// ALL grok versions must be untouched
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.138')), 'grok-0.1.138 must survive pager cleanup');
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.139')), 'grok-0.1.139 must survive pager cleanup');
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.141')), 'grok-0.1.141 must survive pager cleanup');
} finally {
cleanup(dir);
}
});
test('full dual-binary lifecycle: install, upgrade, cleanup both', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
const vendored = path.join(dir, 'vendored');
const vendoredPager = path.join(dir, 'vendored-pager');
// v1
fs.writeFileSync(vendored, 'grok-v1');
fs.writeFileSync(vendoredPager, 'pager-v1');
installNamedBinary(vendored, 'grok', '0.1.140', binDir);
installNamedBinary(vendoredPager, 'grok-pager', '0.1.140', binDir);
// v2
fs.writeFileSync(vendored, 'grok-v2');
fs.writeFileSync(vendoredPager, 'pager-v2');
installNamedBinary(vendored, 'grok', '0.1.141', binDir);
installNamedBinary(vendoredPager, 'grok-pager', '0.1.141', binDir);
// v3
fs.writeFileSync(vendored, 'grok-v3');
fs.writeFileSync(vendoredPager, 'pager-v3');
installNamedBinary(vendored, 'grok', '0.1.142', binDir);
installNamedBinary(vendoredPager, 'grok-pager', '0.1.142', binDir);
// Cleanup both independently
cleanupOldVersionsNamed(binDir, 'grok', '0.1.142');
cleanupOldVersionsNamed(binDir, 'grok-pager', '0.1.142');
// Current + N-1 for each
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.142')));
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.141')));
assert.ok(!fs.existsSync(path.join(binDir, 'grok-0.1.140')));
assert.ok(fs.existsSync(path.join(binDir, 'grok-pager-0.1.142')));
assert.ok(fs.existsSync(path.join(binDir, 'grok-pager-0.1.141')));
assert.ok(!fs.existsSync(path.join(binDir, 'grok-pager-0.1.140')));
// Symlinks correct
assert.strictEqual(fs.readlinkSync(path.join(binDir, 'grok')), 'grok-0.1.142');
assert.strictEqual(fs.readlinkSync(path.join(binDir, 'grok-pager')), 'grok-pager-0.1.142');
} finally {
cleanup(dir);
}
});
// ═══════════════════════════════════════════════════════════════════════
// macOS-only Pager Platform Split Tests
// ═══════════════════════════════════════════════════════════════════════
console.log('\nmacOS-only pager platform split tests\n');
test('grok installs normally regardless of platform key', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
const vendored = path.join(dir, 'vendored-grok');
fs.writeFileSync(vendored, 'grok-binary');
for (const platform of ['darwin-arm64', 'linux-x64', 'linux-arm64']) {
const result = installNamedBinary(vendored, 'grok', '0.1.150', binDir);
assert.ok(fs.existsSync(result.versionedPath), `grok should install for ${platform}`);
assert.strictEqual(fs.readlinkSync(result.canonicalPath), 'grok-0.1.150');
}
} finally {
cleanup(dir);
}
});
test('grok-pager installs when vendored binary exists (darwin-arm64 path)', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
const vendoredPager = path.join(dir, 'vendored-pager');
fs.writeFileSync(vendoredPager, 'pager-binary');
const result = installNamedBinary(vendoredPager, 'grok-pager', '0.1.150', binDir);
assert.ok(fs.existsSync(result.versionedPath), 'pager versioned binary should exist');
assert.strictEqual(fs.readlinkSync(result.canonicalPath), 'grok-pager-0.1.150');
assert.strictEqual(fs.readFileSync(result.canonicalPath, 'utf8'), 'pager-binary');
} finally {
cleanup(dir);
}
});
test('Linux pager vendor files are not required for grok install', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
const vendorBase = path.join(dir, 'vendor');
// Only darwin-arm64 pager exists (mirrors npm tarball)
fs.mkdirSync(path.join(vendorBase, 'darwin-arm64'), { recursive: true });
fs.writeFileSync(path.join(vendorBase, 'darwin-arm64', 'grok-pager'), 'mac-pager');
// Linux pager vendor dirs exist but without pager binaries
fs.mkdirSync(path.join(vendorBase, 'linux-x64'), { recursive: true });
fs.mkdirSync(path.join(vendorBase, 'linux-arm64'), { recursive: true });
// Verify no Linux pager binaries
assert.ok(!fs.existsSync(path.join(vendorBase, 'linux-x64', 'grok-pager')));
assert.ok(!fs.existsSync(path.join(vendorBase, 'linux-arm64', 'grok-pager')));
// grok install should succeed independently
const grokVendored = path.join(dir, 'vendored-grok');
fs.writeFileSync(grokVendored, 'grok-linux');
const result = installNamedBinary(grokVendored, 'grok', '0.1.150', binDir);
assert.ok(fs.existsSync(result.versionedPath), 'grok should install without Linux pager');
} finally {
cleanup(dir);
}
});
test('skipping pager install on Linux does not affect grok cleanup', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
const vendored = path.join(dir, 'vendored');
// Install grok across two versions
fs.writeFileSync(vendored, 'grok-v1');
installNamedBinary(vendored, 'grok', '0.1.149', binDir);
fs.writeFileSync(vendored, 'grok-v2');
installNamedBinary(vendored, 'grok', '0.1.150', binDir);
// Simulate Linux: only run grok cleanup, skip pager entirely
cleanupOldVersionsNamed(binDir, 'grok', '0.1.150');
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.150')), 'current grok should exist');
assert.ok(fs.existsSync(path.join(binDir, 'grok-0.1.149')), 'N-1 grok should be kept');
assert.strictEqual(fs.readlinkSync(path.join(binDir, 'grok')), 'grok-0.1.150');
// No pager files should exist at all
const entries = fs.readdirSync(binDir);
const pagerEntries = entries.filter(e => e.includes('pager'));
assert.strictEqual(pagerEntries.length, 0, 'no pager artifacts on Linux');
} finally {
cleanup(dir);
}
});
test('canonical pager from non-npm install is preserved on Linux', () => {
const dir = makeTmpDir();
try {
const binDir = path.join(dir, 'bin');
fs.mkdirSync(binDir, { recursive: true });
// Simulate pager installed by install-grok.sh (not npm)
const pagerVersioned = path.join(binDir, 'grok-pager-0.1.150');
fs.writeFileSync(pagerVersioned, 'installer-pager-binary');
fs.chmodSync(pagerVersioned, 0o755);
const pagerCanonical = path.join(binDir, 'grok-pager');
fs.symlinkSync('grok-pager-0.1.150', pagerCanonical);
// Run grok-only install + cleanup (simulating Linux postinstall)
const vendored = path.join(dir, 'vendored');
fs.writeFileSync(vendored, 'grok-binary');
installNamedBinary(vendored, 'grok', '0.1.150', binDir);
cleanupOldVersionsNamed(binDir, 'grok', '0.1.150');
// Pager installed by other means must be untouched
assert.ok(fs.existsSync(pagerCanonical), 'canonical pager should survive');
assert.ok(fs.existsSync(pagerVersioned), 'versioned pager should survive');
assert.strictEqual(fs.readlinkSync(pagerCanonical), 'grok-pager-0.1.150');
} finally {
cleanup(dir);
}
});
// ─── Summary ───────────────────────────────────────────────────────────
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);
@@ -1,334 +0,0 @@
#
# Grok CLI installer (enterprise channel) for PowerShell — https://x.ai/cli/enterprise-install.ps1
#
# Standalone installer for the enterprise channel. Intentionally a full copy of
# the install logic so changes to the stable installer cannot break enterprise.
#
# Auth: KIGI_DEPLOYMENT_KEY env var (takes precedence) or ~/.grok/auth.json from `grok login`.
# Env: KIGI_BIN_DIR, KIGI_PROXY_URL
#
# Usage:
# irm https://x.ai/cli/enterprise-install.ps1 | iex # latest enterprise
# & ([scriptblock]::Create((irm https://x.ai/cli/enterprise-install.ps1))) -Version 0.1.42 # specific version
# $env:KIGI_VERSION="0.1.42"; irm https://x.ai/cli/enterprise-install.ps1 | iex # specific version (alt)
# $env:KIGI_DEPLOYMENT_KEY="<key>"; irm https://x.ai/cli/enterprise-install.ps1 | iex
#
param(
[Parameter(Position = 0)]
[string]$Version
)
$ErrorActionPreference = 'Stop'
# PS 5.1 defaults to TLS 1.0; GCS requires TLS 1.2.
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
# PS 5.1's Invoke-WebRequest progress bar is extremely slow; disable it.
$ProgressPreference = 'SilentlyContinue'
# Accept version from environment variable (useful with irm | iex).
if (-not $Version -and $env:KIGI_VERSION) {
$Version = $env:KIGI_VERSION
}
# This script is Windows-only. PS 5.1 has no Platform property and only runs on Windows.
if ($PSVersionTable.Platform -and $PSVersionTable.Platform -ne 'Win32NT') {
Write-Error "This installer is for Windows. On macOS/Linux, use: curl -fsSL https://x.ai/cli/enterprise-install.sh | bash"
exit 1
}
$GrokDir = Join-Path $env:USERPROFILE '.grok'
# --- Helpers ---
function Download-String([string]$Url) {
try {
$response = Invoke-WebRequest -Uri $Url -UseBasicParsing
return $response.Content
} catch {
return $null
}
}
function Download-File([string]$Url, [string]$OutFile) {
# TODO: parallel byte-range download (matches install-enterprise.sh download_file_parallel).
# Skipped for now: requires Start-ThreadJob / RunspacePool for true parallelism on PS 5.1
# and HEAD + Range request orchestration. Single-connection HttpWebRequest below remains.
# Stream via HttpWebRequest — faster than Invoke-WebRequest on PS 5.1 and supports progress.
$request = [System.Net.HttpWebRequest]::Create($Url)
$request.Timeout = 300000 # 5 min
$request.AutomaticDecompression = [System.Net.DecompressionMethods]::GZip -bor [System.Net.DecompressionMethods]::Deflate
$response = $request.GetResponse()
$totalBytes = $response.ContentLength
$stream = $response.GetResponseStream()
$fileStream = [System.IO.File]::Create($OutFile)
$buffer = New-Object byte[] 65536
$totalRead = 0
$lastPercent = -1
$lastMb = -1
try {
while (($read = $stream.Read($buffer, 0, $buffer.Length)) -gt 0) {
$fileStream.Write($buffer, 0, $read)
$totalRead += $read
$mb = [math]::Round($totalRead / 1MB, 1)
if ($totalBytes -gt 0) {
$percent = [math]::Min(100, [math]::Floor(($totalRead / $totalBytes) * 100))
if ($percent -ne $lastPercent) {
$totalMb = [math]::Round($totalBytes / 1MB, 1)
Write-Host "`r Downloading... ${mb} MB / ${totalMb} MB (${percent}%)" -NoNewline
$lastPercent = $percent
}
} elseif ($mb -ne $lastMb) {
Write-Host "`r Downloading... ${mb} MB" -NoNewline
$lastMb = $mb
}
}
Write-Host ''
} finally {
$fileStream.Close()
$stream.Close()
$response.Close()
}
}
function Read-GrokToken([string]$Scope) {
$authFile = Join-Path $GrokDir 'auth.json'
if (-not (Test-Path $authFile)) { return $null }
try {
$auth = Get-Content -Raw $authFile | ConvertFrom-Json
$entry = $auth.$Scope
if ($entry -and $entry.key) { return $entry.key }
} catch {}
return $null
}
# --- Validate version ---
if ($Version -and $Version -notmatch '^\d+\.\d+\.\d+(-\S+)?$') {
Write-Error "Invalid version format: $Version (expected X.Y.Z or X.Y.Z-suffix)"
exit 1
}
# --- Resolve auth ---
$OidcScope = 'https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828'
$LegacyScope = 'https://accounts.x.ai/sign-in'
$AuthSource = ''
if ($env:KIGI_DEPLOYMENT_KEY) {
$AuthSource = 'deployment key'
Write-Host 'Auth: using deployment key.' -ForegroundColor DarkGray
} else {
$oidcToken = Read-GrokToken $OidcScope
$legacyToken = Read-GrokToken $LegacyScope
if ($oidcToken) {
$AuthSource = 'auth.json (oidc)'
Write-Host 'Auth: using OIDC token from ~/.grok/auth.json.' -ForegroundColor DarkGray
} elseif ($legacyToken) {
$AuthSource = 'auth.json (legacy)'
Write-Host 'Auth: using legacy token from ~/.grok/auth.json.' -ForegroundColor DarkGray
}
}
# --- Detect architecture ---
$arch = switch ($env:PROCESSOR_ARCHITECTURE) {
'AMD64' { 'x86_64' }
'x86' { 'x86_64' } # 32-bit PS on 64-bit Windows
'ARM64' { 'aarch64' }
default { $null }
}
if (-not $arch) {
Write-Error "Unsupported architecture: $env:PROCESSOR_ARCHITECTURE"
exit 1
}
$platform = "windows-$arch"
# --- Resolve version ---
$BaseUrlPrimary = 'https://x.ai/cli'
$BaseUrlFallback = 'https://storage.googleapis.com/grok-build-public-artifacts/cli'
$DownloadDir = Join-Path $GrokDir 'downloads'
$BinDir = if ($env:KIGI_BIN_DIR) { $env:KIGI_BIN_DIR } else { Join-Path $GrokDir 'bin' }
New-Item -ItemType Directory -Path $DownloadDir -Force | Out-Null
New-Item -ItemType Directory -Path $BinDir -Force | Out-Null
$Channel = 'enterprise'
# Pick a working BaseUrl: try Cloudflare-fronted x.ai first, fall back to
# direct GCS if it's unreachable. The probe doubles as the channel-pointer
# fetch when no -Version was passed, so the happy path costs zero extra requests.
if (-not $Version) { Write-Host "Fetching latest $Channel version..." -ForegroundColor DarkGray }
$probeResult = Download-String "$BaseUrlPrimary/$Channel"
if ($probeResult) {
$BaseUrl = $BaseUrlPrimary
} else {
Write-Host "Note: $BaseUrlPrimary unreachable, falling back to direct GCS." -ForegroundColor Yellow
$BaseUrl = $BaseUrlFallback
$probeResult = Download-String "$BaseUrl/$Channel"
}
if ($Version) {
$resolvedVersion = $Version
} elseif ($probeResult) {
$resolvedVersion = $probeResult.Trim()
} else {
Write-Error "Failed to fetch latest version from $BaseUrlPrimary/$Channel and $BaseUrlFallback/$Channel"
exit 1
}
if ($AuthSource) {
Write-Host "Installing Grok $resolvedVersion ($platform, $AuthSource)..." -ForegroundColor Cyan
} else {
Write-Host "Installing Grok $resolvedVersion ($platform)..." -ForegroundColor Cyan
}
# --- Download binary ---
$binaryPath = Join-Path $DownloadDir "grok-$platform.exe"
$artifactBase = "$BaseUrl/grok-$resolvedVersion-$platform"
$downloaded = $false
foreach ($url in @("$artifactBase.exe", $artifactBase)) {
try {
Download-File $url $binaryPath
$downloaded = $true
break
} catch {
continue
}
}
if (-not $downloaded) {
if (Test-Path $binaryPath) { Remove-Item $binaryPath -Force }
Write-Error "Binary download failed from $artifactBase.exe and $artifactBase"
exit 1
}
# --- Install binary (locked-file safe) ---
foreach ($binName in @('grok.exe', 'agent.exe')) {
$dest = Join-Path $BinDir $binName
$old = "$dest.old"
if (Test-Path $old) { Remove-Item $old -Force -ErrorAction SilentlyContinue }
try {
Copy-Item -Path $binaryPath -Destination $dest -Force
} catch {
try {
if (Test-Path $dest) { Rename-Item $dest $old -Force -ErrorAction SilentlyContinue }
Copy-Item -Path $binaryPath -Destination $dest -Force
} catch {
if (Test-Path $old) { Rename-Item $old $dest -Force -ErrorAction SilentlyContinue }
Write-Error "Failed to install $binName"
exit 1
}
}
}
Write-Host " Installed to $BinDir\grok.exe and $BinDir\agent.exe." -ForegroundColor DarkGray
# --- Generate completions (best-effort) ---
$completionsDir = Join-Path (Join-Path $GrokDir 'completions') 'powershell'
try {
New-Item -ItemType Directory -Path $completionsDir -Force | Out-Null
& (Join-Path $BinDir 'grok.exe') completions powershell 2>$null |
Set-Content (Join-Path $completionsDir 'grok.ps1') -ErrorAction SilentlyContinue
} catch {}
# --- Persist installer config ---
$ConfigFile = Join-Path $GrokDir 'config.toml'
$cliLines = @('installer = "internal"', 'channel = "enterprise"')
if (-not (Test-Path $ConfigFile)) {
New-Item -ItemType Directory -Path (Split-Path $ConfigFile) -Force | Out-Null
$content = "[cli]`r`n" + ($cliLines -join "`r`n") + "`r`n"
[System.IO.File]::WriteAllText($ConfigFile, $content, [System.Text.Encoding]::UTF8)
} elseif ((Get-Content -Raw $ConfigFile) -match '(?m)^\[cli\]') {
# Section-aware: only replace installer/channel under [cli], not other sections.
$existingLines = Get-Content $ConfigFile
$output = [System.Collections.ArrayList]::new()
$inCli = $false
foreach ($line in $existingLines) {
if ($line -match '^\[cli\]\s*(#.*)?$') {
[void]$output.Add($line)
foreach ($cl in $cliLines) { [void]$output.Add($cl) }
$inCli = $true
continue
}
if ($line -match '^\[.+\]\s*(#.*)?$') {
$inCli = $false
}
if ($inCli -and $line -match '^\s*(installer|channel)\s*=') {
continue
}
[void]$output.Add($line)
}
[System.IO.File]::WriteAllLines($ConfigFile, [string[]]$output.ToArray(), [System.Text.Encoding]::UTF8)
} else {
Add-Content -Path $ConfigFile -Value "`r`n[cli]`r`n$($cliLines -join "`r`n")`r`n"
}
# --- Fetch deployment config (deployment key only) ---
if ($env:KIGI_DEPLOYMENT_KEY) {
$ProxyUrl = if ($env:KIGI_PROXY_URL) { $env:KIGI_PROXY_URL } else { 'https://cli-chat-proxy.grok.com/v1' }
Write-Host ' Fetching deployment config...' -ForegroundColor DarkGray
try {
$headers = @{ 'Authorization' = "Bearer $($env:KIGI_DEPLOYMENT_KEY)" }
$deployResponse = Invoke-RestMethod -Uri "$ProxyUrl/deployment/config" -Headers $headers -UseBasicParsing
} catch {
Write-Host " Warning: failed to fetch deployment config from $ProxyUrl/deployment/config" -ForegroundColor Yellow
$deployResponse = $null
}
if ($deployResponse) {
$managedConfig = $deployResponse.managed_config
$requirements = $deployResponse.requirements
$managedConfigPath = Join-Path $GrokDir 'managed_config.toml'
$requirementsPath = Join-Path $GrokDir 'requirements.toml'
if ($managedConfig -and $managedConfig -ne 'null') {
[System.IO.File]::WriteAllText($managedConfigPath, $managedConfig, [System.Text.Encoding]::UTF8)
Write-Host ' Managed config applied.' -ForegroundColor DarkGray
} else {
if (Test-Path $managedConfigPath) { Remove-Item $managedConfigPath -Force }
}
if ($requirements -and $requirements -ne 'null') {
[System.IO.File]::WriteAllText($requirementsPath, $requirements, [System.Text.Encoding]::UTF8)
Write-Host ' Requirements applied.' -ForegroundColor DarkGray
} else {
if (Test-Path $requirementsPath) { Remove-Item $requirementsPath -Force }
}
}
}
Write-Host "Grok $resolvedVersion installed to $BinDir\grok.exe" -ForegroundColor Green
# --- Ensure grok is on PATH ---
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
$pathEntries = if ($userPath) { $userPath -split ';' | Where-Object { $_ -ne '' } } else { @() }
if ($pathEntries -notcontains $BinDir) {
$newPath = (@($BinDir) + $pathEntries) -join ';'
[Environment]::SetEnvironmentVariable('Path', $newPath, 'User')
Write-Host " Added $BinDir to your User PATH." -ForegroundColor DarkGray
# Update current session so grok works immediately.
if ($env:Path -notlike "*$BinDir*") {
$env:Path = "$BinDir;$env:Path"
}
}
Write-Host ''
Write-Host "Run 'grok' or 'agent' to get started!" -ForegroundColor Cyan
@@ -1,430 +0,0 @@
#!/bin/bash
#
# Grok CLI installer (enterprise channel) — https://x.ai/cli/enterprise-install.sh
#
# Standalone installer for the enterprise channel. This is intentionally a full
# copy of the install logic (not a wrapper around install.sh) so that changes to
# the stable installer cannot accidentally break enterprise deployments.
#
# Auth: KIGI_DEPLOYMENT_KEY (takes precedence) or ~/.grok/auth.json from `grok login`.
# Env: KIGI_BIN_DIR, KIGI_PROXY_URL
#
# Usage:
# curl -fsSL https://x.ai/cli/enterprise-install.sh | bash # latest enterprise
# curl -fsSL https://x.ai/cli/enterprise-install.sh | bash -s 0.1.42 # specific version
# KIGI_DEPLOYMENT_KEY=<key> bash <(curl -fsSL https://x.ai/cli/enterprise-install.sh)
#
# Windows: run under Git for Windows / MSYS2 Bash (same curl | bash flow); WSL
# uses the Linux binary.
set -e
TARGET="$1"
if [[ -n "$TARGET" ]] && [[ ! "$TARGET" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9._]+)?$ ]]; then
echo "Invalid version format: $TARGET (expected X.Y.Z or X.Y.Z-suffix)" >&2
exit 1
fi
DOWNLOADER=""
if command -v curl >/dev/null 2>&1; then
DOWNLOADER="curl"
elif command -v wget >/dev/null 2>&1; then
DOWNLOADER="wget"
else
echo "Either curl or wget is required but neither is installed" >&2
exit 1
fi
download_file() {
local url="$1" output="$2"
if [ "$DOWNLOADER" = "curl" ]; then
if [ -n "$output" ]; then
curl -fsSL -o "$output" "$url"
else
curl -fsSL "$url"
fi
else
if [ -n "$output" ]; then
wget -q -O "$output" "$url"
else
wget -q -O - "$url"
fi
fi
}
# Parallel byte-range download. Falls back to single-connection download_file
# whenever HEAD lacks Content-Length, the file is small (<16 MiB), curl is
# unavailable, or any chunk fetch / concat fails.
download_file_parallel() {
local url="$1" output="$2"
if [ "$DOWNLOADER" != "curl" ]; then
download_file "$url" "$output"
return
fi
local size
size=$(curl -fsSL --head "$url" 2>/dev/null | awk -F'[: \r\n]+' 'tolower($1)=="content-length"{print $2; exit}')
if [ -z "$size" ] || ! [ "$size" -ge 16777216 ] 2>/dev/null; then
download_file "$url" "$output"
return
fi
local n=8
local chunk_size=$(( (size + n - 1) / n ))
local tmpdir
tmpdir=$(mktemp -d 2>/dev/null) || { download_file "$url" "$output"; return; }
local pids=() i start end
for i in $(seq 0 $((n - 1))); do
start=$((i * chunk_size))
end=$((start + chunk_size - 1))
[ $end -ge $size ] && end=$((size - 1))
curl -fsSL -r "${start}-${end}" -o "${tmpdir}/$(printf 'chunk.%03d' "$i")" "$url" &
pids+=($!)
done
local all_ok=true pid
for pid in "${pids[@]}"; do
wait "$pid" || all_ok=false
done
if [ "$all_ok" = true ] && cat "${tmpdir}"/chunk.* > "$output" 2>/dev/null; then
rm -rf "$tmpdir"
return 0
fi
rm -rf "$tmpdir"
download_file "$url" "$output"
}
# Return 0 if a HEAD request for the URL gets HTTP 404.
is_not_found() {
local url="$1" code
if [ "$DOWNLOADER" = "curl" ]; then
code=$(curl -o /dev/null -sSL -w '%{http_code}' --head "$url" 2>/dev/null) || true
else
code=$(wget --server-response --spider "$url" 2>&1 | awk '/HTTP\//{print $2}' | tail -1) || true
fi
[ "$code" = "404" ]
}
# JSON field extractor — extract a top-level string value using sed.
json_get() {
local json="$1" field="$2"
# Extract value (handling \" inside strings), then unescape JSON sequences.
printf '%s' "$json" | sed -n -E 's/.*"'"$field"'"[[:space:]]*:[[:space:]]*"(([^"\\]|\\.)*)".*/\1/p' | head -1 \
| sed -e 's/\\"/"/g' -e 's/\\n/\'$'\n''/g' -e 's/\\t/\'$'\t''/g' -e 's/\\\\/\\/g'
}
# Read a token from ~/.grok/auth.json for the given scope key.
# Format: {"scope_url": {"key": "token"}, ...}
read_grok_token() {
local auth_file="$HOME/.grok/auth.json"
local scope="$1"
[ -f "$auth_file" ] || return 1
# Flatten to one line then extract: find the scope, then the "key" value after it
tr -d '\n' < "$auth_file" | sed -n 's|.*"'"$scope"'"[[:space:]]*:[[:space:]]*{[^}]*"key"[[:space:]]*:[[:space:]]*"\([^"]*\)".*|\1|p' | head -1
}
# Resolve auth: KIGI_DEPLOYMENT_KEY > OIDC token > legacy token
OIDC_SCOPE="https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828"
LEGACY_SCOPE="https://accounts.x.ai/sign-in"
AUTH_SOURCE=""
if [ -n "$KIGI_DEPLOYMENT_KEY" ]; then
AUTH_SOURCE="deployment key"
echo "Auth: using deployment key." >&2
else
OIDC_TOKEN=$(read_grok_token "$OIDC_SCOPE" 2>/dev/null) || true
LEGACY_TOKEN=$(read_grok_token "$LEGACY_SCOPE" 2>/dev/null) || true
if [ -n "$OIDC_TOKEN" ]; then
AUTH_SOURCE="auth.json (oidc)"
echo "Auth: using OIDC token from ~/.grok/auth.json." >&2
elif [ -n "$LEGACY_TOKEN" ]; then
AUTH_SOURCE="auth.json (legacy)"
echo "Auth: using legacy token from ~/.grok/auth.json." >&2
fi
fi
case "$(uname -s)" in
Darwin) os="macos" ;;
Linux) os="linux" ;;
# Git for Windows / MSYS2 / Cygwin host — native Windows builds
MINGW* | MSYS* | CYGWIN*) os="windows" ;;
*) echo "Unsupported OS: $(uname -s)" >&2; exit 1 ;;
esac
case "$(uname -m)" in
x86_64|amd64|AMD64) arch="x86_64" ;;
arm64|aarch64|ARM64) arch="aarch64" ;;
*) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;;
esac
BASE_URL_PRIMARY="https://x.ai/cli"
BASE_URL_FALLBACK="https://storage.googleapis.com/grok-build-public-artifacts/cli"
DOWNLOAD_DIR="$HOME/.grok/downloads"
BIN_DIR="${KIGI_BIN_DIR:-$HOME/.grok/bin}"
mkdir -p "$DOWNLOAD_DIR" "$BIN_DIR"
platform="${os}-${arch}"
CHANNEL="enterprise"
# Pick a working BASE_URL: try Cloudflare-fronted x.ai first, fall back to
# direct GCS if it's unreachable. The probe doubles as the channel-pointer
# fetch when no explicit TARGET was passed, so the happy path costs zero
# extra HTTP requests.
if [ -z "$TARGET" ]; then echo "Fetching latest ${CHANNEL} version..." >&2; fi
probe_result=$(download_file "${BASE_URL_PRIMARY}/${CHANNEL}" 2>/dev/null) || true
if [ -n "$probe_result" ]; then
BASE_URL="$BASE_URL_PRIMARY"
else
echo "Note: ${BASE_URL_PRIMARY} unreachable, falling back to direct GCS." >&2
BASE_URL="$BASE_URL_FALLBACK"
probe_result=$(download_file "${BASE_URL}/${CHANNEL}" 2>/dev/null) || true
fi
if [ -n "$TARGET" ]; then
version="$TARGET"
else
version=$(printf '%s' "$probe_result" | tr -d '\r' | head -n1 | tr -d '[:space:]')
if [ -z "$version" ]; then
echo "Error: failed to fetch latest version from ${BASE_URL_PRIMARY}/${CHANNEL} and ${BASE_URL_FALLBACK}/${CHANNEL}" >&2
exit 1
fi
fi
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9._]+)?$ ]]; then
echo "Invalid version format: $version (expected X.Y.Z or X.Y.Z-suffix)" >&2
exit 1
fi
if [ -n "$AUTH_SOURCE" ]; then
echo "Installing Grok $version ($platform, $AUTH_SOURCE)..." >&2
else
echo "Installing Grok $version ($platform)..." >&2
fi
binary_path="$DOWNLOAD_DIR/grok-$platform"
artifact_base="${BASE_URL}/grok-${version}-${platform}"
if [ "$os" = "windows" ]; then
binary_path="${binary_path}.exe"
fi
echo " Downloading grok ${version}..." >&2
if [ "$os" = "windows" ]; then
if ! download_file_parallel "${artifact_base}.exe" "$binary_path"; then
if ! download_file_parallel "$artifact_base" "$binary_path"; then
rm -f "$binary_path"
if is_not_found "${artifact_base}.exe"; then
echo "Error: Grok is not yet available for your system ($platform)." >&2
else
echo "Error: binary download failed (${artifact_base}.exe and ${artifact_base})" >&2
fi
exit 1
fi
fi
elif ! download_file_parallel "$artifact_base" "$binary_path"; then
rm -f "$binary_path"
if is_not_found "$artifact_base"; then
echo "Error: Grok is not yet available for your system ($platform)." >&2
else
echo "Error: binary download failed from ${artifact_base}" >&2
fi
exit 1
fi
if [ "$os" = "windows" ]; then
# Symlinks require Developer Mode on Windows; copy instead.
# If the exe is locked by a running process, rename it aside then retry.
for bin_name in grok.exe agent.exe; do
rm -f "$BIN_DIR/$bin_name.old" 2>/dev/null || true # stale backup from prior update
if ! cp -f "$binary_path" "$BIN_DIR/$bin_name" 2>/dev/null; then
mv -f "$BIN_DIR/$bin_name" "$BIN_DIR/$bin_name.old" 2>/dev/null || true
if ! cp -f "$binary_path" "$BIN_DIR/$bin_name" 2>/dev/null; then
# Rollback: restore the old binary so the install isn't broken.
mv -f "$BIN_DIR/$bin_name.old" "$BIN_DIR/$bin_name" 2>/dev/null || true
echo "Error: failed to install $bin_name" >&2
exit 1
fi
fi
done
echo " Binary installed to $BIN_DIR/grok.exe and $BIN_DIR/agent.exe." >&2
else
chmod +x "$binary_path"
ln -sf "$binary_path" "$BIN_DIR/grok"
ln -sf "$binary_path" "$BIN_DIR/agent"
echo " Binary linked to $BIN_DIR/grok and $BIN_DIR/agent." >&2
fi
# Generate shell completions (best-effort)
mkdir -p "$HOME/.grok/completions/bash" "$HOME/.grok/completions/zsh"
"$BIN_DIR/grok" completions bash > "$HOME/.grok/completions/bash/grok.bash" 2>/dev/null || true
"$BIN_DIR/grok" completions zsh > "$HOME/.grok/completions/zsh/_grok" 2>/dev/null || true
# Fish: write to the auto-loaded completions dir so it works immediately
if mkdir -p "$HOME/.config/fish/completions" 2>/dev/null; then
"$BIN_DIR/grok" completions fish > "$HOME/.config/fish/completions/grok.fish" 2>/dev/null || true
fi
# Persist installer source and channel to config
CONFIG_FILE="$HOME/.grok/config.toml"
CLI_BLOCK="installer = \"internal\"\nchannel = \"enterprise\""
if [ ! -f "$CONFIG_FILE" ]; then
printf '[cli]\n%b\n' "$CLI_BLOCK" > "$CONFIG_FILE"
elif grep -q '^\[cli\]' "$CONFIG_FILE"; then
tmp="$CONFIG_FILE.tmp.$$"
awk -v block="$CLI_BLOCK" '
/^\[cli\][[:space:]]*(#.*)?$/ { print; printf "%s\n", block; in_cli=1; next }
/^\[.*\][[:space:]]*(#.*)?$/ { in_cli=0 }
in_cli && /^[[:space:]]*(installer|channel)[[:space:]]*=/ { next }
{ print }
' "$CONFIG_FILE" > "$tmp" && mv "$tmp" "$CONFIG_FILE"
else
printf '\n[cli]\n%b\n' "$CLI_BLOCK" >> "$CONFIG_FILE"
fi
# Fetch managed_config.toml + requirements.toml from server (deployment key only).
if [ -n "$KIGI_DEPLOYMENT_KEY" ]; then
PROXY_URL="${KIGI_PROXY_URL:-https://cli-chat-proxy.grok.com/v1}"
echo " Fetching deployment config..." >&2
DEPLOY_RESPONSE=""
AUTH_HEADER_FILE=$(mktemp 2>/dev/null) || AUTH_HEADER_FILE=""
if [ -n "$AUTH_HEADER_FILE" ]; then
chmod 600 "$AUTH_HEADER_FILE" 2>/dev/null || true
printf 'Authorization: Bearer %s\n' "$KIGI_DEPLOYMENT_KEY" > "$AUTH_HEADER_FILE"
DEPLOY_RESPONSE=$(curl -sS -f \
-H "@${AUTH_HEADER_FILE}" \
"${PROXY_URL}/deployment/config" 2>/dev/null) || DEPLOY_RESPONSE=""
: > "$AUTH_HEADER_FILE" 2>/dev/null || true
rm -f "$AUTH_HEADER_FILE"
fi
if [ -z "$DEPLOY_RESPONSE" ]; then
echo " Warning: failed to fetch deployment config from ${PROXY_URL}/deployment/config" >&2
fi
if [ -n "$DEPLOY_RESPONSE" ]; then
MANAGED_CONFIG=$(json_get "$DEPLOY_RESPONSE" "managed_config")
REQUIREMENTS=$(json_get "$DEPLOY_RESPONSE" "requirements")
if [ -n "$MANAGED_CONFIG" ] && [ "$MANAGED_CONFIG" != "null" ]; then
printf '%s\n' "$MANAGED_CONFIG" > "$HOME/.grok/managed_config.toml"
echo " Managed config applied." >&2
else
rm -f "$HOME/.grok/managed_config.toml"
fi
if [ -n "$REQUIREMENTS" ] && [ "$REQUIREMENTS" != "null" ]; then
printf '%s\n' "$REQUIREMENTS" > "$HOME/.grok/requirements.toml"
echo " Requirements applied." >&2
else
rm -f "$HOME/.grok/requirements.toml"
fi
fi
fi
if [ "$os" = "windows" ]; then
echo "Grok $version installed to $BIN_DIR/grok.exe" >&2
else
echo "Grok $version installed to $BIN_DIR/grok" >&2
fi
# --- Ensure grok is on PATH ---
path_has_dir() {
case ":$PATH:" in *":$1:"*) return 0 ;; *) return 1 ;; esac
}
# Try to symlink into a directory already on PATH so grok works immediately
# without restarting the shell. Candidate dirs in preference order.
SYMLINK_CREATED=""
if [ "$os" != "windows" ] && ! path_has_dir "$BIN_DIR"; then
for candidate in "$HOME/.local/bin" "/usr/local/bin"; do
if path_has_dir "$candidate" && [ -d "$candidate" ] && [ -w "$candidate" ]; then
ln -sf "$BIN_DIR/grok" "$candidate/grok"
ln -sf "$BIN_DIR/agent" "$candidate/agent"
SYMLINK_CREATED="$candidate"
echo " Symlinked $candidate/grok -> $BIN_DIR/grok" >&2
echo " Symlinked $candidate/agent -> $BIN_DIR/agent" >&2
break
fi
done
fi
# Also update shell config so ~/.grok/bin is on PATH for future sessions
user_shell="$(basename "${SHELL:-}")"
config_file=""
case "$user_shell" in
bash) config_file="$HOME/.bashrc" ;;
zsh) config_file="$HOME/.zshrc" ;;
fish) config_file="$HOME/.config/fish/config.fish" ;;
esac
if [ -n "$config_file" ]; then
mkdir -p "$(dirname "$config_file")"
# Resolve symlinks so tmp+mv rewrites the stow/dotfiles target, not the link.
if [ -e "$config_file" ] || [ -L "$config_file" ]; then
_cf="$config_file"
_depth=0
while [ -L "$_cf" ] && [ "$_depth" -lt 40 ]; do
_link="$(readlink "$_cf")" || break
case "$_link" in
/*) _cf="$_link" ;;
*) _cf="$(cd "$(dirname "$_cf")" && pwd -P)/$_link" ;;
esac
_depth=$((_depth + 1))
done
# Still a symlink (cycle/cap): leave original path so we never rewrite the link.
if [ ! -L "$_cf" ]; then
config_file="$(cd "$(dirname "$_cf")" && pwd -P)/$(basename "$_cf")"
fi
unset _cf _link _depth
fi
# Build the new installer block
if [ "$user_shell" = "fish" ]; then
new_block='# >>> grok installer >>>
fish_add_path $HOME/.grok/bin
# <<< grok installer <<<'
elif [ "$user_shell" = "zsh" ]; then
new_block='# >>> grok installer >>>
export PATH="$HOME/.grok/bin:$PATH"
fpath=(~/.grok/completions/zsh $fpath)
autoload -Uz compinit && compinit -C
# <<< grok installer <<<'
else
new_block='# >>> grok installer >>>
export PATH="$HOME/.grok/bin:$PATH"
[[ -r "$HOME/.grok/completions/bash/grok.bash" ]] && source "$HOME/.grok/completions/bash/grok.bash"
# <<< grok installer <<<'
fi
if grep -qs "grok installer" "$config_file" 2>/dev/null; then
# Replace existing block in-place (strip old >>> to <<< lines, insert new)
tmp="$config_file.tmp.$$"
awk '
/# >>> grok installer >>>/ { skip=1; next }
/# <<< grok installer <<</ { skip=0; next }
!skip { print }
' "$config_file" > "$tmp" && mv "$tmp" "$config_file"
else
[ -f "$config_file" ] && cp "$config_file" "$config_file.bak.$(date +%s)"
# macOS bash: ensure bash_profile sources bashrc
if [ "$user_shell" = "bash" ] && [ "$(uname -s)" = "Darwin" ]; then
if [ -f "$HOME/.bash_profile" ] && ! grep -qs "source ~/.bashrc" "$HOME/.bash_profile"; then
printf '\n[[ -r ~/.bashrc ]] && source ~/.bashrc\n' >> "$HOME/.bash_profile"
fi
fi
fi
printf '\n%s\n' "$new_block" >> "$config_file"
echo " Updated $BIN_DIR in PATH in $config_file." >&2
fi
echo "" >&2
if path_has_dir "$BIN_DIR" || [ -n "$SYMLINK_CREATED" ]; then
echo "Run 'grok' or 'agent' to get started!" >&2
elif [ -n "$config_file" ]; then
echo "Restart your terminal, then run 'grok' or 'agent' to get started!" >&2
else
echo "Add $BIN_DIR to your PATH, then run 'grok' or 'agent' to get started:" >&2
echo ' export PATH="$HOME/.grok/bin:$PATH"' >&2
fi
if [ "$os" = "windows" ]; then
echo "To use grok from cmd.exe or PowerShell, add %USERPROFILE%\\.grok\\bin to your PATH." >&2
fi
-334
View File
@@ -1,334 +0,0 @@
#
# Grok CLI installer for PowerShell — https://x.ai/cli/install.ps1
#
# Auth: KIGI_DEPLOYMENT_KEY env var (takes precedence) or ~/.grok/auth.json from `grok login`.
# Env: KIGI_CHANNEL (stable|alpha|enterprise, default: stable), KIGI_BIN_DIR, KIGI_PROXY_URL
#
# Usage:
# irm https://x.ai/cli/install.ps1 | iex # latest stable
# & ([scriptblock]::Create((irm https://x.ai/cli/install.ps1))) -Version 0.1.42 # specific version
# $env:KIGI_VERSION="0.1.42"; irm https://x.ai/cli/install.ps1 | iex # specific version (alt)
# $env:KIGI_DEPLOYMENT_KEY="<key>"; irm https://x.ai/cli/install.ps1 | iex
#
param(
[Parameter(Position = 0)]
[string]$Version
)
$ErrorActionPreference = 'Stop'
# PS 5.1 defaults to TLS 1.0; GCS requires TLS 1.2.
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
# PS 5.1's Invoke-WebRequest progress bar is extremely slow; disable it.
$ProgressPreference = 'SilentlyContinue'
# Accept version from environment variable (useful with irm | iex).
if (-not $Version -and $env:KIGI_VERSION) {
$Version = $env:KIGI_VERSION
}
# This script is Windows-only. PS 5.1 has no Platform property and only runs on Windows.
if ($PSVersionTable.Platform -and $PSVersionTable.Platform -ne 'Win32NT') {
Write-Error "This installer is for Windows. On macOS/Linux, use: curl -fsSL https://x.ai/cli/install.sh | bash"
exit 1
}
$GrokDir = Join-Path $env:USERPROFILE '.grok'
# --- Helpers ---
function Download-String([string]$Url) {
try {
$response = Invoke-WebRequest -Uri $Url -UseBasicParsing
return $response.Content
} catch {
return $null
}
}
function Download-File([string]$Url, [string]$OutFile) {
# TODO: parallel byte-range download (matches install.sh download_file_parallel).
# Skipped for now: requires Start-ThreadJob / RunspacePool for true parallelism on PS 5.1
# and HEAD + Range request orchestration. Single-connection HttpWebRequest below remains.
# Stream via HttpWebRequest — faster than Invoke-WebRequest on PS 5.1 and supports progress.
$request = [System.Net.HttpWebRequest]::Create($Url)
$request.Timeout = 300000 # 5 min
$request.AutomaticDecompression = [System.Net.DecompressionMethods]::GZip -bor [System.Net.DecompressionMethods]::Deflate
$response = $request.GetResponse()
$totalBytes = $response.ContentLength
$stream = $response.GetResponseStream()
$fileStream = [System.IO.File]::Create($OutFile)
$buffer = New-Object byte[] 65536
$totalRead = 0
$lastPercent = -1
$lastMb = -1
try {
while (($read = $stream.Read($buffer, 0, $buffer.Length)) -gt 0) {
$fileStream.Write($buffer, 0, $read)
$totalRead += $read
$mb = [math]::Round($totalRead / 1MB, 1)
if ($totalBytes -gt 0) {
$percent = [math]::Min(100, [math]::Floor(($totalRead / $totalBytes) * 100))
if ($percent -ne $lastPercent) {
$totalMb = [math]::Round($totalBytes / 1MB, 1)
Write-Host "`r Downloading... ${mb} MB / ${totalMb} MB (${percent}%)" -NoNewline
$lastPercent = $percent
}
} elseif ($mb -ne $lastMb) {
Write-Host "`r Downloading... ${mb} MB" -NoNewline
$lastMb = $mb
}
}
Write-Host ''
} finally {
$fileStream.Close()
$stream.Close()
$response.Close()
}
}
function Read-GrokToken([string]$Scope) {
$authFile = Join-Path $GrokDir 'auth.json'
if (-not (Test-Path $authFile)) { return $null }
try {
$auth = Get-Content -Raw $authFile | ConvertFrom-Json
$entry = $auth.$Scope
if ($entry -and $entry.key) { return $entry.key }
} catch {}
return $null
}
# --- Validate version ---
if ($Version -and $Version -notmatch '^\d+\.\d+\.\d+(-\S+)?$') {
Write-Error "Invalid version format: $Version (expected X.Y.Z or X.Y.Z-suffix)"
exit 1
}
# --- Resolve auth ---
$OidcScope = 'https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828'
$LegacyScope = 'https://accounts.x.ai/sign-in'
$AuthSource = ''
if ($env:KIGI_DEPLOYMENT_KEY) {
$AuthSource = 'deployment key'
Write-Host 'Auth: using deployment key.' -ForegroundColor DarkGray
} else {
$oidcToken = Read-GrokToken $OidcScope
$legacyToken = Read-GrokToken $LegacyScope
if ($oidcToken) {
$AuthSource = 'auth.json (oidc)'
Write-Host 'Auth: using OIDC token from ~/.grok/auth.json.' -ForegroundColor DarkGray
} elseif ($legacyToken) {
$AuthSource = 'auth.json (legacy)'
Write-Host 'Auth: using legacy token from ~/.grok/auth.json.' -ForegroundColor DarkGray
}
}
# --- Detect architecture ---
$arch = switch ($env:PROCESSOR_ARCHITECTURE) {
'AMD64' { 'x86_64' }
'x86' { 'x86_64' } # 32-bit PS on 64-bit Windows
'ARM64' { 'aarch64' }
default { $null }
}
if (-not $arch) {
Write-Error "Unsupported architecture: $env:PROCESSOR_ARCHITECTURE"
exit 1
}
$platform = "windows-$arch"
# --- Resolve version and channel ---
$BaseUrlPrimary = 'https://x.ai/cli'
$BaseUrlFallback = 'https://storage.googleapis.com/grok-build-public-artifacts/cli'
$DownloadDir = Join-Path $GrokDir 'downloads'
$BinDir = if ($env:KIGI_BIN_DIR) { $env:KIGI_BIN_DIR } else { Join-Path $GrokDir 'bin' }
New-Item -ItemType Directory -Path $DownloadDir -Force | Out-Null
New-Item -ItemType Directory -Path $BinDir -Force | Out-Null
$Channel = if ($env:KIGI_CHANNEL) { $env:KIGI_CHANNEL } else { 'stable' }
# Pick a working BaseUrl: try Cloudflare-fronted x.ai first, fall back to
# direct GCS if it's unreachable. The probe doubles as the channel-pointer
# fetch when no -Version was passed, so the happy path costs zero extra requests.
if (-not $Version) { Write-Host "Fetching latest $Channel version..." -ForegroundColor DarkGray }
$probeResult = Download-String "$BaseUrlPrimary/$Channel"
if ($probeResult) {
$BaseUrl = $BaseUrlPrimary
} else {
Write-Host "Note: $BaseUrlPrimary unreachable, falling back to direct GCS." -ForegroundColor Yellow
$BaseUrl = $BaseUrlFallback
$probeResult = Download-String "$BaseUrl/$Channel"
}
if ($Version) {
$resolvedVersion = $Version
} elseif ($probeResult) {
$resolvedVersion = $probeResult.Trim()
} else {
Write-Error "Failed to fetch latest version from $BaseUrlPrimary/$Channel and $BaseUrlFallback/$Channel"
exit 1
}
if ($AuthSource) {
Write-Host "Installing Grok $resolvedVersion ($platform, $AuthSource)..." -ForegroundColor Cyan
} else {
Write-Host "Installing Grok $resolvedVersion ($platform)..." -ForegroundColor Cyan
}
# --- Download binary ---
$binaryPath = Join-Path $DownloadDir "grok-$platform.exe"
$artifactBase = "$BaseUrl/grok-$resolvedVersion-$platform"
$downloaded = $false
foreach ($url in @("$artifactBase.exe", $artifactBase)) {
try {
Download-File $url $binaryPath
$downloaded = $true
break
} catch {
continue
}
}
if (-not $downloaded) {
if (Test-Path $binaryPath) { Remove-Item $binaryPath -Force }
Write-Error "Binary download failed from $artifactBase.exe and $artifactBase"
exit 1
}
# --- Install binary (locked-file safe) ---
foreach ($binName in @('grok.exe', 'agent.exe')) {
$dest = Join-Path $BinDir $binName
$old = "$dest.old"
if (Test-Path $old) { Remove-Item $old -Force -ErrorAction SilentlyContinue }
try {
Copy-Item -Path $binaryPath -Destination $dest -Force
} catch {
try {
if (Test-Path $dest) { Rename-Item $dest $old -Force -ErrorAction SilentlyContinue }
Copy-Item -Path $binaryPath -Destination $dest -Force
} catch {
if (Test-Path $old) { Rename-Item $old $dest -Force -ErrorAction SilentlyContinue }
Write-Error "Failed to install $binName"
exit 1
}
}
}
Write-Host " Installed to $BinDir\grok.exe and $BinDir\agent.exe." -ForegroundColor DarkGray
# --- Generate completions (best-effort) ---
$completionsDir = Join-Path (Join-Path $GrokDir 'completions') 'powershell'
try {
New-Item -ItemType Directory -Path $completionsDir -Force | Out-Null
& (Join-Path $BinDir 'grok.exe') completions powershell 2>$null |
Set-Content (Join-Path $completionsDir 'grok.ps1') -ErrorAction SilentlyContinue
} catch {}
# --- Persist installer config ---
$ConfigFile = Join-Path $GrokDir 'config.toml'
$cliLines = @('installer = "internal"')
if ($Channel -ne 'stable') {
$cliLines += "channel = `"$Channel`""
}
if (-not (Test-Path $ConfigFile)) {
New-Item -ItemType Directory -Path (Split-Path $ConfigFile) -Force | Out-Null
$content = "[cli]`r`n" + ($cliLines -join "`r`n") + "`r`n"
[System.IO.File]::WriteAllText($ConfigFile, $content, [System.Text.Encoding]::UTF8)
} elseif ((Get-Content -Raw $ConfigFile) -match '(?m)^\[cli\]') {
# Section-aware: only replace installer/channel under [cli], not other sections.
$existingLines = Get-Content $ConfigFile
$output = [System.Collections.ArrayList]::new()
$inCli = $false
foreach ($line in $existingLines) {
if ($line -match '^\[cli\]\s*(#.*)?$') {
[void]$output.Add($line)
foreach ($cl in $cliLines) { [void]$output.Add($cl) }
$inCli = $true
continue
}
if ($line -match '^\[.+\]\s*(#.*)?$') {
$inCli = $false
}
if ($inCli -and $line -match '^\s*(installer|channel)\s*=') {
continue
}
[void]$output.Add($line)
}
[System.IO.File]::WriteAllLines($ConfigFile, [string[]]$output.ToArray(), [System.Text.Encoding]::UTF8)
} else {
Add-Content -Path $ConfigFile -Value "`r`n[cli]`r`n$($cliLines -join "`r`n")`r`n"
}
# --- Fetch deployment config (deployment key only) ---
if ($env:KIGI_DEPLOYMENT_KEY) {
$ProxyUrl = if ($env:KIGI_PROXY_URL) { $env:KIGI_PROXY_URL } else { 'https://cli-chat-proxy.grok.com/v1' }
Write-Host ' Fetching deployment config...' -ForegroundColor DarkGray
try {
$headers = @{ 'Authorization' = "Bearer $($env:KIGI_DEPLOYMENT_KEY)" }
$deployResponse = Invoke-RestMethod -Uri "$ProxyUrl/deployment/config" -Headers $headers -UseBasicParsing
} catch {
Write-Host " Warning: failed to fetch deployment config from $ProxyUrl/deployment/config" -ForegroundColor Yellow
$deployResponse = $null
}
if ($deployResponse) {
$managedConfig = $deployResponse.managed_config
$requirements = $deployResponse.requirements
$managedConfigPath = Join-Path $GrokDir 'managed_config.toml'
$requirementsPath = Join-Path $GrokDir 'requirements.toml'
if ($managedConfig -and $managedConfig -ne 'null') {
[System.IO.File]::WriteAllText($managedConfigPath, $managedConfig, [System.Text.Encoding]::UTF8)
Write-Host ' Managed config applied.' -ForegroundColor DarkGray
} else {
if (Test-Path $managedConfigPath) { Remove-Item $managedConfigPath -Force }
}
if ($requirements -and $requirements -ne 'null') {
[System.IO.File]::WriteAllText($requirementsPath, $requirements, [System.Text.Encoding]::UTF8)
Write-Host ' Requirements applied.' -ForegroundColor DarkGray
} else {
if (Test-Path $requirementsPath) { Remove-Item $requirementsPath -Force }
}
}
}
Write-Host "Grok $resolvedVersion installed to $BinDir\grok.exe" -ForegroundColor Green
# --- Ensure grok is on PATH ---
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
$pathEntries = if ($userPath) { $userPath -split ';' | Where-Object { $_ -ne '' } } else { @() }
if ($pathEntries -notcontains $BinDir) {
$newPath = (@($BinDir) + $pathEntries) -join ';'
[Environment]::SetEnvironmentVariable('Path', $newPath, 'User')
Write-Host " Added $BinDir to your User PATH." -ForegroundColor DarkGray
# Update current session so grok works immediately.
if ($env:Path -notlike "*$BinDir*") {
$env:Path = "$BinDir;$env:Path"
}
}
Write-Host ''
Write-Host "Run 'grok' or 'agent' to get started!" -ForegroundColor Cyan
-447
View File
@@ -1,447 +0,0 @@
#!/bin/bash
#
# Grok CLI installer — https://x.ai/cli/install.sh
#
# Auth: KIGI_DEPLOYMENT_KEY (takes precedence) or ~/.grok/auth.json from `grok login`.
# Env: KIGI_CHANNEL (stable|alpha|enterprise, default: stable), KIGI_BIN_DIR, KIGI_PROXY_URL
#
# Usage:
# curl -fsSL https://x.ai/cli/install.sh | bash # latest stable
# curl -fsSL https://x.ai/cli/install.sh | bash -s 0.1.42 # specific version
# KIGI_DEPLOYMENT_KEY=<key> bash <(curl -fsSL https://x.ai/cli/install.sh)
#
# Windows: run under Git for Windows / MSYS2 Bash (same curl | bash flow); WSL
# uses the Linux binary.
set -e
TARGET="$1"
if [[ -n "$TARGET" ]] && [[ ! "$TARGET" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9._]+)?$ ]]; then
echo "Invalid version format: $TARGET (expected X.Y.Z or X.Y.Z-suffix)" >&2
exit 1
fi
DOWNLOADER=""
if command -v curl >/dev/null 2>&1; then
DOWNLOADER="curl"
elif command -v wget >/dev/null 2>&1; then
DOWNLOADER="wget"
else
echo "Either curl or wget is required but neither is installed" >&2
exit 1
fi
download_file() {
local url="$1" output="$2"
if [ "$DOWNLOADER" = "curl" ]; then
if [ -n "$output" ]; then
curl -fsSL -o "$output" "$url"
else
curl -fsSL "$url"
fi
else
if [ -n "$output" ]; then
wget -q -O "$output" "$url"
else
wget -q -O - "$url"
fi
fi
}
# Parallel byte-range download. Falls back to single-connection download_file
# whenever HEAD lacks Content-Length, the file is small (<16 MiB), curl is
# unavailable, or any chunk fetch / concat fails.
download_file_parallel() {
local url="$1" output="$2"
if [ "$DOWNLOADER" != "curl" ]; then
download_file "$url" "$output"
return
fi
local size
size=$(curl -fsSL --head "$url" 2>/dev/null | awk -F'[: \r\n]+' 'tolower($1)=="content-length"{print $2; exit}')
if [ -z "$size" ] || ! [ "$size" -ge 16777216 ] 2>/dev/null; then
download_file "$url" "$output"
return
fi
local n=8
local chunk_size=$(( (size + n - 1) / n ))
local tmpdir
tmpdir=$(mktemp -d 2>/dev/null) || { download_file "$url" "$output"; return; }
local pids=() i start end
for i in $(seq 0 $((n - 1))); do
start=$((i * chunk_size))
end=$((start + chunk_size - 1))
[ $end -ge $size ] && end=$((size - 1))
curl -fsSL -r "${start}-${end}" -o "${tmpdir}/$(printf 'chunk.%03d' "$i")" "$url" &
pids+=($!)
done
local all_ok=true pid
for pid in "${pids[@]}"; do
wait "$pid" || all_ok=false
done
if [ "$all_ok" = true ] && cat "${tmpdir}"/chunk.* > "$output" 2>/dev/null; then
rm -rf "$tmpdir"
return 0
fi
rm -rf "$tmpdir"
download_file "$url" "$output"
}
# Return 0 if a HEAD request for the URL gets HTTP 404.
is_not_found() {
local url="$1" code
if [ "$DOWNLOADER" = "curl" ]; then
code=$(curl -o /dev/null -sSL -w '%{http_code}' --head "$url" 2>/dev/null) || true
else
code=$(wget --server-response --spider "$url" 2>&1 | awk '/HTTP\//{print $2}' | tail -1) || true
fi
[ "$code" = "404" ]
}
# JSON field extractor — extract a top-level string value using sed.
json_get() {
local json="$1" field="$2"
# Extract value (handling \" inside strings), then unescape JSON sequences.
printf '%s' "$json" | sed -n -E 's/.*"'"$field"'"[[:space:]]*:[[:space:]]*"(([^"\\]|\\.)*)".*/\1/p' | head -1 \
| sed -e 's/\\"/"/g' -e 's/\\n/\'$'\n''/g' -e 's/\\t/\'$'\t''/g' -e 's/\\\\/\\/g'
}
# Read a token from ~/.grok/auth.json for the given scope key.
# Format: {"scope_url": {"key": "token"}, ...}
read_grok_token() {
local auth_file="$HOME/.grok/auth.json"
local scope="$1"
[ -f "$auth_file" ] || return 1
# Flatten to one line then extract: find the scope, then the "key" value after it
tr -d '\n' < "$auth_file" | sed -n 's|.*"'"$scope"'"[[:space:]]*:[[:space:]]*{[^}]*"key"[[:space:]]*:[[:space:]]*"\([^"]*\)".*|\1|p' | head -1
}
# Resolve auth: KIGI_DEPLOYMENT_KEY > OIDC token > legacy token
OIDC_SCOPE="https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828"
LEGACY_SCOPE="https://accounts.x.ai/sign-in"
AUTH_SOURCE=""
if [ -n "$KIGI_DEPLOYMENT_KEY" ]; then
AUTH_SOURCE="deployment key"
echo "Auth: using deployment key." >&2
else
OIDC_TOKEN=$(read_grok_token "$OIDC_SCOPE" 2>/dev/null) || true
LEGACY_TOKEN=$(read_grok_token "$LEGACY_SCOPE" 2>/dev/null) || true
if [ -n "$OIDC_TOKEN" ]; then
AUTH_SOURCE="auth.json (oidc)"
echo "Auth: using OIDC token from ~/.grok/auth.json." >&2
elif [ -n "$LEGACY_TOKEN" ]; then
AUTH_SOURCE="auth.json (legacy)"
echo "Auth: using legacy token from ~/.grok/auth.json." >&2
fi
fi
case "$(uname -s)" in
Darwin) os="macos" ;;
Linux) os="linux" ;;
# Git for Windows / MSYS2 / Cygwin host — native Windows builds
MINGW* | MSYS* | CYGWIN*) os="windows" ;;
*) echo "Unsupported OS: $(uname -s)" >&2; exit 1 ;;
esac
case "$(uname -m)" in
x86_64|amd64|AMD64) arch="x86_64" ;;
arm64|aarch64|ARM64) arch="aarch64" ;;
*) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;;
esac
BASE_URL_PRIMARY="https://x.ai/cli"
BASE_URL_FALLBACK="https://storage.googleapis.com/grok-build-public-artifacts/cli"
DOWNLOAD_DIR="$HOME/.grok/downloads"
BIN_DIR="${KIGI_BIN_DIR:-$HOME/.grok/bin}"
mkdir -p "$DOWNLOAD_DIR" "$BIN_DIR"
platform="${os}-${arch}"
CHANNEL="${KIGI_CHANNEL:-stable}"
# Pick a working BASE_URL: try Cloudflare-fronted x.ai first, fall back to
# direct GCS if it's unreachable. The probe doubles as the channel-pointer
# fetch when no explicit TARGET was passed, so the happy path costs zero
# extra HTTP requests.
if [ -z "$TARGET" ]; then echo "Fetching latest ${CHANNEL} version..." >&2; fi
probe_result=$(download_file "${BASE_URL_PRIMARY}/${CHANNEL}" 2>/dev/null) || true
if [ -n "$probe_result" ]; then
BASE_URL="$BASE_URL_PRIMARY"
else
echo "Note: ${BASE_URL_PRIMARY} unreachable, falling back to direct GCS." >&2
BASE_URL="$BASE_URL_FALLBACK"
probe_result=$(download_file "${BASE_URL}/${CHANNEL}" 2>/dev/null) || true
fi
if [ -n "$TARGET" ]; then
version="$TARGET"
else
version=$(printf '%s' "$probe_result" | tr -d '\r' | head -n1 | tr -d '[:space:]')
if [ -z "$version" ]; then
echo "Error: failed to fetch latest version from ${BASE_URL_PRIMARY}/${CHANNEL} and ${BASE_URL_FALLBACK}/${CHANNEL}" >&2
exit 1
fi
fi
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9._]+)?$ ]]; then
echo "Invalid version format: $version (expected X.Y.Z or X.Y.Z-suffix)" >&2
exit 1
fi
if [ -n "$AUTH_SOURCE" ]; then
echo "Installing Grok $version ($platform, $AUTH_SOURCE)..." >&2
else
echo "Installing Grok $version ($platform)..." >&2
fi
binary_path="$DOWNLOAD_DIR/grok-$platform"
artifact_base="${BASE_URL}/grok-${version}-${platform}"
if [ "$os" = "windows" ]; then
binary_path="${binary_path}.exe"
fi
binary_tmp="${binary_path}.tmp.$$"
rm -f "$binary_tmp" 2>/dev/null || true
echo " Downloading grok ${version}..." >&2
if [ "$os" = "windows" ]; then
if ! download_file_parallel "${artifact_base}.exe" "$binary_tmp"; then
if ! download_file_parallel "$artifact_base" "$binary_tmp"; then
rm -f "$binary_tmp"
if is_not_found "${artifact_base}.exe"; then
echo "Error: Grok is not yet available for your system ($platform)." >&2
else
echo "Error: binary download failed (${artifact_base}.exe and ${artifact_base})" >&2
fi
exit 1
fi
fi
elif ! download_file_parallel "$artifact_base" "$binary_tmp"; then
rm -f "$binary_tmp"
if is_not_found "$artifact_base"; then
echo "Error: Grok is not yet available for your system ($platform)." >&2
else
echo "Error: binary download failed from ${artifact_base}" >&2
fi
exit 1
fi
if [ "$os" = "windows" ]; then
mv -f "$binary_tmp" "$binary_path"
# Symlinks require Developer Mode on Windows; copy instead.
# If the exe is locked by a running process, rename it aside then retry.
for bin_name in grok.exe agent.exe; do
rm -f "$BIN_DIR/$bin_name.old" 2>/dev/null || true # stale backup from prior update
if ! cp -f "$binary_path" "$BIN_DIR/$bin_name" 2>/dev/null; then
mv -f "$BIN_DIR/$bin_name" "$BIN_DIR/$bin_name.old" 2>/dev/null || true
if ! cp -f "$binary_path" "$BIN_DIR/$bin_name" 2>/dev/null; then
# Rollback: restore the old binary so the install isn't broken.
mv -f "$BIN_DIR/$bin_name.old" "$BIN_DIR/$bin_name" 2>/dev/null || true
echo "Error: failed to install $bin_name" >&2
exit 1
fi
fi
done
echo " Binary installed to $BIN_DIR/grok.exe and $BIN_DIR/agent.exe." >&2
else
chmod +x "$binary_tmp"
if ! "$binary_tmp" --version </dev/null >/dev/null 2>&1; then
echo "Error: downloaded grok failed to run; keeping the existing install." >&2
rm -f "$binary_tmp"
exit 1
fi
mv -f "$binary_tmp" "$binary_path"
# Use relative symlinks when BIN_DIR and DOWNLOAD_DIR share a parent
# (default layout: ~/.grok/bin and ~/.grok/downloads are siblings).
# Relative symlinks survive Docker bind-mounts with a different $HOME.
if [ "$(dirname "$BIN_DIR")" = "$(dirname "$DOWNLOAD_DIR")" ]; then
link_target="../$(basename "$DOWNLOAD_DIR")/$(basename "$binary_path")"
else
link_target="$binary_path"
fi
ln -sf "$link_target" "$BIN_DIR/grok"
ln -sf "$link_target" "$BIN_DIR/agent"
echo " Binary linked to $BIN_DIR/grok and $BIN_DIR/agent." >&2
fi
# Generate shell completions (best-effort)
mkdir -p "$HOME/.grok/completions/bash" "$HOME/.grok/completions/zsh"
"$BIN_DIR/grok" completions bash > "$HOME/.grok/completions/bash/grok.bash" 2>/dev/null || true
"$BIN_DIR/grok" completions zsh > "$HOME/.grok/completions/zsh/_grok" 2>/dev/null || true
# Fish: write to the auto-loaded completions dir so it works immediately
if mkdir -p "$HOME/.config/fish/completions" 2>/dev/null; then
"$BIN_DIR/grok" completions fish > "$HOME/.config/fish/completions/grok.fish" 2>/dev/null || true
fi
# Persist installer source and channel to config
CONFIG_FILE="$HOME/.grok/config.toml"
CLI_BLOCK="installer = \"internal\""
if [ "$CHANNEL" != "stable" ]; then
CLI_BLOCK="${CLI_BLOCK}\nchannel = \"${CHANNEL}\""
fi
if [ ! -f "$CONFIG_FILE" ]; then
printf '[cli]\n%b\n' "$CLI_BLOCK" > "$CONFIG_FILE"
elif grep -q '^\[cli\]' "$CONFIG_FILE"; then
tmp="$CONFIG_FILE.tmp.$$"
awk -v block="$CLI_BLOCK" '
/^\[cli\][[:space:]]*(#.*)?$/ { print; printf "%s\n", block; in_cli=1; next }
/^\[.*\][[:space:]]*(#.*)?$/ { in_cli=0 }
in_cli && /^[[:space:]]*(installer|channel)[[:space:]]*=/ { next }
{ print }
' "$CONFIG_FILE" > "$tmp" && mv "$tmp" "$CONFIG_FILE"
else
printf '\n[cli]\n%b\n' "$CLI_BLOCK" >> "$CONFIG_FILE"
fi
# Fetch managed_config.toml + requirements.toml from server (deployment key only).
if [ -n "$KIGI_DEPLOYMENT_KEY" ]; then
PROXY_URL="${KIGI_PROXY_URL:-https://cli-chat-proxy.grok.com/v1}"
echo " Fetching deployment config..." >&2
DEPLOY_RESPONSE=""
AUTH_HEADER_FILE=$(mktemp 2>/dev/null) || AUTH_HEADER_FILE=""
if [ -n "$AUTH_HEADER_FILE" ]; then
chmod 600 "$AUTH_HEADER_FILE" 2>/dev/null || true
printf 'Authorization: Bearer %s\n' "$KIGI_DEPLOYMENT_KEY" > "$AUTH_HEADER_FILE"
DEPLOY_RESPONSE=$(curl -sS -f \
-H "@${AUTH_HEADER_FILE}" \
"${PROXY_URL}/deployment/config" 2>/dev/null) || DEPLOY_RESPONSE=""
: > "$AUTH_HEADER_FILE" 2>/dev/null || true
rm -f "$AUTH_HEADER_FILE"
fi
if [ -z "$DEPLOY_RESPONSE" ]; then
echo " Warning: failed to fetch deployment config from ${PROXY_URL}/deployment/config" >&2
fi
if [ -n "$DEPLOY_RESPONSE" ]; then
MANAGED_CONFIG=$(json_get "$DEPLOY_RESPONSE" "managed_config")
REQUIREMENTS=$(json_get "$DEPLOY_RESPONSE" "requirements")
if [ -n "$MANAGED_CONFIG" ] && [ "$MANAGED_CONFIG" != "null" ]; then
printf '%s\n' "$MANAGED_CONFIG" > "$HOME/.grok/managed_config.toml"
echo " Managed config applied." >&2
else
rm -f "$HOME/.grok/managed_config.toml"
fi
if [ -n "$REQUIREMENTS" ] && [ "$REQUIREMENTS" != "null" ]; then
printf '%s\n' "$REQUIREMENTS" > "$HOME/.grok/requirements.toml"
echo " Requirements applied." >&2
else
rm -f "$HOME/.grok/requirements.toml"
fi
fi
fi
if [ "$os" = "windows" ]; then
echo "Grok $version installed to $BIN_DIR/grok.exe" >&2
else
echo "Grok $version installed to $BIN_DIR/grok" >&2
fi
# --- Ensure grok is on PATH ---
path_has_dir() {
case ":$PATH:" in *":$1:"*) return 0 ;; *) return 1 ;; esac
}
# Try to symlink into a directory already on PATH so grok works immediately
# without restarting the shell. Candidate dirs in preference order.
SYMLINK_CREATED=""
if [ "$os" != "windows" ] && ! path_has_dir "$BIN_DIR"; then
for candidate in "$HOME/.local/bin" "/usr/local/bin"; do
if path_has_dir "$candidate" && [ -d "$candidate" ] && [ -w "$candidate" ]; then
ln -sf "$BIN_DIR/grok" "$candidate/grok"
ln -sf "$BIN_DIR/agent" "$candidate/agent"
SYMLINK_CREATED="$candidate"
echo " Symlinked $candidate/grok -> $BIN_DIR/grok" >&2
echo " Symlinked $candidate/agent -> $BIN_DIR/agent" >&2
break
fi
done
fi
# Also update shell config so ~/.grok/bin is on PATH for future sessions
user_shell="$(basename "${SHELL:-}")"
config_file=""
case "$user_shell" in
bash) config_file="$HOME/.bashrc" ;;
zsh) config_file="$HOME/.zshrc" ;;
fish) config_file="$HOME/.config/fish/config.fish" ;;
esac
if [ -n "$config_file" ]; then
mkdir -p "$(dirname "$config_file")"
# Resolve symlinks so tmp+mv rewrites the stow/dotfiles target, not the link.
if [ -e "$config_file" ] || [ -L "$config_file" ]; then
_cf="$config_file"
_depth=0
while [ -L "$_cf" ] && [ "$_depth" -lt 40 ]; do
_link="$(readlink "$_cf")" || break
case "$_link" in
/*) _cf="$_link" ;;
*) _cf="$(cd "$(dirname "$_cf")" && pwd -P)/$_link" ;;
esac
_depth=$((_depth + 1))
done
# Still a symlink (cycle/cap): leave original path so we never rewrite the link.
if [ ! -L "$_cf" ]; then
config_file="$(cd "$(dirname "$_cf")" && pwd -P)/$(basename "$_cf")"
fi
unset _cf _link _depth
fi
# Build the new installer block
if [ "$user_shell" = "fish" ]; then
new_block='# >>> grok installer >>>
fish_add_path $HOME/.grok/bin
# <<< grok installer <<<'
elif [ "$user_shell" = "zsh" ]; then
new_block='# >>> grok installer >>>
export PATH="$HOME/.grok/bin:$PATH"
fpath=(~/.grok/completions/zsh $fpath)
autoload -Uz compinit && compinit -C
# <<< grok installer <<<'
else
new_block='# >>> grok installer >>>
export PATH="$HOME/.grok/bin:$PATH"
[[ -r "$HOME/.grok/completions/bash/grok.bash" ]] && source "$HOME/.grok/completions/bash/grok.bash"
# <<< grok installer <<<'
fi
if grep -qs "grok installer" "$config_file" 2>/dev/null; then
# Replace existing block in-place (strip old >>> to <<< lines, insert new)
tmp="$config_file.tmp.$$"
awk '
/# >>> grok installer >>>/ { skip=1; next }
/# <<< grok installer <<</ { skip=0; next }
!skip { print }
' "$config_file" > "$tmp" && mv "$tmp" "$config_file"
else
[ -f "$config_file" ] && cp "$config_file" "$config_file.bak.$(date +%s)"
# macOS bash: ensure bash_profile sources bashrc
if [ "$user_shell" = "bash" ] && [ "$(uname -s)" = "Darwin" ]; then
if [ -f "$HOME/.bash_profile" ] && ! grep -qs "source ~/.bashrc" "$HOME/.bash_profile"; then
printf '\n[[ -r ~/.bashrc ]] && source ~/.bashrc\n' >> "$HOME/.bash_profile"
fi
fi
fi
printf '\n%s\n' "$new_block" >> "$config_file"
echo " Updated $BIN_DIR in PATH in $config_file." >&2
fi
echo "" >&2
if path_has_dir "$BIN_DIR" || [ -n "$SYMLINK_CREATED" ]; then
echo "Run 'grok' or 'agent' to get started!" >&2
elif [ -n "$config_file" ]; then
echo "Restart your terminal, then run 'grok' or 'agent' to get started!" >&2
else
echo "Add $BIN_DIR to your PATH, then run 'grok' or 'agent' to get started:" >&2
echo ' export PATH="$HOME/.grok/bin:$PATH"' >&2
fi
if [ "$os" = "windows" ]; then
echo "To use grok from cmd.exe or PowerShell, add %USERPROFILE%\\.grok\\bin to your PATH." >&2
fi
+11
View File
@@ -12,6 +12,7 @@ reqwest = { workspace = true }
semver = { workspace = true } semver = { workspace = true }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true } serde_json = { workspace = true }
sha2 = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
time = { workspace = true, features = ["serde"] } time = { workspace = true, features = ["serde"] }
tokio = { workspace = true, features = ["fs", "process", "io-util", "macros", "time"] } tokio = { workspace = true, features = ["fs", "process", "io-util", "macros", "time"] }
@@ -21,10 +22,20 @@ kigi-env = { workspace = true }
kigi-tools = { workspace = true } kigi-tools = { workspace = true }
kigi-version = { workspace = true } kigi-version = { workspace = true }
# Release archives: tar.gz on Unix, zip on Windows (PRD F8).
[target.'cfg(not(windows))'.dependencies]
flate2 = { workspace = true }
tar = { workspace = true }
[target.'cfg(windows)'.dependencies]
zip = { workspace = true }
[dev-dependencies] [dev-dependencies]
flate2 = { workspace = true }
semver = { workspace = true } semver = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
serial_test = { workspace = true } serial_test = { workspace = true }
tar = { workspace = true }
tempfile = { workspace = true } tempfile = { workspace = true }
time = { workspace = true, features = ["serde"] } time = { workspace = true, features = ["serde"] }
tokio = { workspace = true, features = ["fs", "macros", "rt-multi-thread", "test-util"] } tokio = { workspace = true, features = ["fs", "macros", "rt-multi-thread", "test-util"] }
File diff suppressed because it is too large Load Diff
@@ -1,15 +1,15 @@
//! Minimum-version enforcement. //! 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 //! 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 //! Set `KIGI_TEST_VERSION` to manually exercise either path without producing
//! a real out-of-date build. //! a real out-of-date build.
use crate::auto_update::{get_installer, run_install_script}; use crate::auto_update::{get_installer, run_install_script};
use crate::version::{ 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 kigi_shell::util::config;
use tracing::{info, warn}; use tracing::{info, warn};
@@ -36,7 +36,7 @@ enum EnforcementOutcome {
pub(crate) enum MinimumVersionError { pub(crate) enum MinimumVersionError {
/// `source` chains via `Error::source()`; omitted from `Display`. /// `source` chains via `Error::source()`; omitted from `Display`.
#[error( #[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." isn't a valid version number. Update `cli.minimum_version` and try again."
)] )]
InvalidMinimum { InvalidMinimum {
@@ -45,22 +45,22 @@ pub(crate) enum MinimumVersionError {
source: semver::Error, source: semver::Error,
}, },
#[error( #[error(
"This version of Grok ({current}) is no longer supported. \ "This version of Kigi ({current}) is no longer supported. \
Run `grok update` to install version {minimum} or later." Run `kigi update` to install version {minimum} or later."
)] )]
AutoUpdateDisabled { current: String, minimum: String }, AutoUpdateDisabled { current: String, minimum: String },
/// `npm` / `gh` / `internal` GCS — none detected. /// No installer backend detected.
#[error( #[error(
"This version of Grok ({current}) is no longer supported. \ "This version of Kigi ({current}) is no longer supported. \
Run `grok update` to install version {minimum} or later." Run `kigi update` to install version {minimum} or later."
)] )]
NoInstaller { current: String, minimum: String }, NoInstaller { current: String, minimum: String },
/// `detail` is telemetry-only; omitted from `Display` to avoid stacking /// `detail` is telemetry-only; omitted from `Display` to avoid stacking
/// the installer's own action language. /// the installer's own action language.
#[error( #[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\ and the update to version {minimum} didn't complete.\n\n\
Run `grok update` to try again." Run `kigi update` to try again."
)] )]
UpgradeFailed { UpgradeFailed {
current: String, current: String,
@@ -70,7 +70,7 @@ pub(crate) enum MinimumVersionError {
/// Latest release is known but still below the floor (vs `NoReleaseFound`, /// Latest release is known but still below the floor (vs `NoReleaseFound`,
/// which couldn't probe at all). /// which couldn't probe at all).
#[error( #[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}. \ Version {minimum} or later is required, but the most recent release is {latest}. \
Contact your administrator." Contact your administrator."
)] )]
@@ -81,15 +81,15 @@ pub(crate) enum MinimumVersionError {
}, },
/// Couldn't probe the registry — likely transient. /// Couldn't probe the registry — likely transient.
#[error( #[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. \ Version {minimum} or later is required, but no release was found. \
Check your network connection, or contact your administrator." Check your network connection, or contact your administrator."
)] )]
NoReleaseFound { current: String, minimum: String }, 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( #[error(
"Cannot install Grok {target}: the configured minimum is {minimum}. \ "Cannot install Kigi {target}: the configured minimum is {minimum}. \
Run `grok update` to install the latest allowed version." Run `kigi update` to install the latest allowed version."
)] )]
TargetBelowFloor { target: String, minimum: String }, TargetBelowFloor { target: String, minimum: String },
} }
@@ -133,7 +133,7 @@ fn evaluate_minimum_version(
} }
/// Refuse an explicit install target below the configured floor. /// 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> { pub(crate) fn check_install_target(target: &str) -> Result<(), MinimumVersionError> {
let floor = resolve_floor_or_error()?; let floor = resolve_floor_or_error()?;
check_install_target_inner(target, floor.as_deref()) 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. /// `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> { pub(crate) fn apply_floor(target: &str) -> Result<String, MinimumVersionError> {
let floor = resolve_floor_or_error()?; let floor = resolve_floor_or_error()?;
apply_floor_inner(target, floor.as_deref()) apply_floor_inner(target, floor.as_deref())
@@ -193,7 +193,7 @@ async fn enforce_minimum_version(
minimum_version: Option<&str>, minimum_version: Option<&str>,
update_config: &UpdateConfig, update_config: &UpdateConfig,
) -> Result<EnforcementOutcome, MinimumVersionError> { ) -> Result<EnforcementOutcome, MinimumVersionError> {
let current_version = get_installed_grok_version(); let current_version = get_installed_kigi_version();
let decision = evaluate_minimum_version(&current_version, minimum_version)?; let decision = evaluate_minimum_version(&current_version, minimum_version)?;
let MinimumVersionDecision::BelowMinimum { current, minimum } = decision else { let MinimumVersionDecision::BelowMinimum { current, minimum } = decision else {
info!(current = %current_version, "minimum_version: floor satisfied"); info!(current = %current_version, "minimum_version: floor satisfied");
@@ -214,12 +214,12 @@ async fn enforce_minimum_version(
return Err(MinimumVersionError::NoInstaller { current, minimum }); 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); let target = pick_target_version(latest.as_deref(), &minimum);
info!(%current, %target, installer, "minimum_version: installing upgrade"); info!(%current, %target, installer, "minimum_version: installing upgrade");
eprintln!( eprintln!(
"This version of Grok ({current}) is no longer supported. \ "This version of Kigi ({current}) is no longer supported. \
Updating to {target}" 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 { match enforce_minimum_version(Some(&min), update_config).await {
Ok(EnforcementOutcome::Allowed) => {} Ok(EnforcementOutcome::Allowed) => {}
Ok(EnforcementOutcome::Upgraded) => { 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 // SIGABRT risk as the old piped-stderr update path if the
// child process ever writes to a broken pipe. For now this // child process ever writes to a broken pipe. For now this
// path is rare (only fires when the server pushes a minimum // path is rare (only fires when the server pushes a minimum
// version bump), so print a relaunch message instead. // 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); std::process::exit(0);
} }
Err(e) => { Err(e) => {
@@ -370,7 +370,7 @@ mod tests {
// SAFETY: #[serial] excludes other env-touching tests. // SAFETY: #[serial] excludes other env-touching tests.
unsafe { std::env::set_var("KIGI_TEST_VERSION", "0.1.50") }; unsafe { std::env::set_var("KIGI_TEST_VERSION", "0.1.50") };
let decision = 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!( assert!(matches!(
decision, decision,
MinimumVersionDecision::BelowMinimum { .. } MinimumVersionDecision::BelowMinimum { .. }
+350 -396
View File
@@ -2,28 +2,19 @@ use std::time::Duration;
use anyhow::Result; use anyhow::Result;
use serde::Deserialize; use serde::Deserialize;
use serde_json::Value;
use tokio::fs; use tokio::fs;
use tokio::process::Command;
use kigi_shell::util::kigi_home::kigi_home; use kigi_shell::util::kigi_home::kigi_home;
const TTL_SECONDS_BEFORE_AUTO_UPDATE: Duration = Duration::from_secs(60 * 30); 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 /// Release channel: this repo's GitHub Releases (PRD F8). The API base is
/// for binaries and origin-respecting no-cache for channel pointers. /// resolved through [`kigi_env::update_base_url`] — production default
pub(crate) const CLI_BASE_URL_PRIMARY: &str = "https://x.ai/cli"; /// `https://api.github.com/repos/ZacharyZhang-NY/Kigi-CLI/releases`,
/// overridable via `KIGI_UPDATE_BASE_URL` for mirrors and tests.
/// Fallback CLI base URL: direct GCS, used when the primary is unreachable pub(crate) fn update_base_url() -> String {
/// (Cloudflare outage, regional CF egress issue, DNS hijack, etc.). kigi_env::update_base_url()
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. /// Minimal configuration the update system needs from the environment.
/// ///
@@ -40,8 +31,6 @@ pub struct UpdateConfig {
pub alpha_test_key: Option<String>, pub alpha_test_key: Option<String>,
/// Release channel: "stable" or "alpha". Loaded from config. /// Release channel: "stable" or "alpha". Loaded from config.
pub channel: String, pub channel: String,
/// Custom npm registry URL. When set, passed as `--registry=` to npm CLI.
pub npm_registry: Option<String>,
} }
impl UpdateConfig { impl UpdateConfig {
@@ -52,20 +41,216 @@ impl UpdateConfig {
deployment_key: None, deployment_key: None,
alpha_test_key: None, alpha_test_key: None,
channel: "stable".to_string(), 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)] #[derive(Debug, serde::Serialize, Deserialize)]
struct GrokVersion { struct VersionCache {
version: String, version: String,
#[serde(default)] #[serde(default)]
stable_version: Option<String>, stable_version: Option<String>,
checked_at: String, checked_at: String,
} }
impl GrokVersion { impl VersionCache {
fn is_fresh(&self, now: time::OffsetDateTime, ttl: Duration) -> bool { fn is_fresh(&self, now: time::OffsetDateTime, ttl: Duration) -> bool {
if let Ok(dt) = time::OffsetDateTime::parse( if let Ok(dt) = time::OffsetDateTime::parse(
&self.checked_at, &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(&registry_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 /// Write the version cache to disk, recording that `version` was seen at the
/// current time. Call after confirming the version is current (no update /// current time. Call after confirming the version is current (no update
/// needed) or after a successful install. /// 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. /// `channel_label()` can derive `[alpha]` vs `[stable]` without network I/O.
pub async fn write_version_cache(version: &str, stable_version: Option<&str>) { pub async fn write_version_cache(version: &str, stable_version: Option<&str>) {
let version_path = kigi_home().join("version.json"); let version_path = kigi_home().join("version.json");
let now = time::OffsetDateTime::now_utc(); let now = time::OffsetDateTime::now_utc();
let json = GrokVersion::new( let json = VersionCache::new(
version.to_string(), version.to_string(),
stable_version.map(|s| s.to_string()), stable_version.map(|s| s.to_string()),
now, 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. /// True if `version.json` exists and is within TTL.
pub async fn is_version_cache_fresh() -> bool { pub async fn is_version_cache_fresh() -> bool {
let version_path = kigi_home().join("version.json"); let version_path = kigi_home().join("version.json");
let now = time::OffsetDateTime::now_utc(); let now = time::OffsetDateTime::now_utc();
if let Ok(version_str) = fs::read_to_string(&version_path).await 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) && version.is_fresh(now, TTL_SECONDS_BEFORE_AUTO_UPDATE)
{ {
return true; return true;
@@ -411,14 +328,14 @@ pub async fn is_version_cache_fresh() -> bool {
false 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 /// Version of the managed kigi binary currently on disk, read from the
/// `~/.kigi/bin/grok` symlink target (`../downloads/grok-<version>-<platform>`) /// `~/.kigi/bin/kigi` symlink target (`../downloads/kigi-<version>-<platform>`)
/// without exec'ing anything. /// without exec'ing anything.
/// ///
/// Concurrent updaters (TUI background download, leader hourly checker, /// 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 /// compiled-in version, so a binary another process already installed is
/// never downloaded a second time. /// 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` /// link whose target binary was deleted (e.g. manual `~/.kigi/downloads`
/// cleanup) must not report an installed version, or every updater would /// cleanup) must not report an installed version, or every updater would
/// claim "already up to date" forever while no runnable binary exists. /// 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> { pub fn installed_on_disk_version() -> Option<String> {
#[cfg(unix)] #[cfg(unix)]
{ {
@@ -440,7 +352,7 @@ pub fn installed_on_disk_version() -> Option<String> {
// metadata() follows the symlink: Err means the target is gone // metadata() follows the symlink: Err means the target is gone
// (dangling link) and the version it names is not actually on disk. // (dangling link) and the version it names is not actually on disk.
std::fs::metadata(&app).ok()?; 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))] #[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. /// Extract the `<version>` portion of a versioned binary file name.
/// ///
/// Handles the internal layout (`grok-0.1.150-macos-aarch64`, including /// Handles the managed layout (`kigi-0.1.0-macos-aarch64`, including
/// pre-releases: `grok-0.1.150-alpha.1-linux-x86_64` → `0.1.150-alpha.1`) /// pre-releases: `kigi-0.1.0-alpha.1-linux-x86_64` → `0.1.0-alpha.1`) and a
/// and the npm layout without a platform suffix (`grok-0.1.150`, /// bare versioned name without a platform suffix (`kigi-0.1.0`): everything
/// `grok-0.1.150-alpha.1`): everything between the `{bin_prefix}-` prefix /// between the `{bin_prefix}-` prefix and the first platform-OS component is
/// and the first platform-OS component is the version, validated as semver /// the version, validated as semver so unknown layouts (`kigi-latest`)
/// so unknown layouts (`grok-latest`, `grok-pager-*` when `bin_prefix` is /// return `None` instead of garbage.
/// `grok`) return `None` instead of garbage.
/// ///
/// Shared by the disk-version probe above and `cleanup_old_downloads` in /// Shared by the disk-version probe above and `cleanup_old_downloads` in
/// `auto_update` — keep it the single place that understands this naming. /// `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> { pub(crate) fn version_from_versioned_binary_name(name: &str, bin_prefix: &str) -> Option<String> {
const PLATFORM_OS: &[&str] = &["macos", "linux", "darwin", "windows"]; 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 suffix = name.strip_prefix(bin_prefix)?.strip_prefix('-')?;
let parts: Vec<&str> = suffix.split('-').collect(); let parts: Vec<&str> = suffix.split('-').collect();
let platform_start = parts let platform_start = parts
@@ -473,31 +390,6 @@ pub(crate) fn version_from_versioned_binary_name(name: &str, bin_prefix: &str) -
Some(ver_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). /// 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 /// 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> { pub fn cached_stable_version() -> Option<String> {
let version_path = kigi_home().join("version.json"); let version_path = kigi_home().join("version.json");
let content = std::fs::read_to_string(&version_path).ok()?; 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 gv.stable_version
} }
@@ -575,7 +467,7 @@ mod tests {
fn test_is_fresh_rejects_future_timestamp() { fn test_is_fresh_rejects_future_timestamp() {
let now = time::OffsetDateTime::now_utc(); let now = time::OffsetDateTime::now_utc();
let future = now + Duration::from_secs(600); 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!( assert!(
!v.is_fresh(now, Duration::from_secs(30)), !v.is_fresh(now, Duration::from_secs(30)),
"Future timestamp must not be considered fresh (clock-skew guard)." "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 /// 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] #[test]
fn test_version_from_versioned_binary_name() { fn test_version_from_versioned_binary_name() {
let cases: &[(&str, Option<&str>)] = &[ let cases: &[(&str, Option<&str>)] = &[
("grok-0.2.46-darwin-arm64", Some("0.2.46")), ("kigi-0.2.46-darwin-arm64", Some("0.2.46")),
("grok-0.1.220-linux-x86_64", Some("0.1.220")), ("kigi-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.5-windows-x86_64.exe", Some("0.2.5")),
// Pre-releases must round-trip whole — truncating to "0.1.220" // Pre-releases must round-trip whole — truncating to "0.1.220"
// would make an alpha install masquerade as the release and // would make an alpha install masquerade as the release and
// mask alpha → stable updates. // mask alpha → stable updates.
("grok-0.1.220-alpha.4-linux-x86_64", Some("0.1.220-alpha.4")), ("kigi-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 ("kigi-0.1.220-alpha.4", Some("0.1.220-alpha.4")), // no platform suffix
("grok-pager-0.1.5-darwin-arm64", None), // "pager" is not a version ("kigi-garbage-darwin-arm64", None), // unparseable version
("grok-garbage-darwin-arm64", None), // unparseable version ("kigi-0.2.46", Some("0.2.46")), // no platform suffix
("grok-0.2.46", Some("0.2.46")), // no platform suffix
("other-0.2.46-darwin-arm64", None), // wrong prefix ("other-0.2.46-darwin-arm64", None), // wrong prefix
("grok-latest", None), // symlink alias, not a version ("kigi-latest", None), // symlink alias, not a version
("grok", None), // bare name ("kigi", None), // bare name
("", None), ("", 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 { for (name, expected) in cases {
assert_eq!( assert_eq!(
version_from_versioned_binary_name(name, "grok").as_deref(), version_from_versioned_binary_name(name, "kigi").as_deref(),
*expected, *expected,
"version_from_versioned_binary_name({name:?})" "version_from_versioned_binary_name({name:?})"
); );
} }
// bin_prefix discrimination: the pager binary parses under its own // bin_prefix discrimination: a differently-prefixed binary parses
// prefix but not under "grok". // under its own prefix but not under "kigi".
assert_eq!( 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(), .as_deref(),
Some("0.1.5") 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 // VersionCache JSON shape — backward compatibility invariants
// ──────────────────────────────────────────────────────────────────────
#[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] #[test]
fn test_version_json_backward_compat() { fn test_version_json_backward_compat() {
// Old format (no stable_version) must parse — serde(default) fills None. // 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 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_eq!(v.version, "0.1.180");
assert!(v.stable_version.is_none()); assert!(v.stable_version.is_none());
// New format with stable_version round-trips correctly. // New format with stable_version round-trips correctly.
let now = time::OffsetDateTime::now_utc(); 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 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.version, "0.2.5");
assert_eq!(parsed.stable_version.as_deref(), Some("0.2.3")); assert_eq!(parsed.stable_version.as_deref(), Some("0.2.3"));
@@ -730,11 +684,11 @@ mod tests {
// Unknown fields are ignored (forward-compat). // Unknown fields are ignored (forward-compat).
let future = r#"{"version":"0.1.180","checked_at":"2026-04-22T10:30:00Z","future":"ok"}"#; 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. // Missing required field (checked_at) is rejected.
let missing = r#"{"version":"0.1.180"}"#; 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] #[test]
fn test_is_fresh_ttl_boundaries() { fn test_is_fresh_ttl_boundaries() {
let now = time::OffsetDateTime::now_utc(); 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 // Within TTL → fresh
assert!(v.is_fresh(now, Duration::from_secs(60))); assert!(v.is_fresh(now, Duration::from_secs(60)));
@@ -760,7 +714,7 @@ mod tests {
assert!(!v.is_fresh(now, Duration::ZERO)); assert!(!v.is_fresh(now, Duration::ZERO));
// Malformed timestamp → not fresh // Malformed timestamp → not fresh
let bad = GrokVersion { let bad = VersionCache {
version: "0.1.200".to_string(), version: "0.1.200".to_string(),
stable_version: None, stable_version: None,
checked_at: "not-rfc3339".to_string(), checked_at: "not-rfc3339".to_string(),
@@ -1,27 +1,44 @@
//! Controllable raw HTTP/1.1 artifact server shared by the blitz //! Controllable raw HTTP/1.1 GitHub-Releases-shaped server shared by the
//! download/install tests and the concurrent-update convergence tests. //! blitz download/install tests and the concurrent-update convergence tests.
//! //!
//! Serves a real executable artifact and can truncate the body, close the //! Serves the three routes the updater consumes:
//! connection early, serve a right-length-but-garbage body, or hang //!
//! mid-transfer — for both the parallel byte-range path and the //! - `GET /releases/latest` and `GET /releases/tags/v{version}` — release
//! single-connection path. It also counts body-serving GETs (HEAD probes are //! JSON in the real GitHub wire shape
//! excluded) so tests can assert how many downloads actually happened, and //! (<https://docs.github.com/en/rest/releases/releases>):
//! supports a "slow" mode that widens the race window so concurrent //! `{"tag_name":"v0.1.0","assets":[{"name":"...","browser_download_url":"..."}]}`
//! installers genuinely overlap in flight. //! - `GET /dl/{version}/SHA256SUMS` — checksum manifest for the archive
//! - `GET /dl/{version}/kigi-{version}-{triple}.tar.gz` — the archive itself
//!
//! The archive route can serve the real archive, truncate the body, serve a
//! right-length-but-garbage body (defeated by the SHA-256 gate), serve a
//! correctly-checksummed archive whose binary fails to run (defeated by the
//! smoke test), or hang mid-transfer — for both the parallel byte-range path
//! and the single-connection path. It also counts archive-serving GETs (HEAD
//! probes and metadata routes are excluded) so tests can assert how many
//! downloads actually happened, and supports a "slow" mode that widens the
//! race window so concurrent installers genuinely overlap in flight.
use std::collections::HashMap;
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream}; use std::net::{TcpListener, TcpStream};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
/// How the server corrupts (or doesn't) the next download. use super::{archive_name, make_release_archive, sha256_hex};
/// How the server corrupts (or doesn't) the next archive download.
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
pub enum Mode { pub enum Mode {
/// Serve the real artifact correctly. /// Serve the real archive correctly.
Full, Full,
/// Serve a right-length body that exits non-zero (fails the smoke-test). /// Serve a right-length body of wrong bytes — SHA256SUMS still lists the
/// GOOD archive's hash, so the checksum gate must reject it.
Garbage, Garbage,
/// Serve a correctly-checksummed archive whose `kigi` binary exits
/// non-zero — only the smoke test can reject it.
BadBinary,
/// Advertise the full length but send only `k` bytes then close the socket /// Advertise the full length but send only `k` bytes then close the socket
/// (silent truncation: premature EOF / short range chunk). /// (silent truncation: premature EOF / short range chunk).
Truncate(usize), Truncate(usize),
@@ -29,11 +46,41 @@ pub enum Mode {
Hang(usize), Hang(usize),
} }
/// Precomputed per-version fixtures.
struct VersionFixture {
/// Real archive: tar.gz containing `kigi` = the good binary body.
good_archive: Arc<Vec<u8>>,
/// Same-shape archive whose `kigi` exits 1; its hash is served in
/// SHA256SUMS while `Mode::BadBinary` is active.
bad_archive: Arc<Vec<u8>>,
}
struct ServerState { struct ServerState {
body: Arc<Vec<u8>>, versions: HashMap<String, VersionFixture>,
/// The good binary body used to synthesize fixtures for versions
/// requested but not yet registered.
default_binary: Vec<u8>,
latest: String,
mode: Mode, mode: Mode,
} }
impl ServerState {
fn fixture(&mut self, version: &str) -> &VersionFixture {
if !self.versions.contains_key(version) {
let good = make_release_archive(&self.default_binary);
let bad = make_release_archive(b"#!/bin/sh\nexit 1\n");
self.versions.insert(
version.to_string(),
VersionFixture {
good_archive: Arc::new(good),
bad_archive: Arc::new(bad),
},
);
}
&self.versions[version]
}
}
pub struct ArtifactServer { pub struct ArtifactServer {
addr: std::net::SocketAddr, addr: std::net::SocketAddr,
state: Arc<Mutex<ServerState>>, state: Arc<Mutex<ServerState>>,
@@ -43,12 +90,17 @@ pub struct ArtifactServer {
} }
impl ArtifactServer { impl ArtifactServer {
pub fn start(body: Vec<u8>) -> Self { /// Start a server whose release archives contain `binary_body` as the
/// `kigi` binary. `latest` starts as `0.0.0`; set it with
/// [`ArtifactServer::set_latest`] before exercising latest-based flows.
pub fn start(binary_body: Vec<u8>) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let listener = TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap(); listener.set_nonblocking(true).unwrap();
let addr = listener.local_addr().unwrap(); let addr = listener.local_addr().unwrap();
let state = Arc::new(Mutex::new(ServerState { let state = Arc::new(Mutex::new(ServerState {
body: Arc::new(body), versions: HashMap::new(),
default_binary: binary_body,
latest: "0.0.0".to_string(),
mode: Mode::Full, mode: Mode::Full,
})); }));
let shutdown = Arc::new(AtomicBool::new(false)); let shutdown = Arc::new(AtomicBool::new(false));
@@ -86,30 +138,49 @@ impl ArtifactServer {
} }
} }
pub fn uri(&self) -> String { /// Base URL to hand the updater (`…/releases`, mirroring the production
format!("http://{}", self.addr) /// `https://api.github.com/repos/{owner}/{repo}/releases`).
pub fn base(&self) -> String {
format!("http://{}/releases", self.addr)
}
/// Version served by `GET /releases/latest`.
pub fn set_latest(&self, version: &str) {
self.state.lock().unwrap().latest = version.to_string();
} }
pub fn set_mode(&self, mode: Mode) { pub fn set_mode(&self, mode: Mode) {
self.state.lock().unwrap().mode = mode; self.state.lock().unwrap().mode = mode;
} }
/// Number of body-serving GET requests handled so far (HEAD probes from /// Length of the (good) archive for `version` — corruption offsets for
/// the parallel-download path are excluded). Tests use this to assert /// [`Mode::Truncate`]/[`Mode::Hang`] are positions within this body.
/// how many downloads actually happened — e.g. that a sequential updater pub fn archive_len(&self, version: &str) -> usize {
/// converged onto an already-installed binary without re-downloading. self.state
/// One download may span multiple GETs when the parallel byte-range path .lock()
/// splits it, so tests asserting exact counts use a small artifact .unwrap()
/// (single-connection path, 1 GET per download). .fixture(version)
.good_archive
.len()
}
/// Number of archive-serving GET requests handled so far (HEAD probes
/// from the parallel-download path and metadata/SHA256SUMS routes are
/// excluded). Tests use this to assert how many downloads actually
/// happened — e.g. that a sequential updater converged onto an
/// already-installed binary without re-downloading. One download may span
/// multiple GETs when the parallel byte-range path splits it, so tests
/// asserting exact counts use a small artifact (single-connection path,
/// 1 GET per download).
pub fn request_count(&self) -> usize { pub fn request_count(&self) -> usize {
self.gets.load(Ordering::Relaxed) self.gets.load(Ordering::Relaxed)
} }
/// When enabled, hold each Full/Garbage response open ~500ms before /// When enabled, hold each archive response open ~500ms before sending
/// sending the body. This keeps an installer in flight long enough for /// the body. This keeps an installer in flight long enough for concurrent
/// concurrent installers to genuinely overlap even on a heavily loaded /// installers to genuinely overlap even on a heavily loaded CI host — a
/// CI host — a too-short hold would let race tests run the installers /// too-short hold would let race tests run the installers back-to-back
/// back-to-back and never exercise the concurrent window. /// and never exercise the concurrent window.
pub fn set_slow(&self, slow: bool) { pub fn set_slow(&self, slow: bool) {
self.slow.store(slow, Ordering::Relaxed); self.slow.store(slow, Ordering::Relaxed);
} }
@@ -134,6 +205,39 @@ fn parse_range(request: &str) -> Option<(usize, usize)> {
None None
} }
/// The request path, without query string.
fn parse_path(request: &str) -> String {
request
.lines()
.next()
.and_then(|l| l.split_whitespace().nth(1))
.unwrap_or("/")
.split('?')
.next()
.unwrap_or("/")
.to_string()
}
/// Release JSON for `version` with asset URLs rooted at this server.
fn release_json(addr: &std::net::SocketAddr, version: &str) -> String {
let name = archive_name(version);
format!(
r#"{{"tag_name":"v{version}","draft":false,"prerelease":false,"assets":[{{"name":"{name}","browser_download_url":"http://{addr}/dl/{version}/{name}"}},{{"name":"SHA256SUMS","browser_download_url":"http://{addr}/dl/{version}/SHA256SUMS"}}]}}"#
)
}
fn write_simple_response(stream: &mut TcpStream, status: &str, body: &[u8], is_head: bool) {
let head = format!(
"HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
let _ = stream.write_all(head.as_bytes());
if !is_head {
let _ = stream.write_all(body);
}
let _ = stream.flush();
}
fn handle_connection( fn handle_connection(
mut stream: TcpStream, mut stream: TcpStream,
state: Arc<Mutex<ServerState>>, state: Arc<Mutex<ServerState>>,
@@ -169,18 +273,74 @@ fn handle_connection(
} }
let request = String::from_utf8_lossy(&buf).to_string(); let request = String::from_utf8_lossy(&buf).to_string();
let is_head = request.starts_with("HEAD"); let is_head = request.starts_with("HEAD");
let path = parse_path(&request);
let range = parse_range(&request);
let addr = stream.local_addr().unwrap();
// ── Metadata routes ─────────────────────────────────────────────────────
if path == "/releases/latest" {
let latest = state.lock().unwrap().latest.clone();
let body = release_json(&addr, &latest);
write_simple_response(&mut stream, "200 OK", body.as_bytes(), is_head);
return;
}
if let Some(tag) = path.strip_prefix("/releases/tags/v") {
let body = release_json(&addr, tag);
write_simple_response(&mut stream, "200 OK", body.as_bytes(), is_head);
return;
}
// ── Asset routes: /dl/{version}/{name} ──────────────────────────────────
let Some(rest) = path.strip_prefix("/dl/") else {
write_simple_response(&mut stream, "404 Not Found", b"not found", is_head);
return;
};
let Some((version, asset)) = rest.split_once('/') else {
write_simple_response(&mut stream, "404 Not Found", b"not found", is_head);
return;
};
let (good, bad, mode) = {
let mut st = state.lock().unwrap();
let mode = st.mode;
let fixture = st.fixture(version);
(
fixture.good_archive.clone(),
fixture.bad_archive.clone(),
mode,
)
};
if asset == "SHA256SUMS" {
// BadBinary serves the bad archive WITH its correct hash (a release
// whose binary is broken but whose checksums are fine); every other
// mode lists the good archive's hash so in-transit corruption is
// caught by the checksum gate.
let hashed: &[u8] = match mode {
Mode::BadBinary => &bad,
_ => &good,
};
let body = format!("{} {}\n", sha256_hex(hashed), archive_name(version));
write_simple_response(&mut stream, "200 OK", body.as_bytes(), is_head);
return;
}
if asset != archive_name(version) {
write_simple_response(&mut stream, "404 Not Found", b"not found", is_head);
return;
}
// ── Archive body with corruption modes ──────────────────────────────────
// Count only body-serving GETs; the parallel path's HEAD probe is excluded. // Count only body-serving GETs; the parallel path's HEAD probe is excluded.
if !is_head { if !is_head {
gets.fetch_add(1, Ordering::Relaxed); gets.fetch_add(1, Ordering::Relaxed);
} }
let range = parse_range(&request);
let (body, mode) = { let body: &[u8] = match mode {
let st = state.lock().unwrap(); Mode::BadBinary => &bad,
(st.body.clone(), st.mode) _ => &good,
}; };
let total = body.len(); let total = body.len();
let body: &[u8] = &body;
// Determine the byte slice this request is for, plus the length we will // Determine the byte slice this request is for, plus the length we will
// claim in Content-Length. // claim in Content-Length.
@@ -190,7 +350,7 @@ fn handle_connection(
}; };
let claimed_len = slice_end_excl - slice_start; let claimed_len = slice_end_excl - slice_start;
// For truncation/hang, `k` is a GLOBAL cutoff across the whole artifact: // For truncation/hang, `k` is a GLOBAL cutoff across the whole archive:
// a slice that reaches past byte `k` is sent short, so the parallel path's // a slice that reaches past byte `k` is sent short, so the parallel path's
// later chunk (or the single-connection body) is the one truncated. // later chunk (or the single-connection body) is the one truncated.
let send_end = match mode { let send_end = match mode {
@@ -201,9 +361,9 @@ fn handle_connection(
// truncated modes it may be shorter than the advertised `claimed_len`. // truncated modes it may be shorter than the advertised `claimed_len`.
let payload: Vec<u8> = match mode { let payload: Vec<u8> = match mode {
Mode::Garbage => { Mode::Garbage => {
let mut bad = b"#!/bin/sh\nexit 1\n".to_vec(); let mut bad_bytes = b"not the archive you checksummed".to_vec();
bad.resize(claimed_len, b'\n'); bad_bytes.resize(claimed_len, b'\n');
bad bad_bytes
} }
_ => body[slice_start..send_end].to_vec(), _ => body[slice_start..send_end].to_vec(),
}; };
@@ -236,7 +396,7 @@ fn handle_connection(
} }
match mode { match mode {
Mode::Full | Mode::Garbage => { Mode::Full | Mode::Garbage | Mode::BadBinary => {
// Hold the connection open longer so concurrent installers // Hold the connection open longer so concurrent installers
// genuinely overlap mid-download (see `set_slow`). // genuinely overlap mid-download (see `set_slow`).
if slow.load(Ordering::Relaxed) { if slow.load(Ordering::Relaxed) {
+144 -126
View File
@@ -40,8 +40,7 @@ use std::sync::OnceLock;
/// this directory for the lifetime of the process. /// this directory for the lifetime of the process.
/// ///
/// Also clears env vars that the auto-update code consults so a parent shell's /// Also clears env vars that the auto-update code consults so a parent shell's
/// values can't pollute the baseline (e.g. running tests from `npm run` would /// values can't pollute the baseline.
/// otherwise inherit `npm_config_user_agent` and `NPM_TOKEN`).
pub fn test_home() -> &'static PathBuf { pub fn test_home() -> &'static PathBuf {
static HOME: OnceLock<PathBuf> = OnceLock::new(); static HOME: OnceLock<PathBuf> = OnceLock::new();
HOME.get_or_init(|| { HOME.get_or_init(|| {
@@ -52,10 +51,9 @@ pub fn test_home() -> &'static PathBuf {
unsafe { unsafe {
std::env::set_var("KIGI_SHARE_DIR", &path); std::env::set_var("KIGI_SHARE_DIR", &path);
std::env::remove_var("KIGI_TEST_VERSION"); std::env::remove_var("KIGI_TEST_VERSION");
std::env::remove_var("NPM_TOKEN");
std::env::remove_var("KIGI_INSTALLER"); std::env::remove_var("KIGI_INSTALLER");
std::env::remove_var("KIGI_MANAGED_BY_NPM");
std::env::remove_var("KIGI_MANAGED_BY_INTERNAL"); std::env::remove_var("KIGI_MANAGED_BY_INTERNAL");
std::env::remove_var(kigi_env::UPDATE_BASE_URL_ENV);
} }
path path
}) })
@@ -74,24 +72,32 @@ pub fn reset_home() {
// SAFETY: tests using this helper must be `#[serial]`. // SAFETY: tests using this helper must be `#[serial]`.
unsafe { unsafe {
std::env::remove_var("KIGI_TEST_VERSION"); std::env::remove_var("KIGI_TEST_VERSION");
std::env::remove_var("NPM_TOKEN");
std::env::remove_var("KIGI_INSTALLER"); std::env::remove_var("KIGI_INSTALLER");
std::env::remove_var(kigi_env::UPDATE_BASE_URL_ENV);
} }
} }
/// Override the version reported by `get_installed_grok_version()` for the /// Override the version reported by `get_installed_kigi_version()` for the
/// duration of the test (until [`reset_home`] or process exit). /// duration of the test (until [`reset_home`] or process exit).
pub fn set_test_version(v: &str) { pub fn set_test_version(v: &str) {
// SAFETY: tests using this helper must be `#[serial]`. // SAFETY: tests using this helper must be `#[serial]`.
unsafe { std::env::set_var("KIGI_TEST_VERSION", v) }; unsafe { std::env::set_var("KIGI_TEST_VERSION", v) };
} }
/// Point the production update flows (`check_update_status`,
/// `ensure_latest_on_disk`, `run_update`) at a mock GitHub Releases API.
/// Cleared by [`reset_home`].
pub fn set_update_base(base: &str) {
// SAFETY: tests using this helper must be `#[serial]`.
unsafe { std::env::set_var(kigi_env::UPDATE_BASE_URL_ENV, base) };
}
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Install-test fixtures (shared by the blitz + convergence suites) // Install-test fixtures
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
/// Host `{os}-{arch}` string matching the versioned binary naming scheme /// Host `{os}-{arch}` string matching the versioned binary naming scheme
/// (`grok-{version}-{platform}`). /// (`kigi-{version}-{platform}`).
pub fn host_platform() -> String { pub fn host_platform() -> String {
let os = if cfg!(target_os = "macos") { let os = if cfg!(target_os = "macos") {
"macos" "macos"
@@ -110,6 +116,27 @@ pub fn host_platform() -> String {
format!("{os}-{arch}") format!("{os}-{arch}")
} }
/// Host Rust target triple, matching `auto_update::target_triple()` and the
/// release-asset naming in `.github/workflows/release.yml`.
pub fn host_triple() -> &'static str {
if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
"aarch64-apple-darwin"
} else if cfg!(all(target_os = "macos", target_arch = "x86_64")) {
"x86_64-apple-darwin"
} else if cfg!(all(target_os = "linux", target_arch = "aarch64")) {
"aarch64-unknown-linux-gnu"
} else if cfg!(all(target_os = "linux", target_arch = "x86_64")) {
"x86_64-unknown-linux-gnu"
} else {
panic!("unsupported test platform");
}
}
/// Release-archive asset name for `version` on the host platform.
pub fn archive_name(version: &str) -> String {
format!("kigi-{version}-{}.tar.gz", host_triple())
}
/// Minimal [`kigi_update::UpdateConfig`] for install tests. /// Minimal [`kigi_update::UpdateConfig`] for install tests.
pub fn make_update_config(channel: &str) -> kigi_update::UpdateConfig { pub fn make_update_config(channel: &str) -> kigi_update::UpdateConfig {
kigi_update::UpdateConfig { kigi_update::UpdateConfig {
@@ -118,7 +145,6 @@ pub fn make_update_config(channel: &str) -> kigi_update::UpdateConfig {
deployment_key: None, deployment_key: None,
alpha_test_key: None, alpha_test_key: None,
channel: channel.to_string(), channel: channel.to_string(),
npm_registry: None,
} }
} }
@@ -168,7 +194,113 @@ pub fn backdate_downloads() {
} }
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// PATH-override fake binary // GitHub Releases fixtures
//
// Wire shapes mirror the real GitHub REST API
// (https://docs.github.com/en/rest/releases/releases):
// GET /repos/{o}/{r}/releases/latest → release object
// GET /repos/{o}/{r}/releases/tags/{tag} → release object
// GET /repos/{o}/{r}/releases → array of release objects
// Release object: {"tag_name":"v0.1.0","assets":[{"name":"...",
// "browser_download_url":"..."}]}
// ─────────────────────────────────────────────────────────────────────────────
/// Build a tar.gz archive from `(name, bytes)` entries.
#[cfg(unix)]
pub fn make_tar_gz(entries: &[(&str, &[u8])]) -> Vec<u8> {
let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
let mut builder = tar::Builder::new(gz);
for (name, data) in entries {
let mut header = tar::Header::new_gnu();
header.set_size(data.len() as u64);
header.set_mode(0o755);
header.set_cksum();
builder.append_data(&mut header, name, *data).unwrap();
}
builder.into_inner().unwrap().finish().unwrap()
}
/// Release archive containing a single `kigi` entry with `binary` as its body.
#[cfg(unix)]
pub fn make_release_archive(binary: &[u8]) -> Vec<u8> {
make_tar_gz(&[("kigi", binary)])
}
/// Hex SHA-256 of `bytes` (as written into SHA256SUMS manifests).
pub fn sha256_hex(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
format!("{:x}", Sha256::digest(bytes))
}
/// GitHub release JSON for `version`, with asset download URLs rooted at
/// `{server_uri}/dl/v{version}/…`.
pub fn release_json(server_uri: &str, version: &str) -> serde_json::Value {
let name = archive_name(version);
serde_json::json!({
"tag_name": format!("v{version}"),
"draft": false,
"prerelease": !semver::Version::parse(version).unwrap().pre.is_empty(),
"assets": [
{
"name": name,
"browser_download_url": format!("{server_uri}/dl/v{version}/{name}"),
},
{
"name": "SHA256SUMS",
"browser_download_url": format!("{server_uri}/dl/v{version}/SHA256SUMS"),
},
],
})
}
/// Mount the per-release endpoints for `version` on a wiremock server:
/// `GET /releases/tags/v{version}` plus the archive and SHA256SUMS asset
/// downloads. Callers that need `latest` also call [`mount_latest`].
///
/// The base URL to hand the updater is `format!("{}/releases", server.uri())`.
#[cfg(unix)]
pub async fn mount_release(server: &wiremock::MockServer, version: &str, binary: &[u8]) {
use wiremock::matchers::{method, path};
use wiremock::{Mock, ResponseTemplate};
let archive = make_release_archive(binary);
let sums = format!("{} {}\n", sha256_hex(&archive), archive_name(version));
Mock::given(method("GET"))
.and(path(format!("/releases/tags/v{version}")))
.respond_with(
ResponseTemplate::new(200).set_body_json(release_json(&server.uri(), version)),
)
.mount(server)
.await;
Mock::given(method("GET"))
.and(path(format!("/dl/v{version}/{}", archive_name(version))))
.respond_with(ResponseTemplate::new(200).set_body_bytes(archive))
.mount(server)
.await;
Mock::given(method("GET"))
.and(path(format!("/dl/v{version}/SHA256SUMS")))
.respond_with(ResponseTemplate::new(200).set_body_string(sums))
.mount(server)
.await;
}
/// Mount `GET /releases/latest` returning `version`.
pub async fn mount_latest(server: &wiremock::MockServer, version: &str) {
use wiremock::matchers::{method, path};
use wiremock::{Mock, ResponseTemplate};
Mock::given(method("GET"))
.and(path("/releases/latest"))
.respond_with(
ResponseTemplate::new(200).set_body_json(release_json(&server.uri(), version)),
)
.mount(server)
.await;
}
// ─────────────────────────────────────────────────────────────────────────────
// PATH-override fake binary (used by the install.sh harness)
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
/// RAII guard that places a sh-script with name `name` at the head of `PATH`. /// RAII guard that places a sh-script with name `name` at the head of `PATH`.
@@ -215,18 +347,8 @@ impl FakeBinGuard {
} }
} }
/// Install a fake `npm` using the standard [`fake_npm_script`] template. /// The tempdir backing this guard (where canned response files can be
pub fn install_npm() -> Self { /// written by tests, and where `<name>-args.log` is appended).
Self::install("npm", fake_npm_script)
}
/// Install a fake `gh` using the standard [`fake_gh_script`] template.
pub fn install_gh() -> Self {
Self::install("gh", fake_gh_script)
}
/// The tempdir backing this guard (where canned stdout/stderr/exit files
/// can be written by tests, and where `<name>-args.log` is appended).
pub fn dir(&self) -> PathBuf { pub fn dir(&self) -> PathBuf {
self.tmp.path().to_path_buf() self.tmp.path().to_path_buf()
} }
@@ -239,46 +361,6 @@ impl FakeBinGuard {
.map(String::from) .map(String::from)
.collect() .collect()
} }
pub fn set_stdout(&self, content: &str) {
std::fs::write(self.dir().join(format!("{}-stdout", self.name)), content).unwrap();
}
pub fn set_stderr(&self, content: &str) {
std::fs::write(self.dir().join(format!("{}-stderr", self.name)), content).unwrap();
}
pub fn set_alpha_stdout(&self, content: &str) {
std::fs::write(
self.dir().join(format!("{}-alpha-stdout", self.name)),
content,
)
.unwrap();
}
pub fn set_stable_only_stdout(&self, content: &str) {
std::fs::write(
self.dir().join(format!("{}-stable-only-stdout", self.name)),
content,
)
.unwrap();
}
pub fn set_with_pre_stdout(&self, content: &str) {
std::fs::write(
self.dir().join(format!("{}-with-pre-stdout", self.name)),
content,
)
.unwrap();
}
pub fn set_exit_code(&self, code: i32) {
std::fs::write(
self.dir().join(format!("{}-exit", self.name)),
code.to_string(),
)
.unwrap();
}
} }
impl Drop for FakeBinGuard { impl Drop for FakeBinGuard {
@@ -287,67 +369,3 @@ impl Drop for FakeBinGuard {
unsafe { std::env::set_var("PATH", &self.prev_path) }; unsafe { std::env::set_var("PATH", &self.prev_path) };
} }
} }
/// Single-quote a path for safe substitution into a sh script.
fn single_quote_for_sh(p: &Path) -> String {
let s = p.to_string_lossy();
// Escape any embedded single quotes (paranoid — tempdir paths shouldn't
// contain them, but defensively quote).
let escaped = s.replace('\'', "'\\''");
format!("'{escaped}'")
}
/// sh script body for a fake `npm`. Logs argv to `<dir>/npm-args.log` and
/// dispatches stdout based on the first matching argv pattern:
///
/// - argv contains `@alpha` → cat `<dir>/npm-alpha-stdout`
/// - else → cat `<dir>/npm-stdout`
///
/// Always cats `<dir>/npm-stderr` to stderr (if exists). Exits with the integer
/// in `<dir>/npm-exit` (default 0).
pub fn fake_npm_script(dir: &Path) -> String {
let dq = single_quote_for_sh(dir);
format!(
r#"#!/bin/sh
echo "$@" >> {dq}/npm-args.log
if echo "$@" | grep -q '@alpha'; then
if [ -f {dq}/npm-alpha-stdout ]; then cat {dq}/npm-alpha-stdout; fi
elif [ -f {dq}/npm-stdout ]; then
cat {dq}/npm-stdout
fi
if [ -f {dq}/npm-stderr ]; then cat {dq}/npm-stderr >&2; fi
exit_code=0
if [ -f {dq}/npm-exit ]; then exit_code=$(cat {dq}/npm-exit); fi
exit "$exit_code"
"#
)
}
/// sh script body for a fake `gh`. Logs argv to `<dir>/gh-args.log` and
/// dispatches stdout based on `release list` argv:
///
/// - argv contains `release list --exclude-pre-releases` → `<dir>/gh-stable-only-stdout`
/// - argv contains `release list` (no exclude flag) → `<dir>/gh-with-pre-stdout`
/// - else → `<dir>/gh-stdout`
///
/// Exits with `<dir>/gh-exit` (default 0).
pub fn fake_gh_script(dir: &Path) -> String {
let dq = single_quote_for_sh(dir);
format!(
r#"#!/bin/sh
echo "$@" >> {dq}/gh-args.log
if echo "$@" | grep -q 'release list'; then
if echo "$@" | grep -q '\-\-exclude-pre-releases'; then
if [ -f {dq}/gh-stable-only-stdout ]; then cat {dq}/gh-stable-only-stdout; fi
else
if [ -f {dq}/gh-with-pre-stdout ]; then cat {dq}/gh-with-pre-stdout; fi
fi
elif [ -f {dq}/gh-stdout ]; then
cat {dq}/gh-stdout
fi
exit_code=0
if [ -f {dq}/gh-exit ]; then exit_code=$(cat {dq}/gh-exit); fi
exit "$exit_code"
"#
)
}
@@ -2,17 +2,19 @@
//! truncation / corruption / cancel at every point, and after every iteration //! truncation / corruption / cancel at every point, and after every iteration
//! assert the single invariant that makes the brick impossible: //! assert the single invariant that makes the brick impossible:
//! //!
//! > `~/.kigi/bin/grok` resolves to a binary that passes the smoke-test, OR it //! > `~/.kigi/bin/kigi` resolves to a binary that passes the smoke-test, OR it
//! > is still the previous-good binary. It is never a broken/partial binary, //! > is still the previous-good binary. It is never a broken/partial binary,
//! > and a `.tmp` never masquerades as the active binary. //! > and a `.tmp` never masquerades as the active binary.
//! //!
//! The invariant is checked by RE-RESOLVING the symlink and RE-RUNNING the //! The invariant is checked by RE-RESOLVING the symlink and RE-RUNNING the
//! binary from disk every time — never by re-reading a value the harness set. //! binary from disk every time — never by re-reading a value the harness set.
//! //!
//! A controllable raw HTTP/1.1 server serves a real executable ("good") //! A controllable raw HTTP/1.1 GitHub-Releases-shaped server serves release
//! artifact and can truncate the body, close the connection early, serve a //! JSON, SHA256SUMS, and the archive, and can truncate the archive body,
//! right-length-but-garbage body, or hang mid-transfer — for both the parallel //! close the connection early, serve a right-length-but-garbage body (caught
//! byte-range path and the single-connection path. //! by the SHA-256 gate), serve a correctly-checksummed archive whose binary
//! fails to run (caught by the smoke test), or hang mid-transfer — for both
//! the parallel byte-range path and the single-connection path.
#![cfg(unix)] #![cfg(unix)]
@@ -35,38 +37,44 @@ use kigi_update::auto_update::install_internal_from_base;
// Artifacts + fixtures // Artifacts + fixtures
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
/// A real executable larger than the 16 MiB parallel threshold (at least 2 /// A real executable whose ARCHIVE clears the 16 MiB parallel threshold (at
/// chunks), so the parallel byte-range path is exercised. The shell exits on /// least 2 chunks), so the parallel byte-range path is exercised. The shell
/// line 2, never reading the newline padding. /// exits on line 2, never reading the padding — which is pseudo-random bytes
/// so gzip cannot compress the archive below the threshold.
fn large_good_artifact() -> Vec<u8> { fn large_good_artifact() -> Vec<u8> {
let mut v = b"#!/bin/sh\nexit 0\n".to_vec(); let mut v = b"#!/bin/sh\nexit 0\n".to_vec();
v.resize(33 * 1024 * 1024, b'\n'); v.reserve(34 * 1024 * 1024);
// xorshift64* keeps the padding incompressible without an RNG dependency.
let mut x: u64 = 0x243F6A8885A308D3;
while v.len() < 34 * 1024 * 1024 {
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
v.extend_from_slice(&x.wrapping_mul(0x2545F4914F6CDD1D).to_le_bytes());
}
v v
} }
/// Seed a previous-good versioned binary + both managed symlinks /// Seed a previous-good versioned binary + the managed `kigi` symlink.
/// (`grok` and `agent` — see `swap_managed_bin_links`). Returns the /// Returns the absolute path of the seeded binary.
/// absolute path of the seeded binary.
fn seed_previous_good(home: &Path, version: &str, platform: &str) -> PathBuf { fn seed_previous_good(home: &Path, version: &str, platform: &str) -> PathBuf {
let downloads = home.join("downloads"); let downloads = home.join("downloads");
let bin = home.join("bin"); let bin = home.join("bin");
std::fs::create_dir_all(&downloads).unwrap(); std::fs::create_dir_all(&downloads).unwrap();
std::fs::create_dir_all(&bin).unwrap(); std::fs::create_dir_all(&bin).unwrap();
let prev = downloads.join(format!("grok-{version}-{platform}")); let prev = downloads.join(format!("kigi-{version}-{platform}"));
std::fs::write(&prev, small_good_artifact()).unwrap(); std::fs::write(&prev, small_good_artifact()).unwrap();
std::fs::set_permissions(&prev, std::fs::Permissions::from_mode(0o755)).unwrap(); std::fs::set_permissions(&prev, std::fs::Permissions::from_mode(0o755)).unwrap();
let rel = format!("../downloads/grok-{version}-{platform}"); let rel = format!("../downloads/kigi-{version}-{platform}");
for name in ["grok", "agent"] { let link = bin.join("kigi");
let link = bin.join(name);
let _ = std::fs::remove_file(&link); let _ = std::fs::remove_file(&link);
std::os::unix::fs::symlink(&rel, &link).unwrap(); std::os::unix::fs::symlink(&rel, &link).unwrap();
}
dunce::canonicalize(&prev).unwrap() dunce::canonicalize(&prev).unwrap()
} }
/// What the active `grok` should resolve to after an install attempt. /// What the active `kigi` should resolve to after an install attempt.
#[derive(Clone, Copy, PartialEq)] #[derive(Clone, Copy, PartialEq)]
enum Expect { enum Expect {
/// The new version was installed and activated. /// The new version was installed and activated.
@@ -77,34 +85,21 @@ enum Expect {
/// THE invariant. Re-resolves the on-disk symlink and RE-EXECUTES the resolved /// THE invariant. Re-resolves the on-disk symlink and RE-EXECUTES the resolved
/// binary; never inspects a harness-held value. Guarantees the active managed /// binary; never inspects a harness-held value. Guarantees the active managed
/// link is always runnable and is never a `.tmp` or a partial file. Applied /// link is always runnable and is never a `.tmp` or a partial file.
/// to both `grok` and `agent` — `swap_managed_bin_links` moves them together.
fn assert_invariant(home: &Path, prev_good: &Path, new_binary: &Path, expect: Expect) { fn assert_invariant(home: &Path, prev_good: &Path, new_binary: &Path, expect: Expect) {
for name in ["grok", "agent"] { let link = home.join("bin").join("kigi");
assert_link_invariant(home, name, prev_good, new_binary, expect); assert!(link.is_symlink(), "kigi must remain a symlink");
}
}
fn assert_link_invariant(
home: &Path,
name: &str,
prev_good: &Path,
new_binary: &Path,
expect: Expect,
) {
let link = home.join("bin").join(name);
assert!(link.is_symlink(), "{name} must remain a symlink");
// Resolve from disk. canonicalize fails on a dangling link — that alone // Resolve from disk. canonicalize fails on a dangling link — that alone
// would be a brick. // would be a brick.
let resolved = dunce::canonicalize(&link) let resolved = dunce::canonicalize(&link)
.unwrap_or_else(|e| panic!("active {name} symlink does not resolve: {e}")); .unwrap_or_else(|e| panic!("active kigi symlink does not resolve: {e}"));
// A `.tmp` file must never be the live target. // A `.tmp` file must never be the live target.
let resolved_name = resolved.file_name().unwrap().to_string_lossy().to_string(); let resolved_name = resolved.file_name().unwrap().to_string_lossy().to_string();
assert!( assert!(
!resolved_name.contains(".tmp"), !resolved_name.contains(".tmp"),
"active {name} must not be a temp file: {resolved_name}" "active kigi must not be a temp file: {resolved_name}"
); );
// Re-run the resolved binary from disk: the active link must always run. // Re-run the resolved binary from disk: the active link must always run.
@@ -118,7 +113,7 @@ fn assert_link_invariant(
.unwrap_or(false); .unwrap_or(false);
assert!( assert!(
ran_ok, ran_ok,
"active {name} must pass the smoke-test, but {} did not run", "active kigi must pass the smoke-test, but {} did not run",
resolved.display() resolved.display()
); );
@@ -126,11 +121,11 @@ fn assert_link_invariant(
Expect::NewBinary => assert_eq!( Expect::NewBinary => assert_eq!(
resolved, resolved,
dunce::canonicalize(new_binary).unwrap(), dunce::canonicalize(new_binary).unwrap(),
"expected the newly-installed binary to be active for {name}" "expected the newly-installed binary to be active"
), ),
Expect::PreviousGood => assert_eq!( Expect::PreviousGood => assert_eq!(
resolved, prev_good, resolved, prev_good,
"expected the previous-good binary to stay active for {name} after a rejected install" "expected the previous-good binary to stay active after a rejected install"
), ),
} }
} }
@@ -149,12 +144,12 @@ async fn run_one(
let prev_good = seed_previous_good(home, "0.1.100", &platform); let prev_good = seed_previous_good(home, "0.1.100", &platform);
let new_binary = home let new_binary = home
.join("downloads") .join("downloads")
.join(format!("grok-{version}-{platform}")); .join(format!("kigi-{version}-{platform}"));
let cfg = make_update_config("stable"); let cfg = make_update_config("stable");
server.set_mode(mode); server.set_mode(mode);
let base = server.uri(); let base = server.base();
let install = install_internal_from_base(Some(version), &cfg, &base); let install = install_internal_from_base(Some(version), &cfg, &base);
let expect = match (mode, cancel_after) { let expect = match (mode, cancel_after) {
(Mode::Full, None) => { (Mode::Full, None) => {
@@ -180,7 +175,7 @@ async fn run_one(
} }
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Deterministic matrix — single-connection path (small artifact) // Deterministic matrix — single-connection path (small archive)
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
#[tokio::test(flavor = "multi_thread")] #[tokio::test(flavor = "multi_thread")]
@@ -191,15 +186,20 @@ async fn blitz_single_connection_matrix() {
return; return;
} }
let server = ArtifactServer::start(small_good_artifact()); let server = ArtifactServer::start(small_good_artifact());
let len = small_good_artifact().len(); let len = server.archive_len("0.1.181");
// Happy path first so we know the symlink CAN move to the new binary. // Happy path first so we know the symlink CAN move to the new binary.
run_one(&server, Mode::Full, "0.1.181", None).await; run_one(&server, Mode::Full, "0.1.181", None).await;
// Right-length garbage — caught by the smoke-test (Layer 2). // Right-length garbage — caught by the SHA-256 gate.
run_one(&server, Mode::Garbage, "0.1.181", None).await; run_one(&server, Mode::Garbage, "0.1.181", None).await;
// Premature EOF at several offsets — caught by the length/transport checks. // Correctly-checksummed archive with a broken binary — caught by the
// smoke test.
run_one(&server, Mode::BadBinary, "0.1.181", None).await;
// Premature EOF at several offsets — caught by the length/transport
// checks (and the checksum gate as belt-and-suspenders).
for k in [0usize, 1, len / 2, len.saturating_sub(1)] { for k in [0usize, 1, len / 2, len.saturating_sub(1)] {
run_one(&server, Mode::Truncate(k), "0.1.181", None).await; run_one(&server, Mode::Truncate(k), "0.1.181", None).await;
} }
@@ -220,12 +220,12 @@ async fn blitz_single_connection_matrix() {
// calls reset_home() at the start of every case, so this checks the happy // calls reset_home() at the start of every case, so this checks the happy
// path stays reachable — not recovery over a dirty dir. The genuine // path stays reachable — not recovery over a dirty dir. The genuine
// recovery-without-reset assertion lives in // recovery-without-reset assertion lives in
// integrity_failure_is_clean_keeps_previous_good_and_emits_telemetry. // smoke_and_checksum_failures_keep_previous_good_then_recover.
run_one(&server, Mode::Full, "0.1.182", None).await; run_one(&server, Mode::Full, "0.1.182", None).await;
} }
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Deterministic matrix — parallel byte-range path (>= 16 MiB artifact) // Deterministic matrix — parallel byte-range path (>= 16 MiB archive)
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
#[tokio::test(flavor = "multi_thread")] #[tokio::test(flavor = "multi_thread")]
@@ -235,22 +235,23 @@ async fn blitz_parallel_path_matrix() {
eprintln!("skipping: shell scripts cannot execute in this sandbox"); eprintln!("skipping: shell scripts cannot execute in this sandbox");
return; return;
} }
let body = large_good_artifact(); let server = ArtifactServer::start(large_good_artifact());
let len = body.len(); let len = server.archive_len("0.1.181");
let server = ArtifactServer::start(body); assert!(
len >= 16 * 1024 * 1024,
"archive must clear the parallel threshold (got {len} bytes)"
);
// Happy path through the parallel reassembly. // Happy path through the parallel reassembly.
run_one(&server, Mode::Full, "0.1.181", None).await; run_one(&server, Mode::Full, "0.1.181", None).await;
// Right-length garbage reassembled from range chunks — smoke-test catches. // Right-length garbage reassembled from range chunks — checksum catches.
run_one(&server, Mode::Garbage, "0.1.181", None).await; run_one(&server, Mode::Garbage, "0.1.181", None).await;
// Short chunk inside the range / set_len zero region. With Content-Length // Short chunk inside the range / set_len zero region. With Content-Length
// present (the blitz server always sends it), a premature close surfaces as // present (the blitz server always sends it), a premature close surfaces as
// a reqwest stream error that rejects the chunk; the download_range // a reqwest stream error that rejects the chunk; the parallel path falls
// byte-count check is the belt-and-suspenders for the rarer close-delimited // back to single-connection, which hits the same truncation.
// (Content-Length-absent) case. The parallel path falls back to single-
// connection, which classifies the same truncation as DownloadIncomplete.
for k in [0usize, 1024, len / 3, len - 4096] { for k in [0usize, 1024, len / 3, len - 4096] {
run_one(&server, Mode::Truncate(k), "0.1.181", None).await; run_one(&server, Mode::Truncate(k), "0.1.181", None).await;
} }
@@ -269,12 +270,13 @@ async fn blitz_parallel_path_matrix() {
} }
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Smoke-test rejects garbage and keeps previous-good // Checksum + smoke-test rejections keep previous-good, then recover WITHOUT
// a reset in between.
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
#[tokio::test(flavor = "multi_thread")] #[tokio::test(flavor = "multi_thread")]
#[serial] #[serial]
async fn smoke_test_rejects_garbage_and_keeps_previous_good() { async fn smoke_and_checksum_failures_keep_previous_good_then_recover() {
if !can_exec_shell_scripts() { if !can_exec_shell_scripts() {
eprintln!("skipping: shell scripts cannot execute in this sandbox"); eprintln!("skipping: shell scripts cannot execute in this sandbox");
return; return;
@@ -285,23 +287,28 @@ async fn smoke_test_rejects_garbage_and_keeps_previous_good() {
let platform = host_platform(); let platform = host_platform();
let prev_good = seed_previous_good(home, "0.1.100", &platform); let prev_good = seed_previous_good(home, "0.1.100", &platform);
let cfg = make_update_config("stable"); let cfg = make_update_config("stable");
let base = server.base();
server.set_mode(Mode::Garbage);
let base = server.uri();
let result = install_internal_from_base(Some("0.1.181"), &cfg, &base).await;
assert!(result.is_err(), "garbage artifact must not install");
let new_binary = home let new_binary = home
.join("downloads") .join("downloads")
.join(format!("grok-0.1.181-{platform}")); .join(format!("kigi-0.1.181-{platform}"));
// Checksum failure (garbage body) keeps previous good.
server.set_mode(Mode::Garbage);
let result = install_internal_from_base(Some("0.1.181"), &cfg, &base).await;
assert!(result.is_err(), "garbage archive must not install");
assert_invariant(home, &prev_good, &new_binary, Expect::PreviousGood); assert_invariant(home, &prev_good, &new_binary, Expect::PreviousGood);
// A subsequent clean serve must succeed. // Smoke-test failure (valid checksum, broken binary) keeps previous good.
server.set_mode(Mode::BadBinary);
let result = install_internal_from_base(Some("0.1.181"), &cfg, &base).await;
assert!(result.is_err(), "broken binary must not install");
assert_invariant(home, &prev_good, &new_binary, Expect::PreviousGood);
// A subsequent clean serve must succeed over the SAME dirty state.
server.set_mode(Mode::Full); server.set_mode(Mode::Full);
let base = server.uri();
install_internal_from_base(Some("0.1.181"), &cfg, &base) install_internal_from_base(Some("0.1.181"), &cfg, &base)
.await .await
.expect("clean serve after a failure should succeed"); .expect("clean serve after failures should succeed");
assert_invariant(home, &prev_good, &new_binary, Expect::NewBinary); assert_invariant(home, &prev_good, &new_binary, Expect::NewBinary);
} }
@@ -328,7 +335,7 @@ impl Rng {
async fn fuzz_loop(iterations: usize, seed: u64) { async fn fuzz_loop(iterations: usize, seed: u64) {
let server = ArtifactServer::start(small_good_artifact()); let server = ArtifactServer::start(small_good_artifact());
let len = small_good_artifact().len(); let len = server.archive_len("0.1.181");
let mut rng = Rng(seed); let mut rng = Rng(seed);
for i in 0..iterations { for i in 0..iterations {
@@ -340,9 +347,10 @@ async fn fuzz_loop(iterations: usize, seed: u64) {
run_one(&server, Mode::Full, version, None).await; run_one(&server, Mode::Full, version, None).await;
continue; continue;
} }
match rng.below(3) { match rng.below(4) {
0 => run_one(&server, Mode::Garbage, version, None).await, 0 => run_one(&server, Mode::Garbage, version, None).await,
1 => { 1 => run_one(&server, Mode::BadBinary, version, None).await,
2 => {
// k in [0, len): always strictly truncating (k == len would be // k in [0, len): always strictly truncating (k == len would be
// a complete transfer). // a complete transfer).
let k = rng.below(len); let k = rng.below(len);
@@ -381,11 +389,11 @@ async fn blitz_fuzz_bounded() {
} }
/// The "test it a million times, cancelling at every point" stress run. Gated /// The "test it a million times, cancelling at every point" stress run. Gated
/// behind `#[ignore]`; invoke via `just blitz-stress` or /// behind `#[ignore]`; invoke via
/// `cargo nextest run -p kigi-update --run-ignored all`. /// `cargo nextest run -p kigi-update --run-ignored all`.
#[tokio::test(flavor = "multi_thread")] #[tokio::test(flavor = "multi_thread")]
#[serial] #[serial]
#[ignore = "stress: 100k iterations, run via `just blitz-stress`"] #[ignore = "stress: 100k iterations"]
async fn blitz_fuzz_stress() { async fn blitz_fuzz_stress() {
if !can_exec_shell_scripts() { if !can_exec_shell_scripts() {
eprintln!("skipping: shell scripts cannot execute in this sandbox"); eprintln!("skipping: shell scripts cannot execute in this sandbox");
@@ -1,231 +0,0 @@
//! End-to-end regression tests for `check_update_status` that lock in the
//! exact JSON shape produced by `grok update --check --json` for the failure
//! modes that real users have hit in the wild.
//!
//! Seen when a user is behind a corporate npm registry mirror:
//!
//! ```text
//! # Mirror returns 403 for the @xai-official scope
//! { "currentVersion": "0.1.181", "latestVersion": null,
//! "updateAvailable": false, "installer": "npm", "channel": "stable",
//! "autoUpdate": true,
//! "error": "npm view @latest failed: npm error code E403 ..." }
//!
//! # npm falls back to the public registry which has a stale 0.1.4
//! { "currentVersion": "0.1.181", "latestVersion": "0.1.4",
//! "updateAvailable": false, "installer": "npm", "channel": "stable",
//! "autoUpdate": true, "error": null }
//! ```
//!
//! The first case produces `error != null`, the second produces
//! `error == null` but `updateAvailable == false`. Both result in zero
//! visible change for an interactive user — the in-process auto-update
//! check (`run_update_if_available`) silently swallows the same error and
//! the same "already current" outcome.
//!
//! These tests verify the JSON contract so any refactor to `UpdateStatus`,
//! `check_update_status`, or the npm dispatch path will surface a diff.
#![cfg(unix)]
mod common;
use serial_test::serial;
use common::{FakeBinGuard, reset_home, set_test_version, test_home};
use kigi_update::UpdateConfig;
use kigi_update::auto_update::check_update_status;
/// Set up a fake `npm` on PATH, set `KIGI_INSTALLER=npm` so the auto-update
/// code dispatches to npm without consulting config, and pin the installed
/// version to `0.1.181` (matches the user's report).
fn setup() -> FakeBinGuard {
let _ = test_home();
reset_home();
set_test_version("0.1.181");
// SAFETY: serial_test ensures no race; reset_home will clear this between
// tests.
unsafe { std::env::set_var("KIGI_INSTALLER", "npm") };
FakeBinGuard::install_npm()
}
fn make_update_config() -> UpdateConfig {
UpdateConfig {
proxy_base_url: "http://test.invalid/v1".to_string(),
auth_scope: "test".to_string(),
deployment_key: None,
alpha_test_key: None,
channel: "stable".to_string(),
npm_registry: None,
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Scenario A: corporate registry 403.
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
#[serial]
async fn check_status_surfaces_npm_403_in_error_field() {
let g = setup();
// Mimic a corporate registry-mirror 403 response shape (npm exits non-zero,
// writes the error message to stderr).
g.set_exit_code(1);
g.set_stderr(
"npm error code E403\n\
npm error 403 403 Forbidden - GET https://registry-mirror.example.invalid/api/npm/js-virtual/@xai-official%2fgrok\n\
npm error 403 In most cases, you or one of your dependencies are requesting\n\
npm error 403 a package version that is forbidden by your security policy",
);
let cfg = make_update_config();
let status = check_update_status(&cfg).await;
assert_eq!(status.current_version, "0.1.181");
assert_eq!(status.latest_version, None, "no version when fetch fails");
assert!(!status.update_available, "no update when fetch fails");
assert_eq!(status.installer.as_deref(), Some("npm"));
assert_eq!(status.channel, "stable");
let err = status
.error
.as_deref()
.expect("error must be populated when npm fails");
assert!(
err.contains("npm view") && err.contains("failed"),
"error must say what failed: {err}"
);
assert!(
err.contains("403") || err.contains("E403") || err.contains("Forbidden"),
"error must include the underlying HTTP detail: {err}"
);
}
#[tokio::test]
#[serial]
async fn check_status_npm_403_serializes_to_user_visible_json() {
// Verify the public JSON shape matches what the user saw in their terminal.
let g = setup();
g.set_exit_code(1);
g.set_stderr("npm error code E403\nnpm error 403 Forbidden");
let cfg = make_update_config();
let status = check_update_status(&cfg).await;
let json = serde_json::to_value(&status).unwrap();
// Lock in every key the user's tooling depends on.
assert_eq!(json["currentVersion"], "0.1.181");
assert!(json["latestVersion"].is_null());
assert_eq!(json["updateAvailable"], false);
assert_eq!(json["installer"], "npm");
assert_eq!(json["channel"], "stable");
let err = json["error"]
.as_str()
.expect("error key must be a string when fetch fails");
assert!(
err.contains("E403") || err.contains("403"),
"error must include 403: {err}"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Scenario B: public registry returns stale 0.1.4.
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
#[serial]
async fn check_status_returns_no_update_when_registry_has_older_version() {
// The public registry returns 0.1.4 (much older than installed 0.1.181).
// `needs_update("0.1.181", "0.1.4", "stable")` returns Some(false), so
// `updateAvailable` is false and `error` is null. From the user's
// perspective: silent no-op, even though their preferred upgrade lane
// (corporate mirror) was unreachable. There's nothing the auto-update
// code can do here without knowing about scoped registries — but we want
// to lock in this exact shape so a future change doesn't accidentally
// present a downgrade as an upgrade.
let g = setup();
g.set_stdout("\"0.1.4\"");
let cfg = make_update_config();
let status = check_update_status(&cfg).await;
assert_eq!(status.current_version, "0.1.181");
assert_eq!(status.latest_version.as_deref(), Some("0.1.4"));
assert!(
!status.update_available,
"older latest must NOT be reported as update available"
);
assert_eq!(status.installer.as_deref(), Some("npm"));
assert!(status.error.is_none(), "no error on successful fetch");
}
#[tokio::test]
#[serial]
async fn check_status_stale_version_serializes_to_user_visible_json() {
let g = setup();
g.set_stdout("\"0.1.4\"");
let cfg = make_update_config();
let status = check_update_status(&cfg).await;
let json = serde_json::to_value(&status).unwrap();
assert_eq!(json["currentVersion"], "0.1.181");
assert_eq!(json["latestVersion"], "0.1.4");
assert_eq!(json["updateAvailable"], false);
assert_eq!(json["installer"], "npm");
assert_eq!(json["channel"], "stable");
assert!(json["error"].is_null());
}
// ─────────────────────────────────────────────────────────────────────────────
// Sanity: when npm returns a NEWER version, we DO report an update.
// (Anti-regression: the silent-skip paths must only fire on actual no-op
// conditions, not collapse into "always returns no update".)
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
#[serial]
async fn check_status_reports_update_when_registry_has_newer_version() {
let g = setup();
g.set_stdout("\"0.1.182\"");
let cfg = make_update_config();
let status = check_update_status(&cfg).await;
assert_eq!(status.current_version, "0.1.181");
assert_eq!(status.latest_version.as_deref(), Some("0.1.182"));
assert!(status.update_available, "newer version must be reported");
assert!(status.error.is_none());
}
// ─────────────────────────────────────────────────────────────────────────────
// npm rollback safety: npm must NEVER report a downgrade as an update.
// Stale registries / misconfigured Artifactories returning old versions is a
// known failure mode — the auto-updater must ignore them rather than
// downgrading the user.
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
#[serial]
async fn check_status_npm_never_reports_downgrade_as_update() {
// Verify that the npm path still refuses to report a lower version as
// an available update, even after the allow_downgrade feature was added
// for GCS/internal installers. This is the key safety property.
let g = setup();
// Simulate a moderate rollback (not a wildly stale version).
g.set_stdout("\"0.1.179\"");
let cfg = make_update_config();
let status = check_update_status(&cfg).await;
assert_eq!(status.current_version, "0.1.181");
assert_eq!(status.latest_version.as_deref(), Some("0.1.179"));
assert!(
!status.update_available,
"npm must NOT report a downgrade as update available — stale registries \
would force-downgrade users to ancient versions"
);
assert_eq!(status.installer.as_deref(), Some("npm"));
assert!(status.error.is_none());
}
@@ -1,614 +0,0 @@
//! Invariant matrix tests for the rollback/downgrade feature.
//!
//! Covers every combination of:
//! - user's current version vs. channel pointer target
//! - installer type (internal, npm, gh-release)
//! - channel (stable, alpha, enterprise)
//! - pointer-flip scenarios (stable bumped after user upgraded, alpha
//! pointer rolled back, etc.)
//!
//! Also includes wiremock-based installation tests that verify the GCS
//! internal installer actually downloads and symlinks an older binary
//! when the stable pointer is rolled back.
#![cfg(unix)]
mod common;
use serial_test::serial;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use common::{FakeBinGuard, reset_home, set_test_version, test_home};
use kigi_update::UpdateConfig;
use kigi_update::auto_update::{
auto_update_target, check_update_status, ensure_latest_on_disk, install_internal_from_base,
};
use kigi_update::version::installed_on_disk_version;
fn host_platform() -> String {
let os = if cfg!(target_os = "macos") {
"macos"
} else if cfg!(target_os = "linux") {
"linux"
} else {
panic!("unsupported test platform");
};
let arch = if cfg!(target_arch = "x86_64") {
"x86_64"
} else if cfg!(target_arch = "aarch64") {
"aarch64"
} else {
panic!("unsupported test arch");
};
format!("{os}-{arch}")
}
fn make_config(channel: &str) -> UpdateConfig {
UpdateConfig {
proxy_base_url: "http://test.invalid/v1".to_string(),
auth_scope: "test".to_string(),
deployment_key: None,
alpha_test_key: None,
channel: channel.to_string(),
npm_registry: None,
}
}
async fn mount_gcs_with_channels(
stable_version: &str,
alpha_version: Option<&str>,
binary_version: &str,
platform: &str,
) -> MockServer {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/stable"))
.respond_with(ResponseTemplate::new(200).set_body_string(stable_version))
.mount(&server)
.await;
if let Some(alpha_v) = alpha_version {
Mock::given(method("GET"))
.and(path("/alpha"))
.respond_with(ResponseTemplate::new(200).set_body_string(alpha_v))
.mount(&server)
.await;
}
Mock::given(method("GET"))
.and(path(format!("/grok-{binary_version}-{platform}")))
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"#!/bin/sh\nexit 0\n".to_vec()))
.mount(&server)
.await;
server
}
// ─────────────────────────────────────────────────────────────────────────────
// Scenario matrix: GCS internal installer — downgrade via install
//
// Each test simulates a user on version X, with the stable/alpha pointer
// now pointing to version Y. The internal installer should install Y
// regardless of whether Y < X (rollback) or Y > X (upgrade).
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
#[serial]
async fn internal_install_stable_rollback_0_2_7_to_0_2_5() {
// User was on 0.2.7, stable pointer rolled back to 0.2.5.
let _ = test_home();
reset_home();
let platform = host_platform();
let server = mount_gcs_with_channels("0.2.5", None, "0.2.5", &platform).await;
let cfg = make_config("stable");
install_internal_from_base(Some("0.2.5"), &cfg, &server.uri())
.await
.unwrap();
let home = test_home();
let downloaded = home
.join("downloads")
.join(format!("grok-0.2.5-{platform}"));
assert!(downloaded.exists(), "rolled-back binary must be downloaded");
let symlink = home.join("bin").join("grok");
let target = std::fs::read_link(&symlink).unwrap();
assert!(
target.to_string_lossy().contains("0.2.5"),
"symlink must point to rolled-back version: {target:?}"
);
}
#[tokio::test]
#[serial]
async fn internal_install_stable_upgrade_0_2_5_to_0_2_7() {
// Normal upgrade path: user on 0.2.5, pointer at 0.2.7.
let _ = test_home();
reset_home();
let platform = host_platform();
let server = mount_gcs_with_channels("0.2.7", None, "0.2.7", &platform).await;
let cfg = make_config("stable");
install_internal_from_base(Some("0.2.7"), &cfg, &server.uri())
.await
.unwrap();
let symlink = test_home().join("bin").join("grok");
let target = std::fs::read_link(&symlink).unwrap();
assert!(target.to_string_lossy().contains("0.2.7"));
}
#[tokio::test]
#[serial]
async fn internal_install_rollback_then_upgrade_sequence() {
// Simulates: install 0.2.7 → rollback to 0.2.5 → fix ships as 0.2.8.
// All three installs must succeed sequentially.
let _ = test_home();
reset_home();
let platform = host_platform();
for version in ["0.2.7", "0.2.5", "0.2.8"] {
// Age the previous installs: cleanup deliberately never deletes a
// freshly-written binary (it may be a concurrent racer's just-renamed
// download), so the retention assertions below need the earlier
// installs to look like real leftovers from past releases.
common::backdate_downloads();
let server = mount_gcs_with_channels(version, None, version, &platform).await;
let cfg = make_config("stable");
install_internal_from_base(Some(version), &cfg, &server.uri())
.await
.unwrap();
}
let target = std::fs::read_link(test_home().join("bin").join("grok")).unwrap();
assert!(
target.to_string_lossy().contains("0.2.8"),
"final symlink must point to 0.2.8: {target:?}"
);
// Cleanup retains current + highest-semver non-current (N-1 by version, not install order).
let downloads = test_home().join("downloads");
assert!(
downloads.join(format!("grok-0.2.8-{platform}")).exists(),
"current"
);
assert!(
downloads.join(format!("grok-0.2.7-{platform}")).exists(),
"N-1 by semver"
);
assert!(
!downloads.join(format!("grok-0.2.5-{platform}")).exists(),
"lowest cleaned up"
);
}
#[tokio::test]
#[serial]
async fn internal_install_alpha_rollback_pointer_resolves_correctly() {
// Alpha user on 0.2.8-alpha.3. Alpha pointer rolled back to 0.2.8-alpha.1,
// stable pointer is 0.2.7. Alpha channel returns max(alpha, stable) = 0.2.8-alpha.1.
let _ = test_home();
reset_home();
let platform = host_platform();
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/stable"))
.respond_with(ResponseTemplate::new(200).set_body_string("0.2.7"))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/alpha"))
.respond_with(ResponseTemplate::new(200).set_body_string("0.2.8-alpha.1"))
.mount(&server)
.await;
// The resolved version is max(0.2.7, 0.2.8-alpha.1) = 0.2.8-alpha.1.
// Note: semver considers 0.2.8-alpha.1 < 0.2.8 but > 0.2.7.
Mock::given(method("GET"))
.and(path(format!("/grok-0.2.8-alpha.1-{platform}")))
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"#!/bin/sh\nexit 0\n".to_vec()))
.mount(&server)
.await;
let cfg = make_config("alpha");
install_internal_from_base(None, &cfg, &server.uri())
.await
.unwrap();
let downloaded = test_home()
.join("downloads")
.join(format!("grok-0.2.8-alpha.1-{platform}"));
assert!(
downloaded.exists(),
"alpha rollback target must be installed"
);
}
#[tokio::test]
#[serial]
async fn internal_install_alpha_user_gets_newer_stable_after_stable_passes_alpha() {
// Alpha user on 0.2.6-alpha.2. Stable ships 0.2.7 (higher than alpha).
// Alpha channel returns max(alpha=0.2.6-alpha.2, stable=0.2.7) = 0.2.7.
let _ = test_home();
reset_home();
let platform = host_platform();
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/stable"))
.respond_with(ResponseTemplate::new(200).set_body_string("0.2.7"))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/alpha"))
.respond_with(ResponseTemplate::new(200).set_body_string("0.2.6-alpha.2"))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path(format!("/grok-0.2.7-{platform}")))
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"#!/bin/sh\nexit 0\n".to_vec()))
.mount(&server)
.await;
let cfg = make_config("alpha");
install_internal_from_base(None, &cfg, &server.uri())
.await
.unwrap();
assert!(
test_home()
.join("downloads")
.join(format!("grok-0.2.7-{platform}"))
.exists(),
"alpha user should get the newer stable"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Scenario matrix: check_update_status across installer × version direction
//
// Uses check_update_status end-to-end with fake npm/gh binaries.
// The internal (GCS) path can't be end-to-end tested via check_update_status
// (hardcoded URLs), so its update-detection logic is covered by the
// needs_update unit tests and the install tests above.
// ─────────────────────────────────────────────────────────────────────────────
fn setup_npm(current_version: &str) -> FakeBinGuard {
let _ = test_home();
reset_home();
set_test_version(current_version);
// SAFETY: serial_test ensures no race; reset_home clears this between tests.
unsafe { std::env::set_var("KIGI_INSTALLER", "npm") };
FakeBinGuard::install_npm()
}
fn setup_gh(current_version: &str) -> FakeBinGuard {
let _ = test_home();
reset_home();
set_test_version(current_version);
// SAFETY: serial_test ensures no race; reset_home clears this between tests.
unsafe { std::env::set_var("KIGI_INSTALLER", "gh-release") };
FakeBinGuard::install_gh()
}
// ── npm: never downgrades ──
#[tokio::test]
#[serial]
async fn npm_upgrade_reports_update() {
let g = setup_npm("0.2.5");
g.set_stdout("\"0.2.7\"");
let status = check_update_status(&make_config("stable")).await;
assert!(status.update_available);
assert_eq!(status.latest_version.as_deref(), Some("0.2.7"));
}
#[tokio::test]
#[serial]
async fn npm_same_version_no_update() {
let g = setup_npm("0.2.7");
g.set_stdout("\"0.2.7\"");
let status = check_update_status(&make_config("stable")).await;
assert!(!status.update_available);
}
#[tokio::test]
#[serial]
async fn npm_rollback_does_not_report_update() {
// Stable pointer rolled back 0.2.7 → 0.2.5. npm user on 0.2.7 must NOT
// see an update — stale registries make this path unsafe.
let g = setup_npm("0.2.7");
g.set_stdout("\"0.2.5\"");
let status = check_update_status(&make_config("stable")).await;
assert!(
!status.update_available,
"npm must never report a downgrade: current={} latest={:?}",
status.current_version, status.latest_version
);
}
#[tokio::test]
#[serial]
async fn npm_drastically_old_registry_does_not_report_update() {
// Corporate registry returns ancient version.
let g = setup_npm("0.2.7");
g.set_stdout("\"0.1.4\"");
let status = check_update_status(&make_config("stable")).await;
assert!(!status.update_available);
}
// ── gh-release: --check is upgrade-only; rollback handled by auto-install ──
#[tokio::test]
#[serial]
async fn gh_release_upgrade_reports_update() {
let g = setup_gh("0.2.5");
g.set_stable_only_stdout("v0.2.7\n");
let status = check_update_status(&make_config("stable")).await;
assert!(status.update_available);
assert_eq!(status.latest_version.as_deref(), Some("0.2.7"));
}
#[tokio::test]
#[serial]
async fn gh_release_rollback_not_advertised_by_check() {
// `update --check` advertises upgrades only; a rollback still converges via
// the auto-install path (covered by the internal_install_* tests), not here.
let g = setup_gh("0.2.7");
g.set_stable_only_stdout("v0.2.5\n");
let status = check_update_status(&make_config("stable")).await;
assert!(
!status.update_available,
"gh-release rollback must not be advertised by --check: current={} latest={:?}",
status.current_version, status.latest_version
);
assert_eq!(status.latest_version.as_deref(), Some("0.2.5"));
}
#[tokio::test]
#[serial]
async fn gh_release_same_version_no_update() {
let g = setup_gh("0.2.7");
g.set_stable_only_stdout("v0.2.7\n");
let status = check_update_status(&make_config("stable")).await;
assert!(!status.update_available);
}
// ─────────────────────────────────────────────────────────────────────────────
// auto_update_target: the leader/background auto-install decision
//
// Unlike the upgrade-only `check_update_status` report, this is the
// downgrade-aware convergence decision. It gates on the installer, so
// authoritative installers (gh-release/internal) follow a rolled-back pointer
// while npm never downgrades. `fetch_latest_version` keeps these hermetic.
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
#[serial]
async fn auto_update_target_gh_release_rollback_returns_older() {
let g = setup_gh("0.2.26");
g.set_stable_only_stdout("v0.2.22\n");
assert_eq!(
auto_update_target(&make_config("stable")).await,
Some(("gh-release", "0.2.22".to_string())),
"authoritative installer must converge down on a rolled-back pointer"
);
}
#[tokio::test]
#[serial]
async fn auto_update_target_gh_release_upgrade_returns_newer() {
let g = setup_gh("0.2.5");
g.set_stable_only_stdout("v0.2.7\n");
assert_eq!(
auto_update_target(&make_config("stable")).await,
Some(("gh-release", "0.2.7".to_string()))
);
}
#[tokio::test]
#[serial]
async fn auto_update_target_gh_release_same_version_returns_none() {
let g = setup_gh("0.2.7");
g.set_stable_only_stdout("v0.2.7\n");
assert_eq!(auto_update_target(&make_config("stable")).await, None);
}
#[tokio::test]
#[serial]
async fn auto_update_target_npm_rollback_returns_none() {
// npm registries can serve stale versions — never downgrade npm installs.
let g = setup_npm("0.2.26");
g.set_stdout("\"0.2.22\"");
assert_eq!(
auto_update_target(&make_config("stable")).await,
None,
"npm must never be downgraded even when the registry reports an older version"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Disk-aware convergence: ensure_latest_on_disk + installed_on_disk_version
//
// Concurrent updaters (TUI background download, leader hourly checker,
// explicit `grok update`) must decide staleness from the on-disk install, not
// their own compiled-in version — a binary another process already installed
// is never downloaded a second time, but a stale running process still gets
// the relaunch signal.
// ─────────────────────────────────────────────────────────────────────────────
/// Lay down a managed-install layout in the test KIGI_SHARE_DIR:
/// `bin/{kigi,grok,agent} -> ../downloads/grok-<version>-<platform>` (what
/// `install_internal_from_base` produces; `kigi` is the canonical link the
/// disk-version probe reads, `grok` the legacy compat link).
fn fake_managed_install(version: &str) {
let home = test_home();
let downloads = home.join("downloads");
let bin = home.join("bin");
std::fs::create_dir_all(&downloads).unwrap();
std::fs::create_dir_all(&bin).unwrap();
let name = format!("grok-{version}-{}", host_platform());
std::fs::write(downloads.join(&name), b"#!/bin/sh\nexit 0\n").unwrap();
for link in ["kigi", "grok", "agent"] {
std::os::unix::fs::symlink(
std::path::Path::new("../downloads").join(&name),
bin.join(link),
)
.unwrap();
}
}
#[tokio::test]
#[serial]
async fn installed_on_disk_version_reads_symlink_target() {
let _ = test_home();
reset_home();
assert_eq!(installed_on_disk_version(), None, "no install yet");
fake_managed_install("0.2.7");
assert_eq!(installed_on_disk_version().as_deref(), Some("0.2.7"));
}
#[tokio::test]
#[serial]
async fn ensure_latest_skips_download_when_disk_current_but_still_relaunches() {
// Running 0.2.5, pointer 0.2.7, disk already at 0.2.7 (another process
// downloaded it): no download, but the stale running process must relaunch.
let g = setup_gh("0.2.5");
g.set_stable_only_stdout("v0.2.7\n");
fake_managed_install("0.2.7");
let outcome = ensure_latest_on_disk(&make_config("stable")).await.unwrap();
assert_eq!(outcome.installed, None, "must not re-download");
assert!(outcome.relaunch_needed, "running 0.2.5 < disk 0.2.7");
assert!(
!g.args_log().iter().any(|l| l.contains("release download")),
"no gh download invocation expected, got: {:?}",
g.args_log()
);
}
#[tokio::test]
#[serial]
async fn ensure_latest_noop_when_running_and_disk_current() {
let g = setup_gh("0.2.7");
g.set_stable_only_stdout("v0.2.7\n");
fake_managed_install("0.2.7");
let outcome = ensure_latest_on_disk(&make_config("stable")).await.unwrap();
assert_eq!(outcome.installed, None);
assert!(!outcome.relaunch_needed);
}
#[tokio::test]
#[serial]
async fn ensure_latest_relaunches_onto_rolled_back_disk() {
// Pointer rolled back to 0.2.22 and the disk already converged; a running
// 0.2.26 leader must relaunch onto the older binary (gh-release is an
// authoritative installer → downgrades allowed).
let g = setup_gh("0.2.26");
g.set_stable_only_stdout("v0.2.22\n");
fake_managed_install("0.2.22");
let outcome = ensure_latest_on_disk(&make_config("stable")).await.unwrap();
assert_eq!(outcome.installed, None, "disk already at pointer");
assert!(outcome.relaunch_needed, "downgrade relaunch expected");
}
// ─────────────────────────────────────────────────────────────────────────────
// Pointer-flip timing scenarios
//
// These test the race between a user opening grok (which caches the version)
// and a pointer flip happening. The 30-min TTL means the user won't see the
// new pointer until the cache expires, but once it does, the correct behavior
// must kick in.
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
#[serial]
async fn npm_user_upgraded_then_stable_rolled_back_stays_on_newer() {
// User ran `grok update` and got 0.2.7. Then stable was rolled back to
// 0.2.5. Next check_update_status sees 0.2.5 from npm. npm installer
// must NOT report a downgrade.
let g = setup_npm("0.2.7");
g.set_stdout("\"0.2.5\"");
let status = check_update_status(&make_config("stable")).await;
assert!(!status.update_available);
assert_eq!(status.latest_version.as_deref(), Some("0.2.5"));
}
#[tokio::test]
#[serial]
async fn gh_release_user_ahead_of_pointer_check_reports_no_update() {
// User manually installed 0.2.26 (ahead of the stable pointer 0.2.22);
// `update --check` must not present the older pointer as a new version.
let g = setup_gh("0.2.26");
g.set_stable_only_stdout("v0.2.22\n");
let status = check_update_status(&make_config("stable")).await;
assert!(
!status.update_available,
"ahead-of-pointer must not be advertised as an update: current={} latest={:?}",
status.current_version, status.latest_version
);
assert_eq!(status.latest_version.as_deref(), Some("0.2.22"));
}
#[tokio::test]
#[serial]
async fn npm_alpha_user_upgrade_after_stable_surpasses_alpha() {
// Alpha user on 0.2.6-alpha.2. Stable ships 0.2.7. npm returns 0.2.7
// for the @latest tag. User should upgrade.
let g = setup_npm("0.2.6-alpha.2");
g.set_stdout("\"0.2.7\"");
let status = check_update_status(&make_config("stable")).await;
// Pre-release current on stable channel forces install.
assert!(
status.update_available,
"alpha user should upgrade to stable when stable surpasses alpha"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Double-rollback scenario
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
#[serial]
async fn internal_install_double_rollback() {
// Ship 0.2.7 → rollback to 0.2.5 → rollback further to 0.2.3.
// The installer must handle multiple sequential downgrades.
let _ = test_home();
reset_home();
let platform = host_platform();
for version in ["0.2.7", "0.2.5", "0.2.3"] {
let server = mount_gcs_with_channels(version, None, version, &platform).await;
let cfg = make_config("stable");
install_internal_from_base(Some(version), &cfg, &server.uri())
.await
.unwrap();
let target = std::fs::read_link(test_home().join("bin").join("grok")).unwrap();
assert!(
target.to_string_lossy().contains(version),
"symlink must point to {version} after install: {target:?}"
);
}
}
File diff suppressed because it is too large Load Diff
@@ -1,404 +1,305 @@
//! Blitz harness for the bash installer (`install.sh`), the second client that //! Harness for the bootstrap installer (`install.sh` at the repo root), the
//! can brick a machine. Runs the REAL shipped `install.sh` against a fake //! second client that can brick a machine. Runs the REAL shipped script
//! `curl` that can serve the good artifact, truncate it, or serve a right-length //! against a fake `curl` that serves GitHub-Releases-shaped JSON, the
//! garbage body, and asserts the same invariant as the Rust blitz: //! archive, and SHA256SUMS from local fixtures, and asserts:
//! //!
//! > After any install attempt, `$BIN_DIR/grok` resolves to a binary that runs, //! > After any install attempt, `$KIGI_SHARE_DIR/bin/kigi` resolves to a
//! > OR is still the previous-good binary — never a partial/garbage binary. //! > binary that runs, OR the install failed cleanly with nothing activated —
//! > never a partial/garbage binary.
//! //!
//! Also covers shell-rc rewrite: stowed/symlinked `~/.bashrc` etc. must survive //! Each test uses its own tempdir home and passes PATH/KIGI_SHARE_DIR to the
//! reinstall without being replaced by a plain file. //! child process explicitly, so no process-global state is touched and no
//! //! `#[serial]` is needed.
//! The installer lives in the sibling `kigi-tui` crate; it is resolved by
//! relative path. If it cannot be found (e.g. a sandbox that does not vendor it)
//! the test skips rather than fail — under the repo's `cargo nextest` workflow
//! the path resolves and the installer is exercised end to end.
#![cfg(unix)] #![cfg(unix)]
mod common;
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
fn script_path(name: &str) -> Option<PathBuf> { use common::{archive_name, host_platform, make_release_archive, sha256_hex, small_good_artifact};
dunce::canonicalize(
Path::new(env!("CARGO_MANIFEST_DIR")).join(format!("../kigi-tui/scripts/{name}")), fn install_sh_path() -> Option<PathBuf> {
) // crates/codegen/kigi-update → repo root.
dunce::canonicalize(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../install.sh"))
.ok() .ok()
.filter(|p| p.exists()) .filter(|p| p.exists())
} }
fn install_sh_path() -> Option<PathBuf> { /// GitHub release JSON with download URLs whose suffixes the fake curl
script_path("install.sh") /// dispatches on (the host is irrelevant).
fn release_json(version: &str) -> String {
let name = archive_name(version);
serde_json::json!({
"tag_name": format!("v{version}"),
"draft": false,
"prerelease": false,
"assets": [
{ "name": name, "browser_download_url": format!("https://example.test/dl/v{version}/{name}") },
{ "name": "SHA256SUMS", "browser_download_url": format!("https://example.test/dl/v{version}/SHA256SUMS") },
],
})
.to_string()
} }
fn host_platform() -> String { /// Fixture directory holding the fake curl + canned responses.
let os = if cfg!(target_os = "macos") { struct Fixture {
"macos" dir: tempfile::TempDir,
} else { home: tempfile::TempDir,
"linux"
};
let arch = if cfg!(target_arch = "x86_64") {
"x86_64"
} else {
"aarch64"
};
format!("{os}-{arch}")
} }
const GOOD_SCRIPT: &str = "#!/bin/sh\nexit 0\n"; impl Fixture {
const INSTALLER_BLOCK_START: &str = "# >>> grok installer >>>"; /// `binary` becomes the `kigi` entry of the served archive; `sums_hash`
/// overrides the manifest hash when `Some` (to simulate corruption).
/// Write a fake `curl` that intercepts every download `install.sh` performs. fn new(version: &str, binary: &[u8], sums_hash: Option<&str>) -> Self {
/// `$FAKE_MODE` (full|truncate|garbage) selects the corruption. let dir = tempfile::tempdir().unwrap();
fn write_fake_curl(dir: &Path) {
let body = format!(
r#"#!/bin/bash
mode="${{FAKE_MODE:-full}}"
fullsize={fullsize}
head=0; out=""; want_code=0; url=""
while [ $# -gt 0 ]; do
case "$1" in
--head) head=1 ;;
-o) shift; out="$1" ;;
-w) shift; [ "$1" = '%{{http_code}}' ] && want_code=1 ;;
-*) : ;;
*) url="$1" ;;
esac
shift
done
if [ "$head" = 1 ]; then
if [ "$want_code" = 1 ]; then printf '200'; else printf 'HTTP/1.1 200 OK\r\nContent-Length: %s\r\n\r\n' "$fullsize"; fi
exit 0
fi
if [ -n "$out" ]; then
case "$mode" in
full) printf '%s' '{good}' > "$out" ;;
truncate) printf '\0\0\0\0' > "$out" ;;
garbage) head -c "$fullsize" /dev/zero | tr '\0' 'X' > "$out" ;;
esac
exit 0
fi
printf '0.1.181'
exit 0
"#,
fullsize = GOOD_SCRIPT.len(),
good = GOOD_SCRIPT,
);
let path = dir.join("curl");
std::fs::write(&path, body).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
/// Seed a valid previous-good binary + symlink in the isolated home.
fn seed_previous_good(home: &Path, platform: &str) -> PathBuf {
let downloads = home.join(".kigi").join("downloads");
let bin = home.join(".kigi").join("bin");
std::fs::create_dir_all(&downloads).unwrap();
std::fs::create_dir_all(&bin).unwrap();
let prev = downloads.join(format!("grok-{platform}"));
std::fs::write(&prev, GOOD_SCRIPT).unwrap();
std::fs::set_permissions(&prev, std::fs::Permissions::from_mode(0o755)).unwrap();
let link = bin.join("grok");
let _ = std::fs::remove_file(&link);
std::os::unix::fs::symlink(format!("../downloads/grok-{platform}"), &link).unwrap();
dunce::canonicalize(&prev).unwrap()
}
/// Re-resolve `$BIN_DIR/grok` from disk and re-run it: the active grok must
/// always execute, and never be a `.tmp`/partial file.
fn assert_active_grok_runs(home: &Path) {
let link = home.join(".kigi").join("bin").join("grok");
assert!(link.is_symlink(), "grok must remain a symlink");
let resolved =
dunce::canonicalize(&link).unwrap_or_else(|e| panic!("grok symlink dangles: {e}"));
let name = resolved.file_name().unwrap().to_string_lossy().to_string();
assert!(
!name.contains(".tmp"),
"active grok must not be a temp file: {name}"
);
let ok = Command::new(&resolved)
.arg("--version")
.status()
.map(|s| s.success())
.unwrap_or(false);
assert!(ok, "active grok must run: {}", resolved.display());
}
fn run_installer(install_sh: &Path, home: &Path, fakebin: &Path, mode: &str, shell: &str) -> bool {
let path_env = format!("{}:/usr/bin:/bin", fakebin.display());
let status = Command::new("/bin/bash")
.arg(install_sh)
.arg("0.1.181")
.env_clear()
.env("HOME", home)
.env("PATH", path_env)
.env("SHELL", shell)
.env("KIGI_BIN_DIR", home.join(".kigi").join("bin"))
.env("KIGI_CHANNEL", "stable")
.env("FAKE_MODE", mode)
.status()
.expect("spawn bash install.sh");
status.success()
}
fn installer_block_count(body: &str) -> usize {
body.matches(INSTALLER_BLOCK_START).count()
}
fn assert_single_installer_block(path: &Path, preserved: Option<&str>) {
let body = std::fs::read_to_string(path).unwrap_or_else(|e| {
panic!("read {}: {e}", path.display());
});
let n = installer_block_count(&body);
assert_eq!(
n,
1,
"{} must contain exactly one grok installer block, got {n}:\n{body}",
path.display()
);
if let Some(marker) = preserved {
assert!(
body.contains(marker),
"{} must keep pre-existing content ({marker:?}):\n{body}",
path.display()
);
}
}
#[derive(Clone, Copy)]
enum RcLayout {
Missing,
Plain,
StowAbsolute,
StowRelative,
/// `$root/user/.bashrc` → `../packages/bash/bashrc` (physical relative arm).
StowRelativeDotDot,
}
struct ShellRcCase {
name: &'static str,
script: &'static str,
shell: &'static str,
rc_name: &'static str,
stow_name: &'static str,
layout: RcLayout,
reinstall: bool,
}
/// Returns `(installer_home, rc_path, stow_target, expected_link_value)`.
fn setup_rc(
root: &Path,
case: &ShellRcCase,
) -> (PathBuf, PathBuf, Option<PathBuf>, Option<PathBuf>) {
let marker = "# user shell rc\n";
match case.layout {
RcLayout::Missing => {
let home = root.to_path_buf();
(home.clone(), home.join(case.rc_name), None, None)
}
RcLayout::Plain => {
let home = root.to_path_buf();
let rc_link = home.join(case.rc_name);
std::fs::write(&rc_link, marker).unwrap();
(home, rc_link, None, None)
}
RcLayout::StowAbsolute | RcLayout::StowRelative => {
let home = root.to_path_buf();
let stow_dir = home.join("dotfiles");
std::fs::create_dir_all(&stow_dir).unwrap();
let target = stow_dir.join(case.stow_name);
std::fs::write(&target, marker).unwrap();
let link_value = if matches!(case.layout, RcLayout::StowAbsolute) {
target.clone()
} else {
PathBuf::from(format!("dotfiles/{}", case.stow_name))
};
let rc_link = home.join(case.rc_name);
std::os::unix::fs::symlink(&link_value, &rc_link).unwrap();
(home, rc_link, Some(target), Some(link_value))
}
RcLayout::StowRelativeDotDot => {
// $HOME = root/user; package is a sibling of user (relative needs `..`).
let home = root.join("user");
std::fs::create_dir_all(&home).unwrap();
let target = root.join("packages/bash/bashrc");
std::fs::create_dir_all(target.parent().unwrap()).unwrap();
std::fs::write(&target, marker).unwrap();
let link_value = PathBuf::from("../packages/bash/bashrc");
let rc_link = home.join(case.rc_name);
std::os::unix::fs::symlink(&link_value, &rc_link).unwrap();
(home, rc_link, Some(target), Some(link_value))
}
}
}
fn run_shell_rc_case(case: &ShellRcCase) {
let Some(script) = script_path(case.script) else {
eprintln!(
"skipping {}: {} not found relative to crate",
case.name, case.script
);
return;
};
let platform = host_platform();
let fakedir = tempfile::tempdir().unwrap();
write_fake_curl(fakedir.path());
let root = tempfile::tempdir().unwrap();
let (home_path, rc_path, stow_target, expected_link) = setup_rc(root.path(), case);
seed_previous_good(&home_path, &platform);
assert!(
run_installer(&script, &home_path, fakedir.path(), "full", case.shell),
"{}: first install should succeed",
case.name
);
if case.reinstall {
assert!(
run_installer(&script, &home_path, fakedir.path(), "full", case.shell),
"{}: reinstall should succeed",
case.name
);
}
match case.layout {
RcLayout::Missing | RcLayout::Plain => {
assert!(
rc_path.is_file() && !rc_path.is_symlink(),
"{}: {} must be a regular file",
case.name,
case.rc_name
);
let preserved = match case.layout {
RcLayout::Plain => Some("# user shell rc"),
_ => None,
};
assert_single_installer_block(&rc_path, preserved);
}
RcLayout::StowAbsolute | RcLayout::StowRelative | RcLayout::StowRelativeDotDot => {
assert!(
rc_path.is_symlink(),
"{}: {} must remain a symlink after install",
case.name,
case.rc_name
);
let link = std::fs::read_link(&rc_path).unwrap();
assert_eq!(
link,
*expected_link.as_ref().unwrap(),
"{}: symlink target must be unchanged",
case.name
);
let target = stow_target.as_ref().unwrap();
assert_single_installer_block(target, Some("# user shell rc"));
}
}
assert_active_grok_runs(&home_path);
}
#[test]
fn install_sh_blitz_keeps_grok_runnable_under_corruption() {
let Some(install_sh) = install_sh_path() else {
eprintln!("skipping: install.sh not found relative to crate; run under cargo");
return;
};
let platform = host_platform();
let fakedir = tempfile::tempdir().unwrap();
write_fake_curl(fakedir.path());
// Each entry: (mode, should the installer succeed?). Loop a few rounds so a
// re-install over an existing good install is also exercised.
let cases = [
("full", true),
("truncate", false),
("garbage", false),
("full", true),
("truncate", false),
("garbage", false),
("full", true),
];
for (mode, expect_ok) in cases {
let home = tempfile::tempdir().unwrap(); let home = tempfile::tempdir().unwrap();
seed_previous_good(home.path(), &platform);
let ok = run_installer(&install_sh, home.path(), fakedir.path(), mode, "/bin/bash"); let archive = make_release_archive(binary);
assert_eq!( let hash = match sums_hash {
ok, expect_ok, Some(h) => h.to_string(),
"install.sh mode={mode} exit success mismatch" None => sha256_hex(&archive),
};
std::fs::write(dir.path().join("release.json"), release_json(version)).unwrap();
std::fs::write(dir.path().join("archive.tar.gz"), &archive).unwrap();
std::fs::write(
dir.path().join("SHA256SUMS"),
format!("{hash} {}\n", archive_name(version)),
)
.unwrap();
let d = dir.path().to_string_lossy().replace('\'', "'\\''");
let curl = format!(
r#"#!/bin/sh
echo "$@" >> '{d}/curl-args.log'
out=""
url=""
prev=""
for a in "$@"; do
if [ "$prev" = "-o" ]; then out="$a"; fi
case "$a" in
-*) ;;
*) url="$a" ;;
esac
prev="$a"
done
serve() {{
if [ -n "$out" ]; then cat "$1" > "$out"; else cat "$1"; fi
}}
case "$url" in
*/SHA256SUMS) serve '{d}/SHA256SUMS' ;;
*.tar.gz) serve '{d}/archive.tar.gz' ;;
*/latest|*/tags/v*) serve '{d}/release.json' ;;
*) echo "fake curl: unmatched url: $url" >&2; exit 22 ;;
esac
"#
);
let curl_path = dir.path().join("curl");
std::fs::write(&curl_path, curl).unwrap();
std::fs::set_permissions(&curl_path, std::fs::Permissions::from_mode(0o755)).unwrap();
Self { dir, home }
}
fn run(&self, args: &[&str]) -> std::process::Output {
let script = install_sh_path().expect("install.sh present at repo root");
let path = format!(
"{}:{}",
self.dir.path().display(),
std::env::var("PATH").unwrap_or_default()
);
Command::new("sh")
.arg(&script)
.args(args)
.env("PATH", path)
.env("KIGI_SHARE_DIR", self.home.path())
.env("HOME", self.home.path())
.output()
.expect("install.sh must spawn")
}
fn curl_log(&self) -> String {
std::fs::read_to_string(self.dir.path().join("curl-args.log")).unwrap_or_default()
}
fn active_kigi(&self) -> PathBuf {
self.home.path().join("bin").join("kigi")
}
}
fn stderr_of(out: &std::process::Output) -> String {
String::from_utf8_lossy(&out.stderr).to_string()
}
#[test]
fn install_sh_happy_path_installs_versioned_binary_and_symlink() {
if install_sh_path().is_none() {
eprintln!("skipping: install.sh not found (vendored sandbox)");
return;
}
let fx = Fixture::new("0.1.5", &small_good_artifact(), None);
let out = fx.run(&[]);
assert!(
out.status.success(),
"install.sh must succeed: stderr={}",
stderr_of(&out)
); );
// The invariant holds regardless of which path was taken: the active // Managed layout: versioned binary + relative symlink, same as the
// grok always runs (new good binary on success, previous-good on // self-updater produces.
// rejection). let versioned = fx
assert_active_grok_runs(home.path()); .home
} .path()
.join("downloads")
.join(format!("kigi-0.1.5-{}", host_platform()));
assert!(versioned.exists(), "versioned binary installed");
assert_eq!(std::fs::read(&versioned).unwrap(), small_good_artifact());
let link = fx.active_kigi();
assert!(link.is_symlink(), "bin/kigi is a symlink");
assert_eq!(
std::fs::read_link(&link).unwrap(),
Path::new("..")
.join("downloads")
.join(format!("kigi-0.1.5-{}", host_platform())),
"symlink must be relative (survives bind-mounted homes)"
);
// The active link runs.
let status = Command::new(&link).arg("--version").status().unwrap();
assert!(status.success(), "installed kigi must run");
// Resolved the latest endpoint (no pinned version).
assert!(
fx.curl_log().contains("/latest"),
"must resolve via /latest: {}",
fx.curl_log()
);
} }
/// Shell-rc rewrite matrix: stow absolute/relative/`..`, plain, first-create, enterprise.
#[test] #[test]
fn install_sh_shell_rc_rewrite_matrix() { fn install_sh_pinned_version_uses_tag_endpoint() {
let cases = [ if install_sh_path().is_none() {
ShellRcCase { eprintln!("skipping: install.sh not found (vendored sandbox)");
name: "stow absolute bashrc reinstall", return;
script: "install.sh",
shell: "/bin/bash",
rc_name: ".bashrc",
stow_name: "bashrc",
layout: RcLayout::StowAbsolute,
reinstall: true,
},
ShellRcCase {
name: "stow relative bashrc reinstall",
script: "install.sh",
shell: "/bin/bash",
rc_name: ".bashrc",
stow_name: "bashrc",
layout: RcLayout::StowRelative,
reinstall: true,
},
ShellRcCase {
name: "stow relative ../ bashrc reinstall",
script: "install.sh",
shell: "/bin/bash",
rc_name: ".bashrc",
stow_name: "bashrc",
layout: RcLayout::StowRelativeDotDot,
reinstall: true,
},
ShellRcCase {
name: "plain bashrc reinstall",
script: "install.sh",
shell: "/bin/bash",
rc_name: ".bashrc",
stow_name: "bashrc",
layout: RcLayout::Plain,
reinstall: true,
},
ShellRcCase {
name: "missing bashrc first install",
script: "install.sh",
shell: "/bin/bash",
rc_name: ".bashrc",
stow_name: "bashrc",
layout: RcLayout::Missing,
reinstall: false,
},
ShellRcCase {
name: "enterprise stow absolute bashrc reinstall",
script: "install-enterprise.sh",
shell: "/bin/bash",
rc_name: ".bashrc",
stow_name: "bashrc",
layout: RcLayout::StowAbsolute,
reinstall: true,
},
];
for case in &cases {
run_shell_rc_case(case);
} }
let fx = Fixture::new("0.1.5", &small_good_artifact(), None);
let out = fx.run(&["--version", "v0.1.5"]);
assert!(
out.status.success(),
"pinned install must succeed: stderr={}",
stderr_of(&out)
);
assert!(
fx.curl_log().contains("/tags/v0.1.5"),
"must resolve via /tags/v0.1.5: {}",
fx.curl_log()
);
assert!(fx.active_kigi().is_symlink());
}
#[test]
fn install_sh_rejects_checksum_mismatch_and_activates_nothing() {
if install_sh_path().is_none() {
eprintln!("skipping: install.sh not found (vendored sandbox)");
return;
}
let fx = Fixture::new("0.1.5", &small_good_artifact(), Some(&"0".repeat(64)));
let out = fx.run(&[]);
assert!(
!out.status.success(),
"checksum mismatch must fail the install"
);
assert!(
stderr_of(&out).contains("SHA256 mismatch"),
"stderr: {}",
stderr_of(&out)
);
let link = fx.active_kigi();
assert!(
!link.exists() && !link.is_symlink(),
"nothing may be activated after a checksum failure"
);
}
#[test]
fn install_sh_rejects_invalid_version_argument() {
if install_sh_path().is_none() {
eprintln!("skipping: install.sh not found (vendored sandbox)");
return;
}
let fx = Fixture::new("0.1.5", &small_good_artifact(), None);
let out = fx.run(&["--version", "not-a-version"]);
assert!(!out.status.success());
assert!(
stderr_of(&out).contains("invalid version"),
"stderr: {}",
stderr_of(&out)
);
assert!(
fx.curl_log().is_empty(),
"invalid arguments must fail before any network access"
);
}
#[test]
fn install_sh_fails_when_release_lacks_platform_asset() {
if install_sh_path().is_none() {
eprintln!("skipping: install.sh not found (vendored sandbox)");
return;
}
let fx = Fixture::new("0.1.5", &small_good_artifact(), None);
// Rewrite release.json without the platform archive asset.
let json = serde_json::json!({
"tag_name": "v0.1.5",
"assets": [
{ "name": "SHA256SUMS", "browser_download_url": "https://example.test/dl/v0.1.5/SHA256SUMS" },
],
});
std::fs::write(fx.dir.path().join("release.json"), json.to_string()).unwrap();
let out = fx.run(&[]);
assert!(!out.status.success());
assert!(
stderr_of(&out).contains("no asset"),
"stderr: {}",
stderr_of(&out)
);
let link = fx.active_kigi();
assert!(!link.exists() && !link.is_symlink());
}
#[test]
fn install_sh_fails_when_archive_lacks_kigi_binary() {
if install_sh_path().is_none() {
eprintln!("skipping: install.sh not found (vendored sandbox)");
return;
}
let fx = Fixture::new("0.1.5", &small_good_artifact(), None);
// Replace the archive with one that has no `kigi` entry; keep the
// manifest consistent so the checksum gate passes and the extraction
// check is what trips.
let archive = common::make_tar_gz(&[("LICENSE", b"license only")]);
std::fs::write(
fx.dir.path().join("SHA256SUMS"),
format!("{} {}\n", sha256_hex(&archive), archive_name("0.1.5")),
)
.unwrap();
std::fs::write(fx.dir.path().join("archive.tar.gz"), &archive).unwrap();
let out = fx.run(&[]);
assert!(!out.status.success());
assert!(
stderr_of(&out).contains("does not contain a 'kigi' binary"),
"stderr: {}",
stderr_of(&out)
);
let link = fx.active_kigi();
assert!(!link.exists() && !link.is_symlink());
} }
+9 -9
View File
@@ -1,7 +1,7 @@
//! I/O integration tests for the auto-update crate. //! I/O integration tests for the auto-update crate.
//! //!
//! These tests touch global process state — `KIGI_SHARE_DIR` (a `OnceLock` in //! These tests touch global process state — `KIGI_SHARE_DIR` (a `OnceLock` in
//! `kigi-config`), `KIGI_TEST_VERSION`, and `NPM_TOKEN` — so they //! `kigi-config`), `KIGI_TEST_VERSION` — so they
//! must run serially. Once `KIGI_SHARE_DIR` is initialized for a process, it can't //! must run serially. Once `KIGI_SHARE_DIR` is initialized for a process, it can't
//! be changed; we set it from a single shared `OnceLock` and reset the //! be changed; we set it from a single shared `OnceLock` and reset the
//! contents of the directory between tests. //! contents of the directory between tests.
@@ -277,13 +277,13 @@ async fn write_version_cache_idempotent_for_same_version() {
} }
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// get_installed_grok_version env override // get_installed_kigi_version env override
// //
// The function honors `KIGI_TEST_VERSION` for testing. We exercise it // The function honors `KIGI_TEST_VERSION` for testing. We exercise it
// via the public re-export only — no private items leaked. // via the public re-export only — no private items leaked.
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// //
// Note: `get_installed_grok_version` is not re-exported from `lib.rs`, but // Note: `get_installed_kigi_version` is not re-exported from `lib.rs`, but
// it's `pub` from `version` module and accessible via `version::`. // it's `pub` from `version` module and accessible via `version::`.
#[tokio::test] #[tokio::test]
@@ -295,7 +295,7 @@ async fn get_installed_version_uses_env_var_override() {
unsafe { unsafe {
std::env::set_var("KIGI_TEST_VERSION", "9.9.9"); std::env::set_var("KIGI_TEST_VERSION", "9.9.9");
} }
let v = kigi_update::version::get_installed_grok_version(); let v = kigi_update::version::get_installed_kigi_version();
assert_eq!(v, "9.9.9"); assert_eq!(v, "9.9.9");
unsafe { unsafe {
std::env::remove_var("KIGI_TEST_VERSION"); std::env::remove_var("KIGI_TEST_VERSION");
@@ -311,7 +311,7 @@ async fn get_installed_version_falls_back_to_cargo_pkg_version_when_env_unset()
unsafe { unsafe {
std::env::remove_var("KIGI_TEST_VERSION"); std::env::remove_var("KIGI_TEST_VERSION");
} }
let v = kigi_update::version::get_installed_grok_version(); let v = kigi_update::version::get_installed_kigi_version();
// The compile-time CARGO_PKG_VERSION must be a parseable semver string. // The compile-time CARGO_PKG_VERSION must be a parseable semver string.
let _: semver::Version = v let _: semver::Version = v
.parse() .parse()
@@ -328,13 +328,13 @@ async fn get_installed_version_with_env_var_takes_precedence() {
unsafe { unsafe {
std::env::remove_var("KIGI_TEST_VERSION"); std::env::remove_var("KIGI_TEST_VERSION");
} }
kigi_update::version::get_installed_grok_version() kigi_update::version::get_installed_kigi_version()
}; };
unsafe { unsafe {
std::env::set_var("KIGI_TEST_VERSION", "0.0.0-test"); std::env::set_var("KIGI_TEST_VERSION", "0.0.0-test");
} }
let overridden = kigi_update::version::get_installed_grok_version(); let overridden = kigi_update::version::get_installed_kigi_version();
assert_ne!(real, overridden); assert_ne!(real, overridden);
assert_eq!(overridden, "0.0.0-test"); assert_eq!(overridden, "0.0.0-test");
@@ -352,7 +352,7 @@ async fn get_installed_version_handles_alpha_prerelease_in_env() {
unsafe { unsafe {
std::env::set_var("KIGI_TEST_VERSION", "0.1.200-alpha.5"); std::env::set_var("KIGI_TEST_VERSION", "0.1.200-alpha.5");
} }
let v = kigi_update::version::get_installed_grok_version(); let v = kigi_update::version::get_installed_kigi_version();
assert_eq!(v, "0.1.200-alpha.5"); assert_eq!(v, "0.1.200-alpha.5");
unsafe { unsafe {
std::env::remove_var("KIGI_TEST_VERSION"); std::env::remove_var("KIGI_TEST_VERSION");
@@ -370,7 +370,7 @@ async fn get_installed_version_does_not_validate_env_var_format() {
unsafe { unsafe {
std::env::set_var("KIGI_TEST_VERSION", "not-a-version"); std::env::set_var("KIGI_TEST_VERSION", "not-a-version");
} }
let v = kigi_update::version::get_installed_grok_version(); let v = kigi_update::version::get_installed_kigi_version();
assert_eq!(v, "not-a-version"); assert_eq!(v, "not-a-version");
unsafe { unsafe {
std::env::remove_var("KIGI_TEST_VERSION"); std::env::remove_var("KIGI_TEST_VERSION");
+177 -172
View File
@@ -4,6 +4,11 @@
//! directly. We don't need `serial_test` here because each `MockServer` binds //! directly. We don't need `serial_test` here because each `MockServer` binds
//! to its own random port and tests don't touch global state. //! to its own random port and tests don't touch global state.
//! //!
//! Release JSON fixtures mirror the real 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":"..."}]}`.
//!
//! NOTE on retry timing: the prod retry backoff is 1s + 2s + 4s = 7s //! NOTE on retry timing: the prod retry backoff is 1s + 2s + 4s = 7s
//! wall-clock. We can't use `tokio::time::pause()` because reqwest's I/O //! wall-clock. We can't use `tokio::time::pause()` because reqwest's I/O
//! reactor uses the same tokio timer and stalls when time is paused. So //! reactor uses the same tokio timer and stalls when time is paused. So
@@ -15,299 +20,299 @@ use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate}; use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
use kigi_update::auto_update::{download_silent, download_with_progress}; use kigi_update::auto_update::{download_silent, download_with_progress};
use kigi_update::version::fetch_gcs_version_from_base; use kigi_update::version::{fetch_latest_release_from_base, fetch_release_for_version_from_base};
fn tag_json(tag: &str) -> serde_json::Value {
serde_json::json!({ "tag_name": tag, "draft": false, "prerelease": false, "assets": [] })
}
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Happy-path tests (fast, no retries triggered). // Happy-path tests (fast, no retries triggered).
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
#[tokio::test] #[tokio::test]
async fn gcs_pointer_returns_version_on_success() { async fn latest_release_returns_version_on_success() {
let server = MockServer::start().await; let server = MockServer::start().await;
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/stable")) .and(path("/latest"))
.respond_with(ResponseTemplate::new(200).set_body_string("0.1.181\n")) .respond_with(ResponseTemplate::new(200).set_body_json(tag_json("v0.1.181")))
.expect(1) .expect(1)
.mount(&server) .mount(&server)
.await; .await;
let v = fetch_gcs_version_from_base("stable", &server.uri()) let release = fetch_latest_release_from_base("stable", &server.uri())
.await .await
.unwrap(); .unwrap();
assert_eq!(v, "0.1.181"); assert_eq!(release.version().unwrap(), "0.1.181");
} }
#[tokio::test] #[tokio::test]
async fn gcs_pointer_trims_whitespace() { async fn latest_release_accepts_bare_semver_tag() {
let server = MockServer::start().await; let server = MockServer::start().await;
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/stable")) .and(path("/latest"))
.respond_with(ResponseTemplate::new(200).set_body_string(" 0.1.181 \r\n ")) .respond_with(ResponseTemplate::new(200).set_body_json(tag_json("0.1.181")))
.mount(&server) .mount(&server)
.await; .await;
let v = fetch_gcs_version_from_base("stable", &server.uri()) let release = fetch_latest_release_from_base("stable", &server.uri())
.await .await
.unwrap(); .unwrap();
assert_eq!(v, "0.1.181"); assert_eq!(release.version().unwrap(), "0.1.181");
} }
#[tokio::test] #[tokio::test]
async fn gcs_pointer_rejects_invalid_semver_no_retry() { async fn latest_release_rejects_non_semver_tag_without_retry() {
// Invalid semver in the channel pointer is a hard error — must NOT // A non-semver tag is a repo data bug, not a transient failure — the
// retry (it's a server data bug, not a transient failure). // fetch succeeds in one request and version() reports the bad tag.
let server = MockServer::start().await; let server = MockServer::start().await;
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/stable")) .and(path("/latest"))
.respond_with(ResponseTemplate::new(200).set_body_string("not-a-version")) .respond_with(ResponseTemplate::new(200).set_body_json(tag_json("release-one")))
.expect(1) // exactly one request — no retry on parse failure .expect(1)
.mount(&server) .mount(&server)
.await; .await;
let err = fetch_gcs_version_from_base("stable", &server.uri()) let release = fetch_latest_release_from_base("stable", &server.uri())
.await
.unwrap();
let err = release.version().unwrap_err();
assert!(format!("{err}").contains("not semver"), "err: {err}");
}
#[tokio::test]
async fn alpha_channel_picks_semver_max_from_release_list() {
// The list is ordered by publication date (newest first) — NOT semver.
// Alpha must take the semver max, so a newer-published pre-release does
// not shadow a semver-higher stable and vice versa.
let server = MockServer::start().await;
let list = serde_json::json!([
{ "tag_name": "v0.1.180-alpha.5", "draft": false, "prerelease": true, "assets": [] },
{ "tag_name": "v0.1.181", "draft": false, "prerelease": false, "assets": [] },
{ "tag_name": "v0.1.179", "draft": false, "prerelease": false, "assets": [] },
]);
Mock::given(method("GET"))
.and(path("/"))
.respond_with(ResponseTemplate::new(200).set_body_json(list))
.expect(1)
.mount(&server)
.await;
let release = fetch_latest_release_from_base("alpha", &server.uri())
.await
.unwrap();
assert_eq!(release.version().unwrap(), "0.1.181");
}
#[tokio::test]
async fn alpha_channel_returns_prerelease_when_it_is_max() {
let server = MockServer::start().await;
let list = serde_json::json!([
{ "tag_name": "v0.1.182-alpha.1", "draft": false, "prerelease": true, "assets": [] },
{ "tag_name": "v0.1.181", "draft": false, "prerelease": false, "assets": [] },
]);
Mock::given(method("GET"))
.and(path("/"))
.respond_with(ResponseTemplate::new(200).set_body_json(list))
.mount(&server)
.await;
let release = fetch_latest_release_from_base("alpha", &server.uri())
.await
.unwrap();
assert_eq!(release.version().unwrap(), "0.1.182-alpha.1");
}
#[tokio::test]
async fn alpha_channel_skips_drafts_and_non_semver_tags() {
let server = MockServer::start().await;
let list = serde_json::json!([
{ "tag_name": "v9.9.9", "draft": true, "prerelease": false, "assets": [] },
{ "tag_name": "nightly", "draft": false, "prerelease": false, "assets": [] },
{ "tag_name": "v0.1.181", "draft": false, "prerelease": false, "assets": [] },
]);
Mock::given(method("GET"))
.and(path("/"))
.respond_with(ResponseTemplate::new(200).set_body_json(list))
.mount(&server)
.await;
let release = fetch_latest_release_from_base("alpha", &server.uri())
.await
.unwrap();
assert_eq!(
release.version().unwrap(),
"0.1.181",
"drafts and non-semver tags must not win"
);
}
#[tokio::test]
async fn alpha_channel_empty_list_is_an_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
.mount(&server)
.await;
let err = fetch_latest_release_from_base("alpha", &server.uri())
.await .await
.unwrap_err(); .unwrap_err();
let msg = format!("{err:#}"); assert!(format!("{err:#}").contains("no releases"), "err: {err:#}");
assert!(msg.contains("invalid semver"), "msg: {msg}");
} }
#[tokio::test] #[tokio::test]
async fn gcs_pointer_alpha_channel_returns_max_of_alpha_and_stable_when_stable_higher() { async fn stable_channel_does_not_fetch_the_release_list() {
// Stable users resolve /latest only; the list endpoint must not be hit.
let server = MockServer::start().await; let server = MockServer::start().await;
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/alpha")) .and(path("/latest"))
.respond_with(ResponseTemplate::new(200).set_body_string("0.1.180-alpha.5")) .respond_with(ResponseTemplate::new(200).set_body_json(tag_json("v0.1.181")))
.expect(1) .expect(1)
.mount(&server) .mount(&server)
.await; .await;
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/stable")) .and(path("/"))
.respond_with(ResponseTemplate::new(200).set_body_string("0.1.181"))
.expect(1)
.mount(&server)
.await;
let v = fetch_gcs_version_from_base("alpha", &server.uri())
.await
.unwrap();
assert_eq!(v, "0.1.181");
}
#[tokio::test]
async fn gcs_pointer_alpha_returns_alpha_when_higher() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/alpha"))
.respond_with(ResponseTemplate::new(200).set_body_string("0.1.182-alpha.1"))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/stable"))
.respond_with(ResponseTemplate::new(200).set_body_string("0.1.181"))
.mount(&server)
.await;
let v = fetch_gcs_version_from_base("alpha", &server.uri())
.await
.unwrap();
assert_eq!(v, "0.1.182-alpha.1");
}
#[tokio::test]
async fn gcs_pointer_stable_channel_does_not_fetch_alpha() {
// Stable-channel users should not pay the cost of fetching the alpha
// pointer. The mock for /alpha should never be hit.
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/stable"))
.respond_with(ResponseTemplate::new(200).set_body_string("0.1.181"))
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/alpha"))
.respond_with(ResponseTemplate::new(500)) .respond_with(ResponseTemplate::new(500))
.expect(0) .expect(0)
.mount(&server) .mount(&server)
.await; .await;
let v = fetch_gcs_version_from_base("stable", &server.uri()) let release = fetch_latest_release_from_base("stable", &server.uri())
.await .await
.unwrap(); .unwrap();
assert_eq!(v, "0.1.181"); assert_eq!(release.version().unwrap(), "0.1.181");
} }
#[tokio::test] #[tokio::test]
async fn gcs_pointer_with_long_pre_release_version() { async fn release_for_version_fetches_tag_endpoint() {
let server = MockServer::start().await; let server = MockServer::start().await;
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/alpha")) .and(path("/tags/v0.1.150"))
.respond_with(ResponseTemplate::new(200).set_body_string("0.1.190-alpha.42")) .respond_with(ResponseTemplate::new(200).set_body_json(tag_json("v0.1.150")))
.mount(&server) .expect(1)
.await;
Mock::given(method("GET"))
.and(path("/stable"))
.respond_with(ResponseTemplate::new(200).set_body_string("0.1.189"))
.mount(&server) .mount(&server)
.await; .await;
let v = fetch_gcs_version_from_base("alpha", &server.uri()) let release = fetch_release_for_version_from_base("0.1.150", &server.uri())
.await .await
.unwrap(); .unwrap();
assert_eq!(v, "0.1.190-alpha.42"); assert_eq!(release.version().unwrap(), "0.1.150");
} }
#[tokio::test] #[tokio::test]
async fn gcs_pointer_preserves_path_in_base_url() { async fn base_url_trailing_slash_is_tolerated() {
// base_url may include a path component (in practice the prod GCS URL
// does: `/cli`). The function appends `/{channel}`.
let server = MockServer::start().await; let server = MockServer::start().await;
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/cli/stable")) .and(path("/latest"))
.respond_with(ResponseTemplate::new(200).set_body_string("0.1.181")) .respond_with(ResponseTemplate::new(200).set_body_json(tag_json("v0.1.181")))
.mount(&server) .mount(&server)
.await; .await;
let base = format!("{}/cli", server.uri()); let base = format!("{}/", server.uri());
let v = fetch_gcs_version_from_base("stable", &base).await.unwrap(); let release = fetch_latest_release_from_base("stable", &base)
assert_eq!(v, "0.1.181"); .await
.unwrap();
assert_eq!(release.version().unwrap(), "0.1.181");
} }
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Retry behavior — these tests intentionally exercise the 1s+2s+4s backoff, // Retry behavior — these tests intentionally exercise the 1s+2s+4s backoff,
// so each takes ~7 seconds. They run in parallel. // so each takes up to ~7 seconds. They run in parallel.
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
#[tokio::test] #[tokio::test]
async fn gcs_pointer_retries_on_5xx_then_succeeds() { async fn latest_release_retries_on_5xx_then_succeeds() {
let server = MockServer::start().await; let server = MockServer::start().await;
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/stable")) .and(path("/latest"))
.respond_with(ResponseTemplate::new(503).set_body_string("backend down")) .respond_with(ResponseTemplate::new(503).set_body_string("backend down"))
.up_to_n_times(2) .up_to_n_times(2)
.mount(&server) .mount(&server)
.await; .await;
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/stable")) .and(path("/latest"))
.respond_with(ResponseTemplate::new(200).set_body_string("0.1.181")) .respond_with(ResponseTemplate::new(200).set_body_json(tag_json("v0.1.181")))
.mount(&server) .mount(&server)
.await; .await;
let v = fetch_gcs_version_from_base("stable", &server.uri()) let release = fetch_latest_release_from_base("stable", &server.uri())
.await .await
.unwrap(); .unwrap();
assert_eq!(v, "0.1.181"); assert_eq!(release.version().unwrap(), "0.1.181");
} }
#[tokio::test] #[tokio::test]
async fn gcs_pointer_gives_up_after_max_retries() { async fn latest_release_gives_up_after_max_retries() {
let server = MockServer::start().await; let server = MockServer::start().await;
// 4 attempts total: initial + 3 retries. // 4 attempts total: initial + 3 retries.
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/stable")) .and(path("/latest"))
.respond_with(ResponseTemplate::new(500)) .respond_with(ResponseTemplate::new(500))
.expect(4) .expect(4)
.mount(&server) .mount(&server)
.await; .await;
let err = fetch_gcs_version_from_base("stable", &server.uri()) let err = fetch_latest_release_from_base("stable", &server.uri())
.await .await
.unwrap_err(); .unwrap_err();
let msg = format!("{err:#}"); let msg = format!("{err:#}");
assert!(msg.contains("HTTP 500"), "msg: {msg}"); assert!(msg.contains("HTTP 500"), "msg: {msg}");
assert!(msg.contains("/latest"), "url should be in error: {msg}");
} }
#[tokio::test] #[tokio::test]
async fn gcs_pointer_retries_on_empty_body() { async fn latest_release_404_fails_fast_without_retry() {
// 404 = release/repo missing — a data condition, not transient. Exactly
// one request, and the GitHub error body is surfaced.
let server = MockServer::start().await; let server = MockServer::start().await;
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/stable")) .and(path("/latest"))
.respond_with(ResponseTemplate::new(200).set_body_string("")) .respond_with(ResponseTemplate::new(404).set_body_string(r#"{"message":"Not Found"}"#))
.up_to_n_times(2) .expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/stable"))
.respond_with(ResponseTemplate::new(200).set_body_string("0.1.181"))
.mount(&server) .mount(&server)
.await; .await;
let v = fetch_gcs_version_from_base("stable", &server.uri()) let err = fetch_latest_release_from_base("stable", &server.uri())
.await
.unwrap();
assert_eq!(v, "0.1.181");
}
#[tokio::test]
async fn gcs_pointer_alpha_propagates_error_from_either_pointer() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/alpha"))
.respond_with(ResponseTemplate::new(200).set_body_string("0.1.182-alpha.1"))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/stable"))
.respond_with(ResponseTemplate::new(500))
.expect(4)
.mount(&server)
.await;
let err = fetch_gcs_version_from_base("alpha", &server.uri())
.await
.unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("HTTP 500"), "msg: {msg}");
}
#[tokio::test]
async fn gcs_pointer_4xx_is_retryable_until_exhausted() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/stable"))
.respond_with(ResponseTemplate::new(404))
.expect(4)
.mount(&server)
.await;
let err = fetch_gcs_version_from_base("stable", &server.uri())
.await .await
.unwrap_err(); .unwrap_err();
let msg = format!("{err:#}"); let msg = format!("{err:#}");
assert!(msg.contains("HTTP 404"), "msg: {msg}"); assert!(msg.contains("HTTP 404"), "msg: {msg}");
assert!(msg.contains("Not Found"), "msg: {msg}");
} }
#[tokio::test] #[tokio::test]
async fn gcs_pointer_includes_url_in_error_message() { async fn latest_release_malformed_json_fails_without_retry() {
let server = MockServer::start().await; let server = MockServer::start().await;
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/stable")) .and(path("/latest"))
.respond_with(ResponseTemplate::new(500)) .respond_with(ResponseTemplate::new(200).set_body_string("<html>not json</html>"))
.expect(4) .expect(1)
.mount(&server) .mount(&server)
.await; .await;
let err = fetch_gcs_version_from_base("stable", &server.uri()) let err = fetch_latest_release_from_base("stable", &server.uri())
.await .await
.unwrap_err(); .unwrap_err();
let msg = format!("{err:#}"); let msg = format!("{err:#}");
assert!(msg.contains("/stable"), "url should be in error: {msg}"); assert!(msg.contains("unexpected JSON"), "msg: {msg}");
} }
#[tokio::test] #[tokio::test]
async fn gcs_pointer_connection_refused_is_retried_and_returns_error() { async fn latest_release_connection_refused_is_retried_and_returns_error() {
// Bind a TcpListener to claim a port, then drop it so connections refuse. // Bind a TcpListener to claim a port, then drop it so connections refuse.
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port(); let port = listener.local_addr().unwrap().port();
drop(listener); drop(listener);
let url = format!("http://127.0.0.1:{port}"); let url = format!("http://127.0.0.1:{port}");
let err = fetch_gcs_version_from_base("stable", &url) let err = fetch_latest_release_from_base("stable", &url)
.await .await
.unwrap_err(); .unwrap_err();
let msg = format!("{err:#}").to_lowercase(); let msg = format!("{err:#}").to_lowercase();
assert!( assert!(
msg.contains("fetch failed") msg.contains("request failed")
|| msg.contains("connection") || msg.contains("connection")
|| msg.contains("error sending request") || msg.contains("error sending request")
|| msg.contains("refused"), || msg.contains("refused"),
@@ -325,14 +330,14 @@ async fn download_silent_writes_body_to_dest() {
let server = MockServer::start().await; let server = MockServer::start().await;
let body = b"binary contents \x00\x01\x02".to_vec(); let body = b"binary contents \x00\x01\x02".to_vec();
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/grok-0.1.181-macos-aarch64")) .and(path("/kigi-0.1.181-macos-aarch64"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(body.clone())) .respond_with(ResponseTemplate::new(200).set_body_bytes(body.clone()))
.mount(&server) .mount(&server)
.await; .await;
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("grok"); let dest = tmp.path().join("kigi");
let url = format!("{}/grok-0.1.181-macos-aarch64", server.uri()); let url = format!("{}/kigi-0.1.181-macos-aarch64", server.uri());
download_silent(&url, &dest).await.unwrap(); download_silent(&url, &dest).await.unwrap();
let written = std::fs::read(&dest).unwrap(); let written = std::fs::read(&dest).unwrap();
@@ -371,7 +376,7 @@ async fn download_silent_atomically_renames_via_tmp_file() {
.await; .await;
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("grok"); let dest = tmp.path().join("kigi");
download_silent(&format!("{}/bin", server.uri()), &dest) download_silent(&format!("{}/bin", server.uri()), &dest)
.await .await
.unwrap(); .unwrap();
@@ -399,7 +404,7 @@ async fn download_silent_publishes_executable() {
.await; .await;
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("grok-0.1.181-linux-x86_64"); let dest = tmp.path().join("kigi-0.1.181-linux-x86_64");
download_silent(&format!("{}/bin", server.uri()), &dest) download_silent(&format!("{}/bin", server.uri()), &dest)
.await .await
.unwrap(); .unwrap();
@@ -422,7 +427,7 @@ async fn download_silent_fails_on_4xx() {
.await; .await;
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("grok"); let dest = tmp.path().join("kigi");
let err = download_silent(&format!("{}/missing", server.uri()), &dest) let err = download_silent(&format!("{}/missing", server.uri()), &dest)
.await .await
.unwrap_err(); .unwrap_err();
@@ -443,7 +448,7 @@ async fn download_silent_fails_on_5xx() {
.await; .await;
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("grok"); let dest = tmp.path().join("kigi");
let err = download_silent(&format!("{}/x", server.uri()), &dest) let err = download_silent(&format!("{}/x", server.uri()), &dest)
.await .await
.unwrap_err(); .unwrap_err();
@@ -460,7 +465,7 @@ async fn download_silent_overwrites_existing_dest() {
.await; .await;
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("grok"); let dest = tmp.path().join("kigi");
std::fs::write(&dest, "old content").unwrap(); std::fs::write(&dest, "old content").unwrap();
download_silent(&format!("{}/x", server.uri()), &dest) download_silent(&format!("{}/x", server.uri()), &dest)
@@ -481,7 +486,7 @@ async fn download_silent_handles_empty_body() {
.await; .await;
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("grok"); let dest = tmp.path().join("kigi");
download_silent(&format!("{}/x", server.uri()), &dest) download_silent(&format!("{}/x", server.uri()), &dest)
.await .await
.unwrap(); .unwrap();
@@ -503,7 +508,7 @@ async fn download_silent_streams_large_body() {
.await; .await;
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("grok"); let dest = tmp.path().join("kigi");
download_silent(&format!("{}/big", server.uri()), &dest) download_silent(&format!("{}/big", server.uri()), &dest)
.await .await
.unwrap(); .unwrap();
@@ -524,7 +529,7 @@ async fn download_silent_to_nonexistent_parent_dir_fails() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
// Parent directory does NOT exist — should fail at file create. // Parent directory does NOT exist — should fail at file create.
let dest = tmp.path().join("missing-subdir").join("grok"); let dest = tmp.path().join("missing-subdir").join("kigi");
let err = download_silent(&format!("{}/x", server.uri()), &dest) let err = download_silent(&format!("{}/x", server.uri()), &dest)
.await .await
.unwrap_err(); .unwrap_err();
@@ -547,14 +552,14 @@ async fn download_with_progress_writes_body_with_content_length() {
let server = MockServer::start().await; let server = MockServer::start().await;
let body = b"binary content".to_vec(); let body = b"binary content".to_vec();
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/grok")) .and(path("/kigi"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(body.clone())) .respond_with(ResponseTemplate::new(200).set_body_bytes(body.clone()))
.mount(&server) .mount(&server)
.await; .await;
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("grok"); let dest = tmp.path().join("kigi");
download_with_progress(&format!("{}/grok", server.uri()), &dest) download_with_progress(&format!("{}/kigi", server.uri()), &dest)
.await .await
.unwrap(); .unwrap();
@@ -571,7 +576,7 @@ async fn download_with_progress_fails_on_http_error() {
.await; .await;
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("grok"); let dest = tmp.path().join("kigi");
let err = download_with_progress(&format!("{}/x", server.uri()), &dest) let err = download_with_progress(&format!("{}/x", server.uri()), &dest)
.await .await
.unwrap_err(); .unwrap_err();
@@ -590,7 +595,7 @@ async fn download_with_progress_atomic_rename() {
.await; .await;
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("grok"); let dest = tmp.path().join("kigi");
download_with_progress(&format!("{}/x", server.uri()), &dest) download_with_progress(&format!("{}/x", server.uri()), &dest)
.await .await
.unwrap(); .unwrap();
@@ -663,7 +668,7 @@ async fn download_silent_parallel_path_reassembles_bytes() {
.await; .await;
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("grok-binary"); let dest = tmp.path().join("kigi-binary");
download_silent(&format!("{}/big", server.uri()), &dest) download_silent(&format!("{}/big", server.uri()), &dest)
.await .await
.unwrap(); .unwrap();
@@ -1,446 +0,0 @@
//! Subprocess-based integration tests using fake `npm` / `gh` shell scripts
//! placed first on `PATH`.
//!
//! `auto_update::install_npm` and `version::fetch_npm_tag` spawn `npm` by
//! bare name (`Command::new("npm")`). To test them without touching the real
//! npm registry, we install a tempdir-resident shell script named `npm`
//! that logs its args and prints canned stdout, then prepend that tempdir
//! to `PATH` for the duration of the test.
//!
//! Same pattern for `gh` for the `gh-release` installer paths.
//!
//! All tests in this file mutate `PATH` (global), so they're serialized with
//! `#[serial]`.
#![cfg(unix)]
mod common;
use std::time::Duration;
use serial_test::serial;
use common::FakeBinGuard;
use kigi_update::auto_update::install_npm_for_test;
use kigi_update::version::{
fetch_gh_release_version, fetch_npm_tag_for_test, fetch_npm_version_for_test,
};
// ─────────────────────────────────────────────────────────────────────────────
// fetch_npm_tag — reads a single dist-tag from `npm view`.
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
#[serial]
async fn fetch_npm_tag_returns_string_response() {
let g = FakeBinGuard::install_npm();
g.set_stdout("\"0.1.181\"\n");
let v = fetch_npm_tag_for_test("latest", None).await.unwrap();
assert_eq!(v, "0.1.181");
}
#[tokio::test]
#[serial]
async fn fetch_npm_tag_returns_array_response_picks_last() {
// npm view sometimes returns an array of versions for ambiguous specs.
// The implementation picks the LAST one (rev().find_map).
let g = FakeBinGuard::install_npm();
g.set_stdout(r#"["0.1.179", "0.1.180", "0.1.181"]"#);
let v = fetch_npm_tag_for_test("latest", None).await.unwrap();
assert_eq!(v, "0.1.181");
}
#[tokio::test]
#[serial]
async fn fetch_npm_tag_passes_pkg_and_tag_to_npm() {
let g = FakeBinGuard::install_npm();
g.set_stdout("\"0.1.181\"");
let _ = fetch_npm_tag_for_test("latest", None).await.unwrap();
let log = g.args_log();
assert_eq!(log.len(), 1, "exactly one npm invocation");
let args = &log[0];
assert!(args.contains("view"), "args: {args}");
// For "latest" tag, no `@latest` suffix is appended in pkg_spec.
assert!(args.contains("@xai-official/grok"), "args: {args}");
assert!(!args.contains("@latest"), "args: {args}");
assert!(args.contains("--json"), "args: {args}");
}
#[tokio::test]
#[serial]
async fn fetch_npm_tag_alpha_appends_at_alpha_suffix() {
let g = FakeBinGuard::install_npm();
g.set_alpha_stdout("\"0.1.181-alpha.1\"");
let v = fetch_npm_tag_for_test("alpha", None).await.unwrap();
assert_eq!(v, "0.1.181-alpha.1");
let log = g.args_log();
assert!(
log[0].contains("@xai-official/grok@alpha"),
"args: {}",
log[0]
);
}
#[tokio::test]
#[serial]
async fn fetch_npm_tag_passes_registry_flag_when_set() {
let g = FakeBinGuard::install_npm();
g.set_stdout("\"0.1.181\"");
let _ = fetch_npm_tag_for_test("latest", Some("https://npm.example.com"))
.await
.unwrap();
let log = g.args_log();
assert!(
log[0].contains("--registry=https://npm.example.com"),
"args: {}",
log[0]
);
}
#[tokio::test]
#[serial]
async fn fetch_npm_tag_no_registry_flag_when_unset() {
let g = FakeBinGuard::install_npm();
g.set_stdout("\"0.1.181\"");
let _ = fetch_npm_tag_for_test("latest", None).await.unwrap();
let log = g.args_log();
assert!(!log[0].contains("--registry"), "args: {}", log[0]);
}
#[tokio::test]
#[serial]
async fn fetch_npm_tag_propagates_npm_failure() {
let g = FakeBinGuard::install_npm();
g.set_exit_code(1);
let err = fetch_npm_tag_for_test("latest", None).await.unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("npm view"), "msg: {msg}");
assert!(msg.contains("failed"), "msg: {msg}");
}
#[tokio::test]
#[serial]
async fn fetch_npm_tag_invalid_json_returns_err() {
let g = FakeBinGuard::install_npm();
g.set_stdout("not valid json {");
let err = fetch_npm_tag_for_test("latest", None).await.unwrap_err();
// serde_json should error on this.
let msg = format!("{err:#}");
assert!(!msg.is_empty());
}
#[tokio::test]
#[serial]
async fn fetch_npm_tag_unexpected_json_shape_returns_err() {
// npm view can return null, an object, etc. The function expects string
// or array of strings — anything else is an error.
let g = FakeBinGuard::install_npm();
g.set_stdout("42");
let err = fetch_npm_tag_for_test("latest", None).await.unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("unexpected JSON"), "msg: {msg}");
}
#[tokio::test]
#[serial]
async fn fetch_npm_tag_empty_array_returns_err() {
let g = FakeBinGuard::install_npm();
g.set_stdout("[]");
let err = fetch_npm_tag_for_test("latest", None).await.unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("empty"), "msg: {msg}");
}
// ─────────────────────────────────────────────────────────────────────────────
// fetch_npm_version — alpha channel calls both tags and returns the max.
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
#[serial]
async fn fetch_npm_version_stable_calls_only_latest() {
let g = FakeBinGuard::install_npm();
g.set_stdout("\"0.1.181\"");
let v = fetch_npm_version_for_test("stable", None).await.unwrap();
assert_eq!(v, "0.1.181");
assert_eq!(g.args_log().len(), 1, "stable should make one call");
}
#[tokio::test]
#[serial]
async fn fetch_npm_version_alpha_returns_max_of_alpha_and_latest_when_alpha_higher() {
let g = FakeBinGuard::install_npm();
g.set_stdout("\"0.1.181\""); // latest tag → stable
g.set_alpha_stdout("\"0.1.182-alpha.1\""); // alpha tag
let v = fetch_npm_version_for_test("alpha", None).await.unwrap();
assert_eq!(v, "0.1.182-alpha.1");
assert_eq!(g.args_log().len(), 2, "alpha should make two calls");
}
#[tokio::test]
#[serial]
async fn fetch_npm_version_alpha_returns_stable_when_higher() {
// Common case: stable shipped after a stale alpha tag — must not strand
// alpha users on the older alpha.
let g = FakeBinGuard::install_npm();
g.set_stdout("\"0.1.182\"");
g.set_alpha_stdout("\"0.1.181-alpha.1\"");
let v = fetch_npm_version_for_test("alpha", None).await.unwrap();
assert_eq!(v, "0.1.182");
}
// ─────────────────────────────────────────────────────────────────────────────
// install_npm — spawns `npm i -g @pkg@version`.
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
#[serial]
async fn install_npm_calls_npm_with_version_arg() {
let g = FakeBinGuard::install_npm();
// No stdout/exit setup → succeeds with empty stdout.
install_npm_for_test(Some("0.1.181"), "stable", None).unwrap();
let log = g.args_log();
assert_eq!(log.len(), 1, "exactly one npm invocation");
let args = &log[0];
assert!(args.contains("i -g"), "args: {args}");
assert!(args.contains("@xai-official/grok@0.1.181"), "args: {args}");
}
#[tokio::test]
#[serial]
async fn install_npm_falls_back_to_dist_tag_on_no_target() {
let g = FakeBinGuard::install_npm();
install_npm_for_test(None, "stable", None).unwrap();
let log = g.args_log();
assert!(
log[0].contains("@xai-official/grok@latest"),
"stable channel uses @latest dist-tag: {}",
log[0]
);
}
#[tokio::test]
#[serial]
async fn install_npm_falls_back_to_alpha_dist_tag_on_alpha_channel() {
let g = FakeBinGuard::install_npm();
install_npm_for_test(None, "alpha", None).unwrap();
let log = g.args_log();
assert!(
log[0].contains("@xai-official/grok@alpha"),
"alpha channel uses @alpha dist-tag: {}",
log[0]
);
}
#[tokio::test]
#[serial]
async fn install_npm_passes_registry_flag_when_set() {
let g = FakeBinGuard::install_npm();
install_npm_for_test(Some("0.1.181"), "stable", Some("https://npm.example.com")).unwrap();
let log = g.args_log();
assert!(
log[0].contains("--registry=https://npm.example.com"),
"args: {}",
log[0]
);
}
#[tokio::test]
#[serial]
async fn install_npm_no_registry_flag_when_unset() {
let g = FakeBinGuard::install_npm();
install_npm_for_test(Some("0.1.181"), "stable", None).unwrap();
let log = g.args_log();
assert!(!log[0].contains("--registry"), "args: {}", log[0]);
}
#[tokio::test]
#[serial]
async fn install_npm_returns_err_on_npm_failure() {
let g = FakeBinGuard::install_npm();
g.set_exit_code(1);
let err = install_npm_for_test(Some("0.1.181"), "stable", None).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("npm install failed"), "msg: {msg}");
}
#[tokio::test]
#[serial]
async fn install_npm_with_token_passes_userconfig() {
// SAFETY: serial_test ensures no other thread touches NPM_TOKEN.
unsafe { std::env::set_var("NPM_TOKEN", "secrettoken") };
let g = FakeBinGuard::install_npm();
install_npm_for_test(Some("0.1.181"), "stable", None).unwrap();
let log = g.args_log();
assert!(
log[0].contains("--userconfig="),
"with NPM_TOKEN, must pass --userconfig: {}",
log[0]
);
// The userconfig path should be cleaned up afterwards.
let userconfig_arg = log[0]
.split_whitespace()
.find(|a| a.starts_with("--userconfig="))
.unwrap()
.trim_start_matches("--userconfig=");
assert!(
!std::path::Path::new(userconfig_arg).exists(),
"userconfig file should be cleaned up: {userconfig_arg}"
);
unsafe { std::env::remove_var("NPM_TOKEN") };
}
#[tokio::test]
#[serial]
async fn install_npm_no_token_no_userconfig() {
unsafe { std::env::remove_var("NPM_TOKEN") };
let g = FakeBinGuard::install_npm();
install_npm_for_test(Some("0.1.181"), "stable", None).unwrap();
let log = g.args_log();
assert!(!log[0].contains("--userconfig"), "args: {}", log[0]);
}
// ─────────────────────────────────────────────────────────────────────────────
// fetch_gh_release_version — exercises the `gh release list` shell-out.
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
#[serial]
async fn fetch_gh_release_stable_returns_tag_stripped() {
let g = FakeBinGuard::install_gh();
// For stable channel, only the `--exclude-pre-releases` invocation is made.
g.set_stable_only_stdout("v0.1.181\n");
let v = fetch_gh_release_version("stable").await.unwrap();
assert_eq!(v, "0.1.181");
let log = g.args_log();
assert_eq!(log.len(), 1);
assert!(
log[0].contains("--exclude-pre-releases"),
"args: {}",
log[0]
);
}
#[tokio::test]
#[serial]
async fn fetch_gh_release_stable_handles_tag_without_v_prefix() {
let g = FakeBinGuard::install_gh();
g.set_stable_only_stdout("0.1.181");
let v = fetch_gh_release_version("stable").await.unwrap();
assert_eq!(v, "0.1.181");
}
#[tokio::test]
#[serial]
async fn fetch_gh_release_alpha_returns_max_of_pre_and_stable() {
// Alpha channel makes two `gh release list` calls (with and without
// --exclude-pre-releases) and returns the semver-max.
let g = FakeBinGuard::install_gh();
g.set_with_pre_stdout("v0.1.182-alpha.1");
g.set_stable_only_stdout("v0.1.181");
let v = fetch_gh_release_version("alpha").await.unwrap();
assert_eq!(v, "0.1.182-alpha.1");
assert_eq!(g.args_log().len(), 2);
}
#[tokio::test]
#[serial]
async fn fetch_gh_release_alpha_returns_stable_when_higher() {
let g = FakeBinGuard::install_gh();
g.set_with_pre_stdout("v0.1.180-alpha.5");
g.set_stable_only_stdout("v0.1.181");
let v = fetch_gh_release_version("alpha").await.unwrap();
assert_eq!(v, "0.1.181");
}
#[tokio::test]
#[serial]
async fn fetch_gh_release_propagates_gh_failure() {
let g = FakeBinGuard::install_gh();
g.set_exit_code(1);
let err = fetch_gh_release_version("stable").await.unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("gh release list"), "msg: {msg}");
assert!(msg.contains("failed"), "msg: {msg}");
}
#[tokio::test]
#[serial]
async fn fetch_gh_release_empty_response_returns_err() {
let g = FakeBinGuard::install_gh();
g.set_stable_only_stdout("");
let err = fetch_gh_release_version("stable").await.unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("No releases found"), "msg: {msg}");
}
#[tokio::test]
#[serial]
async fn fetch_gh_release_passes_repo_flag() {
let g = FakeBinGuard::install_gh();
g.set_stable_only_stdout("v0.1.181");
let _ = fetch_gh_release_version("stable").await.unwrap();
let log = g.args_log();
assert!(log[0].contains("--repo"), "args: {}", log[0]);
assert!(
log[0].contains("xai-org-shared/grok-build"),
"args: {}",
log[0]
);
}
#[tokio::test]
#[serial]
async fn fetch_gh_release_uses_jq_to_extract_tag() {
// The function constructs `gh release list --json tagName --jq '.[0].tagName'`
// — we verify the args include the jq filter so a refactor doesn't accidentally
// drop it.
let g = FakeBinGuard::install_gh();
g.set_stable_only_stdout("v0.1.181");
let _ = fetch_gh_release_version("stable").await.unwrap();
let log = g.args_log();
assert!(log[0].contains("--json"), "args: {}", log[0]);
assert!(log[0].contains("--jq"), "args: {}", log[0]);
}
#[tokio::test]
#[serial]
async fn fetch_gh_release_does_not_hang_on_quick_responses() {
// Sanity: every call should return well under our test timeout.
let g = FakeBinGuard::install_gh();
g.set_stable_only_stdout("v0.1.181");
let res =
tokio::time::timeout(Duration::from_secs(5), fetch_gh_release_version("stable")).await;
assert!(res.is_ok(), "should not hang");
}
@@ -1,32 +1,21 @@
//! End-to-end tests for the lock-free concurrent-updater convergence model //! End-to-end tests for the production update flows (`check_update_status`,
//! (the "double download" fix): updaters key staleness off the on-disk //! `ensure_latest_on_disk`, `run_update`, `run_update_if_available`) against
//! install, so a binary another process already installed is never //! a GitHub-Releases-shaped [`common::artifact_server::ArtifactServer`],
//! downloaded again — and the accepted same-instant residual race is //! injected via the `KIGI_UPDATE_BASE_URL` override that
//! genuinely harmless thanks to per-attempt download temp names. //! `kigi_env::update_base_url()` honors.
//! //!
//! Production has three independent downloader paths that can race around a //! Three invariant families:
//! release:
//! //!
//! 1. TUI startup: `check_update_background` spawns a detached `grok update` //! 1. **Convergence**: a binary already on disk (installed by another
//! (the Ctrl+U path now adopts this child instead of spawning a second). //! process) is never downloaded a second time, but stale runners still
//! 2. Explicit `grok update` (incl. the Ctrl+U fallback when there is no //! get the relaunch/report signal.
//! live child). //! 2. **Status**: `kigi update --check` reports upgrades only, surfaces
//! 3. Leader mode: the hourly checker runs `ensure_latest_on_disk` //! fetch errors in the `error` field, and never advertises downgrades.
//! in-process. //! 3. **Race integrity**: concurrent installers — even for different
//! versions — never leave a corrupt active binary.
//! //!
//! Two layers are exercised here: //! Everything here is `#[serial]`: KIGI_SHARE_DIR, KIGI_UPDATE_BASE_URL and
//! //! KIGI_TEST_VERSION are process-global.
//! - **Convergence** (`ensure_latest_on_disk`, `run_update`): a sequential
//! updater finds the target already on disk and skips the download. The
//! artifact server / fake `gh` count downloads so the skip is asserted,
//! not assumed.
//! - **Race integrity** (`install_internal_from_base` run concurrently): the
//! same-instant race is accepted as rare; these tests pin the property
//! that makes it acceptable — concurrent installs (same or *different*
//! versions) never corrupt the active binary. Before the per-attempt
//! temp-name fix, every `0.1.x` download shared one `grok-0.1.tmp`
//! (`with_extension("tmp")` eats everything after the last dot), so racer
//! A could atomically rename racer B's half-written file into place.
#![cfg(unix)] #![cfg(unix)]
@@ -39,12 +28,35 @@ use serial_test::serial;
use common::artifact_server::ArtifactServer; use common::artifact_server::ArtifactServer;
use common::{ use common::{
FakeBinGuard, can_exec_shell_scripts, host_platform, make_update_config, reset_home, can_exec_shell_scripts, host_platform, make_update_config, reset_home, set_test_version,
set_test_version, small_good_artifact, test_home, set_update_base, small_good_artifact, test_home,
};
use kigi_update::auto_update::{
UpdateRunMode, check_update_status, ensure_latest_on_disk, install_internal_from_base,
run_update, run_update_if_available,
}; };
use kigi_update::auto_update::{ensure_latest_on_disk, install_internal_from_base, run_update};
use kigi_update::version::installed_on_disk_version; use kigi_update::version::installed_on_disk_version;
/// Lay down a managed-install layout in the test KIGI_SHARE_DIR:
/// `bin/kigi -> ../downloads/kigi-<version>-<platform>` (what the installer
/// produces; the canonical link the disk-version probe reads).
fn fake_managed_install(version: &str) {
let home = test_home();
let downloads = home.join("downloads");
let bin = home.join("bin");
std::fs::create_dir_all(&downloads).unwrap();
std::fs::create_dir_all(&bin).unwrap();
let name = format!("kigi-{version}-{}", host_platform());
std::fs::write(downloads.join(&name), small_good_artifact()).unwrap();
std::fs::set_permissions(
downloads.join(&name),
std::fs::Permissions::from_mode(0o755),
)
.unwrap();
let _ = std::fs::remove_file(bin.join("kigi"));
std::os::unix::fs::symlink(Path::new("../downloads").join(&name), bin.join("kigi")).unwrap();
}
/// Assert the active `~/.kigi/bin/kigi` resolves to the expected versioned /// Assert the active `~/.kigi/bin/kigi` resolves to the expected versioned
/// binary, actually runs, and has exactly the expected content (the content /// binary, actually runs, and has exactly the expected content (the content
/// check is what catches a cross-racer temp-file corruption). /// check is what catches a cross-racer temp-file corruption).
@@ -55,8 +67,8 @@ fn assert_active_binary(home: &Path, version: &str, platform: &str, expected_con
.unwrap_or_else(|e| panic!("active kigi symlink does not resolve: {e}")); .unwrap_or_else(|e| panic!("active kigi symlink does not resolve: {e}"));
assert_eq!( assert_eq!(
resolved.file_name().unwrap().to_string_lossy(), resolved.file_name().unwrap().to_string_lossy(),
format!("grok-{version}-{platform}"), format!("kigi-{version}-{platform}"),
"active grok must be the expected version" "active kigi must be the expected version"
); );
assert_eq!( assert_eq!(
std::fs::read(&resolved).unwrap(), std::fs::read(&resolved).unwrap(),
@@ -72,88 +84,20 @@ fn assert_active_binary(home: &Path, version: &str, platform: &str, expected_con
.status() .status()
.map(|s| s.success()) .map(|s| s.success())
.unwrap_or(false); .unwrap_or(false);
assert!(ran_ok, "active grok must pass the smoke-test"); assert!(ran_ok, "active kigi must pass the smoke-test");
} }
/// Lay down a managed-install layout in the test KIGI_SHARE_DIR: fn setup(server: &ArtifactServer, latest: &str, running: &str) {
/// `bin/{kigi,grok,agent} -> ../downloads/grok-<version>-<platform>` (what
/// `install_internal_from_base` produces; `kigi` is the canonical link the
/// disk-version probe reads, `grok` the legacy compat link).
fn fake_managed_install(version: &str) {
let home = test_home();
let downloads = home.join("downloads");
let bin = home.join("bin");
std::fs::create_dir_all(&downloads).unwrap();
std::fs::create_dir_all(&bin).unwrap();
let name = format!("grok-{version}-{}", host_platform());
std::fs::write(downloads.join(&name), small_good_artifact()).unwrap();
std::fs::set_permissions(
downloads.join(&name),
std::fs::Permissions::from_mode(0o755),
)
.unwrap();
for link in ["kigi", "grok", "agent"] {
std::os::unix::fs::symlink(
std::path::Path::new("../downloads").join(&name),
bin.join(link),
)
.unwrap();
}
}
/// Fake `gh` that logs argv to `<dir>/gh-args.log`, answers
/// `release list --exclude-pre-releases` from `<dir>/gh-stable-only-stdout`,
/// and for `release download ... --output <path>` writes a smoke-passing
/// artifact to the output path.
fn fake_gh_serving_releases(dir: &std::path::Path) -> String {
let dq = format!("'{}'", dir.to_string_lossy().replace('\'', "'\\''"));
format!(
r#"#!/bin/sh
echo "$@" >> {dq}/gh-args.log
case "$*" in
*"release list"*)
if [ -f {dq}/gh-stable-only-stdout ]; then cat {dq}/gh-stable-only-stdout; fi
;;
*"release download"*)
out=""
prev=""
for a in "$@"; do
if [ "$prev" = "--output" ]; then out="$a"; fi
prev="$a"
done
if [ -n "$out" ]; then
printf '#!/bin/sh\nexit 0\n' > "$out"
chmod +x "$out"
fi
;;
esac
exit 0
"#
)
}
/// Count `release download` invocations in the fake gh's argv log.
fn gh_download_count(g: &FakeBinGuard) -> usize {
g.args_log()
.iter()
.filter(|l| l.contains("release download"))
.count()
}
fn setup_gh_release(running_version: &str) -> FakeBinGuard {
let _ = test_home(); let _ = test_home();
reset_home(); reset_home();
set_test_version(running_version); server.set_latest(latest);
// SAFETY: serial_test ensures no race; reset_home clears this between tests. set_update_base(&server.base());
unsafe { std::env::set_var("KIGI_INSTALLER", "gh-release") }; set_test_version(running);
FakeBinGuard::install("gh", fake_gh_serving_releases)
} }
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Convergence: ensure_latest_on_disk downloads once, then every subsequent // Convergence: ensure_latest_on_disk downloads once, then every subsequent
// pass (the leader's hourly re-entry) converges without re-downloading. // pass (the leader's hourly re-entry) converges without re-downloading.
// This is the e2e companion to the decision-level tests in
// test_downgrade_matrix.rs — it asserts on actual download invocations.
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
#[tokio::test] #[tokio::test]
@@ -163,15 +107,15 @@ async fn ensure_latest_downloads_once_then_converges_without_redownload() {
eprintln!("skipping: shell scripts cannot execute in this sandbox"); eprintln!("skipping: shell scripts cannot execute in this sandbox");
return; return;
} }
let g = setup_gh_release("0.2.5"); let server = ArtifactServer::start(small_good_artifact());
g.set_stable_only_stdout("v0.2.7\n"); setup(&server, "0.2.7", "0.2.5");
let cfg = make_update_config("stable"); let cfg = make_update_config("stable");
// Pass 1: disk is empty → downloads and installs. // Pass 1: disk is empty → downloads and installs.
let first = ensure_latest_on_disk(&cfg).await.unwrap(); let first = ensure_latest_on_disk(&cfg).await.unwrap();
assert_eq!(first.installed.as_deref(), Some("0.2.7")); assert_eq!(first.installed.as_deref(), Some("0.2.7"));
assert!(first.relaunch_needed, "running 0.2.5 < disk 0.2.7"); assert!(first.relaunch_needed, "running 0.2.5 < disk 0.2.7");
assert_eq!(gh_download_count(&g), 1, "first pass downloads"); assert_eq!(server.request_count(), 1, "first pass downloads");
assert_eq!(installed_on_disk_version().as_deref(), Some("0.2.7")); assert_eq!(installed_on_disk_version().as_deref(), Some("0.2.7"));
// Pass 2 (the pre-fix hourly re-download): disk already current → // Pass 2 (the pre-fix hourly re-download): disk already current →
@@ -181,18 +125,12 @@ async fn ensure_latest_downloads_once_then_converges_without_redownload() {
assert_eq!(second.installed, None, "second pass must not re-download"); assert_eq!(second.installed, None, "second pass must not re-download");
assert!(second.relaunch_needed, "still running 0.2.5 < disk 0.2.7"); assert!(second.relaunch_needed, "still running 0.2.5 < disk 0.2.7");
assert_eq!( assert_eq!(
gh_download_count(&g), server.request_count(),
1, 1,
"hourly re-entry must not download again" "hourly re-entry must not download again"
); );
} }
// ─────────────────────────────────────────────────────────────────────────────
// Convergence: explicit `grok update` (the Ctrl+U fallback path) finds the
// binary another process already installed and skips the download — while
// still returning the target version so stale leaders get signalled.
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test] #[tokio::test]
#[serial] #[serial]
async fn run_update_skips_download_when_disk_already_current() { async fn run_update_skips_download_when_disk_already_current() {
@@ -200,8 +138,8 @@ async fn run_update_skips_download_when_disk_already_current() {
eprintln!("skipping: shell scripts cannot execute in this sandbox"); eprintln!("skipping: shell scripts cannot execute in this sandbox");
return; return;
} }
let g = setup_gh_release("0.2.5"); let server = ArtifactServer::start(small_good_artifact());
g.set_stable_only_stdout("v0.2.7\n"); setup(&server, "0.2.7", "0.2.5");
// Another process (TUI background download) already installed 0.2.7. // Another process (TUI background download) already installed 0.2.7.
fake_managed_install("0.2.7"); fake_managed_install("0.2.7");
let mut cfg = make_update_config("stable"); let mut cfg = make_update_config("stable");
@@ -215,7 +153,7 @@ async fn run_update_skips_download_when_disk_already_current() {
signals stale leaders to relaunch" signals stale leaders to relaunch"
); );
assert_eq!( assert_eq!(
gh_download_count(&g), server.request_count(),
0, 0,
"a binary someone else installed must not be downloaded again" "a binary someone else installed must not be downloaded again"
); );
@@ -228,8 +166,8 @@ async fn run_update_force_still_redownloads_when_disk_current() {
eprintln!("skipping: shell scripts cannot execute in this sandbox"); eprintln!("skipping: shell scripts cannot execute in this sandbox");
return; return;
} }
let g = setup_gh_release("0.2.7"); let server = ArtifactServer::start(small_good_artifact());
g.set_stable_only_stdout("v0.2.7\n"); setup(&server, "0.2.7", "0.2.7");
fake_managed_install("0.2.7"); fake_managed_install("0.2.7");
let mut cfg = make_update_config("stable"); let mut cfg = make_update_config("stable");
@@ -237,88 +175,41 @@ async fn run_update_force_still_redownloads_when_disk_current() {
assert_eq!(result.as_deref(), Some("0.2.7")); assert_eq!(result.as_deref(), Some("0.2.7"));
assert_eq!( assert_eq!(
gh_download_count(&g), server.request_count(),
1, 1,
"--force must bypass the disk-current skip and reinstall" "--force must bypass the disk-current skip and reinstall"
); );
} }
// ─────────────────────────────────────────────────────────────────────────────
// Installer gating: the disk-version probe must only be trusted for
// installers that actually maintain the managed `~/.kigi/bin/grok` symlink
// (internal, gh-release). For npm, a symlink left over from a previous
// internal install LIES about the npm install's version — and in the worst
// direction (leftover "newer" than the registry) it would silently suppress
// npm updates forever.
// ─────────────────────────────────────────────────────────────────────────────
fn setup_npm(running_version: &str) -> FakeBinGuard {
let _ = test_home();
reset_home();
set_test_version(running_version);
// SAFETY: serial_test ensures no race; reset_home clears this between tests.
unsafe { std::env::set_var("KIGI_INSTALLER", "npm") };
FakeBinGuard::install_npm()
}
#[tokio::test] #[tokio::test]
#[serial] #[serial]
async fn npm_update_not_suppressed_by_leftover_newer_internal_symlink() { async fn run_update_rolls_back_when_latest_moved_backwards() {
// Release rollback: the latest release points BELOW the on-disk install
// (a bad release was deleted). The internal installer is authoritative,
// so run_update must converge the disk down to it.
if !can_exec_shell_scripts() { if !can_exec_shell_scripts() {
eprintln!("skipping: shell scripts cannot execute in this sandbox"); eprintln!("skipping: shell scripts cannot execute in this sandbox");
return; return;
} }
let g = setup_npm("0.2.5"); let server = ArtifactServer::start(small_good_artifact());
g.set_stdout("\"0.2.7\"\n"); setup(&server, "0.2.5", "0.2.7");
// Leftover symlink from a previous internal install, claiming to be fake_managed_install("0.2.7");
// NEWER than the npm registry. It says nothing about the npm-managed
// global install and must be ignored for npm staleness decisions.
fake_managed_install("0.2.9");
let mut cfg = make_update_config("stable"); let mut cfg = make_update_config("stable");
let result = run_update(false, None, None, &mut cfg).await.unwrap(); let result = run_update(false, None, None, &mut cfg).await.unwrap();
assert_eq!( assert_eq!(
result.as_deref(), result.as_deref(),
Some("0.2.7"), Some("0.2.5"),
"npm update must proceed despite the lying leftover symlink" "rollback target installed"
);
assert!(
g.args_log().iter().any(|l| l.contains("i -g")),
"npm install must actually run: {:?}",
g.args_log()
); );
assert_eq!(installed_on_disk_version().as_deref(), Some("0.2.5"));
assert_eq!(server.request_count(), 1);
} }
#[tokio::test] // ─────────────────────────────────────────────────────────────────────────────
#[serial] // Disk-version probe
async fn ensure_latest_npm_ignores_leftover_internal_symlink() { // ─────────────────────────────────────────────────────────────────────────────
if !can_exec_shell_scripts() {
eprintln!("skipping: shell scripts cannot execute in this sandbox");
return;
}
let g = setup_npm("0.2.5");
g.set_stdout("\"0.2.7\"\n");
fake_managed_install("0.2.9");
let cfg = make_update_config("stable");
let outcome = ensure_latest_on_disk(&cfg).await.unwrap();
assert_eq!(
outcome.installed.as_deref(),
Some("0.2.7"),
"npm leader pass must install despite the lying leftover symlink"
);
assert!(
outcome.relaunch_needed,
"running 0.2.5 < freshly installed 0.2.7"
);
assert!(
g.args_log().iter().any(|l| l.contains("i -g")),
"npm install must actually run: {:?}",
g.args_log()
);
}
#[tokio::test] #[tokio::test]
#[serial] #[serial]
@@ -349,7 +240,7 @@ async fn disk_probe_rejects_dangling_symlink() {
std::fs::remove_file( std::fs::remove_file(
home.join("downloads") home.join("downloads")
.join(format!("grok-0.2.7-{platform}")), .join(format!("kigi-0.2.7-{platform}")),
) )
.unwrap(); .unwrap();
@@ -370,14 +261,14 @@ async fn ensure_latest_repairs_dangling_symlink_by_downloading() {
// Dangling symlink + stale running process: the probe returns None, so // Dangling symlink + stale running process: the probe returns None, so
// the decision falls back to the running version and the download runs, // the decision falls back to the running version and the download runs,
// repairing the install instead of wedging on "already up to date". // repairing the install instead of wedging on "already up to date".
let g = setup_gh_release("0.2.5"); let server = ArtifactServer::start(small_good_artifact());
g.set_stable_only_stdout("v0.2.7\n"); setup(&server, "0.2.7", "0.2.5");
let home = test_home(); let home = test_home();
let platform = host_platform(); let platform = host_platform();
fake_managed_install("0.2.7"); fake_managed_install("0.2.7");
std::fs::remove_file( std::fs::remove_file(
home.join("downloads") home.join("downloads")
.join(format!("grok-0.2.7-{platform}")), .join(format!("kigi-0.2.7-{platform}")),
) )
.unwrap(); .unwrap();
let cfg = make_update_config("stable"); let cfg = make_update_config("stable");
@@ -389,7 +280,7 @@ async fn ensure_latest_repairs_dangling_symlink_by_downloading() {
Some("0.2.7"), Some("0.2.7"),
"dangling symlink must be repaired by an actual download" "dangling symlink must be repaired by an actual download"
); );
assert_eq!(gh_download_count(&g), 1); assert_eq!(server.request_count(), 1);
assert_eq!( assert_eq!(
installed_on_disk_version().as_deref(), installed_on_disk_version().as_deref(),
Some("0.2.7"), Some("0.2.7"),
@@ -397,19 +288,128 @@ async fn ensure_latest_repairs_dangling_symlink_by_downloading() {
); );
} }
// ─────────────────────────────────────────────────────────────────────────────
// check_update_status (`kigi update --check`)
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
#[serial]
async fn check_status_reports_update_when_release_is_newer() {
let server = ArtifactServer::start(small_good_artifact());
setup(&server, "0.2.7", "0.2.5");
let cfg = make_update_config("stable");
let status = check_update_status(&cfg).await;
assert_eq!(status.current_version, "0.2.5");
assert_eq!(status.latest_version.as_deref(), Some("0.2.7"));
assert!(status.update_available);
assert_eq!(status.installer.as_deref(), Some("internal"));
assert_eq!(status.error, None);
}
#[tokio::test]
#[serial]
async fn check_status_never_reports_downgrade_as_update() {
// --check reports upgrades only; a rolled-back release is not advertised
// (auto-update converges separately).
let server = ArtifactServer::start(small_good_artifact());
setup(&server, "0.2.5", "0.2.7");
let cfg = make_update_config("stable");
let status = check_update_status(&cfg).await;
assert_eq!(status.latest_version.as_deref(), Some("0.2.5"));
assert!(!status.update_available, "downgrade must not be advertised");
assert_eq!(status.error, None);
}
#[tokio::test]
#[serial]
async fn check_status_surfaces_fetch_error_in_error_field() {
// Point the updater at a dead endpoint (bound then dropped port →
// connection refused). The status must carry the error rather than
// pretending "up to date".
let _ = test_home();
reset_home();
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
set_update_base(&format!("http://127.0.0.1:{port}/releases"));
set_test_version("0.2.5");
let cfg = make_update_config("stable");
let status = check_update_status(&cfg).await;
assert!(!status.update_available);
assert_eq!(status.latest_version, None);
let err = status.error.as_deref().expect("error must be surfaced");
assert!(!err.is_empty());
// And it serializes into the --json contract.
let v = serde_json::to_value(&status).unwrap();
assert!(v["error"].is_string());
assert_eq!(v["updateAvailable"], false);
}
#[tokio::test]
#[serial]
async fn check_status_unsupported_channel_reports_error() {
let server = ArtifactServer::start(small_good_artifact());
setup(&server, "0.2.7", "0.2.5");
let cfg = make_update_config("beta");
let status = check_update_status(&cfg).await;
assert!(!status.update_available);
let err = status.error.as_deref().expect("channel error surfaced");
assert!(
err.contains("Unsupported release channel 'beta'"),
"err: {err}"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// run_update_if_available — the auto-update opt-out gate.
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
#[serial]
async fn run_update_if_available_respects_auto_update_false() {
// With cli.auto_update = false persisted, the startup check must return
// without ever touching the network (the update base points at a dead
// port — any fetch would error, any download would install).
let _ = test_home();
reset_home();
std::fs::write(
test_home().join("config.toml"),
"[cli]\nauto_update = false\n",
)
.unwrap();
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
set_update_base(&format!("http://127.0.0.1:{port}/releases"));
set_test_version("0.1.0");
let cfg = make_update_config("stable");
let ran = run_update_if_available(UpdateRunMode::Blocking, false, &cfg)
.await
.unwrap();
assert!(!ran, "auto_update=false must suppress the update entirely");
}
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Race integrity: the accepted same-instant race must stay harmless. Two (or // Race integrity: the accepted same-instant race must stay harmless. Two (or
// three) installers running concurrently — even for DIFFERENT versions — // three) installers running concurrently — even for DIFFERENT versions —
// must never leave a corrupt active binary. Pre-fix, all 0.1.x downloads // must never leave a corrupt active binary.
// shared one `grok-0.1.tmp`, so a concurrent racer could atomically rename a
// half-written file into place.
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
async fn run_concurrent_installs( async fn run_concurrent_installs(
server: &ArtifactServer, server: &ArtifactServer,
versions: &[&str], versions: &[&str],
) -> Vec<anyhow::Result<()>> { ) -> Vec<anyhow::Result<()>> {
let base = server.uri(); let base = server.base();
let mut tasks = Vec::new(); let mut tasks = Vec::new();
for version in versions { for version in versions {
let base = base.clone(); let base = base.clone();
@@ -466,9 +466,6 @@ async fn concurrent_different_version_installs_do_not_corrupt_each_other() {
let server = ArtifactServer::start(artifact.clone()); let server = ArtifactServer::start(artifact.clone());
server.set_slow(true); server.set_slow(true);
// Pre-fix, BOTH of these wrote to downloads/grok-0.1.tmp concurrently
// (with_extension("tmp") truncates at the last dot), so one racer could
// rename the other's partial file into its own versioned path.
let results = run_concurrent_installs(&server, &["0.1.181", "0.1.182"]).await; let results = run_concurrent_installs(&server, &["0.1.181", "0.1.182"]).await;
for r in results { for r in results {
r.expect("both racing installs must succeed"); r.expect("both racing installs must succeed");
@@ -478,7 +475,7 @@ async fn concurrent_different_version_installs_do_not_corrupt_each_other() {
for version in ["0.1.181", "0.1.182"] { for version in ["0.1.181", "0.1.182"] {
let path = home let path = home
.join("downloads") .join("downloads")
.join(format!("grok-{version}-{platform}")); .join(format!("kigi-{version}-{platform}"));
assert_eq!( assert_eq!(
std::fs::read(&path).unwrap(), std::fs::read(&path).unwrap(),
artifact, artifact,
@@ -488,17 +485,18 @@ async fn concurrent_different_version_installs_do_not_corrupt_each_other() {
// The active symlink points at whichever racer swapped last; it must // The active symlink points at whichever racer swapped last; it must
// resolve and run regardless. // resolve and run regardless.
let resolved = dunce::canonicalize(home.join("bin").join("grok")).unwrap(); let resolved = dunce::canonicalize(home.join("bin").join("kigi")).unwrap();
assert_eq!(std::fs::read(&resolved).unwrap(), artifact); assert_eq!(std::fs::read(&resolved).unwrap(), artifact);
let name = resolved.file_name().unwrap().to_string_lossy().to_string(); let name = resolved.file_name().unwrap().to_string_lossy().to_string();
assert!( assert!(
!name.contains(".tmp"), !name.contains(".tmp"),
"active grok must never be a temp file: {name}" "active kigi must never be a temp file: {name}"
); );
// No stray shared temp file left behind (the pre-fix collision name). // No stray shared temp file left behind (a with_extension-style
// collision name).
assert!( assert!(
!home.join("downloads").join("grok-0.1.tmp").exists(), !home.join("downloads").join("kigi-0.1.tmp").exists(),
"the pre-fix shared temp name must not exist" "the shared-temp-name collision must not exist"
); );
} }
+58
View File
@@ -0,0 +1,58 @@
# Release checklist (PRD F8)
Kigi ships as a single-file binary for five targets, published on this
repo's GitHub Releases by `.github/workflows/release.yml`. Users install via
`install.sh` / `install.ps1` and stay current through the in-app
self-updater (`kigi-update`), which resolves the same Releases API.
## Cutting a release
1. Bump `[workspace.package] version` in `Cargo.toml`; land the change on
`main` with green CI.
2. Regenerate the third-party notices (not enforced by CI — this is the
step that keeps `THIRD-PARTY-NOTICES.md` fresh):
```sh
cargo install cargo-about --locked # once
cargo about generate about.hbs -o THIRD-PARTY-NOTICES.md
```
Commit the result if it changed.
3. Tag and push:
```sh
git tag vX.Y.Z && git push origin vX.Y.Z
```
The tag must equal the workspace version (`vX.Y.Z``X.Y.Z`); the
workflow fails fast on a mismatch.
4. The `Release` workflow builds all five targets with the hardened
`release-dist` profile, packages
`kigi-<version>-<target-triple>.{tar.gz|zip}` archives (binary +
LICENSE + NOTICE + THIRD-PARTY-NOTICES), generates `SHA256SUMS`, and
publishes the GitHub Release. Tags containing `-` (e.g.
`v0.2.0-alpha.1`) publish as pre-releases, which only the `alpha`
update channel picks up.
5. Smoke-test an installed artifact:
```sh
curl -fsSL https://raw.githubusercontent.com/ZacharyZhang-NY/Kigi-CLI/main/install.sh | sh
~/.kigi/bin/kigi --version
```
## Invariants to keep in lockstep
- Asset naming `kigi-<version>-<target-triple>.{tar.gz|zip}` and the
`SHA256SUMS` manifest are consumed by three clients: `install.sh`,
`install.ps1`, and `auto_update::release_asset_name()` in
`crates/codegen/kigi-update`. Change one, change all (the kigi-update
test `test_release_asset_name_matches_release_workflow_naming` pins the
Rust side).
- Never publish two builds of the same semver version differing only in
build metadata (`+…`) — the `semver` crate orders build metadata, so
auto-update would bounce users between them.
- Rollbacks: deleting the bad release (or re-pointing "latest") is enough —
the internal installer treats the Releases API as authoritative and
downgrades clients on its own.
- No PyPI/npm packages, ever; in particular never squat the `kimi-cli`
package name (PRD F8).
+149
View File
@@ -0,0 +1,149 @@
# Kigi installer (Windows x86_64) — PRD F8.
#
# Downloads the x86_64-pc-windows-msvc artifact from this repo's GitHub
# Releases, verifies its SHA-256 against the release's SHA256SUMS manifest,
# and installs the binary as %USERPROFILE%\.kigi\bin\kigi.exe.
#
# Usage:
# irm https://raw.githubusercontent.com/ZacharyZhang-NY/Kigi-CLI/main/install.ps1 | iex
# powershell -ExecutionPolicy Bypass -File install.ps1 -Version v0.1.0
#
# Environment:
# KIGI_SHARE_DIR install root (default: %USERPROFILE%\.kigi)
# KIGI_UPDATE_BASE_URL GitHub-Releases-shaped API base (default:
# https://api.github.com/repos/ZacharyZhang-NY/Kigi-CLI/releases)
[CmdletBinding()]
param(
[string]$Version = ""
)
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
function Fail([string]$Message) {
Write-Error "install.ps1: error: $Message"
exit 1
}
$Repo = "ZacharyZhang-NY/Kigi-CLI"
$ApiBase = if ($env:KIGI_UPDATE_BASE_URL) { $env:KIGI_UPDATE_BASE_URL } else { "https://api.github.com/repos/$Repo/releases" }
$KigiHome = if ($env:KIGI_SHARE_DIR) { $env:KIGI_SHARE_DIR } else { Join-Path $env:USERPROFILE ".kigi" }
$Triple = "x86_64-pc-windows-msvc"
# ── Platform gate ────────────────────────────────────────────────────────────
if (-not [System.Environment]::Is64BitOperatingSystem) {
Fail "kigi requires 64-bit Windows (x86_64)"
}
$arch = $env:PROCESSOR_ARCHITECTURE
if ($arch -ne "AMD64") {
Fail "unsupported architecture '$arch' (only x86_64/AMD64 Windows builds are published)"
}
# ── Version argument ─────────────────────────────────────────────────────────
$Version = $Version.TrimStart("v")
if ($Version -and $Version -notmatch '^\d+\.\d+\.\d+([-.][0-9A-Za-z.-]+)?$') {
Fail "invalid version '$Version' (expected X.Y.Z or vX.Y.Z)"
}
# TLS 1.2 for older PowerShell 5.1 defaults.
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
$Headers = @{ "User-Agent" = "kigi-install"; "Accept" = "application/vnd.github+json" }
# ── Resolve the release ──────────────────────────────────────────────────────
$ReleaseUrl = if ($Version) { "$ApiBase/tags/v$Version" } else { "$ApiBase/latest" }
Write-Host "Resolving release from $ReleaseUrl"
try {
$Release = Invoke-RestMethod -Uri $ReleaseUrl -Headers $Headers
} catch {
Fail "could not fetch release metadata from ${ReleaseUrl}: $($_.Exception.Message)"
}
$Tag = [string]$Release.tag_name
if (-not $Tag) { Fail "release metadata has no tag_name (endpoint: $ReleaseUrl)" }
$ResolvedVersion = $Tag.TrimStart("v")
if ($Version -and $ResolvedVersion -ne $Version) {
Fail "requested version $Version but release tag is $Tag"
}
$Asset = "kigi-$ResolvedVersion-$Triple.zip"
$ArchiveAsset = $Release.assets | Where-Object { $_.name -eq $Asset } | Select-Object -First 1
$SumsAsset = $Release.assets | Where-Object { $_.name -eq "SHA256SUMS" } | Select-Object -First 1
if (-not $ArchiveAsset) { Fail "release $Tag has no asset $Asset" }
if (-not $SumsAsset) { Fail "release $Tag has no SHA256SUMS asset; refusing to install unverified binaries" }
# ── Download + verify ────────────────────────────────────────────────────────
$TmpDir = Join-Path ([System.IO.Path]::GetTempPath()) ("kigi-install-" + [System.IO.Path]::GetRandomFileName())
New-Item -ItemType Directory -Path $TmpDir -Force | Out-Null
try {
$ArchivePath = Join-Path $TmpDir $Asset
$SumsPath = Join-Path $TmpDir "SHA256SUMS"
Write-Host "Downloading kigi v$ResolvedVersion ($Triple)..."
Invoke-WebRequest -Uri $ArchiveAsset.browser_download_url -Headers $Headers -OutFile $ArchivePath
Invoke-WebRequest -Uri $SumsAsset.browser_download_url -Headers $Headers -OutFile $SumsPath
$Expected = $null
foreach ($line in Get-Content $SumsPath) {
$parts = $line.Trim() -split '\s+', 2
if ($parts.Count -eq 2 -and $parts[1].TrimStart('*') -eq $Asset) {
$Expected = $parts[0].ToLowerInvariant()
}
}
if (-not $Expected) { Fail "SHA256SUMS has no entry for $Asset" }
$Actual = (Get-FileHash -Algorithm SHA256 -Path $ArchivePath).Hash.ToLowerInvariant()
if ($Actual -ne $Expected) {
Fail "SHA256 mismatch for ${Asset}: expected $Expected, got $Actual"
}
Write-Host "Checksum verified."
# ── Extract + install ────────────────────────────────────────────────────
$ExtractDir = Join-Path $TmpDir "extracted"
Expand-Archive -Path $ArchivePath -DestinationPath $ExtractDir -Force
$Binary = Get-ChildItem -Path $ExtractDir -Recurse -Filter "kigi.exe" | Select-Object -First 1
if (-not $Binary) { Fail "archive $Asset does not contain kigi.exe" }
$BinDir = Join-Path $KigiHome "bin"
New-Item -ItemType Directory -Path $BinDir -Force | Out-Null
$Dest = Join-Path $BinDir "kigi.exe"
# A running kigi.exe blocks writes but allows renames — move it aside
# first (mirrors the self-updater's windows_replace_exe strategy).
if (Test-Path $Dest) {
$Aside = "$Dest.old"
Remove-Item -Path $Aside -Force -ErrorAction SilentlyContinue
try {
Move-Item -Path $Dest -Destination $Aside -Force
} catch {
Fail "cannot replace $Dest (close all running kigi sessions and retry): $($_.Exception.Message)"
}
}
Move-Item -Path $Binary.FullName -Destination $Dest -Force
# Smoke-test the installed binary.
& $Dest --version *> $null
if ($LASTEXITCODE -ne 0) {
Fail "installed binary failed to run (exit $LASTEXITCODE)"
}
Write-Host ""
Write-Host "kigi v$ResolvedVersion installed to $Dest"
$UserPath = [Environment]::GetEnvironmentVariable("Path", "User")
$OnPath = ($UserPath -split ";" | Where-Object { $_ -eq $BinDir }).Count -gt 0 -or
($env:Path -split ";" | Where-Object { $_ -eq $BinDir }).Count -gt 0
if (-not $OnPath) {
Write-Host ""
Write-Host "$BinDir is not on your PATH. Add it for the current user with:"
Write-Host ""
Write-Host " [Environment]::SetEnvironmentVariable('Path', `"$BinDir;`" + [Environment]::GetEnvironmentVariable('Path', 'User'), 'User')"
Write-Host ""
Write-Host "Then open a new terminal and run 'kigi' to get started."
} else {
Write-Host "Run 'kigi' to get started."
}
} finally {
Remove-Item -Path $TmpDir -Recurse -Force -ErrorAction SilentlyContinue
}
+199
View File
@@ -0,0 +1,199 @@
#!/bin/sh
#
# Kigi installer (macOS / Linux) — PRD F8.
#
# Downloads the matching platform artifact from this repo's GitHub Releases,
# verifies its SHA-256 against the release's SHA256SUMS manifest, and installs
# the binary as ~/.kigi/bin/kigi (the same managed layout the self-updater
# maintains: versioned binary in ~/.kigi/downloads/, atomic symlink in bin/).
#
# Usage:
# curl -fsSL https://raw.githubusercontent.com/ZacharyZhang-NY/Kigi-CLI/main/install.sh | sh
# sh install.sh --version v0.1.0 # pin a specific release
#
# Environment:
# KIGI_SHARE_DIR install root (default: ~/.kigi)
# KIGI_UPDATE_BASE_URL GitHub-Releases-shaped API base (default:
# https://api.github.com/repos/ZacharyZhang-NY/Kigi-CLI/releases)
#
# Fails fast on any error; never leaves a partial binary as the active kigi.
set -eu
REPO="ZacharyZhang-NY/Kigi-CLI"
API_BASE="${KIGI_UPDATE_BASE_URL:-https://api.github.com/repos/${REPO}/releases}"
KIGI_HOME="${KIGI_SHARE_DIR:-$HOME/.kigi}"
err() {
printf 'install.sh: error: %s\n' "$*" >&2
exit 1
}
usage() {
sed -n '2,20p' "$0" 2>/dev/null | sed 's/^# \{0,1\}//'
}
# ── Arguments ────────────────────────────────────────────────────────────────
VERSION=""
while [ $# -gt 0 ]; do
case "$1" in
--version)
[ $# -ge 2 ] || err "--version requires an argument (e.g. --version v0.1.0)"
VERSION="$2"
shift
;;
--version=*)
VERSION="${1#--version=}"
;;
-h|--help)
usage
exit 0
;;
*)
err "unknown argument: $1 (supported: --version vX.Y.Z)"
;;
esac
shift
done
VERSION="${VERSION#v}"
if [ -n "$VERSION" ]; then
case "$VERSION" in
[0-9]*.[0-9]*.[0-9]*) ;;
*) err "invalid version '$VERSION' (expected X.Y.Z or vX.Y.Z)" ;;
esac
fi
# ── Platform detection ───────────────────────────────────────────────────────
OS="$(uname -s)"
ARCH="$(uname -m)"
case "$OS" in
Darwin)
PLATFORM_OS="macos"
case "$ARCH" in
arm64|aarch64) TRIPLE="aarch64-apple-darwin"; PLATFORM_ARCH="aarch64" ;;
x86_64) TRIPLE="x86_64-apple-darwin"; PLATFORM_ARCH="x86_64" ;;
*) err "unsupported macOS architecture: $ARCH" ;;
esac
;;
Linux)
PLATFORM_OS="linux"
case "$ARCH" in
arm64|aarch64) TRIPLE="aarch64-unknown-linux-gnu"; PLATFORM_ARCH="aarch64" ;;
x86_64|amd64) TRIPLE="x86_64-unknown-linux-gnu"; PLATFORM_ARCH="x86_64" ;;
*) err "unsupported Linux architecture: $ARCH" ;;
esac
;;
*)
err "unsupported OS: $OS (Windows: use install.ps1)"
;;
esac
# ── Downloader ───────────────────────────────────────────────────────────────
if command -v curl >/dev/null 2>&1; then
fetch() { curl -fsSL -o "$2" "$1"; }
fetch_stdout() { curl -fsSL "$1"; }
elif command -v wget >/dev/null 2>&1; then
fetch() { wget -q -O "$2" "$1"; }
fetch_stdout() { wget -q -O - "$1"; }
else
err "either curl or wget is required"
fi
# ── SHA-256 tool ─────────────────────────────────────────────────────────────
if command -v sha256sum >/dev/null 2>&1; then
sha256_of() { sha256sum "$1" | cut -d' ' -f1; }
elif command -v shasum >/dev/null 2>&1; then
sha256_of() { shasum -a 256 "$1" | cut -d' ' -f1; }
else
err "either sha256sum or shasum is required to verify the download"
fi
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/kigi-install.XXXXXX")"
trap 'rm -rf "$TMP_DIR"' EXIT INT TERM
# ── Resolve the release ──────────────────────────────────────────────────────
if [ -n "$VERSION" ]; then
RELEASE_URL="$API_BASE/tags/v$VERSION"
else
RELEASE_URL="$API_BASE/latest"
fi
printf 'Resolving release from %s\n' "$RELEASE_URL"
RELEASE_JSON="$(fetch_stdout "$RELEASE_URL")" \
|| err "could not fetch release metadata from $RELEASE_URL"
TAG="$(printf '%s' "$RELEASE_JSON" \
| tr ',' '\n' \
| sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \
| head -n 1)"
[ -n "$TAG" ] || err "release metadata has no tag_name (endpoint: $RELEASE_URL)"
RESOLVED_VERSION="${TAG#v}"
if [ -n "$VERSION" ] && [ "$RESOLVED_VERSION" != "$VERSION" ]; then
err "requested version $VERSION but release tag is $TAG"
fi
ASSET="kigi-${RESOLVED_VERSION}-${TRIPLE}.tar.gz"
# Pull every browser_download_url out of the JSON, then select by asset name.
URLS="$(printf '%s' "$RELEASE_JSON" \
| tr ',' '\n' \
| sed -n 's/.*"browser_download_url"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')"
ARCHIVE_URL="$(printf '%s\n' "$URLS" | grep -F "/$ASSET" | head -n 1 || true)"
SUMS_URL="$(printf '%s\n' "$URLS" | grep -F "/SHA256SUMS" | head -n 1 || true)"
[ -n "$ARCHIVE_URL" ] || err "release $TAG has no asset $ASSET (this platform may not be published yet)"
[ -n "$SUMS_URL" ] || err "release $TAG has no SHA256SUMS asset; refusing to install unverified binaries"
# ── Download + verify ────────────────────────────────────────────────────────
printf 'Downloading kigi v%s (%s)...\n' "$RESOLVED_VERSION" "$TRIPLE"
fetch "$ARCHIVE_URL" "$TMP_DIR/$ASSET" || err "download failed: $ARCHIVE_URL"
fetch "$SUMS_URL" "$TMP_DIR/SHA256SUMS" || err "download failed: $SUMS_URL"
EXPECTED=""
while IFS=' ' read -r hash name; do
name="${name#\*}"
if [ "$name" = "$ASSET" ]; then
EXPECTED="$hash"
fi
done < "$TMP_DIR/SHA256SUMS"
[ -n "$EXPECTED" ] || err "SHA256SUMS has no entry for $ASSET"
ACTUAL="$(sha256_of "$TMP_DIR/$ASSET")"
if [ "$ACTUAL" != "$EXPECTED" ]; then
err "SHA256 mismatch for $ASSET: expected $EXPECTED, got $ACTUAL"
fi
printf 'Checksum verified.\n'
# ── Extract + install ────────────────────────────────────────────────────────
tar -xzf "$TMP_DIR/$ASSET" -C "$TMP_DIR" || err "failed to extract $ASSET"
[ -f "$TMP_DIR/kigi" ] || err "archive $ASSET does not contain a 'kigi' binary"
chmod 0755 "$TMP_DIR/kigi"
DOWNLOADS_DIR="$KIGI_HOME/downloads"
BIN_DIR="$KIGI_HOME/bin"
mkdir -p "$DOWNLOADS_DIR" "$BIN_DIR"
# Versioned binary + atomic symlink swap — the exact layout the self-updater
# maintains, so `kigi update` takes over seamlessly from here.
VERSIONED="kigi-${RESOLVED_VERSION}-${PLATFORM_OS}-${PLATFORM_ARCH}"
mv -f "$TMP_DIR/kigi" "$DOWNLOADS_DIR/$VERSIONED"
TMP_LINK="$BIN_DIR/kigi.install.$$"
ln -s "../downloads/$VERSIONED" "$TMP_LINK"
mv -f "$TMP_LINK" "$BIN_DIR/kigi"
# Smoke-test the installed binary through the managed link.
"$BIN_DIR/kigi" --version >/dev/null 2>&1 \
|| err "installed binary failed to run; your PATH still has no working kigi"
printf '\nkigi v%s installed to %s\n' "$RESOLVED_VERSION" "$BIN_DIR/kigi"
case ":$PATH:" in
*":$BIN_DIR:"*)
printf 'Run `kigi` to get started.\n'
;;
*)
printf '\n%s is not on your PATH. Add it with:\n\n' "$BIN_DIR"
printf ' export PATH="%s:$PATH" # sh / bash / zsh (add to your shell rc)\n' "$BIN_DIR"
printf ' fish_add_path %s # fish\n\n' "$BIN_DIR"
printf 'Then run `kigi` to get started.\n'
;;
esac