mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
fix(search): bundle cross-platform ripgrep #923
This commit is contained in:
parent
86393ac179
commit
7e998e0365
6
.github/workflows/build-desktop-dev.yml
vendored
6
.github/workflows/build-desktop-dev.yml
vendored
@ -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:
|
||||
|
||||
6
.github/workflows/release-desktop.yml
vendored
6
.github/workflows/release-desktop.yml
vendored
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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),
|
||||
},
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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<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
|
||||
|
||||
73
desktop/scripts/prepare-ripgrep.test.ts
Normal file
73
desktop/scripts/prepare-ripgrep.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
187
desktop/scripts/prepare-ripgrep.ts
Normal file
187
desktop/scripts/prepare-ripgrep.ts
Normal 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 })
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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[] = []
|
||||
|
||||
|
||||
@ -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'],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user