mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-24 10:40:07 +08:00
304 lines
10 KiB
TypeScript
304 lines
10 KiB
TypeScript
import { loggerService } from '@logger'
|
|
import type { GitBashPathInfo, GitBashPathSource } from '@shared/config/constant'
|
|
import { HOME_CHERRY_DIR } from '@shared/config/constant'
|
|
import { execFileSync, spawn } 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 '.'
|
|
|
|
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)
|
|
}
|
|
|
|
/**
|
|
* 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 .exe extension)
|
|
* @returns Full path to the executable or null if not found
|
|
*/
|
|
export function findExecutable(name: string): string | null {
|
|
// This implementation uses where.exe which is Windows-only
|
|
if (!isWin) {
|
|
return null
|
|
}
|
|
|
|
// Special handling for git - check common installation paths first
|
|
if (name === 'git') {
|
|
const commonGitPaths = [
|
|
path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Git', 'cmd', 'git.exe'),
|
|
path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Git', 'cmd', 'git.exe')
|
|
]
|
|
|
|
for (const gitPath of commonGitPaths) {
|
|
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 {
|
|
// Add .exe extension for more precise matching on Windows
|
|
const executableName = `${name}.exe`
|
|
const result = execFileSync('where.exe', [executableName], {
|
|
encoding: 'utf8',
|
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
})
|
|
|
|
// Handle both Windows (\r\n) and Unix (\n) line endings
|
|
const paths = result.trim().split(/\r?\n/).filter(Boolean)
|
|
const currentDir = process.cwd().toLowerCase()
|
|
|
|
// Security check: skip executables in current directory
|
|
for (const exePath of paths) {
|
|
// Trim whitespace from where.exe output
|
|
const cleanPath = exePath.trim()
|
|
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
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Find Git Bash executable 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 and derive bash.exe path
|
|
const gitPath = findExecutable('git')
|
|
if (gitPath) {
|
|
// Try multiple possible locations for bash.exe relative to git.exe
|
|
// 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 Bash paths directly
|
|
const commonBashPaths = [
|
|
path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Git', 'bin', 'bash.exe'),
|
|
path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Git', 'bin', 'bash.exe'),
|
|
...(process.env.LOCALAPPDATA ? [path.join(process.env.LOCALAPPDATA, 'Programs', 'Git', 'bin', 'bash.exe')] : [])
|
|
]
|
|
|
|
for (const bashPath of commonBashPaths) {
|
|
if (fs.existsSync(bashPath)) {
|
|
logger.debug('Found bash.exe at common path', { path: bashPath })
|
|
return bashPath
|
|
}
|
|
}
|
|
|
|
logger.debug('Git Bash 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 }
|
|
}
|