diff --git a/.changeset/bundle-ripgrep-fallback.md b/.changeset/bundle-ripgrep-fallback.md new file mode 100644 index 0000000000..b7c6a35002 --- /dev/null +++ b/.changeset/bundle-ripgrep-fallback.md @@ -0,0 +1,5 @@ +--- +"zoo-code": patch +--- + +Bundle a ripgrep binary with the extension and fall back to it when ripgrep cannot be located in the VS Code installation. Fixes "Could not find ripgrep binary" errors that break search_files / list_files on VS Code Insiders' newer staged-install layout. diff --git a/.github/workflows/marketplace-publish.yml b/.github/workflows/marketplace-publish.yml index 1e0f7d7335..2e6b8ee1bf 100644 --- a/.github/workflows/marketplace-publish.yml +++ b/.github/workflows/marketplace-publish.yml @@ -63,6 +63,7 @@ jobs: grep -q "extension/webview-ui/build/assets/index.js" /tmp/zoo-code-vsix-contents.txt grep -q "extension/assets/codicons/codicon.ttf" /tmp/zoo-code-vsix-contents.txt grep -q "extension/assets/vscode-material-icons/icons/3d.svg" /tmp/zoo-code-vsix-contents.txt + grep -qE "extension/dist/bin/[^/]+/rg" /tmp/zoo-code-vsix-contents.txt - name: Validate packaged manifest identity run: | diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15f5e74d0c..1517fa8651 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -788,6 +788,9 @@ importers: '@vitest/coverage-v8': specifier: ^3.2.3 version: 3.2.4(vitest@3.2.4) + '@vscode/ripgrep': + specifier: ^1.17.0 + version: 1.17.0 '@vscode/test-electron': specifier: ^2.5.2 version: 2.5.2 diff --git a/src/esbuild.mjs b/src/esbuild.mjs index 890318cd26..4fbb86141a 100644 --- a/src/esbuild.mjs +++ b/src/esbuild.mjs @@ -85,6 +85,31 @@ async function main() { }) }, }, + { + name: "copyRipgrep", + setup(build) { + build.onEnd(async () => { + // Copy the ripgrep binary into dist/bin/-/ so it + // ships inside the universal VSIX. getBinPath() in + // src/services/ripgrep/index.ts falls back to this bundled copy + // when ripgrep cannot be located in the VS Code installation + // (e.g. VS Code Insiders' staged-install layout). The + // - subfolder keeps the runtime fallback from + // picking a binary built for a different OS (the name `rg` is + // shared by Linux and macOS). + const { rgPath } = await import("@vscode/ripgrep") + if (!rgPath) { + throw new Error("[copyRipgrep] @vscode/ripgrep did not provide rgPath") + } + const rgDestDir = path.join(distDir, "bin", `${process.platform}-${process.arch}`) + fs.mkdirSync(rgDestDir, { recursive: true }) + const rgDest = path.join(rgDestDir, path.basename(rgPath)) + fs.copyFileSync(rgPath, rgDest) + fs.chmodSync(rgDest, 0o755) + console.log(`[copyRipgrep] Copied ${rgPath} to ${rgDest}`) + }) + }, + }, { name: "copyWasms", setup(build) { diff --git a/src/package.json b/src/package.json index 9bb5d430e3..66128079f6 100644 --- a/src/package.json +++ b/src/package.json @@ -554,6 +554,7 @@ "@types/tmp": "^0.2.6", "@types/turndown": "^5.0.5", "@types/vscode": "^1.84.0", + "@vscode/ripgrep": "^1.17.0", "@vscode/test-electron": "^2.5.2", "@vscode/vsce": "3.3.2", "ai": "^6.0.75", diff --git a/src/services/ripgrep/__tests__/index.spec.ts b/src/services/ripgrep/__tests__/index.spec.ts index 0c4d79f09e..298e83ff4a 100644 --- a/src/services/ripgrep/__tests__/index.spec.ts +++ b/src/services/ripgrep/__tests__/index.spec.ts @@ -1,6 +1,16 @@ // npx vitest run src/services/ripgrep/__tests__/index.spec.ts -import { truncateLine } from "../index" +import path from "path" +import { vi, describe, it, expect, beforeEach } from "vitest" + +import { truncateLine, getBinPath, bundledRgPath } from "../index" +import { fileExistsAtPath } from "../../../utils/fs" + +vi.mock("../../../utils/fs", () => ({ + fileExistsAtPath: vi.fn(), +})) + +const mockFileExists = vi.mocked(fileExistsAtPath) describe("Ripgrep line truncation", () => { // The default MAX_LINE_LENGTH is 500 in the implementation @@ -48,3 +58,45 @@ describe("Ripgrep line truncation", () => { expect(truncated).toContain("[truncated...]") }) }) + +describe("getBinPath bundled-ripgrep fallback", () => { + beforeEach(() => { + mockFileExists.mockReset() + }) + + it("falls back to the bundled ripgrep when ripgrep is absent under the VS Code app root", async () => { + // VS Code Insiders' staged-install layout: nothing under appRoot. + mockFileExists.mockImplementation(async (p: string) => p === bundledRgPath) + + const result = await getBinPath("/fake/vscode/app/root") + + expect(result).toBe(bundledRgPath) + }) + + it("prefers VS Code's own ripgrep over the bundled copy", async () => { + const appRoot = "/fake/vscode/app/root" + // Derive the binary name from bundledRgPath so this test tracks the + // module's own platform logic instead of duplicating it. + const vscodeRg = path.join(appRoot, "node_modules/@vscode/ripgrep/bin", path.basename(bundledRgPath)) + mockFileExists.mockImplementation(async (p: string) => p === vscodeRg || p === bundledRgPath) + + const result = await getBinPath(appRoot) + + expect(result).toBe(vscodeRg) + }) + + it("returns undefined when ripgrep exists nowhere", async () => { + mockFileExists.mockResolvedValue(false) + + const result = await getBinPath("/fake/vscode/app/root") + + expect(result).toBeUndefined() + }) + + it("scopes the bundled ripgrep path to the current platform and arch", () => { + // Guards the universal VSIX from handing a wrong-OS binary to the + // fallback: the path must carry a - segment so a + // macOS host never resolves a Linux-built `dist/bin/rg`. + expect(bundledRgPath).toContain(path.join("bin", `${process.platform}-${process.arch}`)) + }) +}) diff --git a/src/services/ripgrep/index.ts b/src/services/ripgrep/index.ts index 5dd800ac6f..cbfed13b4d 100644 --- a/src/services/ripgrep/index.ts +++ b/src/services/ripgrep/index.ts @@ -80,9 +80,33 @@ export function truncateLine(line: string, maxLength: number = MAX_LINE_LENGTH): return line.length > maxLength ? line.substring(0, maxLength) + " [truncated...]" : line } /** - * Get the path to the ripgrep binary within the VSCode installation + * Path to the ripgrep binary bundled inside the extension itself. + * + * esbuild copies the platform `rg`/`rg.exe` into `dist/bin/-/` + * at build time (see the `copyRipgrep` plugin in esbuild.mjs). At runtime this + * module is bundled into `dist/extension.js`, so `__dirname` is the extension's + * `dist/` directory. + * + * The `-` segment is required: the published VSIX is universal + * but carries only the build host's binary. Without it, a macOS user would + * resolve a Linux-built `dist/bin/rg` (the binary name `rg` is shared by Linux + * and macOS) and execute an incompatible binary. With it, the fallback resolves + * only on a host matching the bundled binary. + */ +export const bundledRgPath = path.join(__dirname, "bin", `${process.platform}-${process.arch}`, binName) + +/** + * Get the path to the ripgrep binary. + * + * Prefers the copy shipped inside the VS Code installation; falls back to the + * binary bundled with this extension when the VS Code copy cannot be located + * (e.g. VS Code Insiders' staged-install layout, see microsoft/vscode#252063). */ export async function getBinPath(vscodeAppRoot: string): Promise { + /** + * Resolve `/` under the VS Code application root, + * returning the absolute path when the ripgrep binary exists there. + */ const checkPath = async (pkgFolder: string) => { const fullPath = path.join(vscodeAppRoot, pkgFolder, binName) return (await fileExistsAtPath(fullPath)) ? fullPath : undefined @@ -92,7 +116,8 @@ export async function getBinPath(vscodeAppRoot: string): Promise