From a6168560bc498b896e63f5185ec86c33f4e8d557 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Fri, 31 Jul 2026 22:18:08 +0700 Subject: [PATCH] feat(install): stage ripgrep into CoDev Code's cache during setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CoDev Code needs a ripgrep binary for file search (the @ mention index on Windows, where its bundled fff indexer is disabled, plus the Grep/Glob tools everywhere). When rg is neither on PATH nor cached it downloads one from GitHub on first use — which fails on corporate networks that block GitHub, leaving file search silently empty. Both `codevhub install` and `codevhub config` now stage the official ripgrep 15.1.0 binary for the current platform/arch from the landing page's downloads dir into /codev/bin/rg[.exe] — the location CoDev Code checks before attempting its own download. Runs during the finalize phase, best-effort: an existing cached rg is never overwritten, failure logs and surfaces a yellow warning row with the manual fix, and the write is staged-then-renamed so a crash can't leave a partial file that CoDev Code's bare existence check would trust forever. Companion PR: codev-landing-page#64 hosts the binaries. Co-Authored-By: Claude Fable 5 --- src/SetupApp.tsx | 47 +++++++++++---- src/lib/const.ts | 3 + src/lib/ripgrep.ts | 90 ++++++++++++++++++++++++++++ tests/ConfigApp.test.tsx | 8 +++ tests/InstallApp.test.tsx | 8 +++ tests/lib/ripgrep.test.ts | 121 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 267 insertions(+), 10 deletions(-) create mode 100644 src/lib/ripgrep.ts create mode 100644 tests/lib/ripgrep.test.ts diff --git a/src/SetupApp.tsx b/src/SetupApp.tsx index a3ab848..887eb37 100644 --- a/src/SetupApp.tsx +++ b/src/SetupApp.tsx @@ -64,6 +64,7 @@ import { } from "@/lib/doctor.js"; import { logApiKeyConfigured, logDebug, logError, logWarn } from "@/lib/log.js"; import { providerFromName } from "@/lib/provider.js"; +import { installRipgrep } from "@/lib/ripgrep.js"; import { installShims, toolToShimAgent } from "@/lib/shims.js"; import { disableClaudeCodeLoginPrompt } from "@/lib/vscode-settings.js"; @@ -195,6 +196,10 @@ export function SetupApp({ mode }: SetupAppProps) { // CodeGraph-eligible tools, so the row never renders. const [codegraphResult, setCodegraphResult] = useState(null); + // Set only when staging ripgrep into CoDev Code's cache fails during the + // finalize Phase (best-effort, CoDev Code selections only). Drives a yellow + // ▲ row so the user learns file search may be degraded and how to fix it. + const [ripgrepWarning, setRipgrepWarning] = useState(null); const [chosenModel, setChosenModel] = useState(null); // Set when ModelSelect falls back to FALLBACK_MODEL because the gateway's // model list couldn't be fetched. Drives a persistent yellow ▲ row above @@ -582,7 +587,7 @@ export function SetupApp({ mode }: SetupAppProps) { // The flow holds on "finalizing" (spinner) until this resolves; a // failure surfaces as a warning row but never blocks completion. // setupCodegraph never throws, but the catch is defensive. - setupCodegraph(installedTools) + const codegraph = setupCodegraph(installedTools) .then(setCodegraphResult) .catch((err: unknown) => setCodegraphResult({ @@ -592,15 +597,29 @@ export function SetupApp({ mode }: SetupAppProps) { err instanceof Error ? err.message : String(err) }`, }), - ) - .finally(() => { - setStep("done"); - // Hold the terminal frame for ~1s so the user can read "Done! Run - // exec $SHELL" and "Happy coding!" before Ink tears down. Without - // this, React's render of the "done" Phase wouldn't flush to the - // terminal before exit() unmounts the app. - setTimeout(() => exit(), 1000); - }); + ); + // Best-effort ripgrep staging for CoDev Code's file search — see + // lib/ripgrep.ts for why the agent can't be left to download its own + // on corporate networks. Failure warns but never blocks completion; + // the agent still tries PATH and its own GitHub download at runtime. + const ripgrep = installedTools.includes("codev-code") + ? installRipgrep().catch((err: unknown) => { + logError("ripgrep staging failed during finalize", { err }); + setRipgrepWarning( + `Could not stage ripgrep for CoDev Code: ${ + err instanceof Error ? err.message : String(err) + }. File search may be empty on Windows — install ripgrep (winget install BurntSushi.ripgrep.MSVC) and restart the agent.`, + ); + }) + : Promise.resolve(); + Promise.all([codegraph, ripgrep]).finally(() => { + setStep("done"); + // Hold the terminal frame for ~1s so the user can read "Done! Run + // exec $SHELL" and "Happy coding!" before Ink tears down. Without + // this, React's render of the "done" Phase wouldn't flush to the + // terminal before exit() unmounts the app. + setTimeout(() => exit(), 1000); + }); }, [installedTools, exit], ); @@ -880,6 +899,14 @@ export function SetupApp({ mode }: SetupAppProps) { )} )} + {(step === "finalizing" || step === "done") && ripgrepWarning && ( + File search}> + + + {` ${ripgrepWarning}`} + + + )} {step === "done" && ( /codev/bin/rg[.exe]`. Its cache root comes from the xdg-basedir +// package — $XDG_CACHE_HOME or ~/.cache on every platform, Windows included. +export function ripgrepCachePath(): string { + const cacheRoot = process.env.XDG_CACHE_HOME || join(homedir(), ".cache"); + return join( + cacheRoot, + "codev", + "bin", + process.platform === "win32" ? "rg.exe" : "rg", + ); +} + +export function ripgrepDownloadUrl(): string | null { + const key = `${process.platform}-${process.arch}`; + if (!SUPPORTED.has(key)) return null; + const ext = process.platform === "win32" ? ".exe" : ""; + return `${CODE_DOWNLOADS_URL}/rg-${RG_VERSION}-${key}${ext}`; +} + +export async function installRipgrep(): Promise { + const target = ripgrepCachePath(); + if (existsSync(target)) return { status: "present", path: target }; + const url = ripgrepDownloadUrl(); + if (url === null) return { status: "unsupported", path: null }; + + // Bounded so a blackholing proxy can't hang the finalize step; ~4–5 MB from + // the internal host comfortably fits. + const res = await fetch(url, { signal: AbortSignal.timeout(30_000) }); + if (!res.ok) + throw new Error(`ripgrep download failed (${res.status}): ${url}`); + const bytes = Buffer.from(await res.arrayBuffer()); + if (bytes.length === 0) throw new Error(`ripgrep download was empty: ${url}`); + + // Write-then-rename: CoDev Code's cache check is bare file existence, so a + // partial file left by a crash here would permanently satisfy it and brick + // the agent's search instead of helping it. + mkdirSync(dirname(target), { recursive: true }); + const partial = `${target}.partial`; + writeFileSync(partial, bytes, { mode: 0o755 }); + chmodSync(partial, 0o755); + renameSync(partial, target); + logInfo("staged ripgrep into CoDev Code cache", { + action: "ripgrep.install", + extra: { path: target, url, bytes: bytes.length }, + }); + return { status: "installed", path: target }; +} diff --git a/tests/ConfigApp.test.tsx b/tests/ConfigApp.test.tsx index e9cb021..a10ec4a 100644 --- a/tests/ConfigApp.test.tsx +++ b/tests/ConfigApp.test.tsx @@ -16,6 +16,7 @@ import * as auth from "@/lib/auth.js"; import * as backend from "@/lib/backend.js"; import * as codegraph from "@/lib/codegraph.js"; import * as configure from "@/lib/configure.js"; +import * as ripgrep from "@/lib/ripgrep.js"; import * as shims from "@/lib/shims.js"; // ConfigApp shares its state machine with InstallApp (both render ). @@ -64,6 +65,13 @@ beforeEach(() => { status: "skipped", targets: [], }); + // The finalize Phase also stages ripgrep into CoDev Code's cache dir (a + // real download from the landing page). Default to "present" so full-flow + // tests neither hit the network nor write outside the temp home. + vi.spyOn(ripgrep, "installRipgrep").mockResolvedValue({ + status: "present", + path: null, + }); // Default to "no saved API key" so the validating-existing branch doesn't // surface a stale dev-machine key. vi.spyOn(auth, "loadApiKey").mockReturnValue(null); diff --git a/tests/InstallApp.test.tsx b/tests/InstallApp.test.tsx index 516cad1..adf12b9 100644 --- a/tests/InstallApp.test.tsx +++ b/tests/InstallApp.test.tsx @@ -18,6 +18,7 @@ import * as codegraph from "@/lib/codegraph.js"; import * as configure from "@/lib/configure.js"; import { FALLBACK_MODEL } from "@/lib/const.js"; import * as npm from "@/lib/npm.js"; +import * as ripgrep from "@/lib/ripgrep.js"; import * as shims from "@/lib/shims.js"; import { vscodeSettingsPath, @@ -81,6 +82,13 @@ beforeEach(() => { status: "skipped", targets: [], }); + // The finalize Phase also stages ripgrep into CoDev Code's cache dir (a + // real download from the landing page). Default to "present" so full-flow + // tests neither hit the network nor write outside the temp home. + vi.spyOn(ripgrep, "installRipgrep").mockResolvedValue({ + status: "present", + path: null, + }); // The post-model gateway smoke test hits the gateway with a real completion; // default it to a pass so full-flow tests don't make a network call. The // failure-path test overrides it. diff --git a/tests/lib/ripgrep.test.ts b/tests/lib/ripgrep.test.ts new file mode 100644 index 0000000..e89015e --- /dev/null +++ b/tests/lib/ripgrep.test.ts @@ -0,0 +1,121 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { CODE_DOWNLOADS_URL } from "@/lib/const.js"; +import { + installRipgrep, + RG_VERSION, + ripgrepCachePath, + ripgrepDownloadUrl, +} from "@/lib/ripgrep.js"; + +let tempCache: string; +beforeEach(() => { + tempCache = mkdtempSync(join(tmpdir(), "codev-ripgrep-")); + // Pin the cache root so tests never touch the real ~/.cache/codev. + vi.stubEnv("XDG_CACHE_HOME", tempCache); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + rmSync(tempCache, { recursive: true, force: true }); +}); + +const rgName = process.platform === "win32" ? "rg.exe" : "rg"; + +describe("ripgrepCachePath", () => { + test("resolves inside $XDG_CACHE_HOME/codev/bin", () => { + expect(ripgrepCachePath()).toBe(join(tempCache, "codev", "bin", rgName)); + }); + + test("falls back to ~/.cache without XDG_CACHE_HOME", () => { + vi.stubEnv("XDG_CACHE_HOME", ""); + vi.stubEnv("HOME", tempCache); + vi.stubEnv("USERPROFILE", tempCache); + expect(ripgrepCachePath()).toBe( + join(tempCache, ".cache", "codev", "bin", rgName), + ); + }); +}); + +describe("ripgrepDownloadUrl", () => { + // Every platform the suite runs on (darwin/linux/win32, x64/arm64) has a + // hosted binary, so the URL is always concrete here. + test("points at the landing page downloads dir, pinned version", () => { + const url = ripgrepDownloadUrl(); + expect(url).not.toBeNull(); + expect(url).toBe( + `${CODE_DOWNLOADS_URL}/rg-${RG_VERSION}-${process.platform}-${process.arch}${ + process.platform === "win32" ? ".exe" : "" + }`, + ); + }); +}); + +describe("installRipgrep", () => { + test("downloads and stages an executable rg", async () => { + const body = Buffer.from("fake-rg-binary"); + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response(body)); + + const result = await installRipgrep(); + + expect(fetchSpy).toHaveBeenCalledWith( + ripgrepDownloadUrl(), + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect(result.status).toBe("installed"); + expect(result.path).toBe(ripgrepCachePath()); + expect(readFileSync(ripgrepCachePath())).toEqual(body); + // No .partial left behind once the rename landed. + expect(existsSync(`${ripgrepCachePath()}.partial`)).toBe(false); + if (process.platform !== "win32") { + expect(statSync(ripgrepCachePath()).mode & 0o111).not.toBe(0); + } + }); + + test("leaves an existing cached rg alone without fetching", async () => { + const target = ripgrepCachePath(); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, "user-provided-rg"); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + + const result = await installRipgrep(); + + expect(result).toEqual({ status: "present", path: target }); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(readFileSync(target, "utf-8")).toBe("user-provided-rg"); + }); + + test("rejects on an HTTP error and stages nothing", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("blocked", { status: 403 }), + ); + + await expect(installRipgrep()).rejects.toThrow( + /ripgrep download failed \(403\)/, + ); + expect(existsSync(ripgrepCachePath())).toBe(false); + expect(existsSync(`${ripgrepCachePath()}.partial`)).toBe(false); + }); + + test("rejects on an empty body and stages nothing", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(Buffer.alloc(0)), + ); + + await expect(installRipgrep()).rejects.toThrow(/empty/); + expect(existsSync(ripgrepCachePath())).toBe(false); + }); +});