-
Notifications
You must be signed in to change notification settings - Fork 3.8k
Expand file tree
/
Copy pathprocess.ts
More file actions
678 lines (587 loc) · 22.3 KB
/
process.ts
File metadata and controls
678 lines (587 loc) · 22.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
import { loggerService } from '@logger'
import type { GitBashPathInfo, GitBashPathSource } from '@shared/config/constant'
import { HOME_CHERRY_DIR } from '@shared/config/constant'
import { type ChildProcess, execFileSync, spawn, type SpawnOptions } from 'child_process'
import fs from 'fs'
import os from 'os'
import path from 'path'
import { isWin } from '../constant'
import { ConfigKeys, configManager } from '../services/ConfigManager'
import { getResourcePath } from '.'
import getShellEnv, { refreshShellEnv } from './shell-env'
const logger = loggerService.withContext('Utils:Process')
export function runInstallScript(scriptPath: string): Promise<void> {
return new Promise<void>((resolve, reject) => {
const installScriptPath = path.join(getResourcePath(), 'scripts', scriptPath)
logger.info(`Running script at: ${installScriptPath}`)
const nodeProcess = spawn(process.execPath, [installScriptPath], {
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }
})
nodeProcess.stdout.on('data', (data) => {
logger.debug(`Script output: ${data}`)
})
nodeProcess.stderr.on('data', (data) => {
logger.error(`Script error: ${data}`)
})
nodeProcess.on('close', (code) => {
if (code === 0) {
logger.debug('Script completed successfully')
resolve()
} else {
logger.warn(`Script exited with code ${code}`)
reject(new Error(`Process exited with code ${code}`))
}
})
})
}
export async function getBinaryName(name: string): Promise<string> {
if (isWin) {
return `${name}.exe`
}
return name
}
export async function getBinaryPath(name?: string): Promise<string> {
if (!name) {
return path.join(os.homedir(), HOME_CHERRY_DIR, 'bin')
}
const binaryName = await getBinaryName(name)
const binariesDir = path.join(os.homedir(), HOME_CHERRY_DIR, 'bin')
const binariesDirExists = fs.existsSync(binariesDir)
return binariesDirExists ? path.join(binariesDir, binaryName) : binaryName
}
export async function isBinaryExists(name: string): Promise<boolean> {
const cmd = await getBinaryPath(name)
return fs.existsSync(cmd)
}
// Timeout for command lookup operations (in milliseconds)
const COMMAND_LOOKUP_TIMEOUT_MS = 5000
// Regex to validate command names - must start with alphanumeric or underscore, max 128 chars
const VALID_COMMAND_NAME_REGEX = /^[a-zA-Z0-9_][a-zA-Z0-9_-]{0,127}$/
// Maximum output size to prevent buffer overflow (10KB)
const MAX_OUTPUT_SIZE = 10240
/**
* Check if a command is available in the user's login shell environment
* @param command - Command name to check (e.g., 'npx', 'uvx')
* @param loginShellEnv - The login shell environment from getLoginShellEnvironment()
* @returns Full path to the command if found, null otherwise
*/
export async function findCommandInShellEnv(
command: string,
loginShellEnv: Record<string, string>
): Promise<string | null> {
// Validate command name to prevent command injection
if (!VALID_COMMAND_NAME_REGEX.test(command)) {
logger.warn(`Invalid command name '${command}' - must only contain alphanumeric characters, underscore, or hyphen`)
return null
}
return new Promise((resolve) => {
let resolved = false
const safeResolve = (value: string | null) => {
if (resolved) return
resolved = true
resolve(value)
}
if (isWin) {
// On Windows, use 'where' command
const child = spawn('where', [command], {
env: loginShellEnv,
stdio: ['ignore', 'pipe', 'pipe']
})
let output = ''
const timeoutId = setTimeout(() => {
if (resolved) return
child.kill('SIGKILL')
logger.debug(`Timeout checking command '${command}' on Windows`)
safeResolve(null)
}, COMMAND_LOOKUP_TIMEOUT_MS)
child.stdout.on('data', (data) => {
if (output.length < MAX_OUTPUT_SIZE) {
output += data.toString()
}
})
child.on('close', (code) => {
clearTimeout(timeoutId)
if (resolved) return
if (code === 0 && output.trim()) {
const paths = output.trim().split(/\r?\n/)
// Only accept .exe files on Windows - .cmd/.bat files cannot be executed
// with spawn({ shell: false }) which is used by MCP SDK's StdioClientTransport
const exePath = paths.find((p) => p.toLowerCase().endsWith('.exe'))
if (exePath) {
safeResolve(exePath)
} else {
logger.debug(`Command '${command}' found but not as .exe (${paths[0]}), treating as not found`)
safeResolve(null)
}
} else {
logger.debug(`Command '${command}' not found in shell environment`)
safeResolve(null)
}
})
child.on('error', (error) => {
clearTimeout(timeoutId)
if (resolved) return
logger.warn(`Error checking command '${command}':`, { error, platform: 'windows' })
safeResolve(null)
})
} else {
// Unix/Linux/macOS: use 'command -v' which is POSIX standard
// Use /bin/sh for reliability - it's POSIX compliant and always available
// This avoids issues with user's custom shell (csh, fish, etc.)
// SECURITY: Use positional parameter $1 to prevent command injection
const child = spawn('/bin/sh', ['-c', 'command -v "$1"', '--', command], {
env: loginShellEnv,
stdio: ['ignore', 'pipe', 'pipe']
})
let output = ''
const timeoutId = setTimeout(() => {
if (resolved) return
child.kill('SIGKILL')
logger.debug(`Timeout checking command '${command}'`)
safeResolve(null)
}, COMMAND_LOOKUP_TIMEOUT_MS)
child.stdout.on('data', (data) => {
if (output.length < MAX_OUTPUT_SIZE) {
output += data.toString()
}
})
child.on('close', (code) => {
clearTimeout(timeoutId)
if (resolved) return
if (code === 0 && output.trim()) {
const commandPath = output.trim().split('\n')[0]
// Validate the output is an absolute path (not an alias, function, or builtin)
// command -v can return just the command name for aliases/builtins
if (path.isAbsolute(commandPath)) {
safeResolve(commandPath)
} else {
logger.debug(`Command '${command}' resolved to non-path '${commandPath}', treating as not found`)
safeResolve(null)
}
} else {
logger.debug(`Command '${command}' not found in shell environment`)
safeResolve(null)
}
})
child.on('error', (error) => {
clearTimeout(timeoutId)
if (resolved) return
logger.warn(`Error checking command '${command}':`, { error, platform: 'unix' })
safeResolve(null)
})
}
})
}
export interface FindExecutableOptions {
/** File extensions to search for (default: ['.exe', '.cmd']) */
extensions?: string[]
/** Environment variables to use for where.exe lookup (default: process.env) */
env?: Record<string, string>
}
/**
* Find executable in common paths or PATH environment variable
* Based on Claude Code's implementation with security checks
* @param name - Name of the executable to find (without extension)
* @param options - Optional configuration for extensions and common paths
* @returns Full path to the executable or null if not found
*/
export function findExecutable(name: string, options?: FindExecutableOptions): string | null {
// This implementation uses where.exe which is Windows-only
if (!isWin) {
return null
}
const extensions = options?.extensions ?? ['.exe', '.cmd']
// Special handling for git - check common installation paths first
// Uses getCommonGitRoots() which includes ProgramFiles, ProgramFiles(x86), and LOCALAPPDATA
if (name === 'git') {
for (const root of getCommonGitRoots()) {
const gitPath = path.join(root, 'cmd', 'git.exe')
if (fs.existsSync(gitPath)) {
logger.debug(`Found ${name} at common path`, { path: gitPath })
return gitPath
}
}
}
// Use where.exe to find executable in PATH
// Use execFileSync to prevent command injection
try {
// Search without extension - where.exe returns all matches (npm, npm.cmd, npm.exe, etc.)
// We then filter by allowed extensions below for security and precision
const result = execFileSync('where.exe', [name], {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
env: options?.env
})
// Handle both Windows (\r\n) and Unix (\n) line endings
const paths = result.trim().split(/\r?\n/).filter(Boolean)
const currentDir = process.cwd().toLowerCase()
// Filter by allowed extensions
for (const exePath of paths) {
// Trim whitespace from where.exe output
const cleanPath = exePath.trim()
const lowerPath = cleanPath.toLowerCase()
// Check if the file has an allowed extension
const hasAllowedExtension = extensions.some((ext) => lowerPath.endsWith(ext.toLowerCase()))
if (!hasAllowedExtension) {
continue
}
const resolvedPath = path.resolve(cleanPath).toLowerCase()
const execDir = path.dirname(resolvedPath).toLowerCase()
// Skip if in current directory or subdirectory (potential malware)
if (execDir === currentDir || execDir.startsWith(currentDir + path.sep)) {
logger.warn('Skipping potentially malicious executable in current directory', {
path: cleanPath
})
continue
}
logger.debug(`Found ${name} via where.exe`, { path: cleanPath })
return cleanPath
}
return null
} catch (error) {
logger.debug(`where.exe ${name} failed`, { error })
return null
}
}
// ============================================================================
// Unified Shell Environment Utilities
// ============================================================================
/** Timeout for mise operations (in milliseconds) */
const MISE_TIMEOUT_MS = 5000
/**
* Find an executable via `mise which <name>` on Windows.
*
* When Node.js is installed through mise, the shims are `.cmd` files that
* `findCommandInShellEnv` rejects (it only accepts `.exe`), and `mise activate`
* may not be visible in the registry-based PATH used by `getWindowsEnvironment`.
*
* This function locates `mise.exe` via `where.exe` and asks it directly for
* the real binary path, bypassing shim/PATH issues entirely.
*
* @param name - Tool name to resolve (e.g. 'node', 'npm')
* @param env - Environment variables for subprocess
* @returns Absolute path to the real executable, or null
*/
export function findViaMise(name: string, env: Record<string, string>): string | null {
if (!isWin) {
return null
}
// Validate command name (reuse the same regex used by findCommandInShellEnv)
if (!VALID_COMMAND_NAME_REGEX.test(name)) {
return null
}
const misePath = findMiseExecutable(env)
if (!misePath) {
logger.debug('mise not found, skipping mise fallback')
return null
}
try {
const result = execFileSync(misePath, ['which', name], {
encoding: 'utf8',
timeout: MISE_TIMEOUT_MS,
stdio: ['pipe', 'pipe', 'pipe'],
env
})
const resolvedPath = result.trim().split(/\r?\n/)[0]?.trim()
if (!resolvedPath || !path.isAbsolute(resolvedPath)) {
logger.debug(`mise which ${name} returned non-absolute path: ${resolvedPath}`)
return null
}
if (!fs.existsSync(resolvedPath)) {
logger.debug(`mise which ${name} returned non-existent path: ${resolvedPath}`)
return null
}
logger.debug(`Found ${name} via mise`, { path: resolvedPath })
return resolvedPath
} catch (error) {
// Expected when the tool is not managed by mise, or mise times out
logger.debug(`mise which ${name} failed`, { error })
return null
}
}
/**
* Locate `mise.exe` on the local machine via `where.exe`.
*/
function findMiseExecutable(env: Record<string, string>): string | null {
try {
const result = execFileSync('where.exe', ['mise'], {
encoding: 'utf8',
timeout: MISE_TIMEOUT_MS,
stdio: ['pipe', 'pipe', 'pipe'],
env
})
const firstLine = result.trim().split(/\r?\n/)[0]?.trim()
if (firstLine && firstLine.toLowerCase().endsWith('.exe')) {
return firstLine
}
} catch {
// mise not on PATH
}
return null
}
/**
* Find an executable in the user's shell environment.
* This is a pure query -- it reads the (possibly cached) shell env and searches for the command.
* It does NOT refresh the shell env cache. Callers that need a fresh environment should call
* refreshShellEnv() explicitly before calling this function.
*
* Cross-platform: uses findCommandInShellEnv first, falls back to findExecutable on Windows,
* and finally tries mise as a last resort on Windows.
*/
export async function findExecutableInEnv(name: string): Promise<string | null> {
const env = await getShellEnv()
// Cross-platform: try shell environment lookup first
const found = await findCommandInShellEnv(name, env)
if (found) {
return found
}
// Windows fallback: findExecutable handles .cmd/.exe filtering and security checks
if (isWin) {
const winFound = findExecutable(name, { env })
if (winFound) {
return winFound
}
// Last resort on Windows: ask mise for the real binary path
return findViaMise(name, env)
}
return null
}
/**
* Spawn a process with proper Windows handling for .cmd files and npm shims.
* On Windows, .cmd/.bat files need `shell: true` so Node.js delegates quoting
* to cmd.exe via `/d /s /c "..."`. Manually constructing `cmd.exe /c` args
* breaks when both the command path and arguments contain spaces (cmd.exe's
* quote-stripping rule 2 kicks in and mangles the command line).
*/
export function crossPlatformSpawn(
command: string,
args: string[],
options: SpawnOptions & { env: Record<string, string> }
): ChildProcess {
if (isWin && !command.toLowerCase().endsWith('.exe')) {
return spawn(command, args, { ...options, shell: true, stdio: options.stdio ?? 'pipe' })
}
return spawn(command, args, { ...options, stdio: options.stdio ?? 'pipe' })
}
/**
* Execute a command and return its output.
* Uses crossPlatformSpawn internally for proper Windows .cmd handling.
* If no env is provided, automatically uses the shell environment.
*/
export async function executeCommand(
command: string,
args: string[],
options?: {
/** Capture and return stdout (default: false) */
capture?: boolean
/** Environment variables (defaults to getShellEnv()) */
env?: Record<string, string>
/** Timeout in milliseconds */
timeout?: number
}
): Promise<string> {
const env = options?.env ?? (await getShellEnv())
return new Promise<string>((resolve, reject) => {
const child = crossPlatformSpawn(command, args, { env })
let stdout = ''
let stderr = ''
child.stdout?.on('data', (chunk) => {
stdout += chunk.toString()
})
child.stderr?.on('data', (chunk) => {
stderr += chunk.toString()
})
let timeoutId: ReturnType<typeof setTimeout> | undefined
if (options?.timeout) {
timeoutId = setTimeout(() => {
child.kill('SIGKILL')
reject(new Error(`Command timed out after ${options.timeout}ms`))
}, options.timeout)
}
child.on('error', (err) => {
if (timeoutId) clearTimeout(timeoutId)
reject(err)
})
child.on('close', (code) => {
if (timeoutId) clearTimeout(timeoutId)
if (code === 0) {
resolve(options?.capture ? stdout : '')
} else {
reject(new Error(stderr || `Command failed with code ${code}`))
}
})
})
}
/**
* Common Git installation root directories on Windows
* Used by findExecutable() (git special case) and findGitBash() to check fallback paths
*/
function getCommonGitRoots(): string[] {
return [
path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Git'),
path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Git'),
...(process.env.LOCALAPPDATA ? [path.join(process.env.LOCALAPPDATA, 'Programs', 'Git')] : [])
]
}
/**
* Check if git is available in the user's environment
* Refreshes shell env cache to detect newly installed Git
* @returns Object with availability status and path to git executable
*/
export async function checkGitAvailable(): Promise<{ available: boolean; path: string | null }> {
await refreshShellEnv()
const gitPath = await findExecutableInEnv('git')
logger.debug(`git check result: ${gitPath ? `found at ${gitPath}` : 'not found'}`)
return { available: gitPath !== null, path: gitPath }
}
/**
* Find Git Bash (bash.exe) on Windows
* @param customPath - Optional custom path from config
* @returns Full path to bash.exe or null if not found
*/
export function findGitBash(customPath?: string | null): string | null {
// Git Bash is Windows-only
if (!isWin) {
return null
}
// 1. Check custom path from config first
if (customPath) {
const validated = validateGitBashPath(customPath)
if (validated) {
logger.debug('Using custom Git Bash path from config', { path: validated })
return validated
}
logger.warn('Custom Git Bash path provided but invalid', { path: customPath })
}
// 2. Check environment variable override
const envOverride = process.env.CLAUDE_CODE_GIT_BASH_PATH
if (envOverride) {
const validated = validateGitBashPath(envOverride)
if (validated) {
logger.debug('Using CLAUDE_CODE_GIT_BASH_PATH override for bash.exe', { path: validated })
return validated
}
logger.warn('CLAUDE_CODE_GIT_BASH_PATH provided but path is invalid', { path: envOverride })
}
// 3. Find git.exe via findExecutable (checks PATH + common Git install paths)
const gitPath = findExecutable('git')
if (gitPath) {
// Derive bash.exe from git.exe location
// Different Git installations have different directory structures
const possibleBashPaths = [
path.join(gitPath, '..', '..', 'bin', 'bash.exe'), // Standard Git: git.exe at Git/cmd/ -> navigate up 2 levels -> then bin/bash.exe
path.join(gitPath, '..', 'bash.exe'), // Portable Git: git.exe at Git/bin/ -> bash.exe in same directory
path.join(gitPath, '..', '..', 'usr', 'bin', 'bash.exe') // MSYS2 Git: git.exe at msys64/usr/bin/ -> navigate up 2 levels -> then usr/bin/bash.exe
]
for (const bashPath of possibleBashPaths) {
const resolvedBashPath = path.resolve(bashPath)
if (fs.existsSync(resolvedBashPath)) {
logger.debug('Found bash.exe via git.exe path derivation', { path: resolvedBashPath })
return resolvedBashPath
}
}
logger.debug('bash.exe not found at expected locations relative to git.exe', {
gitPath,
checkedPaths: possibleBashPaths.map((p) => path.resolve(p))
})
}
// 4. Fallback: check common Git installation paths directly
for (const root of getCommonGitRoots()) {
const fullPath = path.join(root, 'bin', 'bash.exe')
if (fs.existsSync(fullPath)) {
logger.debug('Found bash.exe at common path', { path: fullPath })
return fullPath
}
}
logger.debug('bash.exe not found - checked git derivation and common paths')
return null
}
export function validateGitBashPath(customPath?: string | null): string | null {
if (!customPath) {
return null
}
const resolved = path.resolve(customPath)
if (!fs.existsSync(resolved)) {
logger.warn('Custom Git Bash path does not exist', { path: resolved })
return null
}
const isExe = resolved.toLowerCase().endsWith('bash.exe')
if (!isExe) {
logger.warn('Custom Git Bash path is not bash.exe', { path: resolved })
return null
}
logger.debug('Validated custom Git Bash path', { path: resolved })
return resolved
}
/**
* Auto-discover and persist Git Bash path if not already configured
* Only called when Git Bash is actually needed
*
* Precedence order:
* 1. CLAUDE_CODE_GIT_BASH_PATH environment variable (highest - runtime override)
* 2. Configured path from settings (manual or auto)
* 3. Auto-discovery via findGitBash (only if no valid config exists)
*/
export function autoDiscoverGitBash(): string | null {
if (!isWin) {
return null
}
// 1. Check environment variable override first (highest priority)
const envOverride = process.env.CLAUDE_CODE_GIT_BASH_PATH
if (envOverride) {
const validated = validateGitBashPath(envOverride)
if (validated) {
logger.debug('Using CLAUDE_CODE_GIT_BASH_PATH override', { path: validated })
return validated
}
logger.warn('CLAUDE_CODE_GIT_BASH_PATH provided but path is invalid', { path: envOverride })
}
// 2. Check if a path is already configured
const existingPath = configManager.get<string | undefined>(ConfigKeys.GitBashPath)
const existingSource = configManager.get<GitBashPathSource | undefined>(ConfigKeys.GitBashPathSource)
if (existingPath) {
const validated = validateGitBashPath(existingPath)
if (validated) {
return validated
}
// Existing path is invalid, try to auto-discover
logger.warn('Existing Git Bash path is invalid, attempting auto-discovery', {
path: existingPath,
source: existingSource
})
}
// 3. Try to find Git Bash via auto-discovery
const discoveredPath = findGitBash()
if (discoveredPath) {
// Persist the discovered path with 'auto' source
configManager.set(ConfigKeys.GitBashPath, discoveredPath)
configManager.set(ConfigKeys.GitBashPathSource, 'auto')
logger.info('Auto-discovered Git Bash path', { path: discoveredPath })
}
return discoveredPath
}
/**
* Get Git Bash path info including source
* If no path is configured, triggers auto-discovery first
*/
export function getGitBashPathInfo(): GitBashPathInfo {
if (!isWin) {
return { path: null, source: null }
}
let path = configManager.get<string | null>(ConfigKeys.GitBashPath) ?? null
let source = configManager.get<GitBashPathSource | null>(ConfigKeys.GitBashPathSource) ?? null
// If no path configured, trigger auto-discovery (handles upgrade from old versions)
if (!path) {
path = autoDiscoverGitBash()
source = path ? 'auto' : null
}
return { path, source }
}
/**
* Strip proxy-related environment variables from an env object.
* Prevents child processes from inheriting proxy settings that may cause crashes
* (e.g. undici does not support socks5:// protocol).
*/
export function stripProxyEnvVars<T extends Record<string, string>>(env: T): T {
return Object.fromEntries(Object.entries(env).filter(([key]) => !key.toLowerCase().endsWith('_proxy'))) as T
}