diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index ee47e4b6..d25d8614 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -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); @@ -546,6 +548,32 @@ fn wait_for_server(url_host: &str, port: u16) -> Result<(), String> { )) } +fn push_server_startup_log(logs: &Arc>>, 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>>) -> String { + let log_text = logs + .lock() + .ok() + .map(|guard| guard.iter().cloned().collect::>().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 { // 历史用途:此前 sidecar launcher 用 dynamic file:// import 加载磁盘上 // 的 src/server/index.ts 和 preload.ts,所以 Tauri 必须把整个 src/ + @@ -575,19 +603,25 @@ fn start_server_sidecar(app: &AppHandle) -> Result { 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 { 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 { 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() diff --git a/desktop/src/components/layout/AppShell.tsx b/desktop/src/components/layout/AppShell.tsx index 1d661d8a..a1af3279 100644 --- a/desktop/src/components/layout/AppShell.tsx +++ b/desktop/src/components/layout/AppShell.tsx @@ -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 ( -
-
-

- {t('app.serverFailed')} -

-

- {startupError} -

-
-
- ) + return } if (!ready) { diff --git a/desktop/src/components/layout/StartupErrorView.test.tsx b/desktop/src/components/layout/StartupErrorView.test.tsx new file mode 100644 index 00000000..757a63c2 --- /dev/null +++ b/desktop/src/components/layout/StartupErrorView.test.tsx @@ -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( + , + ) + + 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() + }) +}) diff --git a/desktop/src/components/layout/StartupErrorView.tsx b/desktop/src/components/layout/StartupErrorView.tsx new file mode 100644 index 00000000..7238fcfa --- /dev/null +++ b/desktop/src/components/layout/StartupErrorView.tsx @@ -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 ( +
+
+
+
+

+ {t('app.serverFailed')} +

+

+ {t('app.serverFailedHint')} +

+
+ +
+
+ {t('app.startupError')} +
+
+              {message}
+            
+
+ + {logs ? ( +
+
+ {t('app.serverLogs')} +
+
+                {logs}
+              
+
+ ) : null} + +
+ + +
+
+
+
+ ) +} diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 1ae3b485..1df1c39e 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -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 ────────────────────────────────────── diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 5bf553dc..54f55deb 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -852,6 +852,11 @@ export const zh: Record = { // ─── 应用外壳 ────────────────────────────────────── 'app.serverFailed': '本地服务启动失败', + 'app.serverFailedHint': '反馈这个问题时,请把这块诊断信息截图一起发到 issue 里。', + 'app.startupError': '启动错误', + 'app.serverLogs': '服务日志', + 'app.copyDiagnostics': '复制诊断信息', + 'app.copiedDiagnostics': '已复制', 'app.launching': '正在启动本地工作区...', // ─── Error Codes ────────────────────────────────────── diff --git a/src/server/__tests__/conversation-service.test.ts b/src/server/__tests__/conversation-service.test.ts index dc678359..9e812b76 100644 --- a/src/server/__tests__/conversation-service.test.ts +++ b/src/server/__tests__/conversation-service.test.ts @@ -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 + + 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 }) diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts index 08e2d807..0db3170d 100644 --- a/src/server/services/conversationService.ts +++ b/src/server/services/conversationService.ts @@ -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, diff --git a/src/utils/__tests__/ripgrep.test.ts b/src/utils/__tests__/ripgrep.test.ts new file mode 100644 index 00000000..dc5c147d --- /dev/null +++ b/src/utils/__tests__/ripgrep.test.ts @@ -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) + }) +}) diff --git a/src/utils/bash/ShellSnapshot.ts b/src/utils/bash/ShellSnapshot.ts index d26f052c..a8d90261 100644 --- a/src/utils/bash/ShellSnapshot.ts +++ b/src/utils/bash/ShellSnapshot.ts @@ -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 { - // 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 } diff --git a/src/utils/ripgrep.ts b/src/utils/ripgrep.ts index 683da051..8128aae7 100644 --- a/src/utils/ripgrep.ts +++ b/src/utils/ripgrep.ts @@ -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() + 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 { 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((resolve, reject) => { const child = spawn(rgPath, [...rgArgs, ...args, target], { @@ -300,6 +400,11 @@ export async function ripGrepStream( ): Promise { 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((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 => { } 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 }