installer.ts 38 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207
  1. // electron/modules/installer.ts - 安装逻辑模块
  2. import * as os from 'os'
  3. import * as path from 'path'
  4. import * as fs from 'fs'
  5. import { execa, type ResultPromise } from 'execa'
  6. import * as sudo from 'sudo-prompt'
  7. import type { SoftwareType, SoftwareTypeWithAll, InstallOptions, CommandResult, InstalledInfo, AllInstalledInfo, Platform } from './types'
  8. import {
  9. NPM_REGISTRY,
  10. ERROR_MESSAGES,
  11. STATUS_MESSAGES,
  12. BREW_PACKAGES,
  13. APP_NAME,
  14. FALLBACK_VERSIONS
  15. } from './constants'
  16. import { commandExistsWithRefresh, getCommandVersionWithRefresh, downloadFile, getTempDir, cancelCurrentDownload } from './utils'
  17. import { installVscodeExtension } from './vscode-extension'
  18. import { needsAptUpdate, markAptUpdated } from './source-updater'
  19. import { getGitDownloadUrl, getNodejsDownloadUrl, getVSCodeDownloadUrl, getNodejsMirrorConfig } from './version-fetcher'
  20. import logger from './logger'
  21. // 当前安装进程
  22. let currentProcess: ResultPromise | null = null
  23. let installCancelled = false
  24. /**
  25. * 取消当前安装
  26. */
  27. export function cancelInstall(): boolean {
  28. installCancelled = true
  29. let cancelled = false
  30. // 取消正在进行的下载
  31. if (cancelCurrentDownload()) {
  32. logger.installInfo('下载已取消')
  33. cancelled = true
  34. }
  35. // 取消正在运行的进程
  36. if (currentProcess) {
  37. try {
  38. currentProcess.kill('SIGTERM')
  39. logger.installInfo('安装进程已取消')
  40. cancelled = true
  41. } catch (error) {
  42. logger.installError('取消安装进程失败', error)
  43. }
  44. }
  45. if (cancelled) {
  46. logger.installInfo('安装已取消')
  47. }
  48. return cancelled
  49. }
  50. /**
  51. * 重置取消状态
  52. */
  53. function resetCancelState(): void {
  54. installCancelled = false
  55. currentProcess = null
  56. }
  57. /**
  58. * 获取 npm 的完整路径
  59. * Windows: 安装后 PATH 环境变量不会立即对当前进程生效,需要使用完整路径
  60. * macOS/Linux: 通常不需要,但为了健壮性也提供完整路径
  61. */
  62. function getNpmPath(): string {
  63. const platform = os.platform() as Platform
  64. switch (platform) {
  65. case 'win32': {
  66. const programFiles = process.env.ProgramFiles || 'C:\\Program Files'
  67. return path.join(programFiles, 'nodejs', 'npm.cmd')
  68. }
  69. case 'darwin': {
  70. // Homebrew 在 Apple Silicon 上安装到 /opt/homebrew,Intel 上安装到 /usr/local
  71. const arch = os.arch()
  72. if (arch === 'arm64') {
  73. return '/opt/homebrew/bin/npm'
  74. }
  75. return '/usr/local/bin/npm'
  76. }
  77. case 'linux':
  78. return '/usr/bin/npm'
  79. default:
  80. return 'npm'
  81. }
  82. }
  83. /**
  84. * 获取 pnpm 的完整路径
  85. */
  86. function getPnpmPath(): string {
  87. const platform = os.platform() as Platform
  88. switch (platform) {
  89. case 'win32': {
  90. const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming')
  91. return path.join(appData, 'npm', 'pnpm.cmd')
  92. }
  93. case 'darwin': {
  94. const arch = os.arch()
  95. if (arch === 'arm64') {
  96. return '/opt/homebrew/bin/pnpm'
  97. }
  98. return '/usr/local/bin/pnpm'
  99. }
  100. case 'linux':
  101. return '/usr/local/bin/pnpm'
  102. default:
  103. return 'pnpm'
  104. }
  105. }
  106. /**
  107. * 检查是否已取消
  108. */
  109. function checkCancelled(): void {
  110. if (installCancelled) {
  111. throw new Error(ERROR_MESSAGES.INSTALL_CANCELLED)
  112. }
  113. }
  114. /**
  115. * 使用 sudo-prompt 执行需要管理员权限的命令
  116. */
  117. function sudoExec(command: string): Promise<{ stdout: string; stderr: string }> {
  118. return new Promise((resolve, reject) => {
  119. const options = { name: APP_NAME }
  120. sudo.exec(command, options, (error, stdout, stderr) => {
  121. // 过滤乱码字符的辅助函数(Windows 命令行输出可能是 GBK 编码)
  122. const cleanOutput = (str: string | Buffer | undefined): string => {
  123. if (!str) return ''
  124. const raw = str.toString()
  125. // 过滤掉非 ASCII 可打印字符和非中文字符,保留换行符
  126. return raw.replace(/[^\x20-\x7E\u4e00-\u9fa5\n\r]/g, '').trim()
  127. }
  128. if (error) {
  129. // 包含 stderr 和 stdout 信息以便调试
  130. const exitCode = (error as Error & { code?: number }).code
  131. const stderrStr = cleanOutput(stderr)
  132. const stdoutStr = cleanOutput(stdout)
  133. let errorMessage = error.message
  134. if (exitCode !== undefined) {
  135. errorMessage += `\n退出码: ${exitCode}`
  136. }
  137. if (stderrStr) {
  138. errorMessage += `\n详细信息: ${stderrStr}`
  139. }
  140. if (stdoutStr) {
  141. errorMessage += `\n输出: ${stdoutStr}`
  142. }
  143. const enhancedError = new Error(errorMessage)
  144. reject(enhancedError)
  145. } else {
  146. resolve({
  147. stdout: cleanOutput(stdout),
  148. stderr: cleanOutput(stderr)
  149. })
  150. }
  151. })
  152. })
  153. }
  154. /**
  155. * 为包含空格的路径添加引号(Windows)
  156. */
  157. function quoteIfNeeded(str: string): string {
  158. // 如果字符串包含空格且没有被引号包围,则添加引号
  159. if (str.includes(' ') && !str.startsWith('"') && !str.endsWith('"')) {
  160. return `"${str}"`
  161. }
  162. return str
  163. }
  164. /**
  165. * 跨平台执行系统命令(带权限处理)
  166. */
  167. async function executeCommand(
  168. command: string,
  169. args: string[] = [],
  170. needAdmin = false
  171. ): Promise<{ stdout: string; stderr: string }> {
  172. checkCancelled()
  173. const platform = os.platform() as Platform
  174. // 构建完整命令时,对包含空格的部分添加引号
  175. const quotedCommand = quoteIfNeeded(command)
  176. const quotedArgs = args.map(arg => quoteIfNeeded(arg))
  177. const fullCommand = `${quotedCommand} ${quotedArgs.join(' ')}`
  178. logger.installDebug(`执行命令: ${fullCommand}`)
  179. try {
  180. if (needAdmin) {
  181. // 使用 sudo-prompt 执行需要管理员权限的命令
  182. if (platform === 'win32') {
  183. // Windows: 直接执行命令,sudo-prompt 会处理 UAC
  184. return await sudoExec(fullCommand)
  185. } else {
  186. // Linux/macOS: 使用 sudo
  187. return await sudoExec(fullCommand)
  188. }
  189. } else {
  190. // 无需提权,直接执行
  191. // Windows 上执行 .cmd 文件时,使用 shell: true 避免参数解析问题
  192. const useShell = platform === 'win32' && command.endsWith('.cmd')
  193. const proc = execa(command, args, { stdio: 'pipe', shell: useShell })
  194. currentProcess = proc
  195. const result = await proc
  196. currentProcess = null
  197. return { stdout: result.stdout, stderr: result.stderr }
  198. }
  199. } catch (error) {
  200. logger.installError(`命令执行失败: ${fullCommand}`, error)
  201. throw error
  202. }
  203. }
  204. /**
  205. * 检测软件是否已安装
  206. * Windows: 安装后 PATH 环境变量不会立即对当前进程生效,
  207. * 所以使用 commandExistsWithRefresh 从注册表重新读取最新的 PATH
  208. */
  209. export async function checkInstalled(software: SoftwareType): Promise<InstalledInfo> {
  210. let command: string
  211. let versionArgs: string[]
  212. switch (software) {
  213. case 'nodejs':
  214. command = 'node'
  215. versionArgs = ['--version']
  216. break
  217. case 'pnpm':
  218. command = 'pnpm'
  219. versionArgs = ['--version']
  220. break
  221. case 'vscode':
  222. command = 'code'
  223. versionArgs = ['--version']
  224. break
  225. case 'git':
  226. command = 'git'
  227. versionArgs = ['--version']
  228. break
  229. case 'claudeCode':
  230. command = 'claude'
  231. versionArgs = ['--version']
  232. break
  233. default:
  234. return { installed: false, version: null }
  235. }
  236. // 使用刷新后的 PATH 检测命令是否存在(Windows 上会从注册表重新读取 PATH)
  237. const exists = await commandExistsWithRefresh(command)
  238. if (!exists) {
  239. return { installed: false, version: null }
  240. }
  241. // 使用刷新后的 PATH 获取版本
  242. const version = await getCommandVersionWithRefresh(command, versionArgs)
  243. return { installed: true, version }
  244. }
  245. /**
  246. * 检测所有软件的安装状态
  247. */
  248. export async function checkAllInstalled(): Promise<AllInstalledInfo> {
  249. const results = await Promise.allSettled([
  250. checkInstalled('nodejs'),
  251. checkInstalled('pnpm'),
  252. checkInstalled('vscode'),
  253. checkInstalled('git'),
  254. checkInstalled('claudeCode')
  255. ])
  256. return {
  257. nodejs: results[0].status === 'fulfilled' ? results[0].value : { installed: false, version: null },
  258. pnpm: results[1].status === 'fulfilled' ? results[1].value : { installed: false, version: null },
  259. vscode: results[2].status === 'fulfilled' ? results[2].value : { installed: false, version: null },
  260. git: results[3].status === 'fulfilled' ? results[3].value : { installed: false, version: null },
  261. claudeCode: results[4].status === 'fulfilled' ? results[4].value : { installed: false, version: null }
  262. }
  263. }
  264. // ==================== 安装命令生成 ====================
  265. /**
  266. * 获取 Node.js 安装命令 (macOS/Linux 使用包管理器)
  267. * Windows 使用下载 msi 安装包方式,不使用此函数
  268. * @param version 版本号
  269. */
  270. function getNodeInstallArgs(version = 'lts'): CommandResult {
  271. const platform = os.platform() as Platform
  272. const major = version.split('.')[0]
  273. const majorNum = parseInt(major)
  274. let brewPkg: string
  275. if (majorNum === 20) {
  276. brewPkg = BREW_PACKAGES.nodejs['20']
  277. } else if (majorNum === 18) {
  278. brewPkg = BREW_PACKAGES.nodejs['18']
  279. } else {
  280. brewPkg = BREW_PACKAGES.nodejs.default
  281. }
  282. switch (platform) {
  283. case 'darwin':
  284. return { command: 'brew', args: ['install', brewPkg] }
  285. case 'linux':
  286. return { command: 'apt', args: ['install', '-y', 'nodejs', 'npm'] }
  287. default:
  288. throw new Error(`${ERROR_MESSAGES.UNSUPPORTED_PLATFORM}: ${platform}`)
  289. }
  290. }
  291. /**
  292. * 获取 VS Code 安装命令 (macOS/Linux 使用包管理器)
  293. * Windows 使用下载安装包方式,不使用此函数(除了 insiders 版本)
  294. * @param version 版本号
  295. */
  296. function getVSCodeInstallArgs(version = 'stable'): CommandResult {
  297. const platform = os.platform() as Platform
  298. if (version === 'insiders') {
  299. switch (platform) {
  300. case 'darwin':
  301. return {
  302. command: 'brew',
  303. args: ['install', '--cask', BREW_PACKAGES.vscode.insiders]
  304. }
  305. case 'linux':
  306. return { command: 'snap', args: ['install', 'code', '--channel=insiders'] }
  307. default:
  308. throw new Error(`${ERROR_MESSAGES.UNSUPPORTED_PLATFORM}: ${platform}`)
  309. }
  310. }
  311. switch (platform) {
  312. case 'darwin':
  313. return {
  314. command: 'brew',
  315. args: ['install', '--cask', BREW_PACKAGES.vscode.stable]
  316. }
  317. case 'linux':
  318. return { command: 'snap', args: ['install', 'code', '--classic'] }
  319. default:
  320. throw new Error(`${ERROR_MESSAGES.UNSUPPORTED_PLATFORM}: ${platform}`)
  321. }
  322. }
  323. /**
  324. * 获取 Git 安装命令 (macOS/Linux 使用包管理器)
  325. * Windows 使用下载安装包方式,不使用此函数
  326. * @param version 版本号
  327. */
  328. function getGitInstallArgs(version = 'stable'): CommandResult {
  329. const platform = os.platform() as Platform
  330. if (version === 'mingit') {
  331. switch (platform) {
  332. case 'darwin':
  333. return { command: 'brew', args: ['install', BREW_PACKAGES.git.stable] }
  334. case 'linux':
  335. return { command: 'apt', args: ['install', '-y', 'git'] }
  336. default:
  337. throw new Error(`${ERROR_MESSAGES.UNSUPPORTED_PLATFORM}: ${platform}`)
  338. }
  339. }
  340. if (version === 'lfs') {
  341. switch (platform) {
  342. case 'darwin':
  343. return { command: 'brew', args: ['install', BREW_PACKAGES.git.lfs] }
  344. case 'linux':
  345. return { command: 'apt', args: ['install', '-y', 'git-lfs'] }
  346. default:
  347. throw new Error(`${ERROR_MESSAGES.UNSUPPORTED_PLATFORM}: ${platform}`)
  348. }
  349. }
  350. switch (platform) {
  351. case 'darwin':
  352. return { command: 'brew', args: ['install', BREW_PACKAGES.git.stable] }
  353. case 'linux':
  354. return { command: 'apt', args: ['install', '-y', 'git'] }
  355. default:
  356. throw new Error(`${ERROR_MESSAGES.UNSUPPORTED_PLATFORM}: ${platform}`)
  357. }
  358. }
  359. // 可卸载的软件类型
  360. type UninstallableSoftwareType = 'nodejs' | 'vscode' | 'git'
  361. /**
  362. * 获取卸载命令 (macOS/Linux 使用包管理器)
  363. * Windows 暂不支持通过此工具卸载,请使用系统设置
  364. */
  365. function getUninstallArgs(software: UninstallableSoftwareType): CommandResult {
  366. const platform = os.platform() as Platform
  367. switch (platform) {
  368. case 'win32':
  369. // Windows 暂不支持通过此工具卸载,请使用系统设置中的"应用和功能"
  370. throw new Error('Windows 暂不支持通过此工具卸载软件,请使用系统设置中的"应用和功能"')
  371. case 'darwin': {
  372. let brewPkg: string
  373. switch (software) {
  374. case 'nodejs':
  375. brewPkg = BREW_PACKAGES.nodejs.default
  376. break
  377. case 'vscode':
  378. brewPkg = BREW_PACKAGES.vscode.stable
  379. break
  380. case 'git':
  381. brewPkg = BREW_PACKAGES.git.stable
  382. break
  383. }
  384. return { command: 'brew', args: ['uninstall', brewPkg] }
  385. }
  386. case 'linux': {
  387. let aptPkg: string
  388. switch (software) {
  389. case 'nodejs':
  390. aptPkg = 'nodejs'
  391. break
  392. case 'vscode':
  393. return { command: 'snap', args: ['remove', 'code'] }
  394. case 'git':
  395. aptPkg = 'git'
  396. break
  397. }
  398. return { command: 'apt', args: ['remove', '-y', aptPkg] }
  399. }
  400. default:
  401. throw new Error(`${ERROR_MESSAGES.UNSUPPORTED_PLATFORM}: ${platform}`)
  402. }
  403. }
  404. // ==================== 安装流程 ====================
  405. export type StatusCallback = (software: SoftwareTypeWithAll, message: string, progress: number, skipLog?: boolean) => void
  406. export type CompleteCallback = (software: SoftwareTypeWithAll, message: string) => void
  407. export type ErrorCallback = (software: SoftwareTypeWithAll, message: string) => void
  408. /**
  409. * Linux 下执行 apt update(带缓存,1天内只更新一次)
  410. */
  411. export async function aptUpdate(onStatus?: StatusCallback, software?: SoftwareTypeWithAll): Promise<void> {
  412. if (os.platform() !== 'linux') return
  413. if (!needsAptUpdate()) {
  414. logger.installInfo('apt 源已在缓存期内,跳过更新')
  415. return
  416. }
  417. if (onStatus && software) {
  418. onStatus(software, STATUS_MESSAGES.UPDATING_SOURCE, 10)
  419. }
  420. await executeCommand('apt', ['update'], true)
  421. markAptUpdated()
  422. }
  423. /**
  424. * 安装 Node.js (Windows 使用 msi 安装包)
  425. * @param version 版本号
  426. * @param installPnpm 是否安装 pnpm
  427. * @param onStatus 状态回调
  428. * @param customPath 自定义安装路径 (仅 Windows 支持)
  429. */
  430. export async function installNodejs(
  431. version = 'lts',
  432. installPnpm = true,
  433. onStatus: StatusCallback,
  434. customPath?: string
  435. ): Promise<void> {
  436. resetCancelState()
  437. const platform = os.platform() as Platform
  438. // Windows: 直接下载 msi 安装包
  439. if (platform === 'win32') {
  440. // 确定目标版本
  441. let targetVersion = version
  442. if (version === 'lts' || !version || !/^\d+\.\d+\.\d+$/.test(version)) {
  443. // 如果是 lts 或无效版本,使用备用版本
  444. targetVersion = FALLBACK_VERSIONS.nodejs
  445. }
  446. checkCancelled()
  447. // 下载 msi 安装包
  448. const arch = os.arch() === 'x64' ? 'x64' : os.arch() === 'arm64' ? 'arm64' : 'x86'
  449. const downloadUrl = getNodejsDownloadUrl(targetVersion).replace('.zip', '.msi').replace(`-win-${arch}`, `-${arch}`)
  450. const tempDir = getTempDir()
  451. const installerPath = path.join(tempDir, `node-v${targetVersion}-${arch}.msi`)
  452. onStatus('nodejs', `正在下载 Node.js ${targetVersion}...`, 10)
  453. logger.installInfo(`开始下载 Node.js: ${downloadUrl}`)
  454. try {
  455. let lastLoggedPercent = 0
  456. await downloadFile(downloadUrl, installerPath, (downloaded, total, percent) => {
  457. const progress = 10 + Math.round(percent * 0.4)
  458. const downloadedMB = (downloaded / 1024 / 1024).toFixed(1)
  459. const totalMB = (total / 1024 / 1024).toFixed(1)
  460. // 每次都更新进度条,但只在每 5% 时记录日志
  461. const shouldLog = percent - lastLoggedPercent >= 5
  462. if (shouldLog) {
  463. lastLoggedPercent = Math.floor(percent / 5) * 5
  464. }
  465. onStatus('nodejs', `正在下载 Node.js ${targetVersion}... (${downloadedMB}MB / ${totalMB}MB)`, progress, !shouldLog)
  466. })
  467. } catch (error) {
  468. logger.installError('下载 Node.js 失败', error)
  469. throw new Error(`下载 Node.js 失败: ${(error as Error).message}`)
  470. }
  471. checkCancelled()
  472. // 执行 msi 静默安装
  473. onStatus('nodejs', `${STATUS_MESSAGES.INSTALLING} Node.js ${targetVersion}...`, 55)
  474. logger.installInfo(`开始安装 Node.js: ${installerPath}`)
  475. try {
  476. // msiexec 静默安装参数
  477. const installArgs = ['/i', installerPath, '/qn', '/norestart']
  478. if (customPath) {
  479. installArgs.push(`INSTALLDIR="${customPath}"`)
  480. }
  481. await sudoExec(`msiexec ${installArgs.join(' ')}`)
  482. // 清理安装包
  483. try {
  484. await fs.promises.unlink(installerPath)
  485. } catch {
  486. // 忽略清理失败
  487. }
  488. } catch (error) {
  489. logger.installError('安装 Node.js 失败', error)
  490. throw error
  491. }
  492. checkCancelled()
  493. // 使用完整路径,避免 PATH 未生效的问题
  494. const npmCmd = getNpmPath()
  495. // 仅当使用国内镜像下载时,才配置 npm 国内镜像
  496. const { mirror: nodejsMirror } = getNodejsMirrorConfig()
  497. if (nodejsMirror === 'npmmirror') {
  498. const npmConfigCmd = `${npmCmd} config set registry ${NPM_REGISTRY}`
  499. onStatus('nodejs', `${STATUS_MESSAGES.CONFIGURING} npm 镜像: ${npmConfigCmd}`, 70)
  500. try {
  501. await executeCommand(npmCmd, ['config', 'set', 'registry', NPM_REGISTRY], false)
  502. } catch (error) {
  503. logger.installWarn('配置 npm 镜像失败(npm 可能还未加入 PATH)', error)
  504. }
  505. }
  506. checkCancelled()
  507. // 可选安装 pnpm
  508. if (installPnpm) {
  509. onStatus('nodejs', `${STATUS_MESSAGES.INSTALLING} pnpm...`, 80)
  510. await executeCommand(npmCmd, ['install', '-g', 'pnpm'], true)
  511. checkCancelled()
  512. // 获取刷新后的 PATH,确保能找到刚安装的 pnpm
  513. const { getRefreshedPath } = await import('./utils')
  514. const refreshedPath = await getRefreshedPath()
  515. const execEnv = { ...process.env, PATH: refreshedPath }
  516. // 运行 pnpm setup 配置全局 bin 目录
  517. onStatus('nodejs', `${STATUS_MESSAGES.CONFIGURING} pnpm 全局目录...`, 88)
  518. try {
  519. await execa('pnpm', ['setup'], { env: execEnv, shell: platform === 'win32' })
  520. logger.installInfo('pnpm setup 完成')
  521. } catch (error) {
  522. logger.installWarn('pnpm setup 失败', error)
  523. }
  524. checkCancelled()
  525. // 配置 pnpm 镜像
  526. onStatus('nodejs', `${STATUS_MESSAGES.CONFIGURING} pnpm 镜像...`, 95)
  527. try {
  528. await execa('pnpm', ['config', 'set', 'registry', NPM_REGISTRY], { env: execEnv, shell: platform === 'win32' })
  529. } catch (error) {
  530. logger.installWarn('配置 pnpm 镜像失败', error)
  531. }
  532. }
  533. onStatus('nodejs', STATUS_MESSAGES.COMPLETE, 100)
  534. return
  535. }
  536. // macOS/Linux: 使用包管理器安装
  537. onStatus('nodejs', `${STATUS_MESSAGES.INSTALLING} Node.js...`, 20)
  538. const args = getNodeInstallArgs(version)
  539. await executeCommand(args.command, args.args, true)
  540. checkCancelled()
  541. // 使用完整路径,避免 PATH 未生效的问题
  542. const npmCmd = getNpmPath()
  543. // 仅当使用国内镜像下载时,才配置 npm 国内镜像
  544. const { mirror: nodejsMirrorMac } = getNodejsMirrorConfig()
  545. if (nodejsMirrorMac === 'npmmirror') {
  546. const npmConfigCmd = `${npmCmd} config set registry ${NPM_REGISTRY}`
  547. onStatus('nodejs', `${STATUS_MESSAGES.CONFIGURING} npm 镜像: ${npmConfigCmd}`, 50)
  548. try {
  549. await executeCommand(npmCmd, ['config', 'set', 'registry', NPM_REGISTRY], false)
  550. } catch (error) {
  551. logger.installWarn('配置 npm 镜像失败(npm 可能还未加入 PATH)', error)
  552. }
  553. }
  554. checkCancelled()
  555. // 可选安装 pnpm
  556. if (installPnpm) {
  557. onStatus('nodejs', `${STATUS_MESSAGES.INSTALLING} pnpm...`, 70)
  558. await executeCommand(npmCmd, ['install', '-g', 'pnpm'], true)
  559. checkCancelled()
  560. // 获取刷新后的 PATH,确保能找到刚安装的 pnpm
  561. const { getRefreshedPath } = await import('./utils')
  562. const refreshedPath = await getRefreshedPath()
  563. const execEnv = { ...process.env, PATH: refreshedPath }
  564. // 运行 pnpm setup 配置全局 bin 目录
  565. onStatus('nodejs', `${STATUS_MESSAGES.CONFIGURING} pnpm 全局目录...`, 82)
  566. try {
  567. await execa('pnpm', ['setup'], { env: execEnv })
  568. logger.installInfo('pnpm setup 完成')
  569. } catch (error) {
  570. logger.installWarn('pnpm setup 失败', error)
  571. }
  572. checkCancelled()
  573. // 配置 pnpm 镜像
  574. onStatus('nodejs', `${STATUS_MESSAGES.CONFIGURING} pnpm 镜像...`, 90)
  575. try {
  576. await execa('pnpm', ['config', 'set', 'registry', NPM_REGISTRY], { env: execEnv })
  577. } catch (error) {
  578. logger.installWarn('配置 pnpm 镜像失败', error)
  579. }
  580. }
  581. onStatus('nodejs', STATUS_MESSAGES.COMPLETE, 100)
  582. }
  583. /**
  584. * 单独安装 pnpm (需要 Node.js 已安装)
  585. * @param onStatus 状态回调
  586. */
  587. export async function installPnpm(onStatus: StatusCallback): Promise<void> {
  588. resetCancelState()
  589. const platform = os.platform() as Platform
  590. // 检查 Node.js 是否已安装
  591. const nodeInstalled = await checkInstalled('nodejs')
  592. if (!nodeInstalled.installed) {
  593. throw new Error('请先安装 Node.js')
  594. }
  595. // 刷新系统环境变量,确保能找到刚安装的 Node.js
  596. const { getRefreshedPath } = await import('./utils')
  597. await getRefreshedPath()
  598. // 使用完整路径,避免 PATH 未生效的问题
  599. const npmCmd = getNpmPath()
  600. // 检查 npm 命令是否存在
  601. if (platform === 'win32' && !fs.existsSync(npmCmd)) {
  602. throw new Error(`未找到 npm 命令: ${npmCmd},请确保 Node.js 已正确安装`)
  603. }
  604. checkCancelled()
  605. // 安装 pnpm,需要管理员权限进行全局安装
  606. const installCommand = `${npmCmd} install -g pnpm`
  607. onStatus('pnpm', `正在安装 pnpm...`, 20)
  608. onStatus('pnpm', `执行命令: ${installCommand}`, 25)
  609. try {
  610. await executeCommand(npmCmd, ['install', '-g', 'pnpm'], true)
  611. } catch (error) {
  612. // 提取更有意义的错误信息,过滤乱码
  613. const execaError = error as { message?: string; stderr?: string; stdout?: string; shortMessage?: string; exitCode?: number }
  614. let errorMessage = `npm install -g pnpm 失败`
  615. if (execaError.exitCode !== undefined) {
  616. errorMessage += ` (退出码: ${execaError.exitCode})`
  617. }
  618. // 过滤乱码字符,只保留可读字符
  619. if (execaError.stderr) {
  620. const stderrClean = execaError.stderr.replace(/[^\x20-\x7E\u4e00-\u9fa5\n\r]/g, '').trim()
  621. if (stderrClean) {
  622. errorMessage += `\n${stderrClean}`
  623. }
  624. }
  625. if (execaError.stdout) {
  626. const stdoutClean = execaError.stdout.replace(/[^\x20-\x7E\u4e00-\u9fa5\n\r]/g, '').trim()
  627. if (stdoutClean) {
  628. errorMessage += `\n${stdoutClean}`
  629. }
  630. }
  631. logger.installError('安装 pnpm 失败', error)
  632. throw new Error(errorMessage)
  633. }
  634. checkCancelled()
  635. // 重新刷新 PATH,确保能找到刚安装的 pnpm
  636. const pnpmRefreshedPath = await getRefreshedPath()
  637. // 使用 pnpm 的完整路径,因为刚安装的 pnpm 可能还不在 PATH 中
  638. const pnpmCmd = getPnpmPath()
  639. // 构建环境变量,将 pnpm 所在目录添加到 PATH 开头
  640. const pnpmDir = path.dirname(pnpmCmd)
  641. const execEnv = { ...process.env, PATH: `${pnpmDir}${platform === 'win32' ? ';' : ':'}${pnpmRefreshedPath}` }
  642. // 运行 pnpm setup 配置全局 bin 目录
  643. onStatus('pnpm', `${STATUS_MESSAGES.CONFIGURING} pnpm 全局目录...`, 60)
  644. try {
  645. await execa(pnpmCmd, ['setup'], { env: execEnv, shell: platform === 'win32' })
  646. logger.installInfo('pnpm setup 完成')
  647. } catch (error) {
  648. logger.installWarn('pnpm setup 失败', error)
  649. }
  650. checkCancelled()
  651. // 配置 pnpm 镜像
  652. onStatus('pnpm', `${STATUS_MESSAGES.CONFIGURING} pnpm 镜像...`, 80)
  653. try {
  654. await execa(pnpmCmd, ['config', 'set', 'registry', NPM_REGISTRY], { env: execEnv, shell: platform === 'win32' })
  655. } catch (error) {
  656. logger.installWarn('配置 pnpm 镜像失败', error)
  657. }
  658. onStatus('pnpm', STATUS_MESSAGES.COMPLETE, 100)
  659. }
  660. /**
  661. * 安装 VS Code (Windows 使用下载安装包)
  662. * @param version 版本号
  663. * @param onStatus 状态回调
  664. * @param customPath 自定义安装路径 (仅 Windows 支持)
  665. */
  666. export async function installVscode(version = 'stable', onStatus: StatusCallback, customPath?: string): Promise<void> {
  667. resetCancelState()
  668. const platform = os.platform() as Platform
  669. // Windows: 直接下载安装包
  670. if (platform === 'win32' && version !== 'insiders') {
  671. // 确定目标版本
  672. let targetVersion = version
  673. if (version === 'stable' || !version || !/^\d+\.\d+\.\d+$/.test(version)) {
  674. // 如果是 stable 或无效版本,使用备用版本
  675. targetVersion = FALLBACK_VERSIONS.vscode
  676. }
  677. checkCancelled()
  678. // 下载安装包
  679. const downloadUrl = getVSCodeDownloadUrl(targetVersion)
  680. const tempDir = getTempDir()
  681. const installerPath = path.join(tempDir, `VSCodeUserSetup-${targetVersion}.exe`)
  682. onStatus('vscode', `正在下载 VS Code ${targetVersion}...`, 10)
  683. logger.installInfo(`开始下载 VS Code: ${downloadUrl}`)
  684. try {
  685. let lastLoggedPercent = 0
  686. await downloadFile(downloadUrl, installerPath, (downloaded, total, percent) => {
  687. const progress = 10 + Math.round(percent * 0.5)
  688. const downloadedMB = (downloaded / 1024 / 1024).toFixed(1)
  689. const totalMB = (total / 1024 / 1024).toFixed(1)
  690. // 每次都更新进度条,但只在每 5% 时记录日志
  691. const shouldLog = percent - lastLoggedPercent >= 5
  692. if (shouldLog) {
  693. lastLoggedPercent = Math.floor(percent / 5) * 5
  694. }
  695. onStatus('vscode', `正在下载 VS Code ${targetVersion}... (${downloadedMB}MB / ${totalMB}MB)`, progress, !shouldLog)
  696. })
  697. } catch (error) {
  698. logger.installError('下载 VS Code 失败', error)
  699. throw new Error(`下载 VS Code 失败: ${(error as Error).message}`)
  700. }
  701. checkCancelled()
  702. // 执行静默安装
  703. onStatus('vscode', `${STATUS_MESSAGES.INSTALLING} VS Code ${targetVersion}...`, 65)
  704. logger.installInfo(`开始安装 VS Code: ${installerPath}`)
  705. try {
  706. // VS Code 安装程序支持的静默安装参数
  707. // /VERYSILENT: 完全静默安装
  708. // /NORESTART: 不重启
  709. // /MERGETASKS: 指定安装任务
  710. const installArgs = ['/VERYSILENT', '/NORESTART', '/MERGETASKS=!runcode,addcontextmenufiles,addcontextmenufolders,associatewithfiles,addtopath']
  711. if (customPath) {
  712. installArgs.push(`/DIR=${customPath}`)
  713. }
  714. await sudoExec(`"${installerPath}" ${installArgs.join(' ')}`)
  715. // 清理安装包
  716. try {
  717. await fs.promises.unlink(installerPath)
  718. } catch {
  719. // 忽略清理失败
  720. }
  721. onStatus('vscode', STATUS_MESSAGES.COMPLETE, 100)
  722. } catch (error) {
  723. logger.installError('安装 VS Code 失败', error)
  724. throw error
  725. }
  726. return
  727. }
  728. // macOS/Linux 或 insiders 版本: 使用包管理器安装
  729. onStatus('vscode', `${STATUS_MESSAGES.INSTALLING} VS Code...`, 30)
  730. const args = getVSCodeInstallArgs(version)
  731. await executeCommand(args.command, args.args, true)
  732. onStatus('vscode', STATUS_MESSAGES.COMPLETE, 100)
  733. }
  734. /**
  735. * 安装 Git (Windows 直接下载安装)
  736. * @param version 版本号
  737. * @param onStatus 状态回调
  738. * @param customPath 自定义安装路径 (仅 Windows 支持)
  739. */
  740. export async function installGit(version = 'stable', onStatus: StatusCallback, customPath?: string): Promise<void> {
  741. resetCancelState()
  742. const platform = os.platform() as Platform
  743. // Windows: 直接从镜像下载安装
  744. if (platform === 'win32' && version !== 'mingit' && version !== 'lfs') {
  745. // 如果是 stable,尝试获取最新版本号
  746. let targetVersion = version
  747. if (version === 'stable') {
  748. onStatus('git', '正在获取最新版本...', 5)
  749. try {
  750. const response = await fetch('https://api.github.com/repos/git-for-windows/git/releases/latest', {
  751. headers: {
  752. 'Accept': 'application/vnd.github.v3+json',
  753. 'User-Agent': 'ApqInstaller'
  754. },
  755. signal: AbortSignal.timeout(5000) // 5秒超时
  756. })
  757. if (response.ok) {
  758. const release = await response.json() as { tag_name: string }
  759. const match = release.tag_name.match(/^v?(\d+\.\d+\.\d+)/)
  760. if (match) {
  761. targetVersion = match[1]
  762. }
  763. }
  764. } catch (error) {
  765. logger.installWarn('获取最新 Git 版本失败,使用备用版本', error)
  766. // 使用备用版本
  767. targetVersion = FALLBACK_VERSIONS.git
  768. }
  769. }
  770. checkCancelled()
  771. // 下载安装包
  772. const downloadUrl = getGitDownloadUrl(targetVersion)
  773. const tempDir = getTempDir()
  774. const installerPath = path.join(tempDir, `Git-${targetVersion}-installer.exe`)
  775. onStatus('git', `正在下载 Git ${targetVersion}...`, 10)
  776. logger.installInfo(`开始下载 Git: ${downloadUrl}`)
  777. try {
  778. let lastLoggedPercent = 0
  779. await downloadFile(downloadUrl, installerPath, (downloaded, total, percent) => {
  780. // 下载进度占 10% - 60%
  781. const progress = 10 + Math.round(percent * 0.5)
  782. const downloadedMB = (downloaded / 1024 / 1024).toFixed(1)
  783. const totalMB = (total / 1024 / 1024).toFixed(1)
  784. // 每次都更新进度条,但只在每 5% 时记录日志
  785. const shouldLog = percent - lastLoggedPercent >= 5
  786. if (shouldLog) {
  787. lastLoggedPercent = Math.floor(percent / 5) * 5
  788. }
  789. onStatus('git', `正在下载 Git ${targetVersion}... (${downloadedMB}MB / ${totalMB}MB)`, progress, !shouldLog)
  790. })
  791. } catch (error) {
  792. logger.installError('下载 Git 失败', error)
  793. throw new Error(`下载 Git 失败: ${(error as Error).message}`)
  794. }
  795. checkCancelled()
  796. // 执行静默安装
  797. onStatus('git', `${STATUS_MESSAGES.INSTALLING} Git ${targetVersion}...`, 65)
  798. logger.installInfo(`开始安装 Git: ${installerPath}`)
  799. try {
  800. // Git 安装程序支持的静默安装参数
  801. // /VERYSILENT: 完全静默安装
  802. // /NORESTART: 不重启
  803. // /NOCANCEL: 不显示取消按钮
  804. // /SP-: 不显示 "This will install..." 提示
  805. // /CLOSEAPPLICATIONS: 自动关闭相关应用
  806. // /DIR: 指定安装目录
  807. const installArgs = ['/VERYSILENT', '/NORESTART', '/NOCANCEL', '/SP-', '/CLOSEAPPLICATIONS']
  808. if (customPath) {
  809. installArgs.push(`/DIR=${customPath}`)
  810. }
  811. await sudoExec(`"${installerPath}" ${installArgs.join(' ')}`)
  812. // 清理安装包
  813. try {
  814. await fs.promises.unlink(installerPath)
  815. } catch {
  816. // 忽略清理失败
  817. }
  818. onStatus('git', STATUS_MESSAGES.COMPLETE, 100)
  819. } catch (error) {
  820. logger.installError('安装 Git 失败', error)
  821. throw error
  822. }
  823. return
  824. }
  825. // Windows 上的特殊版本 (mingit/lfs) 暂不支持
  826. if (platform === 'win32') {
  827. throw new Error(`Windows 暂不支持安装 ${version} 版本,请选择具体版本号`)
  828. }
  829. // macOS/Linux: 使用包管理器安装
  830. onStatus('git', `${STATUS_MESSAGES.INSTALLING} Git...`, 30)
  831. const args = getGitInstallArgs(version)
  832. await executeCommand(args.command, args.args, true)
  833. onStatus('git', STATUS_MESSAGES.COMPLETE, 100)
  834. }
  835. /**
  836. * 安装 Claude Code (通过 pnpm 或 npm 全局安装)
  837. * @param onStatus 状态回调
  838. */
  839. export async function installClaudeCode(onStatus: StatusCallback): Promise<void> {
  840. resetCancelState()
  841. const platform = os.platform() as Platform
  842. onStatus('claudeCode', '正在安装 Claude Code...', 10)
  843. logger.installInfo('开始安装 Claude Code...')
  844. checkCancelled()
  845. // 检测是否已安装 pnpm,优先使用 pnpm
  846. const hasPnpm = await commandExistsWithRefresh('pnpm')
  847. const pkgManager = hasPnpm ? 'pnpm' : 'npm'
  848. // 获取刷新后的 PATH,确保能找到新安装的命令
  849. const { getRefreshedPath } = await import('./utils')
  850. const refreshedPath = await getRefreshedPath()
  851. const execEnv: Record<string, string> = { ...process.env as Record<string, string>, PATH: refreshedPath }
  852. if (hasPnpm) {
  853. // 使用 pnpm 安装,需要确保 PNPM_HOME 环境变量已设置
  854. onStatus('claudeCode', '配置 pnpm 全局目录...', 20)
  855. // 先执行 pnpm setup
  856. try {
  857. await execa('pnpm', ['setup'], { env: execEnv, shell: platform === 'win32' })
  858. logger.installInfo('pnpm setup 完成')
  859. } catch (error) {
  860. logger.installWarn('pnpm setup 失败', error)
  861. }
  862. // 手动设置 PNPM_HOME 环境变量
  863. if (platform === 'win32') {
  864. const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local')
  865. const pnpmHome = path.join(localAppData, 'pnpm')
  866. execEnv.PNPM_HOME = pnpmHome
  867. execEnv.PATH = `${pnpmHome};${execEnv.PATH}`
  868. logger.installInfo(`设置 PNPM_HOME: ${pnpmHome}`)
  869. } else {
  870. const pnpmHome = path.join(os.homedir(), '.local', 'share', 'pnpm')
  871. execEnv.PNPM_HOME = pnpmHome
  872. execEnv.PATH = `${pnpmHome}:${execEnv.PATH}`
  873. logger.installInfo(`设置 PNPM_HOME: ${pnpmHome}`)
  874. }
  875. }
  876. checkCancelled()
  877. const installArgs = ['install', '-g', '@anthropic-ai/claude-code']
  878. const fullCommand = `${pkgManager} ${installArgs.join(' ')}`
  879. onStatus('claudeCode', `执行命令: ${fullCommand}`, 30)
  880. logger.installInfo(`使用 ${pkgManager} 安装,执行命令: ${fullCommand}`)
  881. try {
  882. if (hasPnpm) {
  883. // 使用 pnpm 安装 Claude Code
  884. await execa('pnpm', installArgs, { env: execEnv, shell: platform === 'win32' })
  885. } else {
  886. // 使用 npm 安装
  887. const npmCmd = getNpmPath()
  888. await executeCommand(npmCmd, installArgs, true)
  889. }
  890. } catch (error) {
  891. logger.installError('安装 Claude Code 失败', error)
  892. throw error
  893. }
  894. checkCancelled()
  895. // 验证安装
  896. onStatus('claudeCode', '验证安装...', 90)
  897. const claudeExists = await commandExistsWithRefresh('claude')
  898. if (claudeExists) {
  899. const { getCommandVersionWithRefresh } = await import('./utils')
  900. const version = await getCommandVersionWithRefresh('claude', ['--version'])
  901. logger.installInfo(`Claude Code 安装成功: ${version}`)
  902. } else {
  903. // 即使验证失败,安装可能已成功,只是 PATH 还没生效
  904. logger.installInfo('Claude Code 安装完成,但验证失败(可能需要重启终端)')
  905. }
  906. onStatus('claudeCode', STATUS_MESSAGES.COMPLETE, 100)
  907. }
  908. /**
  909. * 安装 Claude Code for VS Code 扩展
  910. * 复用 ipc-handlers.ts 中的 installVscodeExtension 函数
  911. * @param _onStatus 状态回调(已由 installVscodeExtension 内部处理)
  912. */
  913. export async function installClaudeCodeExt(_onStatus: StatusCallback): Promise<void> {
  914. resetCancelState()
  915. checkCancelled()
  916. const extensionId = 'anthropic.claude-code'
  917. const result = await installVscodeExtension(extensionId)
  918. if (!result.success) {
  919. throw new Error(result.error || 'VS Code 插件安装失败')
  920. }
  921. }
  922. /**
  923. * 卸载软件
  924. */
  925. export async function uninstallSoftware(software: SoftwareType): Promise<boolean> {
  926. // 只有 nodejs, vscode, git 支持卸载
  927. if (software !== 'nodejs' && software !== 'vscode' && software !== 'git') {
  928. logger.installWarn(`${software} 不支持通过此方式卸载`)
  929. return false
  930. }
  931. try {
  932. const args = getUninstallArgs(software)
  933. await executeCommand(args.command, args.args, true)
  934. logger.installInfo(`${software} 卸载成功`)
  935. return true
  936. } catch (error) {
  937. logger.installError(`${software} 卸载失败`, error)
  938. return false
  939. }
  940. }
  941. /**
  942. * 一键安装所有软件
  943. * 复用单独安装函数,避免代码重复
  944. */
  945. export async function installAll(options: InstallOptions, onStatus: StatusCallback): Promise<string[]> {
  946. resetCancelState()
  947. onStatus('all', '正在准备安装...', 5)
  948. const {
  949. installNodejs: doNodejs = true,
  950. nodejsVersion = 'lts',
  951. nodejsPath,
  952. installPnpm: doPnpm = true,
  953. installVscode: doVscode = true,
  954. vscodeVersion = 'stable',
  955. vscodePath,
  956. installGit: doGit = true,
  957. gitVersion = 'stable',
  958. gitPath,
  959. installClaudeCode: doClaudeCode = false,
  960. installClaudeCodeExt: doClaudeCodeExt = false
  961. } = options
  962. // 计算总步骤数
  963. const steps =
  964. (doNodejs ? 1 : 0) +
  965. (doPnpm && !doNodejs ? 1 : 0) + // 如果安装 Node.js,pnpm 会一起安装
  966. (doGit ? 1 : 0) +
  967. (doVscode ? 1 : 0) +
  968. (doClaudeCode ? 1 : 0) +
  969. (doClaudeCodeExt ? 1 : 0)
  970. let currentStep = 0
  971. const getProgress = (): number => Math.round(((currentStep + 0.5) / steps) * 100)
  972. // 创建包装的状态回调,将子安装的状态转发到 'all'
  973. const createWrappedStatus = (stepName: string): StatusCallback => {
  974. return (_software, message, _progress, skipLog) => {
  975. onStatus('all', `[${stepName}] ${message}`, getProgress(), skipLog)
  976. }
  977. }
  978. // 安装 Node.js(包含 pnpm)
  979. if (doNodejs) {
  980. checkCancelled()
  981. const wrappedStatus = createWrappedStatus('Node.js')
  982. await installNodejs(nodejsVersion, doPnpm, wrappedStatus, nodejsPath)
  983. currentStep++
  984. } else if (doPnpm) {
  985. // 如果不安装 Node.js 但需要安装 pnpm,单独安装 pnpm
  986. checkCancelled()
  987. const wrappedStatus = createWrappedStatus('pnpm')
  988. await installPnpm(wrappedStatus)
  989. currentStep++
  990. }
  991. // 安装 Git (Claude Code 运行时需要 Git)
  992. if (doGit) {
  993. checkCancelled()
  994. const wrappedStatus = createWrappedStatus('Git')
  995. await installGit(gitVersion, wrappedStatus, gitPath)
  996. currentStep++
  997. }
  998. // 安装 Claude Code (需要 Node.js 和 Git,应在 Git 之后安装)
  999. if (doClaudeCode) {
  1000. checkCancelled()
  1001. const wrappedStatus = createWrappedStatus('Claude Code')
  1002. await installClaudeCode(wrappedStatus)
  1003. currentStep++
  1004. }
  1005. // 安装 VS Code (Claude Code Ext 需要 VS Code)
  1006. if (doVscode) {
  1007. checkCancelled()
  1008. const wrappedStatus = createWrappedStatus('VS Code')
  1009. await installVscode(vscodeVersion, wrappedStatus, vscodePath)
  1010. currentStep++
  1011. // 如果需要安装扩展,等待 VS Code CLI 准备就绪
  1012. if (doClaudeCodeExt) {
  1013. onStatus('all', '[VS Code] 等待 VS Code CLI 准备就绪...', getProgress(), true)
  1014. await new Promise(resolve => setTimeout(resolve, 3000))
  1015. }
  1016. }
  1017. // 安装 Claude Code for VS Code 扩展 (需要 VS Code 和 Claude Code)
  1018. if (doClaudeCodeExt) {
  1019. checkCancelled()
  1020. const wrappedStatus = createWrappedStatus('Claude Code 插件')
  1021. try {
  1022. await installClaudeCodeExt(wrappedStatus)
  1023. } catch (error) {
  1024. // 插件安装失败不阻止整体流程
  1025. logger.installWarn('Claude Code for VS Code 扩展安装失败', error)
  1026. }
  1027. currentStep++
  1028. }
  1029. // 生成完成消息
  1030. const installed: string[] = []
  1031. if (doNodejs) installed.push('Node.js')
  1032. if (doPnpm) installed.push('pnpm')
  1033. if (doVscode) installed.push('VS Code')
  1034. if (doGit) installed.push('Git')
  1035. if (doClaudeCode) installed.push('Claude Code')
  1036. if (doClaudeCodeExt) installed.push('Claude Code 插件')
  1037. return installed
  1038. }
  1039. export {
  1040. executeCommand,
  1041. getNodeInstallArgs,
  1042. getVSCodeInstallArgs,
  1043. getGitInstallArgs,
  1044. getNpmPath,
  1045. getPnpmPath
  1046. }