Merge pull request #192 from NanmiCoder/codex/fix-bugs

fix: window grep command error and some bugs fix
This commit is contained in:
程序员阿江-Relakkes 2026-04-27 11:29:05 +08:00 committed by GitHub
commit 25fac65272
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 463 additions and 88 deletions

View File

@ -1,5 +1,5 @@
use std::{
collections::HashMap,
collections::{HashMap, VecDeque},
io::{Error as IoError, ErrorKind, Read, Write},
net::{SocketAddr, TcpListener, TcpStream},
path::PathBuf,
@ -7,7 +7,7 @@ use std::{
str,
sync::{
atomic::{AtomicU32, Ordering},
Mutex,
Arc, Mutex,
},
thread,
time::{Duration, Instant},
@ -24,6 +24,8 @@ use tauri_plugin_shell::{
ShellExt,
};
const SERVER_STARTUP_LOG_LIMIT: usize = 80;
#[derive(Default)]
struct ServerState(Mutex<ServerStatus>);
@ -546,6 +548,32 @@ fn wait_for_server(url_host: &str, port: u16) -> Result<(), String> {
))
}
fn push_server_startup_log(logs: &Arc<Mutex<VecDeque<String>>>, line: String) {
let line = line.trim_end().to_string();
if line.is_empty() {
return;
}
let Ok(mut guard) = logs.lock() else {
return;
};
if guard.len() >= SERVER_STARTUP_LOG_LIMIT {
guard.pop_front();
}
guard.push_back(line);
}
fn format_server_startup_error(message: &str, logs: &Arc<Mutex<VecDeque<String>>>) -> String {
let log_text = logs
.lock()
.ok()
.map(|guard| guard.iter().cloned().collect::<Vec<_>>().join("\n"))
.filter(|text| !text.trim().is_empty())
.unwrap_or_else(|| "No server stdout/stderr was captured before the timeout.".to_string());
format!("{message}\n\nRecent server logs:\n{log_text}")
}
fn resolve_app_root(_app: &AppHandle) -> Result<PathBuf, String> {
// 历史用途:此前 sidecar launcher 用 dynamic file:// import 加载磁盘上
// 的 src/server/index.ts 和 preload.ts所以 Tauri 必须把整个 src/ +
@ -575,19 +603,25 @@ 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);
let (mut rx, child) = sidecar
.spawn()
@ -598,18 +632,33 @@ fn start_server_sidecar(app: &AppHandle) -> Result<ServerRuntime, String> {
match event {
CommandEvent::Stdout(line) => {
let line = String::from_utf8_lossy(&line);
println!("[claude-server] {}", line.trim_end());
let line = line.trim_end();
println!("[claude-server] {line}");
push_server_startup_log(&logs_for_task, format!("[stdout] {line}"));
}
CommandEvent::Stderr(line) => {
let line = String::from_utf8_lossy(&line);
eprintln!("[claude-server] {}", line.trim_end());
let line = line.trim_end();
eprintln!("[claude-server] {line}");
push_server_startup_log(&logs_for_task, format!("[stderr] {line}"));
}
CommandEvent::Terminated(payload) => {
let line = format!(
"sidecar exited (code={:?}, signal={:?})",
payload.code, payload.signal
);
eprintln!("[claude-server] {line}");
push_server_startup_log(&logs_for_task, format!("[exit] {line}"));
}
_ => {}
}
}
});
wait_for_server(host, port)?;
if let Err(err) = wait_for_server(host, port) {
let _ = child.kill();
return Err(format_server_startup_error(&err, &startup_logs));
}
Ok(ServerRuntime { url, child })
}
@ -661,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()

View File

@ -8,6 +8,7 @@ import { useUIStore, type SettingsTab } from '../../stores/uiStore'
import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts'
import { initializeDesktopServerUrl } from '../../lib/desktopRuntime'
import { TabBar } from './TabBar'
import { StartupErrorView } from './StartupErrorView'
import { useTabStore, SETTINGS_TAB_ID } from '../../stores/tabStore'
import { useChatStore } from '../../stores/chatStore'
import { useTranslation } from '../../i18n'
@ -73,18 +74,7 @@ export function AppShell() {
useKeyboardShortcuts()
if (startupError) {
return (
<div className="h-screen flex items-center justify-center bg-[var(--color-surface)] px-6">
<div className="max-w-xl rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-6">
<h1 className="text-lg font-semibold text-[var(--color-text-primary)]">
{t('app.serverFailed')}
</h1>
<p className="mt-2 text-sm text-[var(--color-text-secondary)]">
{startupError}
</p>
</div>
</div>
)
return <StartupErrorView error={startupError} />
}
if (!ready) {

View File

@ -0,0 +1,46 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'
import { describe, expect, it, vi } from 'vitest'
import { splitStartupError, StartupErrorView } from './StartupErrorView'
describe('splitStartupError', () => {
it('separates the timeout message from captured sidecar logs', () => {
const result = splitStartupError(
'desktop server did not start listening on 127.0.0.1:57608 within 10 seconds\n\nRecent server logs:\n[stderr] failed to bind\n[exit] sidecar exited (code=1, signal=None)',
)
expect(result.message).toBe(
'desktop server did not start listening on 127.0.0.1:57608 within 10 seconds',
)
expect(result.logs).toContain('[stderr] failed to bind')
expect(result.diagnostics).toContain('Recent server logs:')
})
})
describe('StartupErrorView', () => {
it('shows diagnostics and copies the full payload', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
value: { writeText },
configurable: true,
})
render(
<StartupErrorView error={'startup failed\n\nRecent server logs:\n[stderr] boom'} />,
)
expect(screen.getByText('本地服务启动失败')).toBeInTheDocument()
expect(screen.getByText('startup failed')).toBeInTheDocument()
expect(screen.getByText('[stderr] boom')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '复制诊断信息' }))
await waitFor(() => {
expect(writeText).toHaveBeenCalledWith(
'startup failed\n\nRecent server logs:\n[stderr] boom',
)
})
expect(screen.getByText('已复制')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,99 @@
import { Copy, RefreshCw } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from '../../i18n'
import { Button } from '../shared/Button'
const LOG_MARKER = '\n\nRecent server logs:\n'
export function splitStartupError(error: string) {
const markerIndex = error.indexOf(LOG_MARKER)
if (markerIndex === -1) {
return {
message: error,
logs: '',
diagnostics: error,
}
}
const message = error.slice(0, markerIndex).trim()
const logs = error.slice(markerIndex + LOG_MARKER.length).trim()
return {
message,
logs,
diagnostics: `${message}\n\nRecent server logs:\n${logs}`,
}
}
type StartupErrorViewProps = {
error: string
}
export function StartupErrorView({ error }: StartupErrorViewProps) {
const t = useTranslation()
const { message, logs, diagnostics } = useMemo(() => splitStartupError(error), [error])
const [copied, setCopied] = useState(false)
const handleCopy = async () => {
await navigator.clipboard?.writeText(diagnostics)
setCopied(true)
window.setTimeout(() => setCopied(false), 1600)
}
return (
<div className="h-screen flex items-center justify-center bg-[var(--color-surface)] px-6">
<section className="w-full max-w-3xl rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-6 shadow-[var(--shadow-md)]">
<div className="flex flex-col gap-4">
<div>
<h1 className="text-lg font-semibold text-[var(--color-text-primary)]">
{t('app.serverFailed')}
</h1>
<p className="mt-2 text-sm text-[var(--color-text-secondary)]">
{t('app.serverFailedHint')}
</p>
</div>
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-4">
<div className="text-xs font-medium uppercase text-[var(--color-text-tertiary)]">
{t('app.startupError')}
</div>
<pre className="mt-2 max-h-28 overflow-auto whitespace-pre-wrap break-words font-mono text-xs text-[var(--color-error)]">
{message}
</pre>
</div>
{logs ? (
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-4">
<div className="text-xs font-medium uppercase text-[var(--color-text-tertiary)]">
{t('app.serverLogs')}
</div>
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-words font-mono text-xs leading-relaxed text-[var(--color-text-secondary)]">
{logs}
</pre>
</div>
) : null}
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="secondary"
size="sm"
icon={<Copy className="h-4 w-4" aria-hidden="true" />}
onClick={handleCopy}
>
{copied ? t('app.copiedDiagnostics') : t('app.copyDiagnostics')}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
icon={<RefreshCw className="h-4 w-4" aria-hidden="true" />}
onClick={() => window.location.reload()}
>
{t('common.retry')}
</Button>
</div>
</div>
</section>
</div>
)
}

View File

@ -850,6 +850,11 @@ export const en = {
// ─── App Shell ──────────────────────────────────────
'app.serverFailed': 'Local server failed to start',
'app.serverFailedHint': 'Please include a screenshot of this diagnostic panel when reporting the issue.',
'app.startupError': 'Startup error',
'app.serverLogs': 'Server logs',
'app.copyDiagnostics': 'Copy diagnostics',
'app.copiedDiagnostics': 'Copied',
'app.launching': 'Launching local workspace...',
// ─── Error Codes ──────────────────────────────────────

View File

@ -852,6 +852,11 @@ export const zh: Record<TranslationKey, string> = {
// ─── 应用外壳 ──────────────────────────────────────
'app.serverFailed': '本地服务启动失败',
'app.serverFailedHint': '反馈这个问题时,请把这块诊断信息截图一起发到 issue 里。',
'app.startupError': '启动错误',
'app.serverLogs': '服务日志',
'app.copyDiagnostics': '复制诊断信息',
'app.copiedDiagnostics': '已复制',
'app.launching': '正在启动本地工作区...',
// ─── Error Codes ──────────────────────────────────────

View File

@ -165,6 +165,32 @@ describe('ConversationService', () => {
expect(env.CLAUDE_CODE_ENTRYPOINT).toBeUndefined()
})
test('buildChildEnv uses the session-selected model for session-scoped providers', async () => {
const providerService = new ProviderService()
const provider = await providerService.addProvider({
presetId: 'custom',
name: 'Switchable',
apiKey: 'provider-key',
baseUrl: 'https://api.switchable.example',
apiFormat: 'openai_chat',
models: {
main: 'old-provider-main',
haiku: 'new-provider-haiku',
sonnet: 'new-provider-sonnet',
opus: 'new-provider-opus',
},
})
const service = new ConversationService() as any
const env = (await service.buildChildEnv('/tmp', undefined, {
providerId: provider.id,
model: 'new-provider-sonnet',
})) as Record<string, string>
expect(env.ANTHROPIC_BASE_URL).toBe(`http://127.0.0.1:3456/proxy/providers/${provider.id}`)
expect(env.ANTHROPIC_MODEL).toBe('new-provider-sonnet')
})
test('buildChildEnv can force official auth even when a custom default provider exists', async () => {
const ccHahaDir = path.join(tmpDir, 'cc-haha')
await fs.mkdir(ccHahaDir, { recursive: true })

View File

@ -556,6 +556,9 @@ export class ConversationService {
typeof options?.providerId === 'string'
? await this.providerService.getProviderRuntimeEnv(options.providerId)
: null
if (explicitProviderEnv && options?.model?.trim()) {
explicitProviderEnv.ANTHROPIC_MODEL = options.model.trim()
}
return {
...cleanEnv,

View 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)
})
})

View File

@ -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
}

View File

@ -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 }