From 3f68af6f3aaea5538a4c0aee716ccb6f72c5d27b Mon Sep 17 00:00:00 2001 From: Ismail Pelaseyed Date: Fri, 3 Apr 2026 09:56:34 +0200 Subject: [PATCH] feat: add binary release workflow, install script, and self-management CLI commands (#241) --- .github/workflows/release.yml | 114 ++++++++ .github/workflows/typecheck.yml | 3 + README.md | 31 ++- install.sh | 297 +++++++++++++++++++++ package.json | 1 + src/index.ts | 31 ++- src/ui/app.tsx | 16 +- src/utils/install-manager.test.ts | 165 ++++++++++++ src/utils/install-manager.ts | 425 ++++++++++++++++++++++++++++++ src/utils/update-checker.test.ts | 50 ++-- src/utils/update-checker.ts | 34 +-- vitest.config.ts | 2 +- 12 files changed, 1097 insertions(+), 72 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 install.sh create mode 100644 src/utils/install-manager.test.ts create mode 100644 src/utils/install-manager.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d10dfe2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,114 @@ +name: Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + version: + description: "Release version to publish (for example v1.1.4)" + required: true + +permissions: + contents: write + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-latest + build_output: grok + asset_name: grok-linux-x64 + - runner: macos-13 + build_output: grok + asset_name: grok-darwin-x64 + - runner: macos-14 + build_output: grok + asset_name: grok-darwin-arm64 + - runner: windows-latest + build_output: grok.exe + asset_name: grok-windows-x64.exe + + runs-on: ${{ matrix.runner }} + + steps: + - uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.11 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Compile standalone binary + run: bun build --compile --outfile "release/${{ matrix.build_output }}" ./src/index.ts + + - name: Compute checksum (unix) + if: runner.os != 'Windows' + shell: bash + working-directory: release + run: | + cp "${{ matrix.build_output }}" "${{ matrix.asset_name }}" + shasum -a 256 "${{ matrix.asset_name }}" > "${{ matrix.asset_name }}.sha256" + + - name: Compute checksum (windows) + if: runner.os == 'Windows' + shell: pwsh + working-directory: release + run: | + Copy-Item "${{ matrix.build_output }}" "${{ matrix.asset_name }}" + $hash = (Get-FileHash "${{ matrix.asset_name }}" -Algorithm SHA256).Hash.ToLower() + "$hash ${{ matrix.asset_name }}" | Out-File -Encoding ascii "${{ matrix.asset_name }}.sha256" + + - name: Upload release artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.asset_name }} + path: | + release/${{ matrix.asset_name }} + release/${{ matrix.asset_name }}.sha256 + + publish: + needs: build + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Resolve release tag + shell: bash + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + tag="${{ github.event.inputs.version }}" + else + tag="${{ github.ref_name }}" + fi + tag="${tag#v}" + echo "RELEASE_TAG=v${tag}" >> "$GITHUB_ENV" + + - name: Download all build artifacts + uses: actions/download-artifact@v4 + with: + path: release-assets + + - name: Merge checksums + shell: bash + run: | + cat release-assets/**/*.sha256 | sort > release-assets/checksums.txt + + - name: Publish GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ env.RELEASE_TAG }} + generate_release_notes: true + files: | + release-assets/**/grok-linux-x64 + release-assets/**/grok-darwin-x64 + release-assets/**/grok-darwin-arm64 + release-assets/**/grok-windows-x64.exe + release-assets/checksums.txt diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 820a46d..6a3948d 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -29,3 +29,6 @@ jobs: - name: Type Check run: bun run typecheck + + - name: Build standalone binary + run: bun run build:binary diff --git a/README.md b/README.md index 2437121..51116fb 100644 --- a/README.md +++ b/README.md @@ -19,12 +19,37 @@ Community-built and unofficial. This project is not affiliated with or endorsed ## Install ```bash -npm i -g grok-dev +curl -fsSL https://raw.githubusercontent.com/superagent-ai/grok-cli/main/install.sh | bash ``` -The CLI binary is `**grok**` (yes, the package name and the command differ—deal with it). +The install script downloads a standalone `**grok**` binary for your platform, places it in `**~/.grok/bin**`, and records install metadata so `**grok update**` and `**grok uninstall**` work automatically. -**Prerequisites:** Node 18+ (for the global install), a **Grok API key** from [x.ai](https://x.ai), and a modern terminal emulator for the interactive OpenTUI experience. Headless `--prompt` mode does not depend on terminal UI support. If you want host desktop automation via the built-in computer sub-agent, also enable **Accessibility** permission for your terminal app on macOS. +Supported targets: macOS Apple Silicon, macOS Intel, Linux x64, and Windows x64. + +**Installer flags:** + +```bash +curl -fsSL .../install.sh | bash -s -- --version 1.1.4 +bash install.sh --binary /path/to/local/grok +bash install.sh --no-modify-path +``` + +**Alternative installs** (requires Bun on PATH): + +```bash +bun add -g grok-dev +``` + +**Self-management** (script-installed only): + +```bash +grok update +grok uninstall +grok uninstall --dry-run +grok uninstall --keep-config +``` + +**Prerequisites:** a **Grok API key** from [x.ai](https://x.ai) and a modern terminal emulator for the interactive OpenTUI experience. Headless `--prompt` mode does not depend on terminal UI support. If you want host desktop automation via the built-in computer sub-agent, also enable **Accessibility** permission for your terminal app on macOS. --- diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..8c7cbdc --- /dev/null +++ b/install.sh @@ -0,0 +1,297 @@ +#!/usr/bin/env bash +set -euo pipefail + +APP="grok" +REPO="superagent-ai/grok-cli" +RELEASES_API="https://api.github.com/repos/${REPO}/releases" +USER_DIR="${HOME}/.grok" +INSTALL_DIR="${USER_DIR}/bin" +METADATA_PATH="${USER_DIR}/install.json" +PATH_MARKER="# grok" + +requested_version="" +binary_path="" +no_modify_path=false +written_config_file="" +written_path_command="" + +usage() { + cat <<'EOF' +Install Grok from GitHub Releases. + +Usage: + curl -fsSL https://raw.githubusercontent.com/superagent-ai/grok-cli/main/install.sh | bash + curl -fsSL https://raw.githubusercontent.com/superagent-ai/grok-cli/main/install.sh | bash -s -- --version 1.1.4 + bash install.sh --binary /path/to/grok + +Options: + -v, --version Install a specific version + -b, --binary Install from a local binary instead of downloading + --no-modify-path Do not edit shell config files + -h, --help Show this help text +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) + usage + exit 0 + ;; + -v|--version) + if [[ -z "${2:-}" ]]; then + echo "Error: --version requires a version argument" >&2 + exit 1 + fi + requested_version="$2" + shift 2 + ;; + -b|--binary) + if [[ -z "${2:-}" ]]; then + echo "Error: --binary requires a path argument" >&2 + exit 1 + fi + binary_path="$2" + shift 2 + ;; + --no-modify-path) + no_modify_path=true + shift + ;; + *) + echo "Warning: Unknown option '$1'" >&2 + shift + ;; + esac +done + +mkdir -p "$INSTALL_DIR" +chmod 700 "$USER_DIR" "$INSTALL_DIR" + +resolve_target() { + local raw_os arch + raw_os=$(uname -s) + case "$raw_os" in + Darwin*) OS="darwin" ;; + Linux*) OS="linux" ;; + MINGW*|MSYS*|CYGWIN*) OS="windows" ;; + *) + echo "Unsupported OS: ${raw_os}" >&2 + exit 1 + ;; + esac + + arch=$(uname -m) + case "$arch" in + arm64|aarch64) ARCH="arm64" ;; + x86_64|amd64) ARCH="x64" ;; + *) + echo "Unsupported architecture: ${arch}" >&2 + exit 1 + ;; + esac + + case "${OS}-${ARCH}" in + darwin-arm64|darwin-x64|linux-x64|windows-x64) ;; + *) + echo "Unsupported platform: ${OS}-${ARCH}" >&2 + exit 1 + ;; + esac + + TARGET="${OS}-${ARCH}" + if [[ "$TARGET" == windows-* ]]; then + ASSET_NAME="grok-${TARGET}.exe" + BINARY_NAME="grok.exe" + else + ASSET_NAME="grok-${TARGET}" + BINARY_NAME="grok" + fi +} + +json_escape() { + printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' +} + +json_or_null() { + if [[ -n "${1:-}" ]]; then + printf '"%s"' "$(json_escape "$1")" + else + printf 'null' + fi +} + +write_metadata() { + local version="$1" + cat > "$METADATA_PATH" </dev/null 2>&1; then + sha256sum "$1" | awk '{ print $1 }' + return + fi + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{ print $1 }' + return + fi + if command -v openssl >/dev/null 2>&1; then + openssl dgst -sha256 "$1" | awk '{ print $NF }' + return + fi + echo "No SHA-256 tool found (expected sha256sum, shasum, or openssl)." >&2 + exit 1 +} + +verify_checksum() { + local binary_file="$1" checksum_file="$2" + local expected actual + expected=$(awk -v asset="$ASSET_NAME" '$2 == asset || $2 == "*"asset { print $1 }' "$checksum_file") + if [[ -z "$expected" ]]; then + echo "Missing checksum for ${ASSET_NAME}" >&2 + exit 1 + fi + actual=$(sha256_file "$binary_file") + if [[ "$actual" != "$expected" ]]; then + echo "Checksum mismatch for ${ASSET_NAME}" >&2 + exit 1 + fi +} + +add_to_path() { + local config_file="$1" command="$2" + if grep -Fxq "$command" "$config_file" 2>/dev/null; then + written_config_file="$config_file" + written_path_command="$command" + return + fi + printf '\n%s\n%s\n' "$PATH_MARKER" "$command" >> "$config_file" + written_config_file="$config_file" + written_path_command="$command" +} + +maybe_update_path() { + if [[ "$no_modify_path" == true ]]; then return; fi + if [[ ":$PATH:" == *":$INSTALL_DIR:"* ]]; then return; fi + + local current_shell config_files config_file path_command + local xdg_config_home="${XDG_CONFIG_HOME:-$HOME/.config}" + current_shell=$(basename "${SHELL:-bash}") + + case "$current_shell" in + fish) + config_files="$xdg_config_home/fish/config.fish" + path_command="fish_add_path $INSTALL_DIR" + ;; + zsh) + config_files="${ZDOTDIR:-$HOME}/.zshrc ${ZDOTDIR:-$HOME}/.zshenv" + path_command="export PATH=$INSTALL_DIR:\$PATH" + ;; + *) + config_files="$HOME/.bashrc $HOME/.bash_profile $HOME/.profile" + path_command="export PATH=$INSTALL_DIR:\$PATH" + ;; + esac + + config_file="" + for file in $config_files; do + if [[ -f "$file" ]]; then + config_file="$file" + break + fi + done + + if [[ -z "$config_file" ]]; then + echo "Add this to your shell config manually:" >&2 + echo " export PATH=$INSTALL_DIR:\$PATH" >&2 + return + fi + + add_to_path "$config_file" "$path_command" +} + +resolve_release_version() { + if [[ -n "$requested_version" ]]; then + RESOLVED_VERSION="${requested_version#v}" + RELEASE_BASE_URL="https://github.com/${REPO}/releases/download/v${RESOLVED_VERSION}" + return + fi + + RESOLVED_VERSION=$(curl -fsSL "${RELEASES_API}/latest" \ + | sed -n 's/.*"tag_name":[[:space:]]*"v\{0,1\}\([^"]*\)".*/\1/p' \ + | head -n 1) + if [[ -z "$RESOLVED_VERSION" ]]; then + echo "Failed to resolve the latest Grok release version." >&2 + exit 1 + fi + RELEASE_BASE_URL="https://github.com/${REPO}/releases/latest/download" +} + +install_downloaded_release() { + local tmp_dir binary_file checksum_file + tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/grok-install.XXXXXX") + trap 'rm -rf "$tmp_dir"' EXIT + + binary_file="${tmp_dir}/${ASSET_NAME}" + checksum_file="${tmp_dir}/checksums.txt" + + echo "Downloading ${ASSET_NAME}..." + curl -fSL "${RELEASE_BASE_URL}/${ASSET_NAME}" -o "$binary_file" + curl -fsSL "${RELEASE_BASE_URL}/checksums.txt" -o "$checksum_file" + verify_checksum "$binary_file" "$checksum_file" + + cp "$binary_file" "${INSTALL_DIR}/${BINARY_NAME}" + [[ "$TARGET" != windows-* ]] && chmod 755 "${INSTALL_DIR}/${BINARY_NAME}" +} + +install_local_binary() { + if [[ ! -f "$binary_path" ]]; then + echo "Binary not found at ${binary_path}" >&2 + exit 1 + fi + cp "$binary_path" "${INSTALL_DIR}/${BINARY_NAME}" + [[ "$TARGET" != windows-* ]] && chmod 755 "${INSTALL_DIR}/${BINARY_NAME}" +} + +resolve_installed_version() { + INSTALLED_VERSION=$("${INSTALL_DIR}/${BINARY_NAME}" --version 2>/dev/null | tr -d '\r') || true + : "${INSTALLED_VERSION:=unknown}" +} + +resolve_target + +if [[ -n "$binary_path" ]]; then + install_local_binary +else + resolve_release_version + install_downloaded_release +fi + +maybe_update_path +resolve_installed_version +write_metadata "$INSTALLED_VERSION" + +echo "" +echo "Grok ${INSTALLED_VERSION} installed to ${INSTALL_DIR}/${BINARY_NAME}" +echo "" +echo "Run:" +echo " grok --help" +echo "" +echo "To uninstall later:" +echo " grok uninstall" +echo "" diff --git a/package.json b/package.json index 7a9417a..352c78e 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "scripts": { "dev": "bun run src/index.ts", "build": "tsc", + "build:binary": "bun build --compile --outfile dist/grok-standalone ./src/index.ts", "start": "bun run dist/index.js", "typecheck": "tsc --noEmit", "test": "bunx vitest run", diff --git a/src/index.ts b/src/index.ts index 23da5ba..e1e883c 100755 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ import { import { runTelegramHeadlessBridge } from "./telegram/headless-bridge"; import { startScheduleDaemon } from "./tools/schedule"; import { processAtMentions } from "./utils/at-mentions.js"; +import { runScriptManagedUninstall } from "./utils/install-manager"; import { getApiKey, getBaseURL, @@ -287,7 +288,7 @@ program .action(async (message: string[], options) => { if (options.update) { console.log("Checking for updates..."); - const result = await runUpdate(); + const result = await runUpdate(packageJson.version); console.log(result.output); process.exit(result.success ? 0 : 1); } @@ -409,6 +410,34 @@ program console.log(); }); +program + .command("update") + .description("Update Grok to the latest release") + .action(async () => { + console.log("Checking for updates..."); + const result = await runUpdate(packageJson.version); + console.log(result.output); + process.exit(result.success ? 0 : 1); + }); + +program + .command("uninstall") + .description("Remove a script-installed Grok binary and optional data") + .option("--dry-run", "Show what would be removed without removing it") + .option("--force", "Skip the confirmation prompt") + .option("--keep-config", "Keep ~/.grok config files") + .option("--keep-data", "Keep ~/.grok data files") + .action(async (options) => { + const result = await runScriptManagedUninstall({ + dryRun: options.dryRun === true, + force: options.force === true, + keepConfig: options.keepConfig === true, + keepData: options.keepData === true, + }); + console.log(result.output); + process.exit(result.success ? 0 : 1); + }); + program .command("daemon") .description("Start the schedule daemon to run scheduled tasks") diff --git a/src/ui/app.tsx b/src/ui/app.tsx index c2d9750..e7faf89 100644 --- a/src/ui/app.tsx +++ b/src/ui/app.tsx @@ -2196,13 +2196,9 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) case "update": setIsUpdating(true); setUpdateOutput(null); - runUpdate().then((result) => { + runUpdate(startupConfig.version).then((result) => { setIsUpdating(false); - setUpdateOutput( - result.success - ? "Update complete! Restart the CLI to use the new version." - : `Update failed: ${result.output}`, - ); + setUpdateOutput(result.success ? result.output : `Update failed: ${result.output}`); }); break; } @@ -2216,6 +2212,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) openScheduleModal, processMessage, resetToNewSession, + startupConfig.version, ], ); @@ -2426,11 +2423,9 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) if (key.name === "return") { setIsUpdating(true); setShowUpdateModal(false); - runUpdate().then((result) => { + runUpdate(startupConfig.version).then((result) => { setIsUpdating(false); - setUpdateOutput( - result.success ? "Update complete! Restart the CLI to use the new version." : result.output, - ); + setUpdateOutput(result.output); }); return; } @@ -3001,6 +2996,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) copyTuiSelectionToHost, toggleSavedMcp, messages, + startupConfig.version, ], ); useKeyboard(handleKey); diff --git a/src/utils/install-manager.test.ts b/src/utils/install-manager.test.ts new file mode 100644 index 0000000..c755b76 --- /dev/null +++ b/src/utils/install-manager.test.ts @@ -0,0 +1,165 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + buildScriptUninstallPlan, + getInstallMetadataPath, + getReleaseTargetForPlatform, + getScriptInstallContext, + getScriptInstallDir, + loadScriptInstallMetadata, + parseChecksumsFile, + saveScriptInstallMetadata, +} from "./install-manager"; + +let tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs) fs.rmSync(dir, { recursive: true, force: true }); + tempDirs = []; +}); + +function createTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +describe("getReleaseTargetForPlatform", () => { + it("maps supported platforms to release asset names", () => { + expect(getReleaseTargetForPlatform("darwin", "arm64")?.assetName).toBe("grok-darwin-arm64"); + expect(getReleaseTargetForPlatform("darwin", "x64")?.assetName).toBe("grok-darwin-x64"); + expect(getReleaseTargetForPlatform("linux", "x64")?.assetName).toBe("grok-linux-x64"); + expect(getReleaseTargetForPlatform("win32", "x64")?.assetName).toBe("grok-windows-x64.exe"); + expect(getReleaseTargetForPlatform("linux", "arm64")).toBeNull(); + }); +}); + +describe("parseChecksumsFile", () => { + it("parses standard and BSD-style checksum entries", () => { + const checksums = parseChecksumsFile( + [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa grok-darwin-arm64", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb *grok-windows-x64.exe", + ].join("\n"), + ); + expect(checksums.get("grok-darwin-arm64")).toBe("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + expect(checksums.get("grok-windows-x64.exe")).toBe( + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ); + }); +}); + +describe("script install metadata", () => { + it("round-trips metadata through write and load", () => { + const homeDir = createTempDir("grok-meta-"); + const installDir = getScriptInstallDir(homeDir); + const metadata = { + schemaVersion: 1, + installMethod: "script" as const, + version: "1.2.3", + repo: "superagent-ai/grok-cli", + binaryPath: path.join(installDir, "grok"), + installDir, + assetName: "grok-darwin-arm64", + target: "darwin-arm64" as const, + installedAt: "2026-04-03T00:00:00.000Z", + shellConfigPath: path.join(homeDir, ".zshrc"), + pathCommand: `export PATH=${installDir}:$PATH`, + }; + + saveScriptInstallMetadata(metadata, homeDir); + expect(loadScriptInstallMetadata(homeDir)).toEqual(metadata); + expect(fs.existsSync(getInstallMetadataPath(homeDir))).toBe(true); + }); + + it("returns null when no metadata file exists", () => { + expect(loadScriptInstallMetadata(createTempDir("grok-empty-"))).toBeNull(); + }); +}); + +describe("getScriptInstallContext", () => { + it("returns context when metadata exists", () => { + const homeDir = createTempDir("grok-ctx-"); + const installDir = getScriptInstallDir(homeDir); + const currentTarget = getReleaseTargetForPlatform(); + expect(currentTarget).not.toBeNull(); + + saveScriptInstallMetadata( + { + schemaVersion: 1, + installMethod: "script" as const, + version: "1.2.3", + repo: "superagent-ai/grok-cli", + binaryPath: path.join(installDir, currentTarget!.binaryName), + installDir, + assetName: currentTarget!.assetName, + target: currentTarget!.key, + installedAt: "2026-04-03T00:00:00.000Z", + }, + homeDir, + ); + + const ctx = getScriptInstallContext(homeDir); + expect(ctx?.metadata.installMethod).toBe("script"); + expect(ctx?.binaryPath).toBe(path.join(installDir, currentTarget!.binaryName)); + }); + + it("returns null when no metadata exists", () => { + expect(getScriptInstallContext(createTempDir("grok-no-ctx-"))).toBeNull(); + }); +}); + +describe("buildScriptUninstallPlan", () => { + it("removes the full ~/.grok directory by default", () => { + const homeDir = createTempDir("grok-uninstall-"); + const installDir = getScriptInstallDir(homeDir); + const currentTarget = getReleaseTargetForPlatform()!; + fs.mkdirSync(installDir, { recursive: true }); + + saveScriptInstallMetadata( + { + schemaVersion: 1, + installMethod: "script" as const, + version: "1.2.3", + repo: "superagent-ai/grok-cli", + binaryPath: path.join(installDir, currentTarget.binaryName), + installDir, + assetName: currentTarget.assetName, + target: currentTarget.key, + installedAt: "2026-04-03T00:00:00.000Z", + }, + homeDir, + ); + + const plan = buildScriptUninstallPlan({}, homeDir); + expect(plan?.removePaths).toContain(path.join(homeDir, ".grok")); + }); + + it("keeps config and data when requested", () => { + const homeDir = createTempDir("grok-keep-"); + const installDir = getScriptInstallDir(homeDir); + const currentTarget = getReleaseTargetForPlatform()!; + fs.mkdirSync(installDir, { recursive: true }); + + saveScriptInstallMetadata( + { + schemaVersion: 1, + installMethod: "script" as const, + version: "1.2.3", + repo: "superagent-ai/grok-cli", + binaryPath: path.join(installDir, currentTarget.binaryName), + installDir, + assetName: currentTarget.assetName, + target: currentTarget.key, + installedAt: "2026-04-03T00:00:00.000Z", + }, + homeDir, + ); + + const plan = buildScriptUninstallPlan({ keepConfig: true, keepData: true }, homeDir); + expect(plan?.removePaths).not.toContain(path.join(homeDir, ".grok")); + expect(plan?.removePaths).toContain(path.join(installDir, currentTarget.binaryName)); + }); +}); diff --git a/src/utils/install-manager.ts b/src/utils/install-manager.ts new file mode 100644 index 0000000..8e9e717 --- /dev/null +++ b/src/utils/install-manager.ts @@ -0,0 +1,425 @@ +import { spawn } from "child_process"; +import { createHash } from "crypto"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import readline from "readline"; +import semverGt from "semver/functions/gt.js"; +import semverValid from "semver/functions/valid.js"; + +export const GROK_GITHUB_REPO = "superagent-ai/grok-cli"; +export const GROK_RELEASES_API = `https://api.github.com/repos/${GROK_GITHUB_REPO}/releases`; +export const SCRIPT_INSTALL_METHOD = "script"; + +const FETCH_TIMEOUT_MS = 5_000; +const INSTALL_SCHEMA_VERSION = 1; +const PATH_MARKER = "# grok"; +const CONFIG_FILENAMES = ["user-settings.json", "AGENTS.md"]; +const DATA_ENTRIES = ["daemon.pid", "delegations", "grok.db", "models", "schedules"]; + +export interface ReleaseTarget { + key: "darwin-arm64" | "darwin-x64" | "linux-x64" | "windows-x64"; + assetName: string; + binaryName: string; +} + +export interface ScriptInstallMetadata { + schemaVersion: number; + installMethod: typeof SCRIPT_INSTALL_METHOD; + version: string; + repo: string; + binaryPath: string; + installDir: string; + assetName: string; + target: ReleaseTarget["key"]; + installedAt: string; + shellConfigPath?: string; + pathCommand?: string; +} + +export interface ScriptInstallContext { + metadata: ScriptInstallMetadata; + target: ReleaseTarget; + binaryPath: string; +} + +export interface ScriptUpdateRunResult { + success: boolean; + output: string; +} + +export interface ScriptUninstallOptions { + dryRun?: boolean; + force?: boolean; + keepConfig?: boolean; + keepData?: boolean; +} + +export interface ScriptUninstallPlan { + removePaths: string[]; + pruneDirs: string[]; + pathCleanup?: { configFile: string; command: string }; +} + +interface GitHubReleaseAsset { + name: string; + browser_download_url: string; +} + +interface GitHubRelease { + tag_name: string; + assets: GitHubReleaseAsset[]; +} + +interface ReleaseDownload { + version: string; + asset: GitHubReleaseAsset; + checksums: GitHubReleaseAsset; +} + +export function getGrokUserDir(homeDir = os.homedir()): string { + return path.join(homeDir, ".grok"); +} + +export function getScriptInstallDir(homeDir = os.homedir()): string { + return path.join(getGrokUserDir(homeDir), "bin"); +} + +export function getInstallMetadataPath(homeDir = os.homedir()): string { + return path.join(getGrokUserDir(homeDir), "install.json"); +} + +export function getReleaseTargetForPlatform(platform = process.platform, arch = process.arch): ReleaseTarget | null { + if (platform === "darwin" && arch === "arm64") + return { key: "darwin-arm64", assetName: "grok-darwin-arm64", binaryName: "grok" }; + if (platform === "darwin" && arch === "x64") + return { key: "darwin-x64", assetName: "grok-darwin-x64", binaryName: "grok" }; + if (platform === "linux" && arch === "x64") + return { key: "linux-x64", assetName: "grok-linux-x64", binaryName: "grok" }; + if (platform === "win32" && arch === "x64") + return { key: "windows-x64", assetName: "grok-windows-x64.exe", binaryName: "grok.exe" }; + return null; +} + +export function loadScriptInstallMetadata(homeDir = os.homedir()): ScriptInstallMetadata | null { + const metadataPath = getInstallMetadataPath(homeDir); + try { + if (!fs.existsSync(metadataPath)) return null; + const parsed = JSON.parse(fs.readFileSync(metadataPath, "utf8")) as Partial; + if (parsed.installMethod !== SCRIPT_INSTALL_METHOD) return null; + if ( + typeof parsed.binaryPath !== "string" || + typeof parsed.installDir !== "string" || + typeof parsed.assetName !== "string" || + typeof parsed.target !== "string" + ) + return null; + return { + schemaVersion: INSTALL_SCHEMA_VERSION, + installMethod: SCRIPT_INSTALL_METHOD, + version: typeof parsed.version === "string" ? parsed.version : "unknown", + repo: typeof parsed.repo === "string" ? parsed.repo : GROK_GITHUB_REPO, + binaryPath: parsed.binaryPath, + installDir: parsed.installDir, + assetName: parsed.assetName, + target: parsed.target as ReleaseTarget["key"], + installedAt: typeof parsed.installedAt === "string" ? parsed.installedAt : new Date(0).toISOString(), + shellConfigPath: typeof parsed.shellConfigPath === "string" ? parsed.shellConfigPath : undefined, + pathCommand: typeof parsed.pathCommand === "string" ? parsed.pathCommand : undefined, + }; + } catch { + return null; + } +} + +export function saveScriptInstallMetadata(metadata: ScriptInstallMetadata, homeDir = os.homedir()): void { + const metadataPath = getInstallMetadataPath(homeDir); + fs.mkdirSync(path.dirname(metadataPath), { recursive: true, mode: 0o700 }); + fs.writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, { mode: 0o600 }); +} + +export function getScriptInstallContext(homeDir = os.homedir()): ScriptInstallContext | null { + const target = getReleaseTargetForPlatform(); + if (!target) return null; + + const metadata = loadScriptInstallMetadata(homeDir); + if (metadata) { + return { + metadata, + target: getReleaseTargetForPlatformKey(metadata.target) ?? target, + binaryPath: metadata.binaryPath, + }; + } + + return null; +} + +export async function fetchLatestReleaseVersion(): Promise { + const release = await fetchReleaseJson(`${GROK_RELEASES_API}/latest`); + return release ? normalizeReleaseVersion(release.tag_name) : null; +} + +export function parseChecksumsFile(contents: string): Map { + const result = new Map(); + for (const rawLine of contents.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line) continue; + const match = line.match(/^([a-fA-F0-9]{64})\s+\*?(.+)$/); + if (!match) continue; + result.set(match[2], match[1].toLowerCase()); + } + return result; +} + +export async function runScriptManagedUpdate(currentVersion: string): Promise { + const context = getScriptInstallContext(); + if (!context) return notScriptManaged("update"); + + const normalizedCurrent = semverValid(currentVersion); + if (!normalizedCurrent) { + return { success: false, output: `Cannot update: current version "${currentVersion}" is invalid.` }; + } + + const release = await resolveReleaseDownload(context.target); + if (!release) { + return { success: false, output: "No matching release found for this platform." }; + } + + if (!semverGt(release.version, normalizedCurrent)) { + return { success: true, output: `Already on the latest version (${normalizedCurrent}).` }; + } + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "grok-update-")); + try { + const downloadedPath = path.join(tempDir, release.asset.name); + await downloadBinary(release.asset.browser_download_url, downloadedPath); + + const checksumsText = await downloadText(release.checksums.browser_download_url); + const expectedHash = parseChecksumsFile(checksumsText).get(release.asset.name); + if (!expectedHash) return { success: false, output: `Missing checksum for ${release.asset.name}.` }; + + if (sha256File(downloadedPath) !== expectedHash) { + return { success: false, output: `Checksum mismatch for ${release.asset.name}; aborting.` }; + } + + fs.mkdirSync(path.dirname(context.binaryPath), { recursive: true, mode: 0o700 }); + + if (process.platform === "win32") { + return applyWindowsUpdate(tempDir, downloadedPath, context, release); + } + + const staging = `${context.binaryPath}.new`; + fs.copyFileSync(downloadedPath, staging); + fs.chmodSync(staging, 0o755); + fs.renameSync(staging, context.binaryPath); + + saveScriptInstallMetadata({ + ...context.metadata, + version: release.version, + installedAt: new Date().toISOString(), + }); + + return { success: true, output: `Updated to Grok ${release.version}.` }; + } catch (error) { + return { success: false, output: error instanceof Error ? error.message : String(error) }; + } finally { + if (process.platform !== "win32") fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +export function buildScriptUninstallPlan( + options: ScriptUninstallOptions = {}, + homeDir = os.homedir(), +): ScriptUninstallPlan | null { + const context = getScriptInstallContext(homeDir); + if (!context) return null; + + const userDir = getGrokUserDir(homeDir); + const removePaths = new Set(); + const pruneDirs = new Set(); + + if (!options.keepConfig && !options.keepData) { + removePaths.add(userDir); + } else { + removePaths.add(context.binaryPath); + removePaths.add(getInstallMetadataPath(homeDir)); + if (!options.keepConfig) for (const f of CONFIG_FILENAMES) removePaths.add(path.join(userDir, f)); + if (!options.keepData) for (const e of DATA_ENTRIES) removePaths.add(path.join(userDir, e)); + pruneDirs.add(getScriptInstallDir(homeDir)); + pruneDirs.add(userDir); + } + + return { + removePaths: sortForRemoval([...removePaths]), + pruneDirs: sortForRemoval([...pruneDirs]), + pathCleanup: + context.metadata.shellConfigPath && context.metadata.pathCommand + ? { configFile: context.metadata.shellConfigPath, command: context.metadata.pathCommand } + : undefined, + }; +} + +export async function runScriptManagedUninstall(options: ScriptUninstallOptions = {}): Promise { + const plan = buildScriptUninstallPlan(options); + if (!plan) return notScriptManaged("uninstall"); + + if (options.dryRun) return { success: true, output: formatDryRun(plan, options) }; + + if (!options.force) { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + return { success: false, output: "Non-interactive terminal. Re-run with --force." }; + } + if (!(await confirm("Remove Grok from this machine?"))) { + return { success: false, output: "Uninstall cancelled." }; + } + } + + try { + if (plan.pathCleanup) removePathLine(plan.pathCleanup.configFile, plan.pathCleanup.command); + for (const p of plan.removePaths) fs.rmSync(p, { recursive: true, force: true }); + for (const d of plan.pruneDirs) removeDirIfEmpty(d); + return { success: true, output: "Grok uninstall complete." }; + } catch (error) { + return { success: false, output: error instanceof Error ? error.message : String(error) }; + } +} + +function notScriptManaged(action: string): ScriptUpdateRunResult { + return { + success: false, + output: `This install is not script-managed, so \`grok ${action}\` cannot proceed. Use the package manager you installed with, or reinstall via install.sh.`, + }; +} + +function getReleaseTargetForPlatformKey(key: string): ReleaseTarget | null { + switch (key) { + case "darwin-arm64": + return { key, assetName: "grok-darwin-arm64", binaryName: "grok" }; + case "darwin-x64": + return { key, assetName: "grok-darwin-x64", binaryName: "grok" }; + case "linux-x64": + return { key, assetName: "grok-linux-x64", binaryName: "grok" }; + case "windows-x64": + return { key, assetName: "grok-windows-x64.exe", binaryName: "grok.exe" }; + default: + return null; + } +} + +async function resolveReleaseDownload(target: ReleaseTarget): Promise { + const release = await fetchReleaseJson(`${GROK_RELEASES_API}/latest`); + if (!release) return null; + const version = normalizeReleaseVersion(release.tag_name); + if (!version) return null; + + const asset = release.assets.find((a) => a.name === target.assetName); + const checksums = release.assets.find((a) => a.name === "checksums.txt"); + if (!asset || !checksums) return null; + + return { version, asset, checksums }; +} + +async function fetchReleaseJson(url: string): Promise { + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + const res = await fetch(url, { signal: controller.signal, headers: { Accept: "application/vnd.github+json" } }); + clearTimeout(timer); + return res.ok ? ((await res.json()) as GitHubRelease) : null; + } catch { + return null; + } +} + +function normalizeReleaseVersion(tagName: string): string | null { + return semverValid(tagName.startsWith("v") ? tagName.slice(1) : tagName); +} + +async function downloadBinary(url: string, dest: string): Promise { + const res = await fetch(url, { headers: { Accept: "application/octet-stream" } }); + if (!res.ok) throw new Error(`Download failed (${res.status}) for ${url}`); + fs.writeFileSync(dest, Buffer.from(await res.arrayBuffer())); +} + +async function downloadText(url: string): Promise { + const res = await fetch(url, { headers: { Accept: "text/plain" } }); + if (!res.ok) throw new Error(`Download failed (${res.status}) for ${url}`); + return await res.text(); +} + +function sha256File(filePath: string): string { + return createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); +} + +function sortForRemoval(paths: string[]): string[] { + return [...new Set(paths)].sort((a, b) => b.length - a.length); +} + +function removeDirIfEmpty(dir: string): void { + try { + if (fs.existsSync(dir) && fs.readdirSync(dir).length === 0) fs.rmdirSync(dir); + } catch { + /* best effort */ + } +} + +function removePathLine(configFile: string, command: string): void { + if (!fs.existsSync(configFile)) return; + const lines = fs.readFileSync(configFile, "utf8").split(/\r?\n/); + fs.writeFileSync( + configFile, + `${lines + .filter((l) => l !== PATH_MARKER && l !== command) + .join("\n") + .replace(/\n+$/, "")}\n`, + ); +} + +function formatDryRun(plan: ScriptUninstallPlan, options: ScriptUninstallOptions): string { + const lines = ["Dry run — would perform:"]; + if (plan.pathCleanup) lines.push(` remove PATH entry from ${plan.pathCleanup.configFile}`); + for (const p of plan.removePaths) lines.push(` remove ${p}`); + if (options.keepConfig) lines.push(" keep config files"); + if (options.keepData) lines.push(" keep data files"); + return lines.join("\n"); +} + +async function confirm(prompt: string): Promise { + return new Promise((resolve) => { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + rl.question(`${prompt} [y/N] `, (answer) => { + rl.close(); + resolve(/^y(es)?$/i.test(answer.trim())); + }); + }); +} + +function applyWindowsUpdate( + tempDir: string, + downloadedPath: string, + context: ScriptInstallContext, + release: ReleaseDownload, +): ScriptUpdateRunResult { + const script = [ + "$ErrorActionPreference = 'Stop'", + "Start-Sleep -Seconds 2", + `Move-Item -LiteralPath '${esc(downloadedPath)}' -Destination '${esc(context.binaryPath)}' -Force`, + ].join("\n"); + + const scriptPath = path.join(tempDir, "apply-update.ps1"); + fs.writeFileSync(scriptPath, script); + + saveScriptInstallMetadata({ ...context.metadata, version: release.version, installedAt: new Date().toISOString() }); + + const child = spawn("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptPath], { + detached: true, + stdio: "ignore", + }); + child.unref(); + + return { success: true, output: `Updated to Grok ${release.version}. Restart the CLI to use the new version.` }; +} + +function esc(s: string): string { + return s.replace(/'/g, "''"); +} diff --git a/src/utils/update-checker.test.ts b/src/utils/update-checker.test.ts index 3736d32..a5f6845 100644 --- a/src/utils/update-checker.test.ts +++ b/src/utils/update-checker.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const REGISTRY_URL = "https://registry.npmjs.org/grok-dev/latest"; +const RELEASE_URL = "https://api.github.com/repos/superagent-ai/grok-cli/releases/latest"; beforeEach(() => { vi.stubGlobal("fetch", vi.fn()); @@ -16,10 +16,10 @@ async function importModule() { } describe("checkForUpdate", () => { - it("returns hasUpdate=true when registry version is newer", async () => { + it("returns hasUpdate=true when release version is newer", async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, - json: () => Promise.resolve({ version: "2.0.0" }), + json: () => Promise.resolve({ tag_name: "v2.0.0", assets: [] }), }); vi.stubGlobal("fetch", mockFetch); @@ -31,8 +31,8 @@ describe("checkForUpdate", () => { expect(result!.latestVersion).toBe("2.0.0"); expect(result!.currentVersion).toBe("1.0.0"); expect(mockFetch).toHaveBeenCalledWith( - REGISTRY_URL, - expect.objectContaining({ headers: { Accept: "application/json" } }), + RELEASE_URL, + expect.objectContaining({ headers: { Accept: "application/vnd.github+json" } }), ); }); @@ -41,7 +41,7 @@ describe("checkForUpdate", () => { "fetch", vi.fn().mockResolvedValue({ ok: true, - json: () => Promise.resolve({ version: "1.0.0" }), + json: () => Promise.resolve({ tag_name: "v1.0.0", assets: [] }), }), ); @@ -57,7 +57,7 @@ describe("checkForUpdate", () => { "fetch", vi.fn().mockResolvedValue({ ok: true, - json: () => Promise.resolve({ version: "1.0.0" }), + json: () => Promise.resolve({ tag_name: "v1.0.0", assets: [] }), }), ); @@ -75,7 +75,7 @@ describe("checkForUpdate", () => { "fetch", vi.fn().mockResolvedValue({ ok: true, - json: () => Promise.resolve({ version: "0.9.0" }), + json: () => Promise.resolve({ tag_name: "v0.9.0", assets: [] }), }), ); @@ -95,7 +95,7 @@ describe("checkForUpdate", () => { expect(result).toBeNull(); }); - it("returns null when the registry returns a non-ok response", async () => { + it("returns null when the release API returns a non-ok response", async () => { vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 404 })); const { checkForUpdate } = await importModule(); @@ -104,12 +104,12 @@ describe("checkForUpdate", () => { expect(result).toBeNull(); }); - it("returns null when the registry returns an invalid version", async () => { + it("returns null when the release API returns an invalid version", async () => { vi.stubGlobal( "fetch", vi.fn().mockResolvedValue({ ok: true, - json: () => Promise.resolve({ version: "not-a-version" }), + json: () => Promise.resolve({ tag_name: "not-a-version", assets: [] }), }), ); @@ -124,7 +124,7 @@ describe("checkForUpdate", () => { "fetch", vi.fn().mockResolvedValue({ ok: true, - json: () => Promise.resolve({ version: "2.0.0" }), + json: () => Promise.resolve({ tag_name: "v2.0.0", assets: [] }), }), ); @@ -148,37 +148,33 @@ describe("checkForUpdate", () => { }); describe("runUpdate", () => { - it("calls npm install and returns success on zero exit", async () => { - vi.doMock("child_process", async () => { - const actual = await vi.importActual("child_process"); + it("returns success when the script-managed updater succeeds", async () => { + vi.doMock("./install-manager", async () => { + const actual = await vi.importActual("./install-manager"); return { ...actual, - exec: (_cmd: string, _opts: unknown, cb: (err: Error | null, stdout: string, stderr: string) => void) => { - cb(null, "added 1 package", ""); - }, + runScriptManagedUpdate: vi.fn().mockResolvedValue({ success: true, output: "Updated to Grok 2.0.0." }), }; }); const { runUpdate } = await importModule(); - const result = await runUpdate(); + const result = await runUpdate("1.0.0"); expect(result.success).toBe(true); - expect(result.output).toContain("added 1 package"); + expect(result.output).toContain("Updated"); }); - it("returns failure when the command errors", async () => { - vi.doMock("child_process", async () => { - const actual = await vi.importActual("child_process"); + it("returns failure when the script-managed updater fails", async () => { + vi.doMock("./install-manager", async () => { + const actual = await vi.importActual("./install-manager"); return { ...actual, - exec: (_cmd: string, _opts: unknown, cb: (err: Error | null, stdout: string, stderr: string) => void) => { - cb(new Error("permission denied"), "", "permission denied"); - }, + runScriptManagedUpdate: vi.fn().mockResolvedValue({ success: false, output: "permission denied" }), }; }); const { runUpdate } = await importModule(); - const result = await runUpdate(); + const result = await runUpdate("1.0.0"); expect(result.success).toBe(false); expect(result.output).toContain("permission denied"); diff --git a/src/utils/update-checker.ts b/src/utils/update-checker.ts index 9f137b4..efb5f92 100644 --- a/src/utils/update-checker.ts +++ b/src/utils/update-checker.ts @@ -1,10 +1,6 @@ -import { exec } from "child_process"; import semverGt from "semver/functions/gt.js"; import semverValid from "semver/functions/valid.js"; - -const PACKAGE_NAME = "grok-dev"; -const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`; -const FETCH_TIMEOUT_MS = 3_000; +import { fetchLatestReleaseVersion, runScriptManagedUpdate } from "./install-manager"; export interface UpdateCheckResult { currentVersion: string; @@ -19,19 +15,7 @@ export interface UpdateRunResult { export async function checkForUpdate(currentVersion: string): Promise { try { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); - - const response = await fetch(REGISTRY_URL, { - signal: controller.signal, - headers: { Accept: "application/json" }, - }); - clearTimeout(timer); - - if (!response.ok) return null; - - const data = (await response.json()) as { version?: string }; - const latestVersion = data.version; + const latestVersion = await fetchLatestReleaseVersion(); if (!latestVersion || !semverValid(latestVersion)) return null; const normalizedCurrent = semverValid(currentVersion); @@ -44,16 +28,6 @@ export async function checkForUpdate(currentVersion: string): Promise { - return new Promise((resolve) => { - const command = `npm install -g ${PACKAGE_NAME}@latest`; - exec(command, { timeout: 60_000 }, (error, stdout, stderr) => { - const output = (stdout || "").trim() + (stderr ? `\n${stderr.trim()}` : ""); - if (error) { - resolve({ success: false, output: output || error.message }); - } else { - resolve({ success: true, output: output || "Update complete." }); - } - }); - }); +export function runUpdate(currentVersion: string): Promise { + return runScriptManagedUpdate(currentVersion); } diff --git a/vitest.config.ts b/vitest.config.ts index f612c07..de94de6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,6 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - exclude: ["dist/**", "node_modules/**"], + exclude: ["dist/**", "node_modules/**", "tmp/**", ".claude/**", ".cursor/**"], }, });