Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 37 additions & 10 deletions src/SetupApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -195,6 +196,10 @@ export function SetupApp({ mode }: SetupAppProps) {
// CodeGraph-eligible tools, so the row never renders.
const [codegraphResult, setCodegraphResult] =
useState<CodegraphSetupResult | null>(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<string | null>(null);
const [chosenModel, setChosenModel] = useState<string | null>(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
Expand Down Expand Up @@ -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({
Expand All @@ -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],
);
Expand Down Expand Up @@ -880,6 +899,14 @@ export function SetupApp({ mode }: SetupAppProps) {
)}
</Step>
)}
{(step === "finalizing" || step === "done") && ripgrepWarning && (
<Step title={<Text bold>File search</Text>}>
<Box>
<Text color="yellow">▲</Text>
<Text color="yellow">{` ${ripgrepWarning}`}</Text>
</Box>
</Step>
)}
{step === "done" && (
<SetupComplete
tools={installedTools}
Expand Down
3 changes: 3 additions & 0 deletions src/lib/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ export const BACKEND_URL = `${BASE_URL}/codev-backend`;
export const SSO_URL = `${BASE_URL}/sso-wrapper`;
export const LOGIN_SUCCESS_URL = `${BASE_URL}/codev/oauth/success`;
export const SKILLHUB_URL = `${BASE_URL}/netmindhub`;
// The landing page serves CoDev Code's static downloads (the vsix, ripgrep
// binaries) from its public/ dir under the site's /codev base path.
export const CODE_DOWNLOADS_URL = `${BASE_URL}/codev/docs/code/downloads`;

export const FALLBACK_MODEL = atob("TWluaU1heC9NaW5pTWF4LU0yLjc=");

Expand Down
90 changes: 90 additions & 0 deletions src/lib/ripgrep.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import {
chmodSync,
existsSync,
mkdirSync,
renameSync,
writeFileSync,
} from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { CODE_DOWNLOADS_URL } from "@/lib/const.js";
import { logInfo } from "@/lib/log.js";

// CoDev Code resolves a ripgrep binary for file search: the `@` mention index
// on Windows (where its bundled fff indexer is disabled) and the Grep/Glob
// tools everywhere. When `rg` is neither on PATH nor in its cache dir it
// downloads one from github.com/BurntSushi/ripgrep — which fails on corporate
// networks that block GitHub, leaving file search silently empty. Staging the
// binary into the cache dir here makes the agent find it before it ever tries
// GitHub.
//
// Must track the version codev-code pins in packages/core/src/ripgrep/binary.ts
// and the binaries hosted by codev-landing-page (public/docs/code/downloads).
export const RG_VERSION = "15.1.0";

// The `${process.platform}-${process.arch}` pairs with a hosted binary.
const SUPPORTED = new Set([
"darwin-arm64",
"darwin-x64",
"linux-arm64",
"linux-x64",
"win32-arm64",
"win32-x64",
]);

export interface RipgrepInstallResult {
// installed → downloaded and staged this run; present → cache already had
// one (never overwritten); unsupported → no hosted binary for this
// platform/arch, nothing to do.
status: "installed" | "present" | "unsupported";
path: string | null;
}

// Where CoDev Code looks for a cached ripgrep before downloading its own:
// `<xdg cache>/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<RipgrepInstallResult> {
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 };
}
8 changes: 8 additions & 0 deletions tests/ConfigApp.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <SetupApp />).
Expand Down Expand Up @@ -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);
Expand Down
8 changes: 8 additions & 0 deletions tests/InstallApp.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
121 changes: 121 additions & 0 deletions tests/lib/ripgrep.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading