// electron/modules/installer.ts - 安装逻辑模块 import * as os from 'os' import * as path from 'path' import * as fs from 'fs' import { execa, type ResultPromise } from 'execa' import * as sudo from 'sudo-prompt' import type { SoftwareType, SoftwareTypeWithAll, InstallOptions, CommandResult, InstalledInfo, AllInstalledInfo, Platform } from './types' import { NPM_REGISTRY, ERROR_MESSAGES, STATUS_MESSAGES, BREW_PACKAGES, APP_NAME, FALLBACK_VERSIONS } from './constants' import { commandExistsWithRefresh, getCommandVersionWithRefresh, downloadFile, getTempDir, cancelCurrentDownload } from './utils' import { installVscodeExtension } from './vscode-extension' import { needsAptUpdate, markAptUpdated } from './source-updater' import { getGitDownloadUrl, getNodejsDownloadUrl, getVSCodeDownloadUrl, getNodejsMirrorConfig } from './version-fetcher' import logger from './logger' // 当前安装进程 let currentProcess: ResultPromise | null = null let installCancelled = false /** * 取消当前安装 */ export function cancelInstall(): boolean { installCancelled = true let cancelled = false // 取消正在进行的下载 if (cancelCurrentDownload()) { logger.installInfo('下载已取消') cancelled = true } // 取消正在运行的进程 if (currentProcess) { try { currentProcess.kill('SIGTERM') logger.installInfo('安装进程已取消') cancelled = true } catch (error) { logger.installError('取消安装进程失败', error) } } if (cancelled) { logger.installInfo('安装已取消') } return cancelled } /** * 重置取消状态 */ function resetCancelState(): void { installCancelled = false currentProcess = null } /** * 获取 npm 的完整路径 * Windows: 安装后 PATH 环境变量不会立即对当前进程生效,需要使用完整路径 * macOS/Linux: 通常不需要,但为了健壮性也提供完整路径 */ function getNpmPath(): string { const platform = os.platform() as Platform switch (platform) { case 'win32': { const programFiles = process.env.ProgramFiles || 'C:\\Program Files' return path.join(programFiles, 'nodejs', 'npm.cmd') } case 'darwin': { // Homebrew 在 Apple Silicon 上安装到 /opt/homebrew,Intel 上安装到 /usr/local const arch = os.arch() if (arch === 'arm64') { return '/opt/homebrew/bin/npm' } return '/usr/local/bin/npm' } case 'linux': return '/usr/bin/npm' default: return 'npm' } } /** * 获取 pnpm 的完整路径 */ function getPnpmPath(): string { const platform = os.platform() as Platform switch (platform) { case 'win32': { const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming') return path.join(appData, 'npm', 'pnpm.cmd') } case 'darwin': { const arch = os.arch() if (arch === 'arm64') { return '/opt/homebrew/bin/pnpm' } return '/usr/local/bin/pnpm' } case 'linux': return '/usr/local/bin/pnpm' default: return 'pnpm' } } /** * 检查是否已取消 */ function checkCancelled(): void { if (installCancelled) { throw new Error(ERROR_MESSAGES.INSTALL_CANCELLED) } } /** * 使用 sudo-prompt 执行需要管理员权限的命令 */ function sudoExec(command: string): Promise<{ stdout: string; stderr: string }> { return new Promise((resolve, reject) => { const options = { name: APP_NAME } sudo.exec(command, options, (error, stdout, stderr) => { // 过滤乱码字符的辅助函数(Windows 命令行输出可能是 GBK 编码) const cleanOutput = (str: string | Buffer | undefined): string => { if (!str) return '' const raw = str.toString() // 过滤掉非 ASCII 可打印字符和非中文字符,保留换行符 return raw.replace(/[^\x20-\x7E\u4e00-\u9fa5\n\r]/g, '').trim() } if (error) { // 包含 stderr 和 stdout 信息以便调试 const exitCode = (error as Error & { code?: number }).code const stderrStr = cleanOutput(stderr) const stdoutStr = cleanOutput(stdout) let errorMessage = error.message if (exitCode !== undefined) { errorMessage += `\n退出码: ${exitCode}` } if (stderrStr) { errorMessage += `\n详细信息: ${stderrStr}` } if (stdoutStr) { errorMessage += `\n输出: ${stdoutStr}` } const enhancedError = new Error(errorMessage) reject(enhancedError) } else { resolve({ stdout: cleanOutput(stdout), stderr: cleanOutput(stderr) }) } }) }) } /** * 为包含空格的路径添加引号(Windows) */ function quoteIfNeeded(str: string): string { // 如果字符串包含空格且没有被引号包围,则添加引号 if (str.includes(' ') && !str.startsWith('"') && !str.endsWith('"')) { return `"${str}"` } return str } /** * 跨平台执行系统命令(带权限处理) */ async function executeCommand( command: string, args: string[] = [], needAdmin = false ): Promise<{ stdout: string; stderr: string }> { checkCancelled() const platform = os.platform() as Platform // 构建完整命令时,对包含空格的部分添加引号 const quotedCommand = quoteIfNeeded(command) const quotedArgs = args.map(arg => quoteIfNeeded(arg)) const fullCommand = `${quotedCommand} ${quotedArgs.join(' ')}` logger.installDebug(`执行命令: ${fullCommand}`) try { if (needAdmin) { // 使用 sudo-prompt 执行需要管理员权限的命令 if (platform === 'win32') { // Windows: 直接执行命令,sudo-prompt 会处理 UAC return await sudoExec(fullCommand) } else { // Linux/macOS: 使用 sudo return await sudoExec(fullCommand) } } else { // 无需提权,直接执行 // Windows 上执行 .cmd 文件时,使用 shell: true 避免参数解析问题 const useShell = platform === 'win32' && command.endsWith('.cmd') const proc = execa(command, args, { stdio: 'pipe', shell: useShell }) currentProcess = proc const result = await proc currentProcess = null return { stdout: result.stdout, stderr: result.stderr } } } catch (error) { logger.installError(`命令执行失败: ${fullCommand}`, error) throw error } } /** * 检测软件是否已安装 * Windows: 安装后 PATH 环境变量不会立即对当前进程生效, * 所以使用 commandExistsWithRefresh 从注册表重新读取最新的 PATH */ export async function checkInstalled(software: SoftwareType): Promise { let command: string let versionArgs: string[] switch (software) { case 'nodejs': command = 'node' versionArgs = ['--version'] break case 'pnpm': command = 'pnpm' versionArgs = ['--version'] break case 'vscode': command = 'code' versionArgs = ['--version'] break case 'git': command = 'git' versionArgs = ['--version'] break case 'claudeCode': command = 'claude' versionArgs = ['--version'] break default: return { installed: false, version: null } } // 使用刷新后的 PATH 检测命令是否存在(Windows 上会从注册表重新读取 PATH) const exists = await commandExistsWithRefresh(command) if (!exists) { return { installed: false, version: null } } // 使用刷新后的 PATH 获取版本 const version = await getCommandVersionWithRefresh(command, versionArgs) return { installed: true, version } } /** * 检测所有软件的安装状态 */ export async function checkAllInstalled(): Promise { const results = await Promise.allSettled([ checkInstalled('nodejs'), checkInstalled('pnpm'), checkInstalled('vscode'), checkInstalled('git'), checkInstalled('claudeCode') ]) return { nodejs: results[0].status === 'fulfilled' ? results[0].value : { installed: false, version: null }, pnpm: results[1].status === 'fulfilled' ? results[1].value : { installed: false, version: null }, vscode: results[2].status === 'fulfilled' ? results[2].value : { installed: false, version: null }, git: results[3].status === 'fulfilled' ? results[3].value : { installed: false, version: null }, claudeCode: results[4].status === 'fulfilled' ? results[4].value : { installed: false, version: null } } } // ==================== 安装命令生成 ==================== /** * 获取 Node.js 安装命令 (macOS/Linux 使用包管理器) * Windows 使用下载 msi 安装包方式,不使用此函数 * @param version 版本号 */ function getNodeInstallArgs(version = 'lts'): CommandResult { const platform = os.platform() as Platform const major = version.split('.')[0] const majorNum = parseInt(major) let brewPkg: string if (majorNum === 20) { brewPkg = BREW_PACKAGES.nodejs['20'] } else if (majorNum === 18) { brewPkg = BREW_PACKAGES.nodejs['18'] } else { brewPkg = BREW_PACKAGES.nodejs.default } switch (platform) { case 'darwin': return { command: 'brew', args: ['install', brewPkg] } case 'linux': return { command: 'apt', args: ['install', '-y', 'nodejs', 'npm'] } default: throw new Error(`${ERROR_MESSAGES.UNSUPPORTED_PLATFORM}: ${platform}`) } } /** * 获取 VS Code 安装命令 (macOS/Linux 使用包管理器) * Windows 使用下载安装包方式,不使用此函数(除了 insiders 版本) * @param version 版本号 */ function getVSCodeInstallArgs(version = 'stable'): CommandResult { const platform = os.platform() as Platform if (version === 'insiders') { switch (platform) { case 'darwin': return { command: 'brew', args: ['install', '--cask', BREW_PACKAGES.vscode.insiders] } case 'linux': return { command: 'snap', args: ['install', 'code', '--channel=insiders'] } default: throw new Error(`${ERROR_MESSAGES.UNSUPPORTED_PLATFORM}: ${platform}`) } } switch (platform) { case 'darwin': return { command: 'brew', args: ['install', '--cask', BREW_PACKAGES.vscode.stable] } case 'linux': return { command: 'snap', args: ['install', 'code', '--classic'] } default: throw new Error(`${ERROR_MESSAGES.UNSUPPORTED_PLATFORM}: ${platform}`) } } /** * 获取 Git 安装命令 (macOS/Linux 使用包管理器) * Windows 使用下载安装包方式,不使用此函数 * @param version 版本号 */ function getGitInstallArgs(version = 'stable'): CommandResult { const platform = os.platform() as Platform if (version === 'mingit') { switch (platform) { case 'darwin': return { command: 'brew', args: ['install', BREW_PACKAGES.git.stable] } case 'linux': return { command: 'apt', args: ['install', '-y', 'git'] } default: throw new Error(`${ERROR_MESSAGES.UNSUPPORTED_PLATFORM}: ${platform}`) } } if (version === 'lfs') { switch (platform) { case 'darwin': return { command: 'brew', args: ['install', BREW_PACKAGES.git.lfs] } case 'linux': return { command: 'apt', args: ['install', '-y', 'git-lfs'] } default: throw new Error(`${ERROR_MESSAGES.UNSUPPORTED_PLATFORM}: ${platform}`) } } switch (platform) { case 'darwin': return { command: 'brew', args: ['install', BREW_PACKAGES.git.stable] } case 'linux': return { command: 'apt', args: ['install', '-y', 'git'] } default: throw new Error(`${ERROR_MESSAGES.UNSUPPORTED_PLATFORM}: ${platform}`) } } // 可卸载的软件类型 type UninstallableSoftwareType = 'nodejs' | 'vscode' | 'git' /** * 获取卸载命令 (macOS/Linux 使用包管理器) * Windows 暂不支持通过此工具卸载,请使用系统设置 */ function getUninstallArgs(software: UninstallableSoftwareType): CommandResult { const platform = os.platform() as Platform switch (platform) { case 'win32': // Windows 暂不支持通过此工具卸载,请使用系统设置中的"应用和功能" throw new Error('Windows 暂不支持通过此工具卸载软件,请使用系统设置中的"应用和功能"') case 'darwin': { let brewPkg: string switch (software) { case 'nodejs': brewPkg = BREW_PACKAGES.nodejs.default break case 'vscode': brewPkg = BREW_PACKAGES.vscode.stable break case 'git': brewPkg = BREW_PACKAGES.git.stable break } return { command: 'brew', args: ['uninstall', brewPkg] } } case 'linux': { let aptPkg: string switch (software) { case 'nodejs': aptPkg = 'nodejs' break case 'vscode': return { command: 'snap', args: ['remove', 'code'] } case 'git': aptPkg = 'git' break } return { command: 'apt', args: ['remove', '-y', aptPkg] } } default: throw new Error(`${ERROR_MESSAGES.UNSUPPORTED_PLATFORM}: ${platform}`) } } // ==================== 安装流程 ==================== export type StatusCallback = (software: SoftwareTypeWithAll, message: string, progress: number, skipLog?: boolean) => void export type CompleteCallback = (software: SoftwareTypeWithAll, message: string) => void export type ErrorCallback = (software: SoftwareTypeWithAll, message: string) => void /** * Linux 下执行 apt update(带缓存,1天内只更新一次) */ export async function aptUpdate(onStatus?: StatusCallback, software?: SoftwareTypeWithAll): Promise { if (os.platform() !== 'linux') return if (!needsAptUpdate()) { logger.installInfo('apt 源已在缓存期内,跳过更新') return } if (onStatus && software) { onStatus(software, STATUS_MESSAGES.UPDATING_SOURCE, 10) } await executeCommand('apt', ['update'], true) markAptUpdated() } /** * 安装 Node.js (Windows 使用 msi 安装包) * @param version 版本号 * @param installPnpm 是否安装 pnpm * @param onStatus 状态回调 * @param customPath 自定义安装路径 (仅 Windows 支持) */ export async function installNodejs( version = 'lts', installPnpm = true, onStatus: StatusCallback, customPath?: string ): Promise { resetCancelState() const platform = os.platform() as Platform // Windows: 直接下载 msi 安装包 if (platform === 'win32') { // 确定目标版本 let targetVersion = version if (version === 'lts' || !version || !/^\d+\.\d+\.\d+$/.test(version)) { // 如果是 lts 或无效版本,使用备用版本 targetVersion = FALLBACK_VERSIONS.nodejs } checkCancelled() // 下载 msi 安装包 const arch = os.arch() === 'x64' ? 'x64' : os.arch() === 'arm64' ? 'arm64' : 'x86' const downloadUrl = getNodejsDownloadUrl(targetVersion).replace('.zip', '.msi').replace(`-win-${arch}`, `-${arch}`) const tempDir = getTempDir() const installerPath = path.join(tempDir, `node-v${targetVersion}-${arch}.msi`) onStatus('nodejs', `正在下载 Node.js ${targetVersion}...`, 10) logger.installInfo(`开始下载 Node.js: ${downloadUrl}`) try { let lastLoggedPercent = 0 await downloadFile(downloadUrl, installerPath, (downloaded, total, percent) => { const progress = 10 + Math.round(percent * 0.4) const downloadedMB = (downloaded / 1024 / 1024).toFixed(1) const totalMB = (total / 1024 / 1024).toFixed(1) // 每次都更新进度条,但只在每 5% 时记录日志 const shouldLog = percent - lastLoggedPercent >= 5 if (shouldLog) { lastLoggedPercent = Math.floor(percent / 5) * 5 } onStatus('nodejs', `正在下载 Node.js ${targetVersion}... (${downloadedMB}MB / ${totalMB}MB)`, progress, !shouldLog) }) } catch (error) { logger.installError('下载 Node.js 失败', error) throw new Error(`下载 Node.js 失败: ${(error as Error).message}`) } checkCancelled() // 执行 msi 静默安装 onStatus('nodejs', `${STATUS_MESSAGES.INSTALLING} Node.js ${targetVersion}...`, 55) logger.installInfo(`开始安装 Node.js: ${installerPath}`) try { // msiexec 静默安装参数 const installArgs = ['/i', installerPath, '/qn', '/norestart'] if (customPath) { installArgs.push(`INSTALLDIR="${customPath}"`) } await sudoExec(`msiexec ${installArgs.join(' ')}`) // 清理安装包 try { await fs.promises.unlink(installerPath) } catch { // 忽略清理失败 } } catch (error) { logger.installError('安装 Node.js 失败', error) throw error } checkCancelled() // 使用完整路径,避免 PATH 未生效的问题 const npmCmd = getNpmPath() // 仅当使用国内镜像下载时,才配置 npm 国内镜像 const { mirror: nodejsMirror } = getNodejsMirrorConfig() if (nodejsMirror === 'npmmirror') { const npmConfigCmd = `${npmCmd} config set registry ${NPM_REGISTRY}` onStatus('nodejs', `${STATUS_MESSAGES.CONFIGURING} npm 镜像: ${npmConfigCmd}`, 70) try { await executeCommand(npmCmd, ['config', 'set', 'registry', NPM_REGISTRY], false) } catch (error) { logger.installWarn('配置 npm 镜像失败(npm 可能还未加入 PATH)', error) } } checkCancelled() // 可选安装 pnpm if (installPnpm) { onStatus('nodejs', `${STATUS_MESSAGES.INSTALLING} pnpm...`, 80) await executeCommand(npmCmd, ['install', '-g', 'pnpm'], true) checkCancelled() // 获取刷新后的 PATH,确保能找到刚安装的 pnpm const { getRefreshedPath } = await import('./utils') const refreshedPath = await getRefreshedPath() const execEnv = { ...process.env, PATH: refreshedPath } // 运行 pnpm setup 配置全局 bin 目录 onStatus('nodejs', `${STATUS_MESSAGES.CONFIGURING} pnpm 全局目录...`, 88) try { await execa('pnpm', ['setup'], { env: execEnv, shell: platform === 'win32' }) logger.installInfo('pnpm setup 完成') } catch (error) { logger.installWarn('pnpm setup 失败', error) } checkCancelled() // 配置 pnpm 镜像 onStatus('nodejs', `${STATUS_MESSAGES.CONFIGURING} pnpm 镜像...`, 95) try { await execa('pnpm', ['config', 'set', 'registry', NPM_REGISTRY], { env: execEnv, shell: platform === 'win32' }) } catch (error) { logger.installWarn('配置 pnpm 镜像失败', error) } } onStatus('nodejs', STATUS_MESSAGES.COMPLETE, 100) return } // macOS/Linux: 使用包管理器安装 onStatus('nodejs', `${STATUS_MESSAGES.INSTALLING} Node.js...`, 20) const args = getNodeInstallArgs(version) await executeCommand(args.command, args.args, true) checkCancelled() // 使用完整路径,避免 PATH 未生效的问题 const npmCmd = getNpmPath() // 仅当使用国内镜像下载时,才配置 npm 国内镜像 const { mirror: nodejsMirrorMac } = getNodejsMirrorConfig() if (nodejsMirrorMac === 'npmmirror') { const npmConfigCmd = `${npmCmd} config set registry ${NPM_REGISTRY}` onStatus('nodejs', `${STATUS_MESSAGES.CONFIGURING} npm 镜像: ${npmConfigCmd}`, 50) try { await executeCommand(npmCmd, ['config', 'set', 'registry', NPM_REGISTRY], false) } catch (error) { logger.installWarn('配置 npm 镜像失败(npm 可能还未加入 PATH)', error) } } checkCancelled() // 可选安装 pnpm if (installPnpm) { onStatus('nodejs', `${STATUS_MESSAGES.INSTALLING} pnpm...`, 70) await executeCommand(npmCmd, ['install', '-g', 'pnpm'], true) checkCancelled() // 获取刷新后的 PATH,确保能找到刚安装的 pnpm const { getRefreshedPath } = await import('./utils') const refreshedPath = await getRefreshedPath() const execEnv = { ...process.env, PATH: refreshedPath } // 运行 pnpm setup 配置全局 bin 目录 onStatus('nodejs', `${STATUS_MESSAGES.CONFIGURING} pnpm 全局目录...`, 82) try { await execa('pnpm', ['setup'], { env: execEnv }) logger.installInfo('pnpm setup 完成') } catch (error) { logger.installWarn('pnpm setup 失败', error) } checkCancelled() // 配置 pnpm 镜像 onStatus('nodejs', `${STATUS_MESSAGES.CONFIGURING} pnpm 镜像...`, 90) try { await execa('pnpm', ['config', 'set', 'registry', NPM_REGISTRY], { env: execEnv }) } catch (error) { logger.installWarn('配置 pnpm 镜像失败', error) } } onStatus('nodejs', STATUS_MESSAGES.COMPLETE, 100) } /** * 单独安装 pnpm (需要 Node.js 已安装) * @param onStatus 状态回调 */ export async function installPnpm(onStatus: StatusCallback): Promise { resetCancelState() const platform = os.platform() as Platform // 检查 Node.js 是否已安装 const nodeInstalled = await checkInstalled('nodejs') if (!nodeInstalled.installed) { throw new Error('请先安装 Node.js') } // 刷新系统环境变量,确保能找到刚安装的 Node.js const { getRefreshedPath } = await import('./utils') await getRefreshedPath() // 使用完整路径,避免 PATH 未生效的问题 const npmCmd = getNpmPath() // 检查 npm 命令是否存在 if (platform === 'win32' && !fs.existsSync(npmCmd)) { throw new Error(`未找到 npm 命令: ${npmCmd},请确保 Node.js 已正确安装`) } checkCancelled() // 安装 pnpm,需要管理员权限进行全局安装 const installCommand = `${npmCmd} install -g pnpm` onStatus('pnpm', `正在安装 pnpm...`, 20) onStatus('pnpm', `执行命令: ${installCommand}`, 25) try { await executeCommand(npmCmd, ['install', '-g', 'pnpm'], true) } catch (error) { // 提取更有意义的错误信息,过滤乱码 const execaError = error as { message?: string; stderr?: string; stdout?: string; shortMessage?: string; exitCode?: number } let errorMessage = `npm install -g pnpm 失败` if (execaError.exitCode !== undefined) { errorMessage += ` (退出码: ${execaError.exitCode})` } // 过滤乱码字符,只保留可读字符 if (execaError.stderr) { const stderrClean = execaError.stderr.replace(/[^\x20-\x7E\u4e00-\u9fa5\n\r]/g, '').trim() if (stderrClean) { errorMessage += `\n${stderrClean}` } } if (execaError.stdout) { const stdoutClean = execaError.stdout.replace(/[^\x20-\x7E\u4e00-\u9fa5\n\r]/g, '').trim() if (stdoutClean) { errorMessage += `\n${stdoutClean}` } } logger.installError('安装 pnpm 失败', error) throw new Error(errorMessage) } checkCancelled() // 重新刷新 PATH,确保能找到刚安装的 pnpm const pnpmRefreshedPath = await getRefreshedPath() // 使用 pnpm 的完整路径,因为刚安装的 pnpm 可能还不在 PATH 中 const pnpmCmd = getPnpmPath() // 构建环境变量,将 pnpm 所在目录添加到 PATH 开头 const pnpmDir = path.dirname(pnpmCmd) const execEnv = { ...process.env, PATH: `${pnpmDir}${platform === 'win32' ? ';' : ':'}${pnpmRefreshedPath}` } // 运行 pnpm setup 配置全局 bin 目录 onStatus('pnpm', `${STATUS_MESSAGES.CONFIGURING} pnpm 全局目录...`, 60) try { await execa(pnpmCmd, ['setup'], { env: execEnv, shell: platform === 'win32' }) logger.installInfo('pnpm setup 完成') } catch (error) { logger.installWarn('pnpm setup 失败', error) } checkCancelled() // 配置 pnpm 镜像 onStatus('pnpm', `${STATUS_MESSAGES.CONFIGURING} pnpm 镜像...`, 80) try { await execa(pnpmCmd, ['config', 'set', 'registry', NPM_REGISTRY], { env: execEnv, shell: platform === 'win32' }) } catch (error) { logger.installWarn('配置 pnpm 镜像失败', error) } onStatus('pnpm', STATUS_MESSAGES.COMPLETE, 100) } /** * 安装 VS Code (Windows 使用下载安装包) * @param version 版本号 * @param onStatus 状态回调 * @param customPath 自定义安装路径 (仅 Windows 支持) */ export async function installVscode(version = 'stable', onStatus: StatusCallback, customPath?: string): Promise { resetCancelState() const platform = os.platform() as Platform // Windows: 直接下载安装包 if (platform === 'win32' && version !== 'insiders') { // 确定目标版本 let targetVersion = version if (version === 'stable' || !version || !/^\d+\.\d+\.\d+$/.test(version)) { // 如果是 stable 或无效版本,使用备用版本 targetVersion = FALLBACK_VERSIONS.vscode } checkCancelled() // 下载安装包 const downloadUrl = getVSCodeDownloadUrl(targetVersion) const tempDir = getTempDir() const installerPath = path.join(tempDir, `VSCodeUserSetup-${targetVersion}.exe`) onStatus('vscode', `正在下载 VS Code ${targetVersion}...`, 10) logger.installInfo(`开始下载 VS Code: ${downloadUrl}`) try { let lastLoggedPercent = 0 await downloadFile(downloadUrl, installerPath, (downloaded, total, percent) => { const progress = 10 + Math.round(percent * 0.5) const downloadedMB = (downloaded / 1024 / 1024).toFixed(1) const totalMB = (total / 1024 / 1024).toFixed(1) // 每次都更新进度条,但只在每 5% 时记录日志 const shouldLog = percent - lastLoggedPercent >= 5 if (shouldLog) { lastLoggedPercent = Math.floor(percent / 5) * 5 } onStatus('vscode', `正在下载 VS Code ${targetVersion}... (${downloadedMB}MB / ${totalMB}MB)`, progress, !shouldLog) }) } catch (error) { logger.installError('下载 VS Code 失败', error) throw new Error(`下载 VS Code 失败: ${(error as Error).message}`) } checkCancelled() // 执行静默安装 onStatus('vscode', `${STATUS_MESSAGES.INSTALLING} VS Code ${targetVersion}...`, 65) logger.installInfo(`开始安装 VS Code: ${installerPath}`) try { // VS Code 安装程序支持的静默安装参数 // /VERYSILENT: 完全静默安装 // /NORESTART: 不重启 // /MERGETASKS: 指定安装任务 const installArgs = ['/VERYSILENT', '/NORESTART', '/MERGETASKS=!runcode,addcontextmenufiles,addcontextmenufolders,associatewithfiles,addtopath'] if (customPath) { installArgs.push(`/DIR=${customPath}`) } await sudoExec(`"${installerPath}" ${installArgs.join(' ')}`) // 清理安装包 try { await fs.promises.unlink(installerPath) } catch { // 忽略清理失败 } onStatus('vscode', STATUS_MESSAGES.COMPLETE, 100) } catch (error) { logger.installError('安装 VS Code 失败', error) throw error } return } // macOS/Linux 或 insiders 版本: 使用包管理器安装 onStatus('vscode', `${STATUS_MESSAGES.INSTALLING} VS Code...`, 30) const args = getVSCodeInstallArgs(version) await executeCommand(args.command, args.args, true) onStatus('vscode', STATUS_MESSAGES.COMPLETE, 100) } /** * 安装 Git (Windows 直接下载安装) * @param version 版本号 * @param onStatus 状态回调 * @param customPath 自定义安装路径 (仅 Windows 支持) */ export async function installGit(version = 'stable', onStatus: StatusCallback, customPath?: string): Promise { resetCancelState() const platform = os.platform() as Platform // Windows: 直接从镜像下载安装 if (platform === 'win32' && version !== 'mingit' && version !== 'lfs') { // 如果是 stable,尝试获取最新版本号 let targetVersion = version if (version === 'stable') { onStatus('git', '正在获取最新版本...', 5) try { const response = await fetch('https://api.github.com/repos/git-for-windows/git/releases/latest', { headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'ApqInstaller' }, signal: AbortSignal.timeout(5000) // 5秒超时 }) if (response.ok) { const release = await response.json() as { tag_name: string } const match = release.tag_name.match(/^v?(\d+\.\d+\.\d+)/) if (match) { targetVersion = match[1] } } } catch (error) { logger.installWarn('获取最新 Git 版本失败,使用备用版本', error) // 使用备用版本 targetVersion = FALLBACK_VERSIONS.git } } checkCancelled() // 下载安装包 const downloadUrl = getGitDownloadUrl(targetVersion) const tempDir = getTempDir() const installerPath = path.join(tempDir, `Git-${targetVersion}-installer.exe`) onStatus('git', `正在下载 Git ${targetVersion}...`, 10) logger.installInfo(`开始下载 Git: ${downloadUrl}`) try { let lastLoggedPercent = 0 await downloadFile(downloadUrl, installerPath, (downloaded, total, percent) => { // 下载进度占 10% - 60% const progress = 10 + Math.round(percent * 0.5) const downloadedMB = (downloaded / 1024 / 1024).toFixed(1) const totalMB = (total / 1024 / 1024).toFixed(1) // 每次都更新进度条,但只在每 5% 时记录日志 const shouldLog = percent - lastLoggedPercent >= 5 if (shouldLog) { lastLoggedPercent = Math.floor(percent / 5) * 5 } onStatus('git', `正在下载 Git ${targetVersion}... (${downloadedMB}MB / ${totalMB}MB)`, progress, !shouldLog) }) } catch (error) { logger.installError('下载 Git 失败', error) throw new Error(`下载 Git 失败: ${(error as Error).message}`) } checkCancelled() // 执行静默安装 onStatus('git', `${STATUS_MESSAGES.INSTALLING} Git ${targetVersion}...`, 65) logger.installInfo(`开始安装 Git: ${installerPath}`) try { // Git 安装程序支持的静默安装参数 // /VERYSILENT: 完全静默安装 // /NORESTART: 不重启 // /NOCANCEL: 不显示取消按钮 // /SP-: 不显示 "This will install..." 提示 // /CLOSEAPPLICATIONS: 自动关闭相关应用 // /DIR: 指定安装目录 const installArgs = ['/VERYSILENT', '/NORESTART', '/NOCANCEL', '/SP-', '/CLOSEAPPLICATIONS'] if (customPath) { installArgs.push(`/DIR=${customPath}`) } await sudoExec(`"${installerPath}" ${installArgs.join(' ')}`) // 清理安装包 try { await fs.promises.unlink(installerPath) } catch { // 忽略清理失败 } onStatus('git', STATUS_MESSAGES.COMPLETE, 100) } catch (error) { logger.installError('安装 Git 失败', error) throw error } return } // Windows 上的特殊版本 (mingit/lfs) 暂不支持 if (platform === 'win32') { throw new Error(`Windows 暂不支持安装 ${version} 版本,请选择具体版本号`) } // macOS/Linux: 使用包管理器安装 onStatus('git', `${STATUS_MESSAGES.INSTALLING} Git...`, 30) const args = getGitInstallArgs(version) await executeCommand(args.command, args.args, true) onStatus('git', STATUS_MESSAGES.COMPLETE, 100) } /** * 安装 Claude Code (通过 pnpm 或 npm 全局安装) * @param onStatus 状态回调 */ export async function installClaudeCode(onStatus: StatusCallback): Promise { resetCancelState() const platform = os.platform() as Platform onStatus('claudeCode', '正在安装 Claude Code...', 10) logger.installInfo('开始安装 Claude Code...') checkCancelled() // 检测是否已安装 pnpm,优先使用 pnpm const hasPnpm = await commandExistsWithRefresh('pnpm') const pkgManager = hasPnpm ? 'pnpm' : 'npm' // 获取刷新后的 PATH,确保能找到新安装的命令 const { getRefreshedPath } = await import('./utils') const refreshedPath = await getRefreshedPath() const execEnv: Record = { ...process.env as Record, PATH: refreshedPath } if (hasPnpm) { // 使用 pnpm 安装,需要确保 PNPM_HOME 环境变量已设置 onStatus('claudeCode', '配置 pnpm 全局目录...', 20) // 先执行 pnpm setup try { await execa('pnpm', ['setup'], { env: execEnv, shell: platform === 'win32' }) logger.installInfo('pnpm setup 完成') } catch (error) { logger.installWarn('pnpm setup 失败', error) } // 手动设置 PNPM_HOME 环境变量 if (platform === 'win32') { const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local') const pnpmHome = path.join(localAppData, 'pnpm') execEnv.PNPM_HOME = pnpmHome execEnv.PATH = `${pnpmHome};${execEnv.PATH}` logger.installInfo(`设置 PNPM_HOME: ${pnpmHome}`) } else { const pnpmHome = path.join(os.homedir(), '.local', 'share', 'pnpm') execEnv.PNPM_HOME = pnpmHome execEnv.PATH = `${pnpmHome}:${execEnv.PATH}` logger.installInfo(`设置 PNPM_HOME: ${pnpmHome}`) } } checkCancelled() const installArgs = ['install', '-g', '@anthropic-ai/claude-code'] const fullCommand = `${pkgManager} ${installArgs.join(' ')}` onStatus('claudeCode', `执行命令: ${fullCommand}`, 30) logger.installInfo(`使用 ${pkgManager} 安装,执行命令: ${fullCommand}`) try { if (hasPnpm) { // 使用 pnpm 安装 Claude Code await execa('pnpm', installArgs, { env: execEnv, shell: platform === 'win32' }) } else { // 使用 npm 安装 const npmCmd = getNpmPath() await executeCommand(npmCmd, installArgs, true) } } catch (error) { logger.installError('安装 Claude Code 失败', error) throw error } checkCancelled() // 验证安装 onStatus('claudeCode', '验证安装...', 90) const claudeExists = await commandExistsWithRefresh('claude') if (claudeExists) { const { getCommandVersionWithRefresh } = await import('./utils') const version = await getCommandVersionWithRefresh('claude', ['--version']) logger.installInfo(`Claude Code 安装成功: ${version}`) } else { // 即使验证失败,安装可能已成功,只是 PATH 还没生效 logger.installInfo('Claude Code 安装完成,但验证失败(可能需要重启终端)') } onStatus('claudeCode', STATUS_MESSAGES.COMPLETE, 100) } /** * 安装 Claude Code for VS Code 扩展 * 复用 ipc-handlers.ts 中的 installVscodeExtension 函数 * @param _onStatus 状态回调(已由 installVscodeExtension 内部处理) */ export async function installClaudeCodeExt(_onStatus: StatusCallback): Promise { resetCancelState() checkCancelled() const extensionId = 'anthropic.claude-code' const result = await installVscodeExtension(extensionId) if (!result.success) { throw new Error(result.error || 'VS Code 插件安装失败') } } /** * 卸载软件 */ export async function uninstallSoftware(software: SoftwareType): Promise { // 只有 nodejs, vscode, git 支持卸载 if (software !== 'nodejs' && software !== 'vscode' && software !== 'git') { logger.installWarn(`${software} 不支持通过此方式卸载`) return false } try { const args = getUninstallArgs(software) await executeCommand(args.command, args.args, true) logger.installInfo(`${software} 卸载成功`) return true } catch (error) { logger.installError(`${software} 卸载失败`, error) return false } } /** * 一键安装所有软件 * 复用单独安装函数,避免代码重复 */ export async function installAll(options: InstallOptions, onStatus: StatusCallback): Promise { resetCancelState() onStatus('all', '正在准备安装...', 5) const { installNodejs: doNodejs = true, nodejsVersion = 'lts', nodejsPath, installPnpm: doPnpm = true, installVscode: doVscode = true, vscodeVersion = 'stable', vscodePath, installGit: doGit = true, gitVersion = 'stable', gitPath, installClaudeCode: doClaudeCode = false, installClaudeCodeExt: doClaudeCodeExt = false } = options // 计算总步骤数 const steps = (doNodejs ? 1 : 0) + (doPnpm && !doNodejs ? 1 : 0) + // 如果安装 Node.js,pnpm 会一起安装 (doGit ? 1 : 0) + (doVscode ? 1 : 0) + (doClaudeCode ? 1 : 0) + (doClaudeCodeExt ? 1 : 0) let currentStep = 0 const getProgress = (): number => Math.round(((currentStep + 0.5) / steps) * 100) // 创建包装的状态回调,将子安装的状态转发到 'all' const createWrappedStatus = (stepName: string): StatusCallback => { return (_software, message, _progress, skipLog) => { onStatus('all', `[${stepName}] ${message}`, getProgress(), skipLog) } } // 安装 Node.js(包含 pnpm) if (doNodejs) { checkCancelled() const wrappedStatus = createWrappedStatus('Node.js') await installNodejs(nodejsVersion, doPnpm, wrappedStatus, nodejsPath) currentStep++ } else if (doPnpm) { // 如果不安装 Node.js 但需要安装 pnpm,单独安装 pnpm checkCancelled() const wrappedStatus = createWrappedStatus('pnpm') await installPnpm(wrappedStatus) currentStep++ } // 安装 Git (Claude Code 运行时需要 Git) if (doGit) { checkCancelled() const wrappedStatus = createWrappedStatus('Git') await installGit(gitVersion, wrappedStatus, gitPath) currentStep++ } // 安装 Claude Code (需要 Node.js 和 Git,应在 Git 之后安装) if (doClaudeCode) { checkCancelled() const wrappedStatus = createWrappedStatus('Claude Code') await installClaudeCode(wrappedStatus) currentStep++ } // 安装 VS Code (Claude Code Ext 需要 VS Code) if (doVscode) { checkCancelled() const wrappedStatus = createWrappedStatus('VS Code') await installVscode(vscodeVersion, wrappedStatus, vscodePath) currentStep++ // 如果需要安装扩展,等待 VS Code CLI 准备就绪 if (doClaudeCodeExt) { onStatus('all', '[VS Code] 等待 VS Code CLI 准备就绪...', getProgress(), true) await new Promise(resolve => setTimeout(resolve, 3000)) } } // 安装 Claude Code for VS Code 扩展 (需要 VS Code 和 Claude Code) if (doClaudeCodeExt) { checkCancelled() const wrappedStatus = createWrappedStatus('Claude Code 插件') try { await installClaudeCodeExt(wrappedStatus) } catch (error) { // 插件安装失败不阻止整体流程 logger.installWarn('Claude Code for VS Code 扩展安装失败', error) } currentStep++ } // 生成完成消息 const installed: string[] = [] if (doNodejs) installed.push('Node.js') if (doPnpm) installed.push('pnpm') if (doVscode) installed.push('VS Code') if (doGit) installed.push('Git') if (doClaudeCode) installed.push('Claude Code') if (doClaudeCodeExt) installed.push('Claude Code 插件') return installed } export { executeCommand, getNodeInstallArgs, getVSCodeInstallArgs, getGitInstallArgs, getNpmPath, getPnpmPath }