fix(search): bundle cross-platform ripgrep #923

This commit is contained in:
程序员阿江(Relakkes) 2026-07-14 22:03:41 +08:00
parent 86393ac179
commit 7e998e0365
18 changed files with 1049 additions and 26 deletions

View File

@ -100,6 +100,12 @@ jobs:
working-directory: desktop
run: bun run test:windows-storage-recovery
- name: Prepare bundled ripgrep
working-directory: desktop
env:
SIDECAR_TARGET_TRIPLE: ${{ matrix.target_triple }}
run: bun run prepare:ripgrep
- name: Build sidecars
working-directory: desktop
env:

View File

@ -173,6 +173,12 @@ jobs:
working-directory: desktop
run: bun run test:windows-storage-recovery
- name: Prepare bundled ripgrep
working-directory: desktop
env:
SIDECAR_TARGET_TRIPLE: ${{ matrix.target_triple }}
run: bun run prepare:ripgrep
- name: Build sidecars
working-directory: desktop
env:

View File

@ -1,6 +1,14 @@
# Third-Party Licenses
This project includes code adapted from the following open source projects.
This project includes code and binaries from the following open source projects.
## ripgrep
- Project: ripgrep (https://github.com/BurntSushi/ripgrep)
- Included as: platform-specific desktop search executable
- Version: 15.1.0
- License: dual-licensed under MIT or the Unlicense
- License texts: bundled beside the executable under `ripgrep-licenses/`
## claude-tap

View File

@ -23,7 +23,9 @@ import {
readLastServerPort,
reserveLocalPort,
reserveServerPort,
resolveBundledRipgrepExecutable,
resolveHostTriple,
RIPGREP_PATH_ENV,
SERVER_STATE_FILE,
spawnSidecar,
waitForServer,
@ -77,6 +79,7 @@ describe('Electron sidecar manager', () => {
expect(resolveHostTriple('darwin', 'x64')).toBe('x86_64-apple-darwin')
expect(resolveHostTriple('win32', 'x64')).toBe('x86_64-pc-windows-msvc')
expect(resolveHostTriple('win32', 'arm64')).toBe('aarch64-pc-windows-msvc')
expect(resolveHostTriple('linux', 'x64')).toBe('x86_64-unknown-linux-gnu')
expect(resolveHostTriple('linux', 'arm64')).toBe('aarch64-unknown-linux-gnu')
})
@ -115,6 +118,57 @@ describe('Electron sidecar manager', () => {
expect(plan.env.CLAUDE_H5_DIST_DIR).toBe('/Applications/App.app/Contents/Resources/app.asar.unpacked/dist')
})
it('passes the packaged ripgrep path to the server and its CLI children', () => {
const desktopRoot = mkdtempSync(path.join(tmpdir(), 'cc-haha-ripgrep-plan-'))
try {
const bundledRipgrep = resolveBundledRipgrepExecutable(desktopRoot)
mkdirSync(path.dirname(bundledRipgrep), { recursive: true })
writeFileSync(bundledRipgrep, 'fixture')
const plan = createServerPlan({
desktopRoot,
appRoot: '/app',
port: 49321,
env: {},
})
expect(plan.env[RIPGREP_PATH_ENV]).toBe(bundledRipgrep)
expect(plan.env.PATH?.split(path.delimiter)).toContain(
path.dirname(bundledRipgrep),
)
const adapter = createAdapterPlan({
desktopRoot,
appRoot: '/app',
serverUrl: 'http://127.0.0.1:49321',
flag: '--telegram',
env: {},
})
expect(adapter.env[RIPGREP_PATH_ENV]).toBe(bundledRipgrep)
} finally {
rmSync(desktopRoot, { recursive: true, force: true })
}
})
it('preserves an explicit ripgrep override', () => {
const customDir = mkdtempSync(path.join(tmpdir(), 'cc-haha-custom-ripgrep-'))
try {
const customRipgrep = path.join(customDir, 'rg')
writeFileSync(customRipgrep, 'fixture')
const plan = createServerPlan({
desktopRoot: '/app/desktop',
appRoot: '/app',
port: 49321,
env: { PATH: '/usr/bin', [RIPGREP_PATH_ENV]: customRipgrep },
})
expect(plan.env[RIPGREP_PATH_ENV]).toBe(customRipgrep)
expect(plan.env.PATH?.split(path.delimiter)).toContain(customDir)
} finally {
rmSync(customDir, { recursive: true, force: true })
}
})
it('passes portable config and adapter server URL through the sidecar env', () => {
const configDir = mkdtempSync(path.join(tmpdir(), 'cc-haha-config-'))
try {

View File

@ -24,6 +24,7 @@ export const SERVER_STARTUP_LOG_LIMIT = 80
export const HOST_DIAGNOSTICS_LINE_LIMIT = 80
export const HOST_DIAGNOSTICS_BYTE_LIMIT = 256 * 1024
export const ELECTRON_DIAGNOSTICS_FILE_ENV = 'CC_HAHA_ELECTRON_DIAGNOSTICS_FILE'
export const RIPGREP_PATH_ENV = 'CC_HAHA_RIPGREP_PATH'
const HOST_DIAGNOSTICS_LINE_BYTE_LIMIT = 4096
// Shared with the Tauri shell (src-tauri/src/lib.rs) so both desktop builds
// reuse the same sticky port across restarts (issue #767).
@ -69,6 +70,43 @@ export function resolveSidecarExecutable(desktopRoot: string, triple = resolveHo
return process.platform === 'win32' ? `${base}.exe` : base
}
export function resolveBundledRipgrepExecutable(
desktopRoot: string,
triple = resolveHostTriple(),
): string {
const extension = triple.includes('windows') ? '.exe' : ''
return path.join(desktopRoot, 'src-tauri', 'binaries', `rg${extension}`)
}
function withBundledRipgrepPath(
env: NodeJS.ProcessEnv,
desktopRoot: string,
): NodeJS.ProcessEnv {
const bundledRipgrep = resolveBundledRipgrepExecutable(desktopRoot)
const explicitRipgrep = env[RIPGREP_PATH_ENV]?.trim()
const selectedRipgrep = explicitRipgrep && existsSync(explicitRipgrep)
? explicitRipgrep
: existsSync(bundledRipgrep)
? bundledRipgrep
: null
if (!selectedRipgrep) return env
const pathKey = process.platform === 'win32'
? Object.keys(env).find(key => key.toLowerCase() === 'path') ?? 'Path'
: 'PATH'
const currentPath = env[pathKey] ?? ''
const ripgrepDirectory = path.dirname(selectedRipgrep)
const nextPath = currentPath
? `${currentPath}${path.delimiter}${ripgrepDirectory}`
: ripgrepDirectory
return {
...env,
[pathKey]: nextPath,
[RIPGREP_PATH_ENV]: explicitRipgrep || bundledRipgrep,
}
}
export function httpToWebSocketUrl(serverHttpUrl: string): string {
if (serverHttpUrl.startsWith('http://')) return `ws://${serverHttpUrl.slice('http://'.length)}`
if (serverHttpUrl.startsWith('https://')) return `wss://${serverHttpUrl.slice('https://'.length)}`
@ -478,7 +516,7 @@ export function createServerPlan({
return {
command: resolveSidecarExecutable(desktopRoot),
args: ['server', '--app-root', appRoot, '--host', bindHost, '--port', String(port)],
env: buildSidecarEnv(env, h5DistDir),
env: buildSidecarEnv(withBundledRipgrepPath(env, desktopRoot), h5DistDir),
}
}
@ -501,7 +539,7 @@ export function createAdapterPlan({
command: resolveSidecarExecutable(desktopRoot),
args: ['adapters', '--app-root', appRoot, flag],
env: {
...buildSidecarEnv(env, h5DistDir),
...buildSidecarEnv(withBundledRipgrepPath(env, desktopRoot), h5DistDir),
ADAPTER_SERVER_URL: httpToWebSocketUrl(serverUrl),
},
}

View File

@ -82,6 +82,7 @@
"prepare:node-pty": "bun run ./scripts/prepare-node-pty.ts",
"build:electron": "bun run prepare:node-pty && bun build ./electron/main.ts --outfile ./electron-dist/main.cjs --target node --format cjs --external electron --external node-pty && bun build ./electron/preload.ts --outfile ./electron-dist/preload.cjs --target node --format cjs --external electron && bun build ./electron/preview-preload.ts --outfile ./electron-dist/preview-preload.cjs --target node --format cjs --external electron",
"build:preview-agent": "bun run ./scripts/build-preview-agent.ts",
"prepare:ripgrep": "bun run ./scripts/prepare-ripgrep.ts",
"build:sidecars": "bun run ./scripts/build-sidecars.ts",
"clean:electron-output": "bun run ./scripts/clean-electron-output.ts",
"build:macos-arm64": "bash ./scripts/build-macos-arm64.sh",

View File

@ -1,5 +1,6 @@
import { mkdir } from 'node:fs/promises'
import { chmod, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { getBundledRipgrepName } from './prepare-ripgrep'
const desktopRoot = path.resolve(import.meta.dir, '..')
const repoRoot = path.resolve(desktopRoot, '..')
@ -26,6 +27,7 @@ if (scanExit !== 0) {
}
await mkdir(binariesDir, { recursive: true })
await stageHostRipgrepForOfflineBuild()
// 单一合并 sidecarserver / cli 共享一份 bun runtime + 共享依赖代码。
// 调用方Electron sidecar manager / legacy Tauri host / conversationService
@ -39,6 +41,71 @@ await compileExecutable({
console.log(`[build-sidecars] Built desktop sidecar for ${targetTriple} (${bunTarget})`)
async function stageHostRipgrepForOfflineBuild() {
const destination = path.join(binariesDir, getBundledRipgrepName(targetTriple))
const manifestPath = path.join(binariesDir, 'ripgrep-manifest.json')
if (
await Bun.file(destination).exists() &&
await hasMatchingRipgrepManifest(manifestPath, targetTriple)
) {
return
}
const hostTriple = await detectHostTriple()
if (hostTriple !== targetTriple) {
throw new Error(
`[build-sidecars] bundled ripgrep missing for cross target ${targetTriple}; run prepare:ripgrep first`,
)
}
const systemRipgrep = Bun.which('rg')
if (!systemRipgrep) {
console.warn(
'[build-sidecars] system ripgrep unavailable for offline build; release builds must run prepare:ripgrep',
)
return
}
const versionCheck = Bun.spawn([systemRipgrep, '--version'], {
stdout: 'pipe',
stderr: 'ignore',
})
const [versionOutput, versionExit] = await Promise.all([
new Response(versionCheck.stdout).text(),
versionCheck.exited,
])
if (versionExit !== 0 || !versionOutput.startsWith('ripgrep ')) {
console.warn(`[build-sidecars] refusing unusable system ripgrep at ${systemRipgrep}`)
return
}
await copyFile(systemRipgrep, destination)
await chmod(destination, 0o755)
await writeFile(
manifestPath,
`${JSON.stringify({
version: versionOutput.split(/\r?\n/, 1)[0]?.replace(/^ripgrep\s+/, ''),
targetTriple,
source: 'system-dev-fallback',
}, null, 2)}\n`,
)
console.log(`[build-sidecars] staged host ripgrep for offline build: ${destination}`)
}
async function hasMatchingRipgrepManifest(
manifestPath: string,
expectedTargetTriple: string,
): Promise<boolean> {
try {
const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as {
targetTriple?: string
}
return manifest.targetTriple === expectedTargetTriple
} catch {
return false
}
}
async function detectHostTriple() {
const platform = process.platform
const arch = process.arch

View File

@ -0,0 +1,73 @@
import { afterEach, describe, expect, test } from 'vitest'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import {
getBundledRipgrepName,
getRipgrepAsset,
getRipgrepDownloadUrl,
prepareRipgrep,
RIPGREP_VERSION,
} from './prepare-ripgrep'
const tempDirs: string[] = []
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map(dir =>
rm(dir, { recursive: true, force: true })))
})
const supportedTargets = [
'aarch64-apple-darwin',
'x86_64-apple-darwin',
'aarch64-pc-windows-msvc',
'x86_64-pc-windows-msvc',
'aarch64-unknown-linux-gnu',
'x86_64-unknown-linux-gnu',
]
describe('prepare-ripgrep target mapping', () => {
test('pins checksummed official assets for every desktop release target', () => {
for (const target of supportedTargets) {
const asset = getRipgrepAsset(target)
expect(asset.sha256).toMatch(/^[a-f0-9]{64}$/)
expect(asset.archiveName).toContain(`ripgrep-${RIPGREP_VERSION}-`)
expect(getRipgrepDownloadUrl(target)).toBe(
`https://github.com/BurntSushi/ripgrep/releases/download/${RIPGREP_VERSION}/${asset.archiveName}`,
)
}
})
test('uses a static musl archive for the x64 Linux release', () => {
expect(getRipgrepAsset('x86_64-unknown-linux-gnu').assetTriple).toBe(
'x86_64-unknown-linux-musl',
)
})
test('stages executables beside the matching sidecar', () => {
expect(getBundledRipgrepName('aarch64-apple-darwin')).toBe(
'rg',
)
expect(getBundledRipgrepName('x86_64-pc-windows-msvc')).toBe(
'rg.exe',
)
})
test('rejects unsupported targets', () => {
expect(() => getRipgrepAsset('armv7-unknown-linux-gnu')).toThrow(
'Unsupported target triple',
)
})
test('rejects a local archive that does not match the pinned checksum', async () => {
const fixtureDir = await mkdtemp(path.join(tmpdir(), 'cc-haha-ripgrep-test-'))
tempDirs.push(fixtureDir)
const archivePath = path.join(fixtureDir, 'ripgrep.tar.gz')
await writeFile(archivePath, 'not an official ripgrep archive')
await expect(prepareRipgrep({
targetTriple: 'aarch64-apple-darwin',
archivePath,
})).rejects.toThrow('SHA256 mismatch')
})
})

View File

@ -0,0 +1,187 @@
import { createHash } from 'node:crypto'
import {
chmod,
copyFile,
mkdir,
mkdtemp,
readFile,
readdir,
rm,
writeFile,
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
export const RIPGREP_VERSION = '15.1.0'
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
type RipgrepAsset = {
assetTriple: string
archiveName: string
sha256: string
executableName: 'rg' | 'rg.exe'
}
const RIPGREP_ASSETS: Record<string, RipgrepAsset> = {
'aarch64-apple-darwin': {
assetTriple: 'aarch64-apple-darwin',
archiveName: `ripgrep-${RIPGREP_VERSION}-aarch64-apple-darwin.tar.gz`,
sha256: '378e973289176ca0c6054054ee7f631a065874a352bf43f0fa60ef079b6ba715',
executableName: 'rg',
},
'x86_64-apple-darwin': {
assetTriple: 'x86_64-apple-darwin',
archiveName: `ripgrep-${RIPGREP_VERSION}-x86_64-apple-darwin.tar.gz`,
sha256: '64811cb24e77cac3057d6c40b63ac9becf9082eedd54ca411b475b755d334882',
executableName: 'rg',
},
'aarch64-pc-windows-msvc': {
assetTriple: 'aarch64-pc-windows-msvc',
archiveName: `ripgrep-${RIPGREP_VERSION}-aarch64-pc-windows-msvc.zip`,
sha256: '00d931fb5237c9696ca49308818edb76d8eb6fc132761cb2a1bd616b2df02f8e',
executableName: 'rg.exe',
},
'x86_64-pc-windows-msvc': {
assetTriple: 'x86_64-pc-windows-msvc',
archiveName: `ripgrep-${RIPGREP_VERSION}-x86_64-pc-windows-msvc.zip`,
sha256: '124510b94b6baa3380d051fdf4650eaa80a302c876d611e9dba0b2e18d87493a',
executableName: 'rg.exe',
},
'aarch64-unknown-linux-gnu': {
assetTriple: 'aarch64-unknown-linux-gnu',
archiveName: `ripgrep-${RIPGREP_VERSION}-aarch64-unknown-linux-gnu.tar.gz`,
sha256: '2b661c6ef508e902f388e9098d9c4c5aca72c87b55922d94abdba830b4dc885e',
executableName: 'rg',
},
// The official musl build is static PIE and is the most portable x64 Linux asset.
'x86_64-unknown-linux-gnu': {
assetTriple: 'x86_64-unknown-linux-musl',
archiveName: `ripgrep-${RIPGREP_VERSION}-x86_64-unknown-linux-musl.tar.gz`,
sha256: '1c9297be4a084eea7ecaedf93eb03d058d6faae29bbc57ecdaf5063921491599',
executableName: 'rg',
},
}
export function getRipgrepAsset(targetTriple: string): RipgrepAsset {
const asset = RIPGREP_ASSETS[targetTriple]
if (!asset) {
throw new Error(`[prepare-ripgrep] Unsupported target triple: ${targetTriple}`)
}
return asset
}
export function getBundledRipgrepName(targetTriple: string): string {
return targetTriple.includes('windows') ? 'rg.exe' : 'rg'
}
export function getRipgrepDownloadUrl(targetTriple: string): string {
const asset = getRipgrepAsset(targetTriple)
return `https://github.com/BurntSushi/ripgrep/releases/download/${RIPGREP_VERSION}/${asset.archiveName}`
}
export async function prepareRipgrep({
targetTriple,
archivePath = process.env.CC_HAHA_RIPGREP_ARCHIVE,
}: {
targetTriple: string
archivePath?: string
}): Promise<string> {
const asset = getRipgrepAsset(targetTriple)
const desktopRoot = path.resolve(scriptDirectory, '..')
const binariesDir = path.join(desktopRoot, 'src-tauri', 'binaries')
const temporaryDir = await mkdtemp(path.join(tmpdir(), 'cc-haha-ripgrep-'))
try {
const downloadedArchive = path.join(temporaryDir, asset.archiveName)
if (archivePath) {
await copyFile(path.resolve(archivePath), downloadedArchive)
} else {
const response = await fetch(getRipgrepDownloadUrl(targetTriple), {
redirect: 'follow',
})
if (!response.ok) {
throw new Error(
`[prepare-ripgrep] Download failed (${response.status} ${response.statusText})`,
)
}
await writeFile(downloadedArchive, Buffer.from(await response.arrayBuffer()))
}
const archiveHash = createHash('sha256')
.update(await readFile(downloadedArchive))
.digest('hex')
if (archiveHash !== asset.sha256) {
throw new Error(
`[prepare-ripgrep] SHA256 mismatch for ${asset.archiveName}: expected ${asset.sha256}, got ${archiveHash}`,
)
}
const extractDir = path.join(temporaryDir, 'extracted')
await mkdir(extractDir, { recursive: true })
const extract = Bun.spawn(['tar', '-xf', downloadedArchive, '-C', extractDir], {
stdout: 'inherit',
stderr: 'inherit',
})
const extractExit = await extract.exited
if (extractExit !== 0) {
throw new Error(`[prepare-ripgrep] Failed to extract ${asset.archiveName} (exit ${extractExit})`)
}
const archiveRoot = path.join(
extractDir,
`ripgrep-${RIPGREP_VERSION}-${asset.assetTriple}`,
)
const extractedExecutable = path.join(archiveRoot, asset.executableName)
const destination = path.join(binariesDir, getBundledRipgrepName(targetTriple))
await mkdir(binariesDir, { recursive: true })
for (const entry of await readdir(binariesDir)) {
if (
(entry === 'rg' || entry === 'rg.exe' || entry.startsWith('rg-')) &&
entry !== path.basename(destination)
) {
await rm(path.join(binariesDir, entry), { force: true })
}
}
await copyFile(extractedExecutable, destination)
await chmod(destination, 0o755)
await writeFile(
path.join(binariesDir, 'ripgrep-manifest.json'),
`${JSON.stringify({
version: RIPGREP_VERSION,
targetTriple,
archiveName: asset.archiveName,
sha256: asset.sha256,
}, null, 2)}\n`,
)
const licensesDir = path.join(binariesDir, 'ripgrep-licenses')
await mkdir(licensesDir, { recursive: true })
await Promise.all([
copyFile(path.join(archiveRoot, 'COPYING'), path.join(licensesDir, 'COPYING')),
copyFile(path.join(archiveRoot, 'LICENSE-MIT'), path.join(licensesDir, 'LICENSE-MIT')),
copyFile(path.join(archiveRoot, 'UNLICENSE'), path.join(licensesDir, 'UNLICENSE')),
])
console.log(`[prepare-ripgrep] ${destination}`)
return destination
} finally {
await rm(temporaryDir, { recursive: true, force: true })
}
}
function parseTargetTriple(argv: string[]): string | null {
const index = argv.indexOf('--target-triple')
if (index >= 0) return argv[index + 1] ?? null
return process.env.SIDECAR_TARGET_TRIPLE ?? null
}
if (import.meta.main) {
const targetTriple = parseTargetTriple(process.argv.slice(2))
if (!targetTriple) {
throw new Error('[prepare-ripgrep] Pass --target-triple or set SIDECAR_TARGET_TRIPLE')
}
await prepareRipgrep({ targetTriple })
}

View File

@ -62,6 +62,24 @@ describe('release desktop workflow', () => {
}
})
test('desktop build workflows prepare the pinned ripgrep asset before sidecars', () => {
for (const workflowPath of [
'.github/workflows/build-desktop-dev.yml',
'.github/workflows/release-desktop.yml',
]) {
const workflow = readFileSync(workflowPath, 'utf8')
const prepareStep = extractStep(workflow, 'Prepare bundled ripgrep')
expect(prepareStep, workflowPath).toContain(
'SIDECAR_TARGET_TRIPLE: ${{ matrix.target_triple }}',
)
expect(prepareStep, workflowPath).toContain('bun run prepare:ripgrep')
expect(workflow.indexOf('Prepare bundled ripgrep')).toBeLessThan(
workflow.indexOf('Build sidecars'),
)
}
})
test('development desktop artifacts exclude unpacked macOS app bundles and updater-only files', () => {
const workflow = readFileSync('.github/workflows/build-desktop-dev.yml', 'utf8')
const collectStep = workflow.match(

View File

@ -1,7 +1,7 @@
import { afterEach, describe, expect, test } from 'bun:test'
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { basename, dirname, join } from 'node:path'
import {
currentPackageSmokeArch,
currentPackageSmokePlatform,
@ -31,6 +31,21 @@ function writeFile(rootDir: string, relativePath: string, content = 'ok') {
const fullPath = join(rootDir, relativePath)
mkdirSync(dirname(fullPath), { recursive: true })
writeFileSync(fullPath, content)
const fileName = basename(fullPath)
if (fileName.startsWith('claude-sidecar-')) {
const ripgrepName = fileName.endsWith('.exe') ? 'rg.exe' : 'rg'
writeFileSync(join(dirname(fullPath), ripgrepName), content)
writeFileSync(
join(dirname(fullPath), 'ripgrep-manifest.json'),
JSON.stringify({ targetTriple: fileName.replace(/^claude-sidecar-/, '').replace(/\.exe$/, '') }),
)
const licensesDir = join(dirname(fullPath), 'ripgrep-licenses')
mkdirSync(licensesDir, { recursive: true })
for (const licenseName of ['COPYING', 'LICENSE-MIT', 'UNLICENSE']) {
writeFileSync(join(licensesDir, licenseName), content)
}
}
}
const tempDirs: string[] = []
@ -104,6 +119,34 @@ describe('packaged artifact inspection', () => {
expect(report.passedChecks.some((check) => check.label === 'macOS unpacked H5 shell')).toBe(true)
})
test('fails macOS inspection when bundled ripgrep is missing', async () => {
const rootDir = createRepoRoot()
tempDirs.push(rootDir)
const appRoot = 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app'
const sidecarRoot = `${appRoot}/Contents/Resources/app.asar.unpacked/src-tauri/binaries`
writeFile(rootDir, `${appRoot}/Contents/Info.plist`)
writeFile(rootDir, `${appRoot}/Contents/MacOS/Claude Code Haha`)
writeFile(rootDir, `${appRoot}/Contents/Resources/app.asar`)
writeFile(rootDir, `${appRoot}/Contents/Resources/app.asar.unpacked/dist/index.html`)
writeFile(rootDir, `${sidecarRoot}/claude-sidecar-aarch64-apple-darwin`)
writeFile(rootDir, `${appRoot}/Contents/Resources/app.asar.unpacked/node_modules/node-pty/package.json`)
writeFile(rootDir, `${appRoot}/Contents/Resources/app.asar.unpacked/node_modules/node-pty/prebuilds/darwin-arm64/pty.node`)
writeFile(rootDir, `${appRoot}/Contents/Resources/app.asar.unpacked/node_modules/node-pty/prebuilds/darwin-arm64/spawn-helper`)
rmSync(join(rootDir, sidecarRoot, 'rg'))
const report = await inspectPackagedArtifacts(rootDir, {
platform: 'macos',
arch: 'arm64',
packageKind: 'dir',
})
expect(report.passed).toBe(false)
expect(report.missingChecks.some(
check => check.label === 'macOS bundled ripgrep binary',
)).toBe(true)
})
test('fails macOS inspection when the H5 shell is not unpacked for the sidecar', async () => {
const rootDir = createRepoRoot()
tempDirs.push(rootDir)

View File

@ -171,6 +171,33 @@ function normalizePath(targetPath: string) {
return targetPath.replaceAll('\\', '/')
}
function bundledRipgrepNeedle(platform: PackageSmokePlatform) {
return platform === 'windows' ? '/rg.exe' : '/rg'
}
function addBundledRipgrepLicenseChecks(
report: PackageSmokeReport,
rootDir: string,
sidecarDir: string,
platformLabel: string,
) {
addPresenceCheck(
report,
rootDir,
`${platformLabel} ripgrep build manifest`,
join(sidecarDir, 'ripgrep-manifest.json'),
)
const licensesDir = join(sidecarDir, 'ripgrep-licenses')
for (const fileName of ['COPYING', 'LICENSE-MIT', 'UNLICENSE']) {
addPresenceCheck(
report,
rootDir,
`${platformLabel} ripgrep license (${fileName})`,
join(licensesDir, fileName),
)
}
}
function walkPaths(rootDir: string, options?: { directoriesOnly?: boolean }) {
if (!existsSync(rootDir)) {
return [] as string[]
@ -511,6 +538,7 @@ function inspectMacosArtifacts(rootDir: string, report: PackageSmokeReport, opti
const unpackedDir = join(resourcesDir, 'app.asar.unpacked')
const nodePtyDir = join(unpackedDir, 'node_modules', 'node-pty')
const prebuildsDir = join(nodePtyDir, 'prebuilds')
const sidecarDir = join(unpackedDir, 'src-tauri', 'binaries')
addPresenceCheck(report, rootDir, 'macOS Info.plist', join(contentsDir, 'Info.plist'))
addPresenceCheck(report, rootDir, 'macOS app executable', join(contentsDir, 'MacOS', report.productName))
@ -528,8 +556,17 @@ function inspectMacosArtifacts(rootDir: string, report: PackageSmokeReport, opti
report,
rootDir,
'macOS unpacked sidecar binary',
findMatches(join(unpackedDir, 'src-tauri', 'binaries'), (candidate) => normalizePath(candidate).includes('/claude-sidecar-')),
join(unpackedDir, 'src-tauri', 'binaries'),
findMatches(sidecarDir, (candidate) => normalizePath(candidate).includes('/claude-sidecar-')),
sidecarDir,
)
addBundledRipgrepLicenseChecks(report, rootDir, sidecarDir, 'macOS')
addMatchCheck(
report,
rootDir,
'macOS bundled ripgrep binary',
findMatches(sidecarDir, candidate =>
normalizePath(candidate).endsWith(bundledRipgrepNeedle('macos'))),
sidecarDir,
)
addMatchCheck(
report,
@ -631,6 +668,15 @@ function inspectWindowsArtifacts(rootDir: string, report: PackageSmokeReport) {
? join(sidecarDir, sidecarNeedle.slice(1))
: sidecarDir,
)
addMatchCheck(
report,
rootDir,
report.arch ? `Windows ${report.arch} bundled ripgrep binary` : 'Windows bundled ripgrep binary',
findMatches(sidecarDir, candidate =>
normalizePath(candidate).endsWith(bundledRipgrepNeedle('windows'))),
sidecarDir,
)
addBundledRipgrepLicenseChecks(report, rootDir, sidecarDir, 'Windows')
addMatchCheck(
report,
rootDir,
@ -726,6 +772,7 @@ function inspectLinuxArtifacts(rootDir: string, report: PackageSmokeReport) {
const resourcesDir = join(unpackedDir, 'resources')
const unpackedResourcesDir = join(resourcesDir, 'app.asar.unpacked')
const nodePtyDir = join(unpackedResourcesDir, 'node_modules', 'node-pty')
const sidecarDir = join(unpackedResourcesDir, 'src-tauri', 'binaries')
addPresenceCheck(report, rootDir, 'Linux app.asar', join(resourcesDir, 'app.asar'))
addInstalledUpdateMetadataCheck(
report,
@ -739,8 +786,17 @@ function inspectLinuxArtifacts(rootDir: string, report: PackageSmokeReport) {
report,
rootDir,
'Linux unpacked sidecar binary',
findMatches(join(unpackedResourcesDir, 'src-tauri', 'binaries'), (candidate) => normalizePath(candidate).includes('/claude-sidecar-')),
join(unpackedResourcesDir, 'src-tauri', 'binaries'),
findMatches(sidecarDir, (candidate) => normalizePath(candidate).includes('/claude-sidecar-')),
sidecarDir,
)
addBundledRipgrepLicenseChecks(report, rootDir, sidecarDir, 'Linux')
addMatchCheck(
report,
rootDir,
'Linux bundled ripgrep binary',
findMatches(sidecarDir, candidate =>
normalizePath(candidate).endsWith(bundledRipgrepNeedle('linux'))),
sidecarDir,
)
addMatchCheck(
report,

View File

@ -4,7 +4,10 @@ import * as fs from 'fs'
import * as fsp from 'fs/promises'
import * as os from 'os'
import * as path from 'path'
import { handleFilesystemRoute } from '../api/filesystem.js'
import {
getProjectSearchFiles,
handleFilesystemRoute,
} from '../api/filesystem.js'
import { clearFilesystemAccessRootsForTests } from '../services/filesystemAccessRoots.js'
import { getRepositoryContext } from '../services/repositoryLaunchService.js'
@ -218,6 +221,56 @@ describe('filesystem API', () => {
expect(body.entries.some((entry) => entry.relativePath === 'node_modules/pkg/cache-result.js')).toBe(false)
})
it('keeps non-git file suggestions working when ripgrep cannot start', async () => {
const fixtureDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'claude-filesystem-fallback-'))
cleanupDirs.add(fixtureDir)
await fsp.mkdir(path.join(fixtureDir, 'app'), { recursive: true })
await fsp.mkdir(path.join(fixtureDir, 'app', 'generated'), { recursive: true })
await fsp.mkdir(path.join(fixtureDir, 'node_modules', 'pkg'), { recursive: true })
await fsp.mkdir(path.join(fixtureDir, '.git', 'objects'), { recursive: true })
await fsp.writeFile(path.join(fixtureDir, '.gitignore'), 'node_modules/\n')
await fsp.writeFile(path.join(fixtureDir, 'app', '.gitignore'), 'generated/\n')
await fsp.writeFile(path.join(fixtureDir, 'app', 'search-result.ts'), 'export {}')
await fsp.writeFile(path.join(fixtureDir, 'app', 'generated', 'search-result.ts'), '')
await fsp.writeFile(path.join(fixtureDir, 'node_modules', 'pkg', 'search-result.js'), '')
await fsp.writeFile(path.join(fixtureDir, '.git', 'objects', 'search-result'), '')
const files = await getProjectSearchFiles(fixtureDir, {
ripGrepFn: async () => {
throw new Error('ripgrep unavailable')
},
})
expect(files).toContain('app/search-result.ts')
expect(files).not.toContain('app/generated/search-result.ts')
expect(files).not.toContain('node_modules/pkg/search-result.js')
expect(files).not.toContain('.git/objects/search-result')
})
it('does not let unrelated files exhaust the fallback search budget', async () => {
const fixtureDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'claude-filesystem-fair-fallback-'))
cleanupDirs.add(fixtureDir)
await fsp.mkdir(path.join(fixtureDir, 'a-target'), { recursive: true })
await fsp.mkdir(path.join(fixtureDir, 'z-heavy'), { recursive: true })
await fsp.writeFile(path.join(fixtureDir, 'a-target', 'needle.ts'), '')
await Promise.all(
Array.from({ length: 10 }, (_, index) =>
fsp.writeFile(path.join(fixtureDir, 'z-heavy', `cache-${index}.tmp`), '')),
)
const files = await getProjectSearchFiles(fixtureDir, {
ripGrepFn: async () => {
throw new Error('ripgrep unavailable')
},
fallbackOptions: {
searchQuery: 'needle',
maxFiles: 1,
},
})
expect(files).toEqual(['a-target/needle.ts'])
})
it('accepts /private/tmp aliases on macOS for browsing and file serving', async () => {
if (process.platform !== 'darwin') return

View File

@ -266,7 +266,9 @@ describe('SearchService.searchSessions', () => {
await writeSessionFile('proj-a', 'session-13', [
{ type: 'user', uuid: 'u1', message: { role: 'user', content: 'fallbackword works fine' } },
])
;(service as unknown as { commandExists: () => Promise<boolean> }).commandExists = async () => false
service = new SearchService({
resolveRipgrepCommand: () => ({ rgPath: '', rgArgs: [] }),
})
const { results } = await service.searchSessions('fallbackword')
expect(results).toHaveLength(1)
@ -274,6 +276,37 @@ describe('SearchService.searchSessions', () => {
expect(results[0].matches[0].snippet).toContain('fallbackword')
})
it('uses the packaged ripgrep resolver without PATH lookup', async () => {
await writeSessionFile('proj-a', 'session-14', [
{ type: 'user', uuid: 'u1', message: { role: 'user', content: 'bundledword works' } },
])
service = new SearchService({
resolveRipgrepCommand: () => ({
rgPath: '/packaged/rg',
rgArgs: ['--no-config'],
}),
})
let invokedCommand = ''
let invokedArgs: string[] = []
;(service as unknown as {
runCommand: (command: string, args: string[]) => Promise<string>
}).runCommand = async (command, args) => {
invokedCommand = command
invokedArgs = args
const filePath = path.join(tmpDir, 'projects', 'proj-a', 'session-14.jsonl')
return `${JSON.stringify({
type: 'match',
data: { path: { text: filePath }, line_number: 1 },
})}\n`
}
const { results } = await service.searchSessions('bundledword')
expect(invokedCommand).toBe('/packaged/rg')
expect(invokedArgs[0]).toBe('--no-config')
expect(results).toHaveLength(1)
})
it('returns empty when the projects dir does not exist', async () => {
await fs.rm(path.join(tmpDir, 'projects'), { recursive: true, force: true })
const { results, truncated } = await service.searchSessions('anything')

View File

@ -30,8 +30,29 @@ type ScoredFilesystemEntry = FilesystemEntry & {
}
const FILE_SEARCH_TIMEOUT_MS = 10_000
const FILE_SEARCH_FALLBACK_MAX_DIRECTORIES = 5_000
const FILE_SEARCH_FALLBACK_MAX_FILES = 20_000
const VCS_METADATA_DIRECTORY_NAMES = new Set(['.git', '.svn', '.hg', '.bzr', '.jj', '.sl'])
type ProjectSearchDependencies = {
ripGrepFn?: (
args: string[],
target: string,
abortSignal: AbortSignal,
) => Promise<string[]>
fallbackOptions?: {
searchQuery?: string
timeoutMs?: number
maxDirectories?: number
maxFiles?: number
}
}
type SearchIgnoreContext = {
baseRelativePath: string
matcher: ReturnType<typeof ignore>
}
const IMAGE_MIME_TYPES: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
@ -201,6 +222,7 @@ export async function searchFilesystemEntries(
rootPath,
options.includeFiles,
options.includeDirectories ?? true,
normalizedQuery,
)
const results = candidates
.map((entry): ScoredFilesystemEntry | null => {
@ -229,8 +251,11 @@ async function getSearchCandidates(
rootPath: string,
includeFiles: boolean,
includeDirectories: boolean,
searchQuery: string,
): Promise<FilesystemEntry[]> {
const files = await getProjectSearchFiles(rootPath)
const files = await getProjectSearchFiles(rootPath, {
fallbackOptions: { searchQuery },
})
const entries = new Map<string, FilesystemEntry>()
for (const filePath of files) {
@ -265,14 +290,29 @@ function addCandidate(entries: Map<string, FilesystemEntry>, rootPath: string, r
})
}
async function getProjectSearchFiles(rootPath: string): Promise<string[]> {
export async function getProjectSearchFiles(
rootPath: string,
dependencies: ProjectSearchDependencies = {},
): Promise<string[]> {
const respectGitignore = shouldRespectGitignore()
const gitFiles = await getFilesUsingGit(rootPath, respectGitignore)
if (gitFiles !== null) {
if (gitFiles !== null && gitFiles.length > 0) {
return gitFiles
}
return getFilesUsingRipgrep(rootPath, respectGitignore)
try {
return await getFilesUsingRipgrep(
rootPath,
respectGitignore,
dependencies.ripGrepFn ?? ripGrep,
)
} catch {
return getFilesUsingFilesystem(
rootPath,
respectGitignore,
dependencies.fallbackOptions,
)
}
}
function shouldRespectGitignore(): boolean {
@ -316,7 +356,11 @@ async function getFilesUsingGit(rootPath: string, respectGitignore: boolean): Pr
return [...new Set(normalized)]
}
async function getFilesUsingRipgrep(rootPath: string, respectGitignore: boolean): Promise<string[]> {
async function getFilesUsingRipgrep(
rootPath: string,
respectGitignore: boolean,
ripGrepFn: NonNullable<ProjectSearchDependencies['ripGrepFn']>,
): Promise<string[]> {
const rgArgs = [
'--files',
'--follow',
@ -338,7 +382,11 @@ async function getFilesUsingRipgrep(rootPath: string, respectGitignore: boolean)
rgArgs.push('--no-ignore-vcs')
}
const files = await ripGrep(rgArgs, rootPath, AbortSignal.timeout(FILE_SEARCH_TIMEOUT_MS))
const files = await ripGrepFn(
rgArgs,
rootPath,
AbortSignal.timeout(FILE_SEARCH_TIMEOUT_MS),
)
let normalized = files
.map(filePath => normalizeRipgrepPath(filePath, rootPath))
.filter((filePath): filePath is string => filePath !== null)
@ -351,6 +399,178 @@ async function getFilesUsingRipgrep(rootPath: string, respectGitignore: boolean)
return normalized
}
async function getFilesUsingFilesystem(
rootPath: string,
respectGitignore: boolean,
options: NonNullable<ProjectSearchDependencies['fallbackOptions']> = {},
): Promise<string[]> {
const deadline = Date.now() + (options.timeoutMs ?? FILE_SEARCH_TIMEOUT_MS)
const maxDirectories = options.maxDirectories ?? FILE_SEARCH_FALLBACK_MAX_DIRECTORIES
const maxFiles = options.maxFiles ?? FILE_SEARCH_FALLBACK_MAX_FILES
const searchQuery = normalizeSearchText(options.searchQuery ?? '')
const files: string[] = []
const directories: Array<{
absolutePath: string
relativePath: string
ignoreContexts: SearchIgnoreContext[]
}> = [{ absolutePath: rootPath, relativePath: '', ignoreContexts: [] }]
let directoryIndex = 0
let visitedDirectories = 0
while (
directoryIndex < directories.length &&
visitedDirectories < maxDirectories &&
files.length < maxFiles &&
Date.now() < deadline
) {
const current = directories[directoryIndex]
directoryIndex += 1
if (!current) continue
visitedDirectories += 1
let entries: fs.Dirent[]
try {
entries = await fs.promises.readdir(current.absolutePath, {
withFileTypes: true,
})
} catch {
continue
}
entries.sort((left, right) => {
if (left.isDirectory() !== right.isDirectory()) {
return left.isDirectory() ? -1 : 1
}
return left.name.localeCompare(right.name)
})
const localIgnore = loadDirectorySearchIgnorePatterns(
current.absolutePath,
respectGitignore,
)
const ignoreContexts = localIgnore
? [
...current.ignoreContexts,
{
baseRelativePath: current.relativePath,
matcher: localIgnore,
},
]
: current.ignoreContexts
for (const entry of entries) {
const relativePath = normalizeRelativePath(
current.relativePath
? `${current.relativePath}/${entry.name}`
: entry.name,
)
if (entry.isDirectory()) {
if (isVcsMetadataDirectoryName(entry.name)) continue
if (isIgnoredBySearchContexts(ignoreContexts, relativePath, true)) continue
directories.push({
absolutePath: path.join(current.absolutePath, entry.name),
relativePath,
ignoreContexts,
})
continue
}
if (
!entry.isFile() ||
isIgnoredBySearchContexts(ignoreContexts, relativePath, false) ||
(searchQuery && !matchesFilesystemFallbackQuery(relativePath, searchQuery))
) {
continue
}
files.push(relativePath)
if (files.length >= maxFiles) break
}
}
return files
}
function loadDirectorySearchIgnorePatterns(
directoryPath: string,
includeGitignore: boolean,
): ReturnType<typeof ignore> | null {
const matcher = ignore()
let hasPatterns = false
const ignoreFiles = includeGitignore
? ['.gitignore', '.ignore', '.rgignore']
: ['.ignore', '.rgignore']
for (const fileName of ignoreFiles) {
try {
matcher.add(fs.readFileSync(path.join(directoryPath, fileName), 'utf8'))
hasPatterns = true
} catch {
// Missing or unreadable ignore files should not break suggestions.
}
}
return hasPatterns ? matcher : null
}
function isIgnoredBySearchContexts(
contexts: SearchIgnoreContext[],
relativePath: string,
isDirectory: boolean,
): boolean {
let ignored = false
for (const context of contexts) {
const scopedPath = context.baseRelativePath
? path.posix.relative(context.baseRelativePath, relativePath)
: relativePath
if (!isRelativeInsideRoot(scopedPath)) continue
const result = context.matcher.test(
isDirectory ? `${scopedPath}/` : scopedPath,
)
if (result.ignored) ignored = true
if (result.unignored) ignored = false
}
return ignored
}
function matchesFilesystemFallbackQuery(
relativePath: string,
searchQuery: string,
): boolean {
if (
scoreFilesystemEntry(
path.posix.basename(relativePath),
relativePath,
searchQuery,
false,
) > 0
) {
return true
}
let directoryPath = path.posix.dirname(relativePath)
while (directoryPath !== '.') {
if (
scoreFilesystemEntry(
path.posix.basename(directoryPath),
directoryPath,
searchQuery,
true,
) > 0
) {
return true
}
const parentPath = path.posix.dirname(directoryPath)
if (parentPath === directoryPath) break
directoryPath = parentPath
}
return false
}
function normalizeGitPath(filePath: string, repoRoot: string, rootPath: string): string | null {
const relativePath = path.relative(rootPath, path.join(repoRoot, filePath))
const normalized = normalizeRelativePath(relativePath)

View File

@ -14,6 +14,7 @@ import {
getCommandMetadataDisplayText,
shouldHideCommandMetadataContent,
} from '../../utils/commandMetadata.js'
import { ripgrepCommand } from '../../utils/ripgrep.js'
export type SearchResult = {
file: string
@ -78,7 +79,15 @@ const SESSION_SNIPPET_WINDOW = 120
/** Guard ripgrep output against multi-MB single lines (base64 / big tool results). */
const RG_MAX_COLUMNS = 500
type SearchServiceDependencies = {
resolveRipgrepCommand?: typeof ripgrepCommand
}
export class SearchService {
constructor(
private readonly dependencies: SearchServiceDependencies = {},
) {}
// ---------------------------------------------------------------------------
// 工作区搜索
// ---------------------------------------------------------------------------
@ -100,9 +109,8 @@ export class SearchService {
const cwd = options?.cwd || process.cwd()
const maxResults = options?.maxResults || 200
// 尝试 rg降级到 grep
const hasRg = await this.commandExists('rg')
if (hasRg) {
// 尝试产品统一解析出的 rg降级到 grep
if (this.hasRipgrep()) {
try {
return await this.searchWithRipgrep(query, cwd, maxResults, options)
} catch {
@ -253,7 +261,7 @@ export class SearchService {
projectsDir: string,
opts: { caseSensitive: boolean },
): Promise<Map<string, Set<number>>> {
if (await this.commandExists('rg')) {
if (this.hasRipgrep()) {
try {
return await this.findSessionCandidatesWithRipgrep(query, projectsDir, opts)
} catch {
@ -272,7 +280,7 @@ export class SearchService {
if (!opts.caseSensitive) args.push('--ignore-case')
args.push('--', query, projectsDir)
const output = await this.runCommand('rg', args)
const output = await this.runRipgrep(args)
const map = new Map<string, Set<number>>()
for (const line of output.split('\n')) {
@ -489,7 +497,7 @@ export class SearchService {
args.push('--', query, cwd)
const output = await this.runCommand('rg', args)
const output = await this.runRipgrep(args)
return this.parseRipgrepJson(output, maxResults)
}
@ -709,10 +717,37 @@ export class SearchService {
// 工具方法
// ---------------------------------------------------------------------------
private hasRipgrep(): boolean {
return Boolean(this.getRipgrepCommand().rgPath)
}
private getRipgrepCommand(): ReturnType<typeof ripgrepCommand> {
return (this.dependencies.resolveRipgrepCommand ?? ripgrepCommand)()
}
private runRipgrep(args: string[]): Promise<string> {
const command = this.getRipgrepCommand()
if (!command.rgPath) {
return Promise.reject(new Error('ripgrep is unavailable'))
}
return this.runCommand(
command.rgPath,
[...command.rgArgs, ...args],
command.argv0 ? { argv0: command.argv0 } : undefined,
)
}
/** 运行外部命令,返回 stdout */
private runCommand(cmd: string, args: string[]): Promise<string> {
private runCommand(
cmd: string,
args: string[],
options?: { argv0?: string },
): Promise<string> {
return new Promise((resolve, reject) => {
const proc = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] })
const proc = spawn(cmd, args, {
stdio: ['ignore', 'pipe', 'pipe'],
...(options?.argv0 ? { argv0: options.argv0 } : {}),
})
const chunks: Buffer[] = []
const errorChunks: Buffer[] = []

View File

@ -2,12 +2,26 @@ import { afterEach, describe, expect, test } from 'bun:test'
import { rm, writeFile } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { isUsableBuiltinRipgrepPath } from '../ripgrep.js'
import {
CC_HAHA_RIPGREP_PATH_ENV,
getBundledRipgrepPath,
getRipgrepStatus,
isUsableBuiltinRipgrepPath,
resetRipgrepStateForTests,
ripgrepCommand,
} from '../ripgrep.js'
const tempFiles: string[] = []
const originalExplicitPath = process.env[CC_HAHA_RIPGREP_PATH_ENV]
afterEach(async () => {
await Promise.all(tempFiles.splice(0).map(path => rm(path, { force: true })))
if (originalExplicitPath === undefined) {
delete process.env[CC_HAHA_RIPGREP_PATH_ENV]
} else {
process.env[CC_HAHA_RIPGREP_PATH_ENV] = originalExplicitPath
}
resetRipgrepStateForTests()
})
describe('isUsableBuiltinRipgrepPath', () => {
@ -34,3 +48,55 @@ describe('isUsableBuiltinRipgrepPath', () => {
expect(isUsableBuiltinRipgrepPath(filePath)).toBe(true)
})
})
describe('packaged ripgrep resolution', () => {
test('maps every desktop runtime to the sidecar sibling binary', () => {
expect(getBundledRipgrepPath({
platform: 'darwin',
arch: 'arm64',
execPath: '/app/claude-sidecar-aarch64-apple-darwin',
})).toBe('/app/rg')
expect(getBundledRipgrepPath({
platform: 'darwin',
arch: 'x64',
execPath: '/app/claude-sidecar-x86_64-apple-darwin',
})).toBe('/app/rg')
expect(getBundledRipgrepPath({
platform: 'win32',
arch: 'x64',
execPath: 'C:\\app\\claude-sidecar-x86_64-pc-windows-msvc.exe',
})).toBe('C:\\app\\rg.exe')
expect(getBundledRipgrepPath({
platform: 'win32',
arch: 'arm64',
execPath: 'C:\\app\\claude-sidecar-aarch64-pc-windows-msvc.exe',
})).toBe('C:\\app\\rg.exe')
expect(getBundledRipgrepPath({
platform: 'linux',
arch: 'x64',
execPath: '/app/claude-sidecar-x86_64-unknown-linux-gnu',
})).toBe('/app/rg')
expect(getBundledRipgrepPath({
platform: 'linux',
arch: 'arm64',
execPath: '/app/claude-sidecar-aarch64-unknown-linux-gnu',
})).toBe('/app/rg')
})
test('prefers an explicit packaged executable over PATH lookup', async () => {
const filePath = join(tmpdir(), `cc-haha-explicit-rg-${Date.now()}`)
await writeFile(filePath, '')
tempFiles.push(filePath)
process.env[CC_HAHA_RIPGREP_PATH_ENV] = filePath
resetRipgrepStateForTests()
expect(getRipgrepStatus()).toMatchObject({
mode: 'builtin',
path: filePath,
})
expect(ripgrepCommand()).toMatchObject({
rgPath: filePath,
rgArgs: ['--no-config'],
})
})
})

View File

@ -31,6 +31,8 @@ type RipgrepConfig = {
argv0?: string
}
export const CC_HAHA_RIPGREP_PATH_ENV = 'CC_HAHA_RIPGREP_PATH'
function isBunVirtualPath(candidatePath: string): boolean {
const normalized = candidatePath.replace(/\\/g, '/')
return BUN_VIRTUAL_PATH_MARKERS.some(marker => normalized.includes(marker))
@ -113,6 +115,51 @@ function builtinRipgrepConfig(): RipgrepConfig {
return { mode: 'builtin', command, args: [] }
}
function runtimeTargetTriple(
platform: NodeJS.Platform,
arch: NodeJS.Architecture,
): string | null {
if (platform === 'darwin' && arch === 'arm64') return 'aarch64-apple-darwin'
if (platform === 'darwin' && arch === 'x64') return 'x86_64-apple-darwin'
if (platform === 'win32' && arch === 'arm64') return 'aarch64-pc-windows-msvc'
if (platform === 'win32' && arch === 'x64') return 'x86_64-pc-windows-msvc'
if (platform === 'linux' && arch === 'arm64') return 'aarch64-unknown-linux-gnu'
if (platform === 'linux' && arch === 'x64') return 'x86_64-unknown-linux-gnu'
return null
}
export function getBundledRipgrepPath({
platform = process.platform,
arch = process.arch,
execPath = process.execPath,
}: {
platform?: NodeJS.Platform
arch?: NodeJS.Architecture
execPath?: string
} = {}): string | null {
const targetTriple = runtimeTargetTriple(platform, arch)
if (!targetTriple) return null
const pathApi = platform === 'win32' ? path.win32 : path.posix
return pathApi.join(
pathApi.dirname(execPath),
platform === 'win32' ? 'rg.exe' : 'rg',
)
}
function packagedRipgrepConfig(): RipgrepConfig | null {
const explicitPath = process.env[CC_HAHA_RIPGREP_PATH_ENV]?.trim()
if (explicitPath && isUsableBuiltinRipgrepPath(explicitPath)) {
return { mode: 'builtin', command: explicitPath, args: ['--no-config'] }
}
const siblingPath = getBundledRipgrepPath()
if (siblingPath && isUsableBuiltinRipgrepPath(siblingPath)) {
return { mode: 'builtin', command: siblingPath, args: ['--no-config'] }
}
return null
}
const getRipgrepConfig = memoize((): RipgrepConfig => {
const userWantsSystemRipgrep = isEnvDefinedFalsy(
process.env.USE_BUILTIN_RIPGREP,
@ -129,6 +176,11 @@ const getRipgrepConfig = memoize((): RipgrepConfig => {
)
}
const packagedConfig = packagedRipgrepConfig()
if (packagedConfig) {
return packagedConfig
}
// In bundled (native) mode, ripgrep is statically compiled into bun-internal
// and dispatches based on argv[0]. We spawn ourselves with argv0='rg'.
if (isInBundledMode()) {
@ -795,3 +847,10 @@ async function codesignRipgrepIfNecessary() {
logError(e)
}
}
export function resetRipgrepStateForTests(): void {
getRipgrepConfig.cache.clear?.()
testRipgrepOnFirstUse.cache.clear?.()
ripgrepStatus = null
alreadyDoneSignCheck = false
}