diff --git a/.github/workflows/build-desktop-dev.yml b/.github/workflows/build-desktop-dev.yml index 3c98f999..3c5efe9a 100644 --- a/.github/workflows/build-desktop-dev.yml +++ b/.github/workflows/build-desktop-dev.yml @@ -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: diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 45267838..1d103164 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -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: diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index 60cc1820..a6fbbf28 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -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 diff --git a/desktop/electron/services/sidecarManager.test.ts b/desktop/electron/services/sidecarManager.test.ts index ba86a6bd..e9c351e5 100644 --- a/desktop/electron/services/sidecarManager.test.ts +++ b/desktop/electron/services/sidecarManager.test.ts @@ -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 { diff --git a/desktop/electron/services/sidecarManager.ts b/desktop/electron/services/sidecarManager.ts index dd5cb9d5..4a23a028 100644 --- a/desktop/electron/services/sidecarManager.ts +++ b/desktop/electron/services/sidecarManager.ts @@ -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), }, } diff --git a/desktop/package.json b/desktop/package.json index 4a8a3ba5..397f70e4 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -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", diff --git a/desktop/scripts/build-sidecars.ts b/desktop/scripts/build-sidecars.ts index ab8a039c..52cf79be 100644 --- a/desktop/scripts/build-sidecars.ts +++ b/desktop/scripts/build-sidecars.ts @@ -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() // 单一合并 sidecar:server / 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 { + 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 diff --git a/desktop/scripts/prepare-ripgrep.test.ts b/desktop/scripts/prepare-ripgrep.test.ts new file mode 100644 index 00000000..abaef226 --- /dev/null +++ b/desktop/scripts/prepare-ripgrep.test.ts @@ -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') + }) +}) diff --git a/desktop/scripts/prepare-ripgrep.ts b/desktop/scripts/prepare-ripgrep.ts new file mode 100644 index 00000000..33fabeaa --- /dev/null +++ b/desktop/scripts/prepare-ripgrep.ts @@ -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 = { + '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 { + 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 }) +} diff --git a/scripts/pr/release-workflow.test.ts b/scripts/pr/release-workflow.test.ts index e92bfccd..b8f53e53 100644 --- a/scripts/pr/release-workflow.test.ts +++ b/scripts/pr/release-workflow.test.ts @@ -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( diff --git a/scripts/quality-gate/package-smoke/index.test.ts b/scripts/quality-gate/package-smoke/index.test.ts index b451eef8..0fc15860 100644 --- a/scripts/quality-gate/package-smoke/index.test.ts +++ b/scripts/quality-gate/package-smoke/index.test.ts @@ -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) diff --git a/scripts/quality-gate/package-smoke/index.ts b/scripts/quality-gate/package-smoke/index.ts index ce35cea9..8a27e2f4 100644 --- a/scripts/quality-gate/package-smoke/index.ts +++ b/scripts/quality-gate/package-smoke/index.ts @@ -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, diff --git a/src/server/__tests__/filesystem.test.ts b/src/server/__tests__/filesystem.test.ts index 200f1b92..e619d720 100644 --- a/src/server/__tests__/filesystem.test.ts +++ b/src/server/__tests__/filesystem.test.ts @@ -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 diff --git a/src/server/__tests__/searchService.sessions.test.ts b/src/server/__tests__/searchService.sessions.test.ts index 6268a2a4..91836707 100644 --- a/src/server/__tests__/searchService.sessions.test.ts +++ b/src/server/__tests__/searchService.sessions.test.ts @@ -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 }).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 + }).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') diff --git a/src/server/api/filesystem.ts b/src/server/api/filesystem.ts index edac419e..a0c35f84 100644 --- a/src/server/api/filesystem.ts +++ b/src/server/api/filesystem.ts @@ -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 + fallbackOptions?: { + searchQuery?: string + timeoutMs?: number + maxDirectories?: number + maxFiles?: number + } +} + +type SearchIgnoreContext = { + baseRelativePath: string + matcher: ReturnType +} + const IMAGE_MIME_TYPES: Record = { '.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 { - const files = await getProjectSearchFiles(rootPath) + const files = await getProjectSearchFiles(rootPath, { + fallbackOptions: { searchQuery }, + }) const entries = new Map() for (const filePath of files) { @@ -265,14 +290,29 @@ function addCandidate(entries: Map, rootPath: string, r }) } -async function getProjectSearchFiles(rootPath: string): Promise { +export async function getProjectSearchFiles( + rootPath: string, + dependencies: ProjectSearchDependencies = {}, +): Promise { 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 { +async function getFilesUsingRipgrep( + rootPath: string, + respectGitignore: boolean, + ripGrepFn: NonNullable, +): Promise { 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 = {}, +): Promise { + 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 | 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) diff --git a/src/server/services/searchService.ts b/src/server/services/searchService.ts index f98ff462..f2199a01 100644 --- a/src/server/services/searchService.ts +++ b/src/server/services/searchService.ts @@ -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>> { - 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>() 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 { + return (this.dependencies.resolveRipgrepCommand ?? ripgrepCommand)() + } + + private runRipgrep(args: string[]): Promise { + 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 { + private runCommand( + cmd: string, + args: string[], + options?: { argv0?: string }, + ): Promise { 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[] = [] diff --git a/src/utils/__tests__/ripgrep.test.ts b/src/utils/__tests__/ripgrep.test.ts index dc5c147d..60a0f230 100644 --- a/src/utils/__tests__/ripgrep.test.ts +++ b/src/utils/__tests__/ripgrep.test.ts @@ -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'], + }) + }) +}) diff --git a/src/utils/ripgrep.ts b/src/utils/ripgrep.ts index 8128aae7..96d8166a 100644 --- a/src/utils/ripgrep.ts +++ b/src/utils/ripgrep.ts @@ -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 +}