mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 21:05:51 +00:00
fix: daemon 子进程 spawn 跨平台修复 + CliLaunchSpec 集中化重构
- 新建 src/utils/cliLaunch.ts: 集中化 CLI 子进程启动层
- buildCliLaunch(): 标准化启动规范(execArgv snapshot + bundled mode 检测)
- spawnCli(): 统一 spawn(自动 windowsHide)
- quoteCliLaunch(): tmux shell 引用
- 修复 --daemon-worker=kind 等号格式解析(cli.tsx)
- 修复 daemon/bg fast path 缺少 setShellIfWindows()(Windows git-bash 发现)
- 修复 checkPathExists 用 execSync('dir') 改为 existsSync(消除 cmd.exe 弹窗)
- 修复 CLAUDE_CODE_GIT_BASH_PATH env 传播给子进程
- 7 个 spawn 站点迁移到 CliLaunchSpec
- BgEngine 接口新增 supportsInteractiveInput capability
- daemon bg 无 -p 时 detached 引擎给出清晰错误提示
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
|||||||
logEventAsync,
|
logEventAsync,
|
||||||
} from '../services/analytics/index.js'
|
} from '../services/analytics/index.js'
|
||||||
import { isInBundledMode } from '../utils/bundledMode.js'
|
import { isInBundledMode } from '../utils/bundledMode.js'
|
||||||
|
import { getBootstrapArgs, getScriptPath } from '../utils/cliLaunch.js'
|
||||||
import { logForDebugging } from '../utils/debug.js'
|
import { logForDebugging } from '../utils/debug.js'
|
||||||
import { rcLog } from './rcDebugLog.js'
|
import { rcLog } from './rcDebugLog.js'
|
||||||
import { logForDiagnosticsNoPII } from '../utils/diagLogs.js'
|
import { logForDiagnosticsNoPII } from '../utils/diagLogs.js'
|
||||||
@@ -111,17 +112,15 @@ function pollSleepDetectionThresholdMs(backoff: BackoffConfig): number {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the args that must precede CLI flags when spawning a child claude
|
* Returns the args that must precede CLI flags when spawning a child claude
|
||||||
* process. In compiled binaries, process.execPath is the claude binary itself
|
* process. Delegates to the centralized cliLaunch module which handles
|
||||||
* and args go directly to it. In npm installs (node running cli.js),
|
* bundled-vs-script mode, execArgv sanitization, and the Bun execArgv leak
|
||||||
* process.execPath is the node runtime — the child spawn must pass the script
|
* quirk. See anthropics/claude-code#28334.
|
||||||
* path as the first arg, otherwise node interprets --sdk-url as a node option
|
|
||||||
* and exits with "bad option: --sdk-url". See anthropics/claude-code#28334.
|
|
||||||
*/
|
*/
|
||||||
function spawnScriptArgs(): string[] {
|
function spawnScriptArgs(): string[] {
|
||||||
if (isInBundledMode() || !process.argv[1]) {
|
const bootstrap = [...getBootstrapArgs()]
|
||||||
return []
|
const script = getScriptPath()
|
||||||
}
|
if (script) bootstrap.push(script)
|
||||||
return [process.argv[1]]
|
return bootstrap
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Attempt to spawn a session; returns error string if spawn throws. */
|
/** Attempt to spawn a session; returns error string if spawn throws. */
|
||||||
|
|||||||
@@ -279,6 +279,32 @@ export async function killHandler(target: string | undefined): Promise<void> {
|
|||||||
export async function handleBgStart(args: string[]): Promise<void> {
|
export async function handleBgStart(args: string[]): Promise<void> {
|
||||||
const engine = await selectEngine()
|
const engine = await selectEngine()
|
||||||
|
|
||||||
|
// Strip --bg/--background from args (for backward-compat shortcut)
|
||||||
|
const filteredArgs = args.filter(a => a !== '--bg' && a !== '--background')
|
||||||
|
|
||||||
|
// Engines without interactive TTY input (e.g. detached) require -p/--print
|
||||||
|
// or piped input. Tmux provides a virtual terminal so it works without -p.
|
||||||
|
if (
|
||||||
|
!engine.supportsInteractiveInput &&
|
||||||
|
!filteredArgs.some(a => a === '-p' || a === '--print' || a === '--pipe')
|
||||||
|
) {
|
||||||
|
console.error(
|
||||||
|
'Error: Background sessions with detached engine require -p/--print flag.\n' +
|
||||||
|
'The detached engine has no terminal for interactive input.\n\n' +
|
||||||
|
'Usage:\n' +
|
||||||
|
' claude daemon bg -p "your prompt here"\n' +
|
||||||
|
' echo "prompt" | claude daemon bg --pipe',
|
||||||
|
)
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
console.error(
|
||||||
|
'\nAlternatively, install tmux for interactive background sessions:\n' +
|
||||||
|
` ${process.platform === 'darwin' ? 'brew install tmux' : 'sudo apt install tmux'}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
process.exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const sessionName = `claude-bg-${randomUUID().slice(0, 8)}`
|
const sessionName = `claude-bg-${randomUUID().slice(0, 8)}`
|
||||||
const logPath = join(
|
const logPath = join(
|
||||||
getClaudeConfigHomeDir(),
|
getClaudeConfigHomeDir(),
|
||||||
@@ -287,9 +313,6 @@ export async function handleBgStart(args: string[]): Promise<void> {
|
|||||||
`${sessionName}.log`,
|
`${sessionName}.log`,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Strip --bg/--background from args (for backward-compat shortcut)
|
|
||||||
const filteredArgs = args.filter(a => a !== '--bg' && a !== '--background')
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await engine.start({
|
const result = await engine.start({
|
||||||
sessionName,
|
sessionName,
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ export interface BgStartResult {
|
|||||||
|
|
||||||
export interface BgEngine {
|
export interface BgEngine {
|
||||||
readonly name: 'tmux' | 'detached'
|
readonly name: 'tmux' | 'detached'
|
||||||
|
/** Whether the engine provides a TTY for interactive REPL input. */
|
||||||
|
readonly supportsInteractiveInput: boolean
|
||||||
available(): Promise<boolean>
|
available(): Promise<boolean>
|
||||||
start(opts: BgStartOptions): Promise<BgStartResult>
|
start(opts: BgStartOptions): Promise<BgStartResult>
|
||||||
attach(session: SessionEntry): Promise<void>
|
attach(session: SessionEntry): Promise<void>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { spawn } from 'child_process'
|
import { closeSync, mkdirSync, openSync } from 'fs'
|
||||||
import { openSync, closeSync, mkdirSync } from 'fs'
|
|
||||||
import { dirname } from 'path'
|
import { dirname } from 'path'
|
||||||
|
import { buildCliLaunch, spawnCli } from '../../../utils/cliLaunch.js'
|
||||||
import type {
|
import type {
|
||||||
BgEngine,
|
BgEngine,
|
||||||
BgStartOptions,
|
BgStartOptions,
|
||||||
@@ -11,6 +11,7 @@ import { tailLog } from '../tail.js'
|
|||||||
|
|
||||||
export class DetachedEngine implements BgEngine {
|
export class DetachedEngine implements BgEngine {
|
||||||
readonly name = 'detached' as const
|
readonly name = 'detached' as const
|
||||||
|
readonly supportsInteractiveInput = false
|
||||||
|
|
||||||
async available(): Promise<boolean> {
|
async available(): Promise<boolean> {
|
||||||
return true
|
return true
|
||||||
@@ -20,17 +21,19 @@ export class DetachedEngine implements BgEngine {
|
|||||||
mkdirSync(dirname(opts.logPath), { recursive: true })
|
mkdirSync(dirname(opts.logPath), { recursive: true })
|
||||||
|
|
||||||
const logFd = openSync(opts.logPath, 'a')
|
const logFd = openSync(opts.logPath, 'a')
|
||||||
const entrypoint = process.argv[1]!
|
|
||||||
|
|
||||||
const child = spawn(process.execPath, [entrypoint, ...opts.args], {
|
const launch = buildCliLaunch(opts.args, {
|
||||||
detached: true,
|
|
||||||
stdio: ['ignore', logFd, logFd],
|
|
||||||
env: {
|
env: {
|
||||||
...opts.env,
|
...opts.env,
|
||||||
CLAUDE_CODE_SESSION_KIND: 'bg',
|
CLAUDE_CODE_SESSION_KIND: 'bg',
|
||||||
CLAUDE_CODE_SESSION_NAME: opts.sessionName,
|
CLAUDE_CODE_SESSION_NAME: opts.sessionName,
|
||||||
CLAUDE_CODE_SESSION_LOG: opts.logPath,
|
CLAUDE_CODE_SESSION_LOG: opts.logPath,
|
||||||
} as Record<string, string>,
|
} as NodeJS.ProcessEnv,
|
||||||
|
})
|
||||||
|
|
||||||
|
const child = spawnCli(launch, {
|
||||||
|
detached: true,
|
||||||
|
stdio: ['ignore', logFd, logFd],
|
||||||
cwd: opts.cwd,
|
cwd: opts.cwd,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { spawnSync } from 'child_process'
|
import { spawnSync } from 'child_process'
|
||||||
import { execFileNoThrow } from '../../../utils/execFileNoThrow.js'
|
import { execFileNoThrow } from '../../../utils/execFileNoThrow.js'
|
||||||
import { quote } from '../../../utils/bash/shellQuote.js'
|
import { buildCliLaunch, quoteCliLaunch } from '../../../utils/cliLaunch.js'
|
||||||
import type {
|
import type {
|
||||||
BgEngine,
|
BgEngine,
|
||||||
BgStartOptions,
|
BgStartOptions,
|
||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
|
|
||||||
export class TmuxEngine implements BgEngine {
|
export class TmuxEngine implements BgEngine {
|
||||||
readonly name = 'tmux' as const
|
readonly name = 'tmux' as const
|
||||||
|
readonly supportsInteractiveInput = true
|
||||||
|
|
||||||
async available(): Promise<boolean> {
|
async available(): Promise<boolean> {
|
||||||
const { code } = await execFileNoThrow('tmux', ['-V'], { useCwd: false })
|
const { code } = await execFileNoThrow('tmux', ['-V'], { useCwd: false })
|
||||||
@@ -17,21 +18,22 @@ export class TmuxEngine implements BgEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async start(opts: BgStartOptions): Promise<BgStartResult> {
|
async start(opts: BgStartOptions): Promise<BgStartResult> {
|
||||||
const entrypoint = process.argv[1]!
|
const launch = buildCliLaunch(opts.args, {
|
||||||
const cmd = quote([process.execPath, entrypoint, ...opts.args])
|
env: {
|
||||||
|
...opts.env,
|
||||||
|
CLAUDE_CODE_SESSION_KIND: 'bg',
|
||||||
|
CLAUDE_CODE_SESSION_NAME: opts.sessionName,
|
||||||
|
CLAUDE_CODE_SESSION_LOG: opts.logPath,
|
||||||
|
CLAUDE_CODE_TMUX_SESSION: opts.sessionName,
|
||||||
|
} as NodeJS.ProcessEnv,
|
||||||
|
})
|
||||||
|
|
||||||
const tmuxEnv: Record<string, string | undefined> = {
|
const cmd = quoteCliLaunch(launch)
|
||||||
...opts.env,
|
|
||||||
CLAUDE_CODE_SESSION_KIND: 'bg',
|
|
||||||
CLAUDE_CODE_SESSION_NAME: opts.sessionName,
|
|
||||||
CLAUDE_CODE_SESSION_LOG: opts.logPath,
|
|
||||||
CLAUDE_CODE_TMUX_SESSION: opts.sessionName,
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = spawnSync(
|
const result = spawnSync(
|
||||||
'tmux',
|
'tmux',
|
||||||
['new-session', '-d', '-s', opts.sessionName, cmd],
|
['new-session', '-d', '-s', opts.sessionName, cmd],
|
||||||
{ stdio: 'inherit', env: tmuxEnv },
|
{ stdio: 'inherit', env: launch.env },
|
||||||
)
|
)
|
||||||
|
|
||||||
if (result.status !== 0) {
|
if (result.status !== 0) {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { spawn } from 'child_process';
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { resolve } from 'path';
|
import { resolve } from 'path';
|
||||||
@@ -8,6 +7,7 @@ import { ListItem } from '../../components/design-system/ListItem.js';
|
|||||||
import { useRegisterOverlay } from '../../context/overlayContext.js';
|
import { useRegisterOverlay } from '../../context/overlayContext.js';
|
||||||
import { useKeybindings } from '../../keybindings/useKeybinding.js';
|
import { useKeybindings } from '../../keybindings/useKeybinding.js';
|
||||||
import { findGitRoot } from '../../utils/git.js';
|
import { findGitRoot } from '../../utils/git.js';
|
||||||
|
import { buildCliLaunch, spawnCli } from '../../utils/cliLaunch.js';
|
||||||
import { getKairosActive, setKairosActive } from '../../bootstrap/state.js';
|
import { getKairosActive, setKairosActive } from '../../bootstrap/state.js';
|
||||||
import type { LocalJSXCommandContext } from '../../commands.js';
|
import type { LocalJSXCommandContext } from '../../commands.js';
|
||||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||||
@@ -65,9 +65,9 @@ export function NewInstallWizard({ defaultDir, onInstalled, onCancel, onError }:
|
|||||||
const dir = defaultDir || resolve('.');
|
const dir = defaultDir || resolve('.');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const execArgs = [...process.execArgv, process.argv[1]!, 'daemon', 'start', `--dir=${dir}`];
|
const launch = buildCliLaunch(['daemon', 'start', `--dir=${dir}`]);
|
||||||
|
|
||||||
const child = spawn(process.execPath, execArgs, {
|
const child = spawnCli(launch, {
|
||||||
cwd: dir,
|
cwd: dir,
|
||||||
stdio: 'ignore',
|
stdio: 'ignore',
|
||||||
detached: true,
|
detached: true,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { spawn, type ChildProcess } from 'child_process';
|
import { type ChildProcess } from 'child_process';
|
||||||
import { resolve } from 'path';
|
import { resolve } from 'path';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
@@ -10,6 +10,7 @@ import { ListItem } from '../../components/design-system/ListItem.js';
|
|||||||
import { useRegisterOverlay } from '../../context/overlayContext.js';
|
import { useRegisterOverlay } from '../../context/overlayContext.js';
|
||||||
import { Box, Text } from '@anthropic/ink';
|
import { Box, Text } from '@anthropic/ink';
|
||||||
import { useKeybindings } from '../../keybindings/useKeybinding.js';
|
import { useKeybindings } from '../../keybindings/useKeybinding.js';
|
||||||
|
import { buildCliLaunch, spawnCli } from '../../utils/cliLaunch.js';
|
||||||
import type { ToolUseContext } from '../../Tool.js';
|
import type { ToolUseContext } from '../../Tool.js';
|
||||||
import type { LocalJSXCommandContext, LocalJSXCommandOnDone } from '../../types/command.js';
|
import type { LocalJSXCommandContext, LocalJSXCommandOnDone } from '../../types/command.js';
|
||||||
import { errorMessage } from '../../utils/errors.js';
|
import { errorMessage } from '../../utils/errors.js';
|
||||||
@@ -202,9 +203,9 @@ async function checkPrerequisites(): Promise<string | null> {
|
|||||||
function startDaemon(): void {
|
function startDaemon(): void {
|
||||||
const dir = resolve('.');
|
const dir = resolve('.');
|
||||||
|
|
||||||
const execArgs = [...process.execArgv, process.argv[1]!, 'daemon', 'start', `--dir=${dir}`];
|
const launch = buildCliLaunch(['daemon', 'start', `--dir=${dir}`]);
|
||||||
|
|
||||||
const child = spawn(process.execPath, execArgs, {
|
const child = spawnCli(launch, {
|
||||||
cwd: dir,
|
cwd: dir,
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
detached: false,
|
detached: false,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { spawn, type ChildProcess } from 'child_process'
|
import { type ChildProcess } from 'child_process'
|
||||||
import { resolve } from 'path'
|
import { resolve } from 'path'
|
||||||
|
import { buildCliLaunch, spawnCli } from '../utils/cliLaunch.js'
|
||||||
import { errorMessage } from '../utils/errors.js'
|
import { errorMessage } from '../utils/errors.js'
|
||||||
import {
|
import {
|
||||||
writeDaemonState,
|
writeDaemonState,
|
||||||
@@ -335,17 +336,11 @@ function spawnWorker(
|
|||||||
CLAUDE_CODE_SESSION_KIND: 'daemon-worker',
|
CLAUDE_CODE_SESSION_KIND: 'daemon-worker',
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the worker command: reuse the same entrypoint with --daemon-worker flag
|
|
||||||
const execArgs = [
|
|
||||||
...process.execArgv,
|
|
||||||
process.argv[1]!,
|
|
||||||
`--daemon-worker=${worker.kind}`,
|
|
||||||
]
|
|
||||||
|
|
||||||
console.log(`[daemon] spawning worker '${worker.kind}'`)
|
console.log(`[daemon] spawning worker '${worker.kind}'`)
|
||||||
|
|
||||||
const child = spawn(process.execPath, execArgs, {
|
const launch = buildCliLaunch([`--daemon-worker=${worker.kind}`], { env })
|
||||||
env,
|
|
||||||
|
const child = spawnCli(launch, {
|
||||||
cwd: dir,
|
cwd: dir,
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -121,9 +121,10 @@ async function main(): Promise<void> {
|
|||||||
// perf-sensitive. No enableConfigs(), no analytics sinks at this layer —
|
// perf-sensitive. No enableConfigs(), no analytics sinks at this layer —
|
||||||
// workers are lean. If a worker kind needs configs/auth (assistant will),
|
// workers are lean. If a worker kind needs configs/auth (assistant will),
|
||||||
// it calls them inside its run() fn.
|
// it calls them inside its run() fn.
|
||||||
if (feature('DAEMON') && args[0] === '--daemon-worker') {
|
if (feature('DAEMON') && (args[0] === '--daemon-worker' || args[0]?.startsWith('--daemon-worker='))) {
|
||||||
|
const kind = args[0] === '--daemon-worker' ? args[1] : args[0].split('=')[1];
|
||||||
const { runDaemonWorker } = await import('../daemon/workerRegistry.js');
|
const { runDaemonWorker } = await import('../daemon/workerRegistry.js');
|
||||||
await runDaemonWorker(args[1]);
|
await runDaemonWorker(kind);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,6 +185,8 @@ async function main(): Promise<void> {
|
|||||||
profileCheckpoint('cli_daemon_path');
|
profileCheckpoint('cli_daemon_path');
|
||||||
const { enableConfigs } = await import('../utils/config.js');
|
const { enableConfigs } = await import('../utils/config.js');
|
||||||
enableConfigs();
|
enableConfigs();
|
||||||
|
const { setShellIfWindows } = await import('../utils/windowsPaths.js');
|
||||||
|
setShellIfWindows();
|
||||||
const { initSinks } = await import('../utils/sinks.js');
|
const { initSinks } = await import('../utils/sinks.js');
|
||||||
initSinks();
|
initSinks();
|
||||||
const { daemonMain } = await import('../daemon/main.js');
|
const { daemonMain } = await import('../daemon/main.js');
|
||||||
@@ -196,6 +199,8 @@ async function main(): Promise<void> {
|
|||||||
profileCheckpoint('cli_daemon_path');
|
profileCheckpoint('cli_daemon_path');
|
||||||
const { enableConfigs } = await import('../utils/config.js');
|
const { enableConfigs } = await import('../utils/config.js');
|
||||||
enableConfigs();
|
enableConfigs();
|
||||||
|
const { setShellIfWindows } = await import('../utils/windowsPaths.js');
|
||||||
|
setShellIfWindows();
|
||||||
const bg = await import('../cli/bg.js');
|
const bg = await import('../cli/bg.js');
|
||||||
await bg.handleBgStart(args.filter(a => a !== '--bg' && a !== '--background'));
|
await bg.handleBgStart(args.filter(a => a !== '--bg' && a !== '--background'));
|
||||||
return;
|
return;
|
||||||
@@ -211,6 +216,8 @@ async function main(): Promise<void> {
|
|||||||
profileCheckpoint('cli_daemon_path');
|
profileCheckpoint('cli_daemon_path');
|
||||||
const { enableConfigs } = await import('../utils/config.js');
|
const { enableConfigs } = await import('../utils/config.js');
|
||||||
enableConfigs();
|
enableConfigs();
|
||||||
|
const { setShellIfWindows } = await import('../utils/windowsPaths.js');
|
||||||
|
setShellIfWindows();
|
||||||
const { initSinks } = await import('../utils/sinks.js');
|
const { initSinks } = await import('../utils/sinks.js');
|
||||||
initSinks();
|
initSinks();
|
||||||
const { daemonMain } = await import('../daemon/main.js');
|
const { daemonMain } = await import('../daemon/main.js');
|
||||||
|
|||||||
180
src/utils/cliLaunch.ts
Normal file
180
src/utils/cliLaunch.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { type ChildProcess, spawn, type SpawnOptions } from 'child_process'
|
||||||
|
import { isInBundledMode } from './bundledMode.js'
|
||||||
|
import { quote } from './bash/shellQuote.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CliLaunchSpec — normalized descriptor for spawning a child CLI process.
|
||||||
|
*
|
||||||
|
* Every site that re-execs the CLI (daemon workers, bg sessions, bridge
|
||||||
|
* sessions, assistant/RCS daemon launchers) should use this instead of
|
||||||
|
* manually assembling `[...process.execArgv, process.argv[1]!, ...]`.
|
||||||
|
*
|
||||||
|
* Centralizing the bootstrap contract prevents the class of bugs where
|
||||||
|
* individual spawn sites forget execArgv, windowsHide, or env propagation.
|
||||||
|
*/
|
||||||
|
export interface CliLaunchSpec {
|
||||||
|
/** Runtime binary path (e.g. bun, node). */
|
||||||
|
execPath: string
|
||||||
|
/** Full argument list including bootstrap args and CLI args. */
|
||||||
|
args: string[]
|
||||||
|
/** Environment for the child process. */
|
||||||
|
env: NodeJS.ProcessEnv
|
||||||
|
/** Whether to hide the console window on Windows. */
|
||||||
|
windowsHide: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Frozen bootstrap snapshot — computed once at module load time.
|
||||||
|
//
|
||||||
|
// Bun quirk (https://github.com/oven-sh/bun/issues/11673): in single-file
|
||||||
|
// executables, app arguments from process.argv can leak into process.execArgv.
|
||||||
|
// We snapshot and filter once, so every child gets a clean, stable set of
|
||||||
|
// runtime flags regardless of when buildCliLaunch is called.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter out leaked application arguments from process.execArgv.
|
||||||
|
* Only keep known runtime flags: -d (defines), --feature, --inspect variants.
|
||||||
|
*/
|
||||||
|
function sanitizeExecArgv(raw: readonly string[]): string[] {
|
||||||
|
const result: string[] = []
|
||||||
|
for (let i = 0; i < raw.length; i++) {
|
||||||
|
const arg = raw[i]!
|
||||||
|
// Bun define flags: -d KEY:VALUE or -dKEY:VALUE
|
||||||
|
if (arg === '-d' || arg.startsWith('-d ') || arg.startsWith('-d\t')) {
|
||||||
|
result.push(arg)
|
||||||
|
if (arg === '-d' && i + 1 < raw.length) {
|
||||||
|
result.push(raw[++i]!)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (arg.startsWith('-d') && arg.includes(':')) {
|
||||||
|
result.push(arg)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Bun feature flags: --feature NAME
|
||||||
|
if (arg === '--feature') {
|
||||||
|
result.push(arg)
|
||||||
|
if (i + 1 < raw.length) {
|
||||||
|
result.push(raw[++i]!)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Node/Bun inspect flags
|
||||||
|
if (/^--inspect(-brk)?(=|$)/.test(arg)) {
|
||||||
|
result.push(arg)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Keep other known runtime flags (e.g. --conditions, --experimental-*)
|
||||||
|
if (arg.startsWith('--') && !arg.includes('=') && i + 1 < raw.length) {
|
||||||
|
// Unknown two-part flag — skip conservatively in bundled mode only
|
||||||
|
if (isInBundledMode()) continue
|
||||||
|
result.push(arg)
|
||||||
|
result.push(raw[++i]!)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (arg.startsWith('-') && !isInBundledMode()) {
|
||||||
|
result.push(arg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const BOOTSTRAP_ARGS: readonly string[] = Object.freeze(
|
||||||
|
sanitizeExecArgv(process.execArgv),
|
||||||
|
)
|
||||||
|
const SCRIPT_PATH: string | undefined = process.argv[1]
|
||||||
|
const EXEC_PATH: string = process.execPath
|
||||||
|
const IS_WINDOWS = process.platform === 'win32'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a normalized launch spec for spawning a child CLI process.
|
||||||
|
*
|
||||||
|
* @param cliArgs Arguments to pass to the CLI entrypoint (e.g. ['daemon', 'start'])
|
||||||
|
* @param opts.env Override environment (defaults to process.env)
|
||||||
|
*/
|
||||||
|
export function buildCliLaunch(
|
||||||
|
cliArgs: string[],
|
||||||
|
opts?: { env?: NodeJS.ProcessEnv },
|
||||||
|
): CliLaunchSpec {
|
||||||
|
const baseEnv = opts?.env ?? process.env
|
||||||
|
|
||||||
|
// In bundled mode the execPath IS the CLI binary — no script path needed.
|
||||||
|
// In script mode (dev / npm) we need the script path between runtime flags
|
||||||
|
// and CLI args so the runtime knows which file to execute.
|
||||||
|
const args: string[] =
|
||||||
|
isInBundledMode() || !SCRIPT_PATH
|
||||||
|
? [...BOOTSTRAP_ARGS, ...cliArgs]
|
||||||
|
: [...BOOTSTRAP_ARGS, SCRIPT_PATH, ...cliArgs]
|
||||||
|
|
||||||
|
// Ensure Windows children can discover git-bash without shelling out
|
||||||
|
const env: NodeJS.ProcessEnv = { ...baseEnv }
|
||||||
|
if (IS_WINDOWS) {
|
||||||
|
if (
|
||||||
|
process.env.CLAUDE_CODE_GIT_BASH_PATH &&
|
||||||
|
!env.CLAUDE_CODE_GIT_BASH_PATH
|
||||||
|
) {
|
||||||
|
env.CLAUDE_CODE_GIT_BASH_PATH = process.env.CLAUDE_CODE_GIT_BASH_PATH
|
||||||
|
}
|
||||||
|
if (process.env.SHELL && !env.SHELL) {
|
||||||
|
env.SHELL = process.env.SHELL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
execPath: EXEC_PATH,
|
||||||
|
args,
|
||||||
|
env,
|
||||||
|
windowsHide: IS_WINDOWS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawn a child CLI process from a launch spec.
|
||||||
|
*
|
||||||
|
* Callers provide transport-level options (stdio, detached, cwd) while the
|
||||||
|
* spec handles bootstrap concerns (execPath, args, env, windowsHide).
|
||||||
|
*
|
||||||
|
* Windows note: `detached: true` on Windows creates a new console window
|
||||||
|
* (unlike Unix where it only creates a new process group). Node.js uses
|
||||||
|
* `windowsHide` to pass CREATE_NO_WINDOW, but Bun may not implement it.
|
||||||
|
* As a fallback, we always set both `windowsHide: true` and keep
|
||||||
|
* `detached` as-is — the child needs `detached` to outlive the parent.
|
||||||
|
*/
|
||||||
|
export function spawnCli(
|
||||||
|
spec: CliLaunchSpec,
|
||||||
|
spawnOpts: Omit<SpawnOptions, 'windowsHide'>,
|
||||||
|
): ChildProcess {
|
||||||
|
return spawn(spec.execPath, spec.args, {
|
||||||
|
...spawnOpts,
|
||||||
|
env: { ...spec.env, ...(spawnOpts.env as NodeJS.ProcessEnv) },
|
||||||
|
windowsHide: spec.windowsHide,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quote a launch spec into a single shell command string (for tmux).
|
||||||
|
*/
|
||||||
|
export function quoteCliLaunch(spec: CliLaunchSpec): string {
|
||||||
|
return quote([spec.execPath, ...spec.args])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the frozen bootstrap args snapshot.
|
||||||
|
* Useful for call sites that need the raw args (e.g. bridgeMain deps).
|
||||||
|
*/
|
||||||
|
export function getBootstrapArgs(): readonly string[] {
|
||||||
|
return BOOTSTRAP_ARGS
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the script path (process.argv[1] at startup).
|
||||||
|
* Returns undefined in bundled mode.
|
||||||
|
*/
|
||||||
|
export function getScriptPath(): string | undefined {
|
||||||
|
return SCRIPT_PATH
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { existsSync } from 'fs'
|
||||||
import memoize from 'lodash-es/memoize.js'
|
import memoize from 'lodash-es/memoize.js'
|
||||||
import * as path from 'path'
|
import * as path from 'path'
|
||||||
import * as pathWin32 from 'path/win32'
|
import * as pathWin32 from 'path/win32'
|
||||||
@@ -8,17 +9,13 @@ import { memoizeWithLRU } from './memoize.js'
|
|||||||
import { getPlatform } from './platform.js'
|
import { getPlatform } from './platform.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a file or directory exists on Windows using the dir command
|
* Check if a file or directory exists on Windows.
|
||||||
* @param path - The path to check
|
* Uses fs.existsSync instead of `dir` shell command to avoid spawning
|
||||||
* @returns true if the path exists, false otherwise
|
* cmd.exe — which can cause brief console window flashes in detached
|
||||||
|
* or windowsHide child processes.
|
||||||
*/
|
*/
|
||||||
function checkPathExists(path: string): boolean {
|
function checkPathExists(filePath: string): boolean {
|
||||||
try {
|
return existsSync(filePath)
|
||||||
execSync_DEPRECATED(`dir "${path}"`, { stdio: 'pipe' })
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -88,6 +85,8 @@ export function setShellIfWindows(): void {
|
|||||||
if (getPlatform() === 'windows') {
|
if (getPlatform() === 'windows') {
|
||||||
const gitBashPath = findGitBashPath()
|
const gitBashPath = findGitBashPath()
|
||||||
process.env.SHELL = gitBashPath
|
process.env.SHELL = gitBashPath
|
||||||
|
// Propagate to child processes so they skip filesystem probing
|
||||||
|
process.env.CLAUDE_CODE_GIT_BASH_PATH = gitBashPath
|
||||||
logForDebugging(`Using bash path: "${gitBashPath}"`)
|
logForDebugging(`Using bash path: "${gitBashPath}"`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user