mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
Merge pull request #192 from NanmiCoder/codex/fix-bugs
fix: window grep command error and some bugs fix
This commit is contained in:
commit
25fac65272
@ -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()
|
||||
|
||||
@ -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) {
|
||||
|
||||
46
desktop/src/components/layout/StartupErrorView.test.tsx
Normal file
46
desktop/src/components/layout/StartupErrorView.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
99
desktop/src/components/layout/StartupErrorView.tsx
Normal file
99
desktop/src/components/layout/StartupErrorView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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 ──────────────────────────────────────
|
||||
|
||||
@ -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 ──────────────────────────────────────
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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,
|
||||
|
||||
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