| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207 |
- // 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<InstalledInfo> {
- 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<AllInstalledInfo> {
- 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<void> {
- 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<void> {
- 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<void> {
- 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<void> {
- 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<void> {
- 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<void> {
- 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<string, string> = { ...process.env as Record<string, string>, 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<void> {
- 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<boolean> {
- // 只有 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<string[]> {
- 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
- }
|