mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
fix: handle unavailable ripgrep paths
This commit is contained in:
parent
869aeb0e77
commit
f86eedb3b5
@ -603,19 +603,22 @@ fn start_server_sidecar(app: &AppHandle) -> Result<ServerRuntime, String> {
|
||||
let app_root_arg = app_root.to_string_lossy().to_string();
|
||||
|
||||
// 单一合并 sidecar:第一个参数选 server / cli / adapters 模式。
|
||||
let sidecar = app
|
||||
let mut sidecar = app
|
||||
.shell()
|
||||
.sidecar("claude-sidecar")
|
||||
.map_err(|err| format!("resolve sidecar: {err}"))?
|
||||
.args([
|
||||
"server",
|
||||
"--app-root",
|
||||
&app_root_arg,
|
||||
"--host",
|
||||
host,
|
||||
"--port",
|
||||
&port.to_string(),
|
||||
]);
|
||||
.map_err(|err| format!("resolve sidecar: {err}"))?;
|
||||
for (key, value) in terminal_environment(&default_shell()) {
|
||||
sidecar = sidecar.env(key, value);
|
||||
}
|
||||
let sidecar = sidecar.args([
|
||||
"server",
|
||||
"--app-root",
|
||||
&app_root_arg,
|
||||
"--host",
|
||||
host,
|
||||
"--port",
|
||||
&port.to_string(),
|
||||
]);
|
||||
|
||||
let startup_logs = Arc::new(Mutex::new(VecDeque::new()));
|
||||
let logs_for_task = Arc::clone(&startup_logs);
|
||||
@ -707,18 +710,20 @@ fn start_adapters_sidecar(app: &AppHandle) -> Result<CommandChild, String> {
|
||||
server_http_url.clone()
|
||||
};
|
||||
|
||||
let sidecar = app
|
||||
let mut sidecar = app
|
||||
.shell()
|
||||
.sidecar("claude-sidecar")
|
||||
.map_err(|err| format!("resolve sidecar: {err}"))?
|
||||
.env("ADAPTER_SERVER_URL", &server_ws_url)
|
||||
.args([
|
||||
"adapters",
|
||||
"--app-root",
|
||||
&app_root_arg,
|
||||
"--feishu",
|
||||
"--telegram",
|
||||
]);
|
||||
.map_err(|err| format!("resolve sidecar: {err}"))?;
|
||||
for (key, value) in terminal_environment(&default_shell()) {
|
||||
sidecar = sidecar.env(key, value);
|
||||
}
|
||||
let sidecar = sidecar.env("ADAPTER_SERVER_URL", &server_ws_url).args([
|
||||
"adapters",
|
||||
"--app-root",
|
||||
&app_root_arg,
|
||||
"--feishu",
|
||||
"--telegram",
|
||||
]);
|
||||
|
||||
let (mut rx, child) = sidecar
|
||||
.spawn()
|
||||
|
||||
36
src/utils/__tests__/ripgrep.test.ts
Normal file
36
src/utils/__tests__/ripgrep.test.ts
Normal file
@ -0,0 +1,36 @@
|
||||
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'
|
||||
|
||||
const tempFiles: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempFiles.splice(0).map(path => rm(path, { force: true })))
|
||||
})
|
||||
|
||||
describe('isUsableBuiltinRipgrepPath', () => {
|
||||
test('rejects Bun virtual filesystem paths', () => {
|
||||
expect(
|
||||
isUsableBuiltinRipgrepPath('B:\\~BUN\\root\\vendor\\ripgrep\\x64-win32\\rg.exe'),
|
||||
).toBe(false)
|
||||
expect(
|
||||
isUsableBuiltinRipgrepPath('/$bunfs/root/vendor/ripgrep/arm64-darwin/rg'),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test('rejects missing paths', () => {
|
||||
expect(
|
||||
isUsableBuiltinRipgrepPath(join(tmpdir(), 'missing-cc-haha-rg')),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test('accepts real filesystem paths', async () => {
|
||||
const filePath = join(tmpdir(), `cc-haha-rg-${Date.now()}`)
|
||||
await writeFile(filePath, '')
|
||||
tempFiles.push(filePath)
|
||||
|
||||
expect(isUsableBuiltinRipgrepPath(filePath)).toBe(true)
|
||||
})
|
||||
})
|
||||
@ -1,5 +1,4 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { execa } from 'execa'
|
||||
import { mkdir, stat } from 'fs/promises'
|
||||
import * as os from 'os'
|
||||
import { join } from 'path'
|
||||
@ -15,7 +14,6 @@ import { getClaudeConfigHomeDir } from '../envUtils.js'
|
||||
import { pathExists } from '../file.js'
|
||||
import { getFsImplementation } from '../fsOperations.js'
|
||||
import { logError } from '../log.js'
|
||||
import { getPlatform } from '../platform.js'
|
||||
import { ripgrepCommand } from '../ripgrep.js'
|
||||
import { subprocessEnv } from '../subprocessEnv.js'
|
||||
import { quote } from './shellQuote.js'
|
||||
@ -65,8 +63,11 @@ function createArgv0ShellFunction(
|
||||
export function createRipgrepShellIntegration(): {
|
||||
type: 'alias' | 'function'
|
||||
snippet: string
|
||||
} {
|
||||
} | null {
|
||||
const rgCommand = ripgrepCommand()
|
||||
if (!rgCommand.rgPath) {
|
||||
return null
|
||||
}
|
||||
|
||||
// For embedded ripgrep (bun-internal), we need a shell function that sets argv0
|
||||
if (rgCommand.argv0) {
|
||||
@ -80,6 +81,12 @@ export function createRipgrepShellIntegration(): {
|
||||
}
|
||||
}
|
||||
|
||||
// If the selected command is the system rg from PATH, the snapshot should not
|
||||
// create a fallback alias. Once PATH is restored below, command -v rg is enough.
|
||||
if (rgCommand.rgPath === 'rg') {
|
||||
return null
|
||||
}
|
||||
|
||||
// For regular ripgrep, use a simple alias target
|
||||
const quotedPath = quote([rgCommand.rgPath])
|
||||
const quotedArgs = rgCommand.rgArgs.map(arg => quote([arg]))
|
||||
@ -267,52 +274,48 @@ function getUserSnapshotContent(configFile: string): string {
|
||||
* This content is always included regardless of user configuration
|
||||
*/
|
||||
async function getClaudeCodeSnapshotContent(): Promise<string> {
|
||||
// Get the appropriate PATH based on platform
|
||||
let pathValue = process.env.PATH
|
||||
if (getPlatform() === 'windows') {
|
||||
// On Windows with git-bash, read the Cygwin PATH
|
||||
const cygwinResult = await execa('echo $PATH', {
|
||||
shell: true,
|
||||
reject: false,
|
||||
})
|
||||
if (cygwinResult.exitCode === 0 && cygwinResult.stdout) {
|
||||
pathValue = cygwinResult.stdout.trim()
|
||||
}
|
||||
// Fall back to process.env.PATH if we can't get Cygwin PATH
|
||||
}
|
||||
|
||||
const rgIntegration = createRipgrepShellIntegration()
|
||||
|
||||
let content = ''
|
||||
|
||||
// Capture PATH from the shell after user config has been sourced. This keeps
|
||||
// macOS GUI launches from overwriting Homebrew paths with the app's minimal PATH.
|
||||
content += `
|
||||
# Add PATH to the file
|
||||
echo "# PATH" >> "$SNAPSHOT_FILE"
|
||||
printf 'export PATH=%q\\n' "$PATH" >> "$SNAPSHOT_FILE"
|
||||
`
|
||||
|
||||
// Check if rg is available, if not create an alias/function to bundled ripgrep
|
||||
// We use a subshell to unalias rg before checking, so that user aliases like
|
||||
// `alias rg='rg --smart-case'` don't shadow the real binary check. The subshell
|
||||
// ensures we don't modify the user's aliases in the parent shell.
|
||||
content += `
|
||||
if (rgIntegration !== null) {
|
||||
content += `
|
||||
# Check for rg availability
|
||||
echo "# Check for rg availability" >> "$SNAPSHOT_FILE"
|
||||
echo "if ! (unalias rg 2>/dev/null; command -v rg) >/dev/null 2>&1; then" >> "$SNAPSHOT_FILE"
|
||||
`
|
||||
|
||||
if (rgIntegration.type === 'function') {
|
||||
// For embedded ripgrep, write the function definition using heredoc
|
||||
content += `
|
||||
if (rgIntegration.type === 'function') {
|
||||
// For embedded ripgrep, write the function definition using heredoc
|
||||
content += `
|
||||
cat >> "$SNAPSHOT_FILE" << 'RIPGREP_FUNC_END'
|
||||
${rgIntegration.snippet}
|
||||
RIPGREP_FUNC_END
|
||||
`
|
||||
} else {
|
||||
// For regular ripgrep, write a simple alias
|
||||
const escapedSnippet = rgIntegration.snippet.replace(/'/g, "'\\''")
|
||||
content += `
|
||||
} else {
|
||||
// For regular ripgrep, write a simple alias
|
||||
const escapedSnippet = rgIntegration.snippet.replace(/'/g, "'\\''")
|
||||
content += `
|
||||
echo ' alias rg='"'${escapedSnippet}'" >> "$SNAPSHOT_FILE"
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
content += `
|
||||
content += `
|
||||
echo "fi" >> "$SNAPSHOT_FILE"
|
||||
`
|
||||
}
|
||||
|
||||
// For ant-native builds, shadow find/grep with bfs/ugrep embedded in the bun
|
||||
// binary. Unlike rg (which only activates if system rg is absent), we always
|
||||
@ -329,13 +332,6 @@ FIND_GREP_FUNC_END
|
||||
`
|
||||
}
|
||||
|
||||
// Add PATH to the file
|
||||
content += `
|
||||
|
||||
# Add PATH to the file
|
||||
echo "export PATH=${quote([pathValue || ''])}" >> "$SNAPSHOT_FILE"
|
||||
`
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { ChildProcess, ExecFileException } from 'child_process'
|
||||
import { execFile, spawn } from 'child_process'
|
||||
import { execFile, spawn, spawnSync } from 'child_process'
|
||||
import { existsSync } from 'fs'
|
||||
import memoize from 'lodash-es/memoize.js'
|
||||
import { homedir } from 'os'
|
||||
import * as path from 'path'
|
||||
@ -21,13 +22,97 @@ const __dirname = path.join(
|
||||
process.env.NODE_ENV === 'test' ? '../../../' : '../',
|
||||
)
|
||||
|
||||
const BUN_VIRTUAL_PATH_MARKERS = ['$bunfs', '~BUN']
|
||||
|
||||
type RipgrepConfig = {
|
||||
mode: 'system' | 'builtin' | 'embedded'
|
||||
mode: 'system' | 'builtin' | 'embedded' | 'unavailable'
|
||||
command: string
|
||||
args: string[]
|
||||
argv0?: string
|
||||
}
|
||||
|
||||
function isBunVirtualPath(candidatePath: string): boolean {
|
||||
const normalized = candidatePath.replace(/\\/g, '/')
|
||||
return BUN_VIRTUAL_PATH_MARKERS.some(marker => normalized.includes(marker))
|
||||
}
|
||||
|
||||
export function isUsableBuiltinRipgrepPath(candidatePath: string): boolean {
|
||||
return !isBunVirtualPath(candidatePath) && existsSync(candidatePath)
|
||||
}
|
||||
|
||||
function systemRipgrepConfig(): RipgrepConfig | null {
|
||||
const systemPath = findUsableSystemRipgrep()
|
||||
if (systemPath === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { mode: 'system', command: systemPath, args: [] }
|
||||
}
|
||||
|
||||
function findUsableSystemRipgrep(): string | null {
|
||||
for (const candidate of systemRipgrepCandidates()) {
|
||||
if (!existsSync(candidate)) continue
|
||||
const result = spawnSync(candidate, ['--version'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
windowsHide: true,
|
||||
})
|
||||
if (
|
||||
result.status === 0 &&
|
||||
typeof result.stdout === 'string' &&
|
||||
result.stdout.startsWith('ripgrep ')
|
||||
) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function systemRipgrepCandidates(): string[] {
|
||||
const candidates: string[] = []
|
||||
const seen = new Set<string>()
|
||||
const addCandidate = (candidate: string | null | undefined) => {
|
||||
if (!candidate || candidate === 'rg') return
|
||||
const key =
|
||||
process.platform === 'win32' ? candidate.toLowerCase() : candidate
|
||||
if (seen.has(key)) return
|
||||
seen.add(key)
|
||||
candidates.push(candidate)
|
||||
}
|
||||
|
||||
addCandidate(findExecutable('rg', []).cmd)
|
||||
|
||||
const pathEntries = (process.env.PATH ?? '').split(path.delimiter)
|
||||
const extensions =
|
||||
process.platform === 'win32'
|
||||
? (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD')
|
||||
.split(';')
|
||||
.filter(Boolean)
|
||||
: ['']
|
||||
|
||||
for (const dir of pathEntries) {
|
||||
if (!dir) continue
|
||||
for (const ext of extensions) {
|
||||
addCandidate(path.join(dir, `rg${ext.toLowerCase()}`))
|
||||
if (process.platform === 'win32') {
|
||||
addCandidate(path.join(dir, `rg${ext.toUpperCase()}`))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
function builtinRipgrepConfig(): RipgrepConfig {
|
||||
const rgRoot = path.resolve(__dirname, 'vendor', 'ripgrep')
|
||||
const command =
|
||||
process.platform === 'win32'
|
||||
? path.resolve(rgRoot, `${process.arch}-win32`, 'rg.exe')
|
||||
: path.resolve(rgRoot, `${process.arch}-${process.platform}`, 'rg')
|
||||
|
||||
return { mode: 'builtin', command, args: [] }
|
||||
}
|
||||
|
||||
const getRipgrepConfig = memoize((): RipgrepConfig => {
|
||||
const userWantsSystemRipgrep = isEnvDefinedFalsy(
|
||||
process.env.USE_BUILTIN_RIPGREP,
|
||||
@ -35,13 +120,13 @@ const getRipgrepConfig = memoize((): RipgrepConfig => {
|
||||
|
||||
// Try system ripgrep if user wants it
|
||||
if (userWantsSystemRipgrep) {
|
||||
const { cmd: systemPath } = findExecutable('rg', [])
|
||||
if (systemPath !== 'rg') {
|
||||
// SECURITY: Use command name 'rg' instead of systemPath to prevent PATH hijacking
|
||||
// If we used systemPath, a malicious ./rg.exe in current directory could be executed
|
||||
// Using just 'rg' lets the OS resolve it safely with NoDefaultCurrentDirectoryInExePath protection
|
||||
return { mode: 'system', command: 'rg', args: [] }
|
||||
}
|
||||
return (
|
||||
systemRipgrepConfig() ?? {
|
||||
mode: 'unavailable',
|
||||
command: '',
|
||||
args: [],
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// In bundled (native) mode, ripgrep is statically compiled into bun-internal
|
||||
@ -55,13 +140,18 @@ const getRipgrepConfig = memoize((): RipgrepConfig => {
|
||||
}
|
||||
}
|
||||
|
||||
const rgRoot = path.resolve(__dirname, 'vendor', 'ripgrep')
|
||||
const command =
|
||||
process.platform === 'win32'
|
||||
? path.resolve(rgRoot, `${process.arch}-win32`, 'rg.exe')
|
||||
: path.resolve(rgRoot, `${process.arch}-${process.platform}`, 'rg')
|
||||
const builtinConfig = builtinRipgrepConfig()
|
||||
if (isUsableBuiltinRipgrepPath(builtinConfig.command)) {
|
||||
return builtinConfig
|
||||
}
|
||||
|
||||
return { mode: 'builtin', command, args: [] }
|
||||
return (
|
||||
systemRipgrepConfig() ?? {
|
||||
mode: 'unavailable',
|
||||
command: '',
|
||||
args: [],
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
export function ripgrepCommand(): {
|
||||
@ -121,6 +211,11 @@ function ripGrepRaw(
|
||||
// pattern is provided
|
||||
|
||||
const { rgPath, rgArgs, argv0 } = ripgrepCommand()
|
||||
if (!rgPath) {
|
||||
throw new Error(
|
||||
'ripgrep is not available. Install ripgrep and ensure rg --version works in this environment.',
|
||||
)
|
||||
}
|
||||
|
||||
// Use single-threaded mode only if explicitly requested for this call's retry
|
||||
const threadArgs = singleThread ? ['-j', '1'] : []
|
||||
@ -250,6 +345,11 @@ async function ripGrepFileCount(
|
||||
): Promise<number> {
|
||||
await codesignRipgrepIfNecessary()
|
||||
const { rgPath, rgArgs, argv0 } = ripgrepCommand()
|
||||
if (!rgPath) {
|
||||
throw new Error(
|
||||
'ripgrep is not available. Install ripgrep and ensure rg --version works in this environment.',
|
||||
)
|
||||
}
|
||||
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
const child = spawn(rgPath, [...rgArgs, ...args, target], {
|
||||
@ -300,6 +400,11 @@ export async function ripGrepStream(
|
||||
): Promise<void> {
|
||||
await codesignRipgrepIfNecessary()
|
||||
const { rgPath, rgArgs, argv0 } = ripgrepCommand()
|
||||
if (!rgPath) {
|
||||
throw new Error(
|
||||
'ripgrep is not available. Install ripgrep and ensure rg --version works in this environment.',
|
||||
)
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(rgPath, [...rgArgs, ...args, target], {
|
||||
@ -533,7 +638,7 @@ let ripgrepStatus: {
|
||||
* Returns current configuration immediately, with working status if available
|
||||
*/
|
||||
export function getRipgrepStatus(): {
|
||||
mode: 'system' | 'builtin' | 'embedded'
|
||||
mode: 'system' | 'builtin' | 'embedded' | 'unavailable'
|
||||
path: string
|
||||
working: boolean | null // null if not yet tested
|
||||
} {
|
||||
@ -555,6 +660,19 @@ const testRipgrepOnFirstUse = memoize(async (): Promise<void> => {
|
||||
}
|
||||
|
||||
const config = getRipgrepConfig()
|
||||
if (config.mode === 'unavailable') {
|
||||
ripgrepStatus = {
|
||||
working: false,
|
||||
lastTested: Date.now(),
|
||||
config,
|
||||
}
|
||||
logForDebugging('Ripgrep first use test: FAILED (mode=unavailable)')
|
||||
logEvent('tengu_ripgrep_availability', {
|
||||
working: 0,
|
||||
using_system: 0,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
let test: { code: number; stdout: string }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user