@ljwei-stak/dsh-model-router 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,8 +14,9 @@
14
14
  import { spawn } from 'node:child_process'
15
15
  import { mkdir, mkdtemp, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises'
16
16
  import { homedir, tmpdir } from 'node:os'
17
- import { isAbsolute, join, relative, resolve, sep } from 'node:path'
17
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
18
18
  import {
19
+ DEFAULT_NPM_REGISTRY,
19
20
  defaultInstallMethod,
20
21
  getOfficialTool,
21
22
  installMethodFor,
@@ -23,6 +24,7 @@ import {
23
24
  OFFICIAL_TOOLS,
24
25
  } from './official-tool-registry.mjs'
25
26
  import {
27
+ expandInstallDir,
26
28
  resolveInstallLocation,
27
29
  resolveInstallPlan,
28
30
  resolveUninstallPlan,
@@ -61,21 +63,49 @@ let preferences = { installDir: '', registry: '', methods: {}, scriptUrls: {}, a
61
63
 
62
64
  export function setInstallPreferences(next) {
63
65
  if (next && typeof next === 'object') {
64
- const changedDirectory = preferences.installDir !== next.installDir
65
- preferences = { ...preferences, ...next }
66
+ const previous = JSON.stringify(preferences)
67
+ preferences = { ...preferences, ...next,
68
+ methods: { ...(next.methods ?? preferences.methods) },
69
+ scriptUrls: { ...(next.scriptUrls ?? preferences.scriptUrls) },
70
+ }
66
71
  // A new directory needs its own PATH entry before the next probe runs.
67
- if (changedDirectory) prefixPromise = null
72
+ if (previous !== JSON.stringify(preferences)) {
73
+ prefixPromise = null
74
+ probeCache.clear()
75
+ }
68
76
  }
69
77
  return preferences
70
78
  }
71
79
 
72
80
  export function installPreferences() {
73
- return { ...preferences }
81
+ return { ...preferences, methods: { ...preferences.methods }, scriptUrls: { ...preferences.scriptUrls } }
74
82
  }
75
83
 
76
84
  /** The machine this Host runs on; the plan resolver needs both to be explicit. */
77
85
  export function currentInstallOptions() {
78
- return { platform: process.platform, home: homedir() }
86
+ return { platform: process.platform, home: homedir(), env: process.env }
87
+ }
88
+
89
+ /** The selected tool's command directory, ahead of unrelated copies on PATH. */
90
+ export function preferredToolDirectories(toolId, selectedPreferences = preferences, options = currentInstallOptions()) {
91
+ const tool = getOfficialTool(toolId)
92
+ if (!tool || tool.unsupported || tool.manager === 'signed-windows-installer') return []
93
+ try {
94
+ const plan = resolveInstallPlan(tool, undefined, {
95
+ ...selectedPreferences,
96
+ // Keep a deliberately selected existing script copy visible, while the
97
+ // automatic method still honors the switch and can select npm instead.
98
+ allowScriptInstall: installMethodFor(tool, selectedPreferences.methods?.[tool.id])?.kind === 'script' ? true : selectedPreferences.allowScriptInstall,
99
+ }, options)
100
+ return plan.binDir ? [plan.binDir] : []
101
+ } catch { return [] }
102
+ }
103
+
104
+ /** A deliberate method/source/directory choice must never invoke a different updater. */
105
+ export function usesConfiguredInstallPlan(toolId, selectedPreferences = preferences, methodId) {
106
+ return Boolean(methodId || selectedPreferences.installDir || selectedPreferences.methods?.[toolId]
107
+ || selectedPreferences.scriptUrls?.[toolId] || selectedPreferences.allowScriptInstall === false
108
+ || (selectedPreferences.registry && selectedPreferences.registry !== DEFAULT_NPM_REGISTRY))
79
109
  }
80
110
 
81
111
  /**
@@ -86,10 +116,20 @@ export function currentInstallOptions() {
86
116
  * cmd strip the wrong quotes and launch `node` against a missing script:
87
117
  * `MODULE_NOT_FOUND` at `executeUserEntryPoint` with an empty requireStack.
88
118
  * That failed `npm config get prefix`, `--version`, and `npm install -g` for
89
- * every official tool. Keep the executable and argv separate.
119
+ * every official tool. Keep bare tokens separate; quote only a path/value that
120
+ * cmd would split or interpret as an operator.
90
121
  */
91
122
  export function shellSpawnSpec(executable, args = [], { useShell = false } = {}) {
92
- return { file: String(executable), args: [...(args ?? [])], shell: Boolean(useShell) }
123
+ const quote = value => {
124
+ const text = String(value)
125
+ if (!/[\s&|<>()^!%"]/.test(text)) return text
126
+ return `"${text.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, '$1$1')}"`
127
+ }
128
+ return {
129
+ file: useShell && IS_WINDOWS ? quote(executable) : String(executable),
130
+ args: useShell && IS_WINDOWS ? (args ?? []).map(quote) : [...(args ?? [])],
131
+ shell: Boolean(useShell),
132
+ }
93
133
  }
94
134
 
95
135
  /** Decode a CLI banner. Native Windows exes often write UTF-16 to a pipe. */
@@ -128,9 +168,10 @@ export function staleInstallMessage(label, installedVersion, latestVersion) {
128
168
  * Which updater to run. `order` is compareReleaseVersions(installed, latest):
129
169
  * -1 older, 0 same, 1 newer, null when either version is unknown.
130
170
  */
131
- export function officialUpdatePlan({ toolId, installed, order, platform }) {
171
+ export function officialUpdatePlan({ toolId, installed, order, platform, configured = false }) {
132
172
  if (installed && order === 1) return 'refuse-downgrade'
133
173
  if (installed && order === 0) return 'already-current'
174
+ if (configured) return 'registry-install'
134
175
  if (installed && order === -1 && toolId === 'claude-code') return 'claude-update-then-registry'
135
176
  if (installed && (order === -1 || order === null) && toolId === 'minimax-code' && platform === 'win32') {
136
177
  return 'minimax-official-installer'
@@ -138,11 +179,11 @@ export function officialUpdatePlan({ toolId, installed, order, platform }) {
138
179
  return 'registry-install'
139
180
  }
140
181
 
141
- async function trustedExecutionReadiness(toolId) {
182
+ async function trustedExecutionReadiness(toolId, options) {
142
183
  // Loaded only during an install to avoid coupling fast version probes to
143
184
  // the executor. The executor itself uses this runtime for PATH discovery.
144
185
  const { officialToolReadiness } = await import('./official-tool-executor.mjs')
145
- return officialToolReadiness(toolId)
186
+ return officialToolReadiness(toolId, process.cwd(), options)
146
187
  }
147
188
 
148
189
  function runCapture(executable, args, { timeoutMs, onOutput, useShell = false, signal, env } = {}) {
@@ -237,33 +278,46 @@ function runCapture(executable, args, { timeoutMs, onOutput, useShell = false, s
237
278
  })
238
279
  }
239
280
 
281
+ export function installerScriptText(source) {
282
+ return decodeProcessOutput(Buffer.isBuffer(source) ? source : Buffer.from(source ?? '')).replace(/^\uFEFF/, '')
283
+ }
284
+
285
+ export function isMarkupDocument(text) {
286
+ const head = String(text ?? '').replace(/^\uFEFF/, '').trimStart()
287
+ if (!head.startsWith('<') || head.startsWith('<#')) return false
288
+ return /^<(?:!|\?|[A-Za-z])/.test(head)
289
+ }
290
+
240
291
  /**
241
292
  * MiniMax's official Windows installer script is run as published (it installs
242
293
  * `@minimax-ai/code@latest`). Only structural sanity is checked: it must be the
243
294
  * MiniMax Code installer, not an HTML error page or another script.
244
295
  */
245
296
  export function officialMiniMaxInstaller(source) {
246
- const bytes = Buffer.isBuffer(source) ? source : Buffer.from(source)
247
- const text = bytes.toString('utf8').replace(/^\uFEFF/, '')
248
- if (!text.includes('@minimax-ai/code') || /^\s*</.test(text)) {
297
+ const text = installerScriptText(source)
298
+ // PowerShell help starts with `<# ... #>`, including the current official
299
+ // installer. It is a script comment, not an HTML response.
300
+ if (isMarkupDocument(text) || !['@minimax-ai/code', 'MCODE_INSTALL_DIR', 'MiniMax Code'].every(marker => text.includes(marker))) {
249
301
  throw new Error('下载内容不是 MiniMax Code 官方 Windows 安装脚本,已拒绝执行。')
250
302
  }
251
303
  return text
252
304
  }
253
305
 
254
306
  async function fetchOfficialMiniMaxInstaller(signal) {
255
- const response = await fetch(MINIMAX_INSTALLER_URL, {
256
- signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000),
257
- })
258
- if (!response.ok || !response.body) throw new Error(`官方脚本下载失败:HTTP ${response.status}`)
259
- const chunks = []
260
- let length = 0
261
- for await (const chunk of response.body) {
262
- length += chunk.length
263
- if (length > MAX_INSTALLER_BYTES) throw new Error('官方安装脚本超过大小上限。')
264
- chunks.push(chunk)
265
- }
266
- return officialMiniMaxInstaller(Buffer.concat(chunks))
307
+ return officialMiniMaxInstaller(await fetchVendorScript(MINIMAX_INSTALLER_URL, signal, { verify: { mustInclude: ['@minimax-ai/code', 'MiniMax Code CLI'] } }))
308
+ }
309
+
310
+ export function minimaxOfficialInstallDir(selectedPreferences = {}, home = homedir(), existingRoot = '') {
311
+ return selectedPreferences.installDir || existingRoot || join(home, '.minimax-code')
312
+ }
313
+
314
+ /** Keep a discovered native installation in place when its legacy updater runs. */
315
+ export function miniMaxFallbackPlan(selectedPreferences, beforeProbe, prefix, options = currentInstallOptions()) {
316
+ const installDir = minimaxOfficialInstallDir(selectedPreferences, options.home,
317
+ beforeProbe?.installed ? beforeProbe.installDir : '')
318
+ const executionPreferences = { ...selectedPreferences, installDir,
319
+ methods: { ...selectedPreferences.methods, 'minimax-code': 'script-powershell' } }
320
+ return { plan: resolveInstallPlan(getOfficialTool('minimax-code'), 'script-powershell', executionPreferences, options), executionPreferences }
267
321
  }
268
322
 
269
323
  async function runOfficialMiniMaxInstaller(job, { reason } = {}) {
@@ -274,13 +328,10 @@ async function runOfficialMiniMaxInstaller(job, { reason } = {}) {
274
328
  job.error = null
275
329
  job.phase = 'downloading-and-verifying'
276
330
  pushLine(job, reason || '— npm 原生依赖未就绪;改用 MiniMax 官方 Windows 安装脚本(安装最新版)—')
277
- const prefixResult = await runCapture('npm', ['config', 'get', 'prefix'], {
278
- timeoutMs: 5_000, useShell: true, signal: job.controller.signal,
279
- })
280
- const rawPrefix = prefixResult.stdout.trim().replace(/^\\\\\?\\/, '')
281
- if (!prefixResult.ok || !isAbsolute(rawPrefix)) throw new Error('无法确定 npm 全局安装目录。')
282
- await mkdir(rawPrefix, { recursive: true })
283
- prefixRoot = await realpath(rawPrefix)
331
+ const { plan, executionPreferences } = miniMaxFallbackPlan(job.preferences, job.beforeProbe, '')
332
+ const installDir = plan.installDir
333
+ await mkdir(installDir, { recursive: true })
334
+ prefixRoot = await realpath(installDir)
284
335
  const script = await fetchOfficialMiniMaxInstaller(job.controller.signal)
285
336
  staging = await mkdtemp(join(prefixRoot, '.model-router-minimax-installer-'))
286
337
  const scriptPath = join(staging, 'install.ps1')
@@ -289,7 +340,11 @@ async function runOfficialMiniMaxInstaller(job, { reason } = {}) {
289
340
  // Windows PowerShell 5 reads a BOM-less .ps1 using the system ANSI page.
290
341
  // The upstream installer contains Unicode art, so write a UTF-8 BOM.
291
342
  await writeFile(scriptPath, `\uFEFF${script}`, 'utf8')
292
- const installDir = join(prefixRoot, '.minimax-code')
343
+ job.plan = plan
344
+ job.methodId = plan.methodId
345
+ job.installDir = installDir
346
+ job.command = planCommandLine(plan)
347
+ pushLine(job, `— 本次更新目录:${installDir} —`)
293
348
  const powershell = join(process.env.SystemRoot ?? 'C:\\Windows', 'System32',
294
349
  'WindowsPowerShell', 'v1.0', 'powershell.exe')
295
350
  const utilityModule = join(process.env.SystemRoot ?? 'C:\\Windows', 'System32',
@@ -302,9 +357,8 @@ async function runOfficialMiniMaxInstaller(job, { reason } = {}) {
302
357
  const outcome = await runCapture(powershell,
303
358
  ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', command], {
304
359
  timeoutMs: INSTALL_TIMEOUT_MS, signal: job.controller.signal,
305
- env: { ...process.env, MCODE_INSTALL_DIR: installDir,
360
+ env: { ...process.env, ...plan.env, MCODE_INSTALL_DIR: installDir,
306
361
  MCODE_NO_MODIFY_PATH: '1', MCODE_DOWNLOAD_MIRROR: 'global',
307
- MCODE_NPM_REGISTRY: 'https://registry.npmjs.org/',
308
362
  npm_config_cache: join(prefixRoot, '.model-router-npm-cache'),
309
363
  TEMP: tempPath, TMP: tempPath,
310
364
  MODEL_ROUTER_UTILITY_MODULE: utilityModule,
@@ -319,13 +373,12 @@ async function runOfficialMiniMaxInstaller(job, { reason } = {}) {
319
373
  if (!outcome.ok) throw new Error(outcome.timedOut
320
374
  ? '官方安装脚本超时并已终止。'
321
375
  : `官方安装脚本退出码 ${outcome.code ?? '信号终止'}。${outcome.stderr.split('\n').find(Boolean)?.slice(0, 180) ?? ''}`)
322
- const currentPath = process.env.PATH ?? ''
323
- if (!currentPath.split(';').includes(installDir)) process.env.PATH = `${currentPath};${installDir}`
376
+ appendToProcessPath(installDir, { prepend: true })
324
377
  job.phase = 'verifying'
325
378
  probeCache.delete(job.tool.id)
326
- const probe = await probeToolWith(job.tool, defaultRunner)
379
+ const probe = await probeToolWith(job.tool, defaultRunner, { preferences: executionPreferences, plan, cacheMs: 0 })
327
380
  job.postInstallProbe = probe
328
- const readiness = await trustedExecutionReadiness(job.tool.id)
381
+ const readiness = await trustedExecutionReadiness(job.tool.id, { preferences: executionPreferences })
329
382
  if (!probe.installed || !readiness.ready) {
330
383
  throw new Error(`官方安装器完成,但执行入口未通过 npm registry 摘要核验:${readiness.reason ?? probe.detail}`)
331
384
  }
@@ -359,10 +412,9 @@ async function runOfficialMiniMaxInstaller(job, { reason } = {}) {
359
412
  * different program.
360
413
  */
361
414
  export function verifiedVendorScript(source, verify = {}) {
362
- const bytes = Buffer.isBuffer(source) ? source : Buffer.from(source)
363
- const text = bytes.toString('utf8').replace(/^\uFEFF/, '')
415
+ const text = installerScriptText(source)
364
416
  if (!text.trim()) throw new Error('下载内容为空,已拒绝执行。')
365
- if (/^\s*</.test(text)) throw new Error('下载内容不是安装脚本(收到 HTML 页面),已拒绝执行。')
417
+ if (isMarkupDocument(text)) throw new Error('下载内容不是安装脚本(收到 HTML 页面),已拒绝执行。')
366
418
  // Check "wrong vendor" first: a mirror that substituted another installer is
367
419
  // a different diagnosis from a truncated download, and the clearer one.
368
420
  const foreign = (verify.mustNotInclude ?? []).filter(marker => text.includes(marker))
@@ -374,19 +426,78 @@ export function verifiedVendorScript(source, verify = {}) {
374
426
  return text
375
427
  }
376
428
 
377
- async function fetchVendorScript(url, signal) {
378
- const response = await fetch(url, {
379
- signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000),
380
- })
381
- if (!response.ok || !response.body) throw new Error(`官方脚本下载失败:HTTP ${response.status}`)
382
- const chunks = []
383
- let length = 0
384
- for await (const chunk of response.body) {
385
- length += chunk.length
386
- if (length > MAX_INSTALLER_BYTES) throw new Error('官方安装脚本超过大小上限。')
387
- chunks.push(chunk)
429
+ /** Windows WebRequest follows the system proxy, which Node fetch may not use. */
430
+ export const WINDOWS_SCRIPT_DOWNLOAD = [
431
+ '$ErrorActionPreference="Stop"',
432
+ '[Net.ServicePointManager]::SecurityProtocol=[Net.SecurityProtocolType]::Tls12',
433
+ '$request=[Net.HttpWebRequest]::Create($env:MODEL_ROUTER_DOWNLOAD_URL)',
434
+ '$request.UserAgent="curl/8.0"',
435
+ '$request.Timeout=30000',
436
+ '$request.ReadWriteTimeout=30000',
437
+ '$response=$request.GetResponse()',
438
+ `if ($response.ContentLength -gt ${MAX_INSTALLER_BYTES}) { $response.Close(); throw "Installer exceeds size limit" }`,
439
+ '$stream=$response.GetResponseStream()',
440
+ '$output=New-Object IO.MemoryStream',
441
+ '$buffer=New-Object byte[] 8192',
442
+ 'try { while (($count=$stream.Read($buffer,0,$buffer.Length)) -gt 0) { '
443
+ + `$output.Write($buffer,0,$count); if ($output.Length -gt ${MAX_INSTALLER_BYTES}) { throw "Installer exceeds size limit" } }; `
444
+ + '[IO.File]::WriteAllBytes($env:MODEL_ROUTER_DOWNLOAD_FILE,$output.ToArray()) '
445
+ + '} finally { $stream.Close(); $response.Close(); $output.Dispose() }',
446
+ ].join('; ')
447
+
448
+ /** Download bytes only; every transport still checks the same vendor markers. */
449
+ export async function fetchVendorScript(url, signal, {
450
+ verify = {}, fetcher = globalThis.fetch, platform = process.platform, powershellRunner = runCapture, fallbackUrls = [],
451
+ } = {}) {
452
+ if (fallbackUrls.length) {
453
+ let lastError
454
+ for (const candidate of [...new Set([url, ...fallbackUrls])]) {
455
+ try { return await fetchVendorScript(candidate, signal, { verify, fetcher, platform, powershellRunner }) }
456
+ catch (error) { if (signal?.aborted) throw error; lastError = error }
457
+ }
458
+ throw lastError
459
+ }
460
+ const check = bytes => { verifiedVendorScript(bytes, verify); return bytes }
461
+ let firstError
462
+ try {
463
+ const response = await fetcher(url, {
464
+ headers: { 'User-Agent': 'curl/8.0', Accept: 'text/plain, application/octet-stream;q=0.9, */*;q=0.8' },
465
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000),
466
+ })
467
+ if (!response.ok || !response.body) throw new Error(`官方脚本下载失败:HTTP ${response.status}`)
468
+ const declared = Number(response.headers?.get?.('content-length') ?? 0)
469
+ if (declared > MAX_INSTALLER_BYTES) throw new Error('官方安装脚本超过大小上限。')
470
+ const chunks = []
471
+ let length = 0
472
+ for await (const chunk of response.body) {
473
+ length += chunk.length
474
+ if (length > MAX_INSTALLER_BYTES) throw new Error('官方安装脚本超过大小上限。')
475
+ chunks.push(chunk)
476
+ }
477
+ return check(Buffer.concat(chunks))
478
+ } catch (error) {
479
+ if (signal?.aborted || platform !== 'win32') throw error
480
+ firstError = error
481
+ }
482
+ // This fixed PowerShell program only downloads to a private temporary file.
483
+ // Neither the URL nor response bytes become executable command text.
484
+ const staging = await mkdtemp(join(tmpdir(), '.model-router-script-download-'))
485
+ try {
486
+ const outputPath = join(staging, 'installer.bin')
487
+ const outcome = await powershellRunner(POWERSHELL, [
488
+ '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(WINDOWS_SCRIPT_DOWNLOAD, 'utf16le').toString('base64'),
489
+ ], { timeoutMs: 40_000, signal,
490
+ env: { ...process.env, MODEL_ROUTER_DOWNLOAD_URL: url, MODEL_ROUTER_DOWNLOAD_FILE: outputPath },
491
+ })
492
+ if (!outcome.ok) {
493
+ throw new Error(`官方脚本下载失败:${String(firstError?.message ?? firstError).slice(0, 150)};系统下载通道${outcome.cancelled ? '已取消' : outcome.timedOut ? '超时' : `退出码 ${outcome.code ?? '未知'}`}。${String(outcome.stderr ?? '').slice(0, 150)}`)
494
+ }
495
+ const size = (await stat(outputPath)).size
496
+ if (size > MAX_INSTALLER_BYTES) throw new Error('官方安装脚本超过大小上限。')
497
+ return check(await readFile(outputPath))
498
+ } finally {
499
+ await rm(staging, { recursive: true, force: true }).catch(() => {})
388
500
  }
389
- return Buffer.concat(chunks)
390
501
  }
391
502
 
392
503
  const POWERSHELL = join(process.env.SystemRoot ?? 'C:\\Windows', 'System32',
@@ -402,7 +513,7 @@ export function vendorScriptInvocation(plan, scriptPath) {
402
513
  args: ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...directoryArgs],
403
514
  }
404
515
  }
405
- return { file: 'bash', args: [scriptPath, ...directoryArgs] }
516
+ return { file: plan.shell === 'sh' ? 'sh' : 'bash', args: [scriptPath, ...directoryArgs] }
406
517
  }
407
518
 
408
519
  /**
@@ -412,12 +523,23 @@ export function vendorScriptInvocation(plan, scriptPath) {
412
523
  * than an older one that happened to be on PATH first.
413
524
  */
414
525
  async function runVendorScriptInstallJob(job, plan) {
526
+ if (job.status !== 'running') return
415
527
  let staging = null
416
528
  try {
529
+ job.phase = 'preflight'
530
+ const before = await probeToolWith(job.tool, defaultRunner, { plan, preferences: job.preferences, cacheMs: 0 })
531
+ job.beforeProbe = before
532
+ const latestBefore = before.installed ? await latestVersions.lookup(job.tool, { fresh: true }).catch(() => null) : null
533
+ if (job.cancelRequested) { markCancelled(job); return }
534
+ if (latestBefore?.version && compareReleaseVersions(before.version, latestBefore.version) === 1) {
535
+ throw new Error(`已安装 ${before.version},高于最新正式版 ${latestBefore.version};不会自动降级。`)
536
+ }
417
537
  job.phase = 'downloading-and-verifying'
418
538
  for (const notice of plan.notices ?? []) pushLine(job, notice)
419
539
  pushLine(job, `— 下载官方安装脚本:${plan.scriptUrl}`)
420
- const source = verifiedVendorScript(await fetchVendorScript(plan.scriptUrl, job.controller.signal), plan.verify)
540
+ const source = verifiedVendorScript(await fetchVendorScript(plan.scriptUrl, job.controller.signal, {
541
+ verify: plan.verify, fallbackUrls: (plan.downloadUrls ?? []).slice(1),
542
+ }), plan.verify)
421
543
  staging = await mkdtemp(join(tmpdir(), '.model-router-tool-install-'))
422
544
  const isPowerShell = plan.shell === 'powershell'
423
545
  const scriptPath = join(staging, isPowerShell ? 'install.ps1' : 'install.sh')
@@ -430,7 +552,7 @@ async function runVendorScriptInstallJob(job, plan) {
430
552
  const outcome = await runCapture(invocation.file, invocation.args, {
431
553
  timeoutMs: INSTALL_TIMEOUT_MS,
432
554
  signal: job.controller.signal,
433
- env: { ...process.env, ...plan.env },
555
+ env: installEnvironment(plan),
434
556
  onOutput: captureLines(job),
435
557
  })
436
558
  if (outcome.cancelled || job.cancelRequested) { markCancelled(job); return }
@@ -440,11 +562,10 @@ async function runVendorScriptInstallJob(job, plan) {
440
562
  throw new Error(outcome.timedOut ? '官方安装脚本超时并已终止。'
441
563
  : `官方安装脚本退出码 ${outcome.code ?? '信号终止'}。${String(outcome.stderr ?? '').split('\n').find(Boolean)?.slice(0, 200) ?? ''}`)
442
564
  }
443
- const location = resolveInstallLocation(job.tool, plan.method, preferences, currentInstallOptions())
444
- if (location?.dir) appendToProcessPath(location.dir)
565
+ if (plan.binDir) appendToProcessPath(plan.binDir, { prepend: true })
445
566
  job.phase = 'verifying'
446
567
  probeCache.delete(job.tool.id)
447
- const probe = await probeToolWith(job.tool, defaultRunner, { cacheMs: 0 })
568
+ const probe = await probeToolWith(job.tool, defaultRunner, { cacheMs: 0, plan, preferences: job.preferences })
448
569
  job.postInstallProbe = probe
449
570
  if (!probe.installed) {
450
571
  throw new Error(`官方安装脚本已完成,但 PATH 上仍找不到 ${job.tool.probeExecutables?.[0] ?? job.tool.label}:${probe.detail}`)
@@ -473,6 +594,15 @@ async function runVendorScriptInstallJob(job, plan) {
473
594
  * the file must sit inside that tool's install directory, and it can never be a
474
595
  * home directory or a filesystem root.
475
596
  */
597
+ export function scriptBinaryCandidate(installDir, relative) {
598
+ const raw = String(relative ?? '')
599
+ const parts = raw.split(/[\\/]/)
600
+ if (!raw || isAbsolute(raw) || /^[A-Za-z]:/.test(raw) || parts.some(part => !part || part === '.' || part === '..')) {
601
+ throw new Error('拒绝删除安装目录之外的文件。')
602
+ }
603
+ return join(installDir, ...parts)
604
+ }
605
+
476
606
  export function checkedRemovableFile(candidate, { allowedNames, installDir, home = homedir() }) {
477
607
  const target = resolve(candidate)
478
608
  const base = target.split(sep).pop() ?? ''
@@ -498,12 +628,13 @@ function isInsideDirectory(parent, child) {
498
628
  * reinstall keeps the user's login.
499
629
  */
500
630
  async function runVendorScriptUninstallJob(job, plan) {
631
+ if (job.status !== 'running') return
501
632
  try {
502
633
  job.phase = 'removing'
503
- const location = resolveInstallLocation(job.tool, plan.method, preferences, currentInstallOptions())
634
+ const location = { dir: plan.dir, binaries: plan.binaries }
504
635
  const removed = []
505
636
  for (const binary of location?.binaries ?? []) {
506
- const candidate = join(location.dir, binary)
637
+ const candidate = scriptBinaryCandidate(location.dir, binary)
507
638
  // checkedRemovableFile re-checks the basename, the install directory and
508
639
  // the root/home guards even though every part came from the registry.
509
640
  const target = checkedRemovableFile(candidate, {
@@ -513,16 +644,16 @@ async function runVendorScriptUninstallJob(job, plan) {
513
644
  await rm(target, { force: true })
514
645
  removed.push(target)
515
646
  }
516
- const stripped = await stripPathProfileBlocks(job.tool).catch(error => `(未能清理 PATH 配置:${String(error?.message ?? error).slice(0, 120)})`)
517
647
  if (removed.length === 0) {
518
648
  throw new Error(`在 ${location?.dir || '安装目录'} 下没有找到 ${job.tool.label} 的可执行文件;它可能是用包管理器安装的,请改用该方式卸载。`)
519
649
  }
650
+ const stripped = await stripPathProfileBlocks(job.tool).catch(error => [`(未能清理 PATH 配置:${String(error?.message ?? error).slice(0, 120)})`])
520
651
  for (const path of removed) pushLine(job, `已删除 ${path}`)
521
652
  if (stripped.length) pushLine(job, `已清理 shell 配置中的 PATH 记录:${stripped.join('、')}`)
522
653
  pushLine(job, `已保留 ${job.tool.label} 的配置与登录信息(${homedir()}/${String(job.tool.defaultScriptInstallDir ?? '').split('/')[0]})。`)
523
654
  job.phase = 'verifying'
524
655
  probeCache.delete(job.tool.id)
525
- const probe = await probeToolWith(job.tool, defaultRunner, { cacheMs: 0 })
656
+ const probe = await probeToolWith(job.tool, defaultRunner, { cacheMs: 0, preferences: job.preferences, plan: { ...plan, binDir: plan.dir } })
526
657
  job.postInstallProbe = probe
527
658
  job.status = 'succeeded'
528
659
  job.finishedAt = new Date().toISOString()
@@ -542,23 +673,25 @@ async function runVendorScriptUninstallJob(job, plan) {
542
673
  * writes. Only a well-formed `# stepcode … # stepcode end` block is removed; a
543
674
  * bare mention is left alone, exactly as the vendor's installer decides.
544
675
  */
545
- export function stripPathBlock(content, marker) {
676
+ export function stripPathBlock(content, marker, closingMarker = `${marker} end`) {
546
677
  const text = String(content ?? '')
547
678
  if (!text.includes(marker)) return null
548
- const lines = text.split(/\r?\n/)
679
+ const lines = text.match(/[^\r\n]*(?:\r\n|\n|$)/g)?.filter(Boolean) ?? []
549
680
  const kept = []
550
681
  let removed = 0
551
- let inside = false
552
- for (const line of lines) {
553
- if (!inside && line.trim() === marker) { inside = true; removed += 1; continue }
554
- if (inside) {
555
- removed += 1
556
- if (line.trim() === `${marker} end`) inside = false
557
- continue
682
+ for (let index = 0; index < lines.length; index += 1) {
683
+ if (lines[index].trim() !== marker) { kept.push(lines[index]); continue }
684
+ let closing = index + 1
685
+ while (closing < lines.length && lines[closing].trim() !== closingMarker) {
686
+ if (lines[closing].trim() === marker) return null
687
+ closing += 1
558
688
  }
559
- kept.push(line)
689
+ // A truncated block never authorizes deleting the rest of the user's file.
690
+ if (closing === lines.length) return null
691
+ removed += closing - index + 1
692
+ index = closing
560
693
  }
561
- return removed > 0 ? kept.join('\n') : null
694
+ return removed > 0 ? kept.join('') : null
562
695
  }
563
696
 
564
697
  const SHELL_PROFILES = ['.bashrc', '.bash_profile', '.zshrc', '.profile']
@@ -572,7 +705,11 @@ async function stripPathProfileBlocks(tool) {
572
705
  let content
573
706
  try { content = await readFile(path, 'utf8') } catch { continue }
574
707
  let next = content
575
- for (const marker of markers) next = stripPathBlock(next, marker) ?? next
708
+ if (tool.id === 'grok-build' && markers.length === 2) {
709
+ next = stripPathBlock(next, markers[0], markers[1]) ?? next
710
+ } else {
711
+ for (const marker of markers) next = stripPathBlock(next, marker) ?? next
712
+ }
576
713
  if (next === content) continue
577
714
  const temporary = `${path}.model-router-${process.pid}.tmp`
578
715
  await writeFile(temporary, next, 'utf8')
@@ -595,17 +732,37 @@ export function versionFromBanner(banner) {
595
732
 
596
733
  /**
597
734
  * Probe one registry tool. `runner` is injectable for offline tests and must
598
- * return { ok, code, stdout, stderr, timedOut }.
735
+ * return { ok, code, stdout, stderr, timedOut }. Package-manager probes inspect
736
+ * their actual package manifest and declared entry; they never execute a shared
737
+ * npm/pnpm command shim. `packageEntries` keeps those inspections injectable.
599
738
  */
600
- export async function probeToolWith(tool, runner, { cache = probeCache, cacheMs = PROBE_CACHE_MS, now = Date.now } = {}) {
739
+ export async function probeToolWith(tool, runner, {
740
+ cache = probeCache, cacheMs = PROBE_CACHE_MS, now = Date.now,
741
+ preferences: selectedPreferences = preferences, plan,
742
+ fileExists = async path => (await stat(path).catch(() => null))?.isFile() === true,
743
+ packageEntries = selectedPackageEntries,
744
+ } = {}) {
745
+ const scope = JSON.stringify([selectedPreferences, plan?.methodId, plan?.binDir, plan?.packageModuleRoots])
601
746
  const cached = cache.get(tool.id)
602
- if (cached && now() - cached.at < cacheMs) return cached.value
603
- const probe = await probeUncached(tool, runner)
604
- cache.set(tool.id, { at: now(), value: probe })
747
+ if (cached && cached.scope === scope && now() - cached.at < cacheMs) return cached.value
748
+ const probe = await probeUncached(tool, runner, { selectedPreferences, plan, fileExists, packageEntries })
749
+ cache.set(tool.id, { at: now(), value: probe, scope })
605
750
  return probe
606
751
  }
607
752
 
608
- async function probeUncached(tool, runner) {
753
+ async function selectedPackageEntries(tool, { plan, preferences: selectedPreferences }) {
754
+ const { findGlobalPackageEntries, officialToolEntrySelection } = await import('./official-tool-executor.mjs')
755
+ const selection = officialToolEntrySelection(tool.id, { preferences: selectedPreferences, plan })
756
+ const entries = []
757
+ for (const executable of tool.probeExecutables) {
758
+ entries.push(...await findGlobalPackageEntries(tool.package, executable, '', process.cwd(), {
759
+ selection, directories: selection.directories, manager: plan.manager, moduleRoots: plan.packageModuleRoots,
760
+ }))
761
+ }
762
+ return entries
763
+ }
764
+
765
+ async function probeUncached(tool, runner, { selectedPreferences, plan, fileExists, packageEntries }) {
609
766
  if (tool.unsupported) {
610
767
  return { id: tool.id, installed: false, version: null, status: 'unsupported', detail: tool.unsupportedReason }
611
768
  }
@@ -621,7 +778,60 @@ async function probeUncached(tool, runner) {
621
778
  ? `检测到已安装的 ZCode ${other.version ?? '(版本未知)'}${other.root ? `(${other.root})` : ''},但 ZCode.exe 未通过智谱发布者签名或 GLM 组件检查,因此不通过插件调用;可以继续直接使用 ZCode 桌面版。`
622
779
  : '未找到具有智谱有效签名的 ZCode 桌面版。' }
623
780
  }
624
- if (tool.id === 'minimax-code' && IS_WINDOWS) {
781
+ let selectedPlan = plan
782
+ if (!selectedPlan) {
783
+ try {
784
+ selectedPlan = resolveInstallPlan(tool, undefined, {
785
+ ...selectedPreferences,
786
+ allowScriptInstall: installMethodFor(tool, selectedPreferences.methods?.[tool.id])?.kind === 'script' ? true : selectedPreferences.allowScriptInstall,
787
+ }, currentInstallOptions())
788
+ } catch { /* a blocked saved method still allows normal detection */ }
789
+ }
790
+ // npm and pnpm can write the same shim into a unified bin directory. Its
791
+ // banner belongs to whichever manager wrote it last, not the selected copy.
792
+ // Bind version detection to the selected manager's package tree and declared
793
+ // bin. Execution readiness independently verifies whether that entry runs.
794
+ if (selectedPlan?.kind === 'package-manager' && tool.package) {
795
+ const entries = await packageEntries(tool, { plan: selectedPlan, preferences: selectedPreferences })
796
+ const selected = [...entries].sort((left, right) => compareReleaseVersions(right.version, left.version) ?? 0)[0]
797
+ const location = { installDir: selectedPlan.installDir, binDir: selectedPlan.binDir,
798
+ methodId: selectedPlan.methodId, manager: selectedPlan.manager }
799
+ if (selected) return { id: tool.id, installed: true, version: selected.version ?? null,
800
+ status: 'installed', ...location, packageRoot: selected.packageRoot,
801
+ packageEntry: selected.entry, source: selected.source,
802
+ detail: `${tool.probeNote ?? ''} ${selectedPlan.manager} 安装包:${selected.packageRoot}`,
803
+ bannerLine: `${tool.label} ${selected.version ?? '(版本未知)'}` }
804
+ return { id: tool.id, installed: false, version: null, status: 'not-installed', ...location,
805
+ detail: `未在所选${selectedPlan.binDir ? `安装目录 ${selectedPlan.binDir}` : '全局安装目录'}中找到 ${selectedPlan.manager} 安装的 ${tool.package} 及其声明的命令入口。` }
806
+ }
807
+ // Native script locations are also concrete targets. A different PATH copy
808
+ // must not satisfy installation or conceal an unsuccessful update.
809
+ if (selectedPlan?.binDir) {
810
+ for (const executable of tool.probeExecutables) {
811
+ const names = IS_WINDOWS
812
+ ? selectedPlan.kind === 'package-manager'
813
+ ? [`${executable}.cmd`, executable, `${executable}.exe`]
814
+ : [`${executable}.exe`, `${executable}.cmd`, executable]
815
+ : [executable]
816
+ for (const name of names) {
817
+ const target = join(selectedPlan.binDir, name)
818
+ if (!(await fileExists(target))) continue
819
+ const attempt = await runner(target, ['--version'], { timeoutMs: PROBE_TIMEOUT_MS, useShell: IS_WINDOWS && /\.(cmd|bat)$/i.test(target) })
820
+ const banner = probeBanner(attempt)
821
+ const version = versionFromBanner(banner)
822
+ return { id: tool.id, installed: Boolean(attempt.ok || version), version,
823
+ status: attempt.timedOut ? 'probe-timeout' : attempt.ok || version ? 'installed' : 'probe-failed',
824
+ detail: attempt.ok || version ? `${tool.probeNote ?? ''} 安装目录:${selectedPlan.binDir}`
825
+ : `${target} --version ${attempt.timedOut ? '超时' : `退出码 ${attempt.code ?? '信号终止'}`}。${String(attempt.stderr ?? '').slice(0, 120)}`,
826
+ executablePath: target, installDir: selectedPlan.installDir, binDir: selectedPlan.binDir,
827
+ bannerLine: banner.split('\n').find(Boolean)?.slice(0, 200) ?? '' }
828
+ }
829
+ }
830
+ return { id: tool.id, installed: false, version: null, status: 'not-installed',
831
+ installDir: selectedPlan.installDir, binDir: selectedPlan.binDir,
832
+ detail: `未在所选安装目录 ${selectedPlan.binDir} 中找到官方命令。` }
833
+ }
834
+ if (tool.id === 'minimax-code' && IS_WINDOWS && !usesConfiguredInstallPlan(tool.id, selectedPreferences)) {
625
835
  // The official Windows installer keeps mcode under a versioned releases
626
836
  // directory. A partial npm install can leave a broken shim earlier on PATH;
627
837
  // prefer the checksum-verified installer release in that case.
@@ -629,6 +839,7 @@ async function probeUncached(tool, runner) {
629
839
  const managed = await findManagedMiniMaxEntry(process.cwd())
630
840
  if (managed) return { id: tool.id, installed: true, version: managed.version,
631
841
  status: 'installed', detail: '已按官方 npm registry 摘要核验 MiniMax 官方 Windows 安装器中的 CLI 文件。',
842
+ installDir: managed.installDir, binDir: managed.installDir,
632
843
  bannerLine: `MiniMax Code ${managed.version}` }
633
844
  }
634
845
  for (const executable of tool.probeExecutables) {
@@ -645,12 +856,12 @@ async function probeUncached(tool, runner) {
645
856
  if (attempt.timedOut) {
646
857
  return { id: tool.id, installed: false, version: null, status: 'probe-timeout', detail: `${executable} --version 超时(${PROBE_TIMEOUT_MS / 1000}s)` }
647
858
  }
648
- const attempts = [attempt]
859
+ const attempts = [{ ...attempt, executablePath: locatorPaths(located.stdout)[0] }]
649
860
  if (!versionFromBanner(probeBanner(attempt))) {
650
861
  for (const target of locatorPaths(located.stdout)) {
651
862
  if (target === executable) continue
652
863
  const direct = await runner(target, ['--version'], { timeoutMs: PROBE_TIMEOUT_MS, useShell: false })
653
- attempts.push(direct)
864
+ attempts.push({ ...direct, executablePath: target })
654
865
  if (direct.timedOut) break
655
866
  if (direct.ok || versionFromBanner(probeBanner(direct))) break
656
867
  }
@@ -665,6 +876,7 @@ async function probeUncached(tool, runner) {
665
876
  version,
666
877
  status: 'installed',
667
878
  detail: tool.probeNote ?? '',
879
+ executablePath: chosen.executablePath,
668
880
  bannerLine: banner.split('\n').find(Boolean)?.slice(0, 200) ?? '',
669
881
  }
670
882
  }
@@ -686,15 +898,16 @@ export const defaultRunner = (executable, args, options = {}) =>
686
898
  let prefixPromise = null
687
899
 
688
900
  /** Add one directory to this process's PATH unless it is already there. */
689
- function appendToProcessPath(directory) {
901
+ export function appendToProcessPath(directory, { prepend = false, env = process.env, platform = process.platform } = {}) {
690
902
  const target = String(directory ?? '').trim()
691
903
  if (!target || !isAbsolute(target)) return
692
- const separator = IS_WINDOWS ? ';' : ':'
693
- const current = process.env.PATH ?? ''
694
- if (current.split(separator).some(entry => entry.toLowerCase() === target.toLowerCase())) return
695
- process.env.PATH = current ? `${current}${separator}${target}` : target
696
- // POSIX package managers put global binaries in <prefix>/bin.
697
- if (!IS_WINDOWS) appendToProcessPath(join(target, 'bin'))
904
+ const separator = platform === 'win32' ? ';' : ':'
905
+ const key = Object.keys(env).find(name => name.toUpperCase() === 'PATH') ?? 'PATH'
906
+ const parts = String(env[key] ?? '').split(separator).filter(Boolean)
907
+ const equal = entry => platform === 'win32' ? entry.toLowerCase() === target.toLowerCase() : entry === target
908
+ if (!prepend && parts.some(equal)) return
909
+ const remaining = parts.filter(entry => !equal(entry))
910
+ env[key] = (prepend ? [target, ...remaining] : [...remaining, target]).join(separator)
698
911
  }
699
912
 
700
913
  /**
@@ -713,9 +926,9 @@ export function ensureNpmPrefixOnPath() {
713
926
  timeoutMs: 5_000,
714
927
  useShell: IS_WINDOWS,
715
928
  })
716
- if (!outcome.ok) return
929
+ if (!outcome.ok) throw new Error('npm prefix is unavailable')
717
930
  let prefix = outcome.stdout.trim()
718
- if (!prefix) return
931
+ if (!prefix) throw new Error('npm prefix is empty')
719
932
  if (prefix.startsWith('\\\\?\\')) prefix = prefix.slice(4)
720
933
  if (IS_WINDOWS && !process.env.GROK_HOME && /^[D-Z]:[\\/]/i.test(prefix)) {
721
934
  // Grok's npm bootstrap otherwise expands its native binary under
@@ -723,11 +936,15 @@ export function ensureNpmPrefixOnPath() {
723
936
  // the same non-C drive as this user's npm global prefix.
724
937
  process.env.GROK_HOME = join(prefix, '.model-router-grok')
725
938
  }
726
- appendToProcessPath(prefix)
939
+ appendToProcessPath(IS_WINDOWS ? prefix : join(prefix, 'bin'))
727
940
  } catch { /* environment stays untouched */ }
728
941
  // A --prefix install lands outside the npm prefix, so it needs its own
729
942
  // PATH entry or every later probe reports the tool as missing.
730
- try { appendToProcessPath(preferences.installDir) } catch { /* environment stays untouched */ }
943
+ for (const tool of OFFICIAL_TOOLS) {
944
+ for (const directory of preferredToolDirectories(tool.id)) {
945
+ try { appendToProcessPath(directory, { prepend: Boolean(preferences.installDir || preferences.methods[tool.id]) }) } catch { /* environment stays untouched */ }
946
+ }
947
+ }
731
948
  })()
732
949
  }
733
950
  return prefixPromise
@@ -803,8 +1020,46 @@ function pushLine(job, line) {
803
1020
  job.outputTail.push(line)
804
1021
  }
805
1022
 
1023
+ /** Bind a mutating package-manager job to the directory its command will write. */
1024
+ export async function resolvePackageJobPlan(tool, plan, selectedPreferences, {
1025
+ operation = 'install', runner = defaultRunner, options = currentInstallOptions(), signal,
1026
+ } = {}) {
1027
+ if (plan?.kind !== 'package-manager' || plan.installDir) return plan
1028
+ const readDirectory = async args => {
1029
+ const outcome = await runner(plan.manager, args, {
1030
+ timeoutMs: PROBE_TIMEOUT_MS, useShell: options.platform === 'win32', signal,
1031
+ })
1032
+ if (!outcome.ok || outcome.timedOut || outcome.cancelled) throw new Error('query failed')
1033
+ const raw = String(outcome.stdout ?? '').trim().replace(/^\\\\\?\\/, '')
1034
+ const directory = expandInstallDir(raw, options)
1035
+ if (!directory || /[\r\n]/.test(directory)) throw new Error('invalid directory')
1036
+ return directory.replace(/[\\/]+$/, '')
1037
+ }
1038
+ if (plan.manager === 'pnpm') {
1039
+ try {
1040
+ const [binDir, moduleRoot] = await Promise.all([readDirectory(['bin', '-g']), readDirectory(['root', '-g'])])
1041
+ const layout = /^(.*)[\\/]\d+[\\/]node_modules$/.exec(moduleRoot)
1042
+ if (!layout || !layout[1]) throw new Error('unsupported global layout')
1043
+ const globalDir = expandInstallDir(layout[1], options)
1044
+ return { ...plan, binDir, installDir: '', globalDir, packageModuleRoots: [moduleRoot],
1045
+ args: [...plan.args, `--config.global-dir=${globalDir}`, `--config.global-bin-dir=${binDir}`] }
1046
+ } catch {
1047
+ throw new Error('无法安全绑定 pnpm 默认全局包目录与命令目录,已停止操作;请检查 pnpm 或在官方工具设置中配置统一安装目录。')
1048
+ }
1049
+ }
1050
+ if (plan.manager !== 'npm') throw new Error('此包管理器的默认安装目录无法安全绑定,请配置统一安装目录。')
1051
+ let prefix
1052
+ try { prefix = await readDirectory(['config', 'get', 'prefix']) } catch {
1053
+ throw new Error('无法读取有效的 npm 默认全局安装目录,已停止操作;请检查 npm 或配置统一安装目录。')
1054
+ }
1055
+ const targetPreferences = { ...selectedPreferences, installDir: prefix }
1056
+ return operation === 'uninstall'
1057
+ ? resolveUninstallPlan(tool, plan.methodId, targetPreferences, options)
1058
+ : resolveInstallPlan(tool, plan.methodId, targetPreferences, options)
1059
+ }
1060
+
806
1061
  /** Reserve the single per-tool job slot and serialise against every other tool. */
807
- function beginJob(tool, { operation, methodId, command, installDir = '', run }) {
1062
+ function beginJob(tool, { operation, methodId, command, installDir = '', plan, configured = false, run }) {
808
1063
  const existing = installJobs.get(tool.id)
809
1064
  if (existing?.status === 'running') throw new Error(`${tool.label} 已有${operation === 'uninstall' ? '卸载' : '安装'}任务正在执行。`)
810
1065
  const job = {
@@ -813,6 +1068,9 @@ function beginJob(tool, { operation, methodId, command, installDir = '', run })
813
1068
  methodId,
814
1069
  command,
815
1070
  installDir,
1071
+ plan,
1072
+ preferences: installPreferences(),
1073
+ configured: configured || plan?.kind === 'package-manager',
816
1074
  status: 'running',
817
1075
  phase: 'queued',
818
1076
  cancelRequested: false,
@@ -828,8 +1086,28 @@ function beginJob(tool, { operation, methodId, command, installDir = '', run })
828
1086
  }
829
1087
  installJobs.set(tool.id, job)
830
1088
  probeCache.delete(tool.id)
831
- installChain = installChain.then(() => run(job)).catch(error => {
1089
+ installChain = installChain.then(async () => {
832
1090
  if (job.status !== 'running') return
1091
+ if (job.plan?.kind === 'package-manager') {
1092
+ job.phase = 'preflight'
1093
+ const target = await resolvePackageJobPlan(job.tool, job.plan, job.preferences, {
1094
+ operation: job.operation, signal: job.controller.signal,
1095
+ })
1096
+ if (job.cancelRequested) { markCancelled(job); return }
1097
+ job.plan = target
1098
+ job.installDir = target.installDir || target.globalDir || target.binDir
1099
+ const resolvedCommand = planCommandLine(target, job.operation)
1100
+ const command = job.operation === 'repair' ? `修复:${resolvedCommand}` : resolvedCommand
1101
+ if (job.command !== command) {
1102
+ job.command = command
1103
+ pushLine(job, `— 本次操作目录:${job.installDir} —`)
1104
+ pushLine(job, `$ ${job.command}`)
1105
+ }
1106
+ }
1107
+ return run(job)
1108
+ }).catch(error => {
1109
+ if (job.status !== 'running') return
1110
+ if (job.cancelRequested || job.controller.signal.aborted) { markCancelled(job); return }
833
1111
  job.status = 'failed'
834
1112
  job.finishedAt = new Date().toISOString()
835
1113
  job.error = `${operation === 'uninstall' ? '卸载' : '安装'}流程失败:${String(error?.message ?? error).slice(0, 200)}`
@@ -840,12 +1118,19 @@ function beginJob(tool, { operation, methodId, command, installDir = '', run })
840
1118
  /** One-line summary of a resolved plan, for the job log and the UI. */
841
1119
  export function planCommandLine(plan, operation = 'install') {
842
1120
  if (plan.kind === 'script') {
843
- const piped = plan.shell === 'powershell' ? `irm ${plan.scriptUrl} | iex` : `curl -fsSL ${plan.scriptUrl} | bash`
1121
+ const quote = value => `'${String(value).replace(/'/g, plan.shell === 'powershell' ? "''" : "'\\''")}'`
1122
+ const environment = Object.entries(plan.env ?? {}).map(([name, value]) => plan.shell === 'powershell'
1123
+ ? `$env:${name}=${quote(value)}; ` : `${name}=${quote(value)} `).join('')
1124
+ const directoryArgs = plan.installDir && plan.installArg?.length ? ` ${plan.installArg.join(' ')} ${quote(plan.installDir)}` : ''
1125
+ const piped = plan.shell === 'powershell'
1126
+ ? `${environment}irm ${plan.scriptUrl} | iex`
1127
+ : `curl -fsSL ${plan.scriptUrl} | ${environment}${plan.shell === 'sh' ? 'sh' : 'bash'}${directoryArgs ? ` -s --${directoryArgs}` : ''}`
844
1128
  return operation === 'uninstall'
845
1129
  ? `删除 ${plan.dir} 下的 ${(plan.binaries ?? []).join('、') || '可执行文件'}`
846
- : `${piped}${plan.installDir ? ` ${plan.installArg} ${plan.installDir}` : ''}`
1130
+ : plan.shell === 'powershell' && directoryArgs
1131
+ ? `${environment}& ([scriptblock]::Create((irm ${plan.scriptUrl})))${directoryArgs}` : piped
847
1132
  }
848
- return `${plan.manager} ${plan.args.join(' ')}`
1133
+ return `${plan.manager} ${plan.args.map(arg => /\s/.test(arg) ? JSON.stringify(arg) : arg).join(' ')}`
849
1134
  }
850
1135
 
851
1136
  /**
@@ -869,9 +1154,11 @@ export function startInstall(toolId, { method } = {}) {
869
1154
  methodId: plan.methodId,
870
1155
  command: planCommandLine(plan),
871
1156
  installDir: plan.installDir ?? '',
1157
+ plan,
1158
+ configured: usesConfiguredInstallPlan(tool.id, preferences, method),
872
1159
  run: job => (plan.kind === 'script'
873
1160
  ? runVendorScriptInstallJob(job, plan)
874
- : runInstallJob(plan.manager, plan.args, job)),
1161
+ : runInstallJob(job.plan, job)),
875
1162
  })
876
1163
  }
877
1164
 
@@ -891,10 +1178,12 @@ export function startRepair(toolId, { method } = {}) {
891
1178
  methodId: plan.methodId,
892
1179
  command: `修复:${planCommandLine(plan)}`,
893
1180
  installDir: plan.installDir ?? '',
1181
+ plan,
1182
+ configured: usesConfiguredInstallPlan(tool.id, preferences, method),
894
1183
  run: async job => {
895
1184
  job.repair = true
896
1185
  if (plan.kind === 'script') return runVendorScriptInstallJob(job, plan)
897
- return runInstallJob(plan.manager, plan.args, job)
1186
+ return runInstallJob(job.plan, job)
898
1187
  },
899
1188
  })
900
1189
  }
@@ -912,17 +1201,20 @@ export function startUninstall(toolId, { method } = {}) {
912
1201
  methodId: plan.methodId,
913
1202
  command: planCommandLine(plan, 'uninstall'),
914
1203
  installDir: plan.dir ?? plan.installDir ?? '',
1204
+ plan,
1205
+ configured: usesConfiguredInstallPlan(tool.id, preferences, method),
915
1206
  run: job => (plan.kind === 'script'
916
1207
  ? runVendorScriptUninstallJob(job, plan)
917
- : runPackageManagerUninstallJob(job, plan)),
1208
+ : runPackageManagerUninstallJob(job, job.plan)),
918
1209
  })
919
1210
  }
920
1211
 
921
1212
  async function runPackageManagerUninstallJob(job, plan) {
1213
+ if (job.status !== 'running') return
922
1214
  try {
923
1215
  job.phase = 'preflight'
924
1216
  await ensureNpmPrefixOnPath()
925
- const before = await probeToolWith(job.tool, defaultRunner)
1217
+ const before = await probeToolWith(job.tool, defaultRunner, { plan, preferences: job.preferences, cacheMs: 0 })
926
1218
  if (job.cancelRequested) { markCancelled(job); return }
927
1219
  if (!before.installed) {
928
1220
  job.status = 'succeeded'
@@ -937,6 +1229,7 @@ async function runPackageManagerUninstallJob(job, plan) {
937
1229
  useShell: IS_WINDOWS,
938
1230
  signal: job.controller.signal,
939
1231
  onOutput: captureLines(job),
1232
+ env: installEnvironment(plan),
940
1233
  })
941
1234
  if (outcome.cancelled || job.cancelRequested) { markCancelled(job); return }
942
1235
  job.exitCode = outcome.code
@@ -947,7 +1240,7 @@ async function runPackageManagerUninstallJob(job, plan) {
947
1240
  }
948
1241
  job.phase = 'verifying'
949
1242
  probeCache.delete(job.tool.id)
950
- const probe = await probeToolWith(job.tool, defaultRunner, { cacheMs: 0 })
1243
+ const probe = await probeToolWith(job.tool, defaultRunner, { cacheMs: 0, preferences: job.preferences, plan })
951
1244
  job.postInstallProbe = probe
952
1245
  job.status = 'succeeded'
953
1246
  job.finishedAt = new Date().toISOString()
@@ -972,6 +1265,13 @@ function captureLines(job) {
972
1265
  }
973
1266
  }
974
1267
 
1268
+ /** pnpm requires its global bin directory on PATH even during installation. */
1269
+ export function installEnvironment(plan, base = process.env) {
1270
+ const env = { ...base, ...(plan.env ?? {}) }
1271
+ if (plan.binDir) appendToProcessPath(plan.binDir, { prepend: true, env })
1272
+ return env
1273
+ }
1274
+
975
1275
  /** Mark the job succeeded only when the fresh probe is installed, ready, and not still older. */
976
1276
  async function finishVerifiedInstall(job, probe) {
977
1277
  job.postInstallProbe = probe
@@ -987,10 +1287,10 @@ async function finishVerifiedInstall(job, probe) {
987
1287
  failBecauseStillOlder(job, probe, latest.version)
988
1288
  return true
989
1289
  }
990
- const readiness = await trustedExecutionReadiness(job.tool.id)
1290
+ const readiness = await trustedExecutionReadiness(job.tool.id, { preferences: job.preferences, plan: job.plan })
991
1291
  if (job.cancelRequested) { markCancelled(job); return true }
992
1292
  if (!readiness.ready) {
993
- if (IS_WINDOWS && job.tool.id === 'minimax-code') {
1293
+ if (IS_WINDOWS && job.tool.id === 'minimax-code' && !job.configured) {
994
1294
  await runOfficialMiniMaxInstaller(job)
995
1295
  return true
996
1296
  }
@@ -1020,7 +1320,7 @@ async function tryClaudeUpdate(job, latestVersion) {
1020
1320
  if (job.cancelRequested) { markCancelled(job); return true }
1021
1321
  pushLine(job, '$ claude update')
1022
1322
  job.phase = 'installing'
1023
- const outcome = await runCapture('claude', CLAUDE_UPDATE_ARGS, {
1323
+ const outcome = await runCapture(job.beforeProbe?.executablePath || 'claude', CLAUDE_UPDATE_ARGS, {
1024
1324
  timeoutMs: CLAUDE_UPDATE_TIMEOUT_MS,
1025
1325
  useShell: IS_WINDOWS,
1026
1326
  signal: job.controller.signal,
@@ -1034,7 +1334,10 @@ async function tryClaudeUpdate(job, latestVersion) {
1034
1334
  }
1035
1335
  await ensureNpmPrefixOnPath()
1036
1336
  probeCache.delete(job.tool.id)
1037
- const probe = await probeToolWith(job.tool, defaultRunner)
1337
+ const nativePlan = job.beforeProbe?.executablePath
1338
+ ? { ...job.plan, binDir: dirname(job.beforeProbe.executablePath), installDir: dirname(job.beforeProbe.executablePath) }
1339
+ : job.plan
1340
+ const probe = await probeToolWith(job.tool, defaultRunner, { preferences: job.preferences, plan: nativePlan, cacheMs: 0 })
1038
1341
  if (job.cancelRequested) { markCancelled(job); return true }
1039
1342
  if (latestVersion && versionStillOlder(probe.version, latestVersion)) {
1040
1343
  job.postInstallProbe = probe
@@ -1044,13 +1347,15 @@ async function tryClaudeUpdate(job, latestVersion) {
1044
1347
  return finishVerifiedInstall(job, probe)
1045
1348
  }
1046
1349
 
1047
- async function runInstallJob(manager, args, job) {
1350
+ async function runInstallJob(installPlan, job) {
1351
+ const { manager, args } = installPlan
1048
1352
  if (job.status !== 'running') return
1049
1353
  job.phase = 'preflight'
1050
1354
  await ensureNpmPrefixOnPath()
1051
1355
  if (job.cancelRequested) { markCancelled(job); return }
1052
1356
  probeCache.delete(job.tool.id)
1053
- const before = await probeToolWith(job.tool, defaultRunner)
1357
+ const before = await probeToolWith(job.tool, defaultRunner, { plan: installPlan, preferences: job.preferences, cacheMs: 0 })
1358
+ job.beforeProbe = before
1054
1359
  if (job.cancelRequested) { markCancelled(job); return }
1055
1360
  let latest = null
1056
1361
  let order = null
@@ -1066,7 +1371,7 @@ async function runInstallJob(manager, args, job) {
1066
1371
  return
1067
1372
  }
1068
1373
  if (order === 0) {
1069
- const readiness = await trustedExecutionReadiness(job.tool.id)
1374
+ const readiness = await trustedExecutionReadiness(job.tool.id, { preferences: job.preferences, plan: job.plan })
1070
1375
  if (job.cancelRequested) { markCancelled(job); return }
1071
1376
  // A repair re-runs the same command even when nothing looks stale: a
1072
1377
  // broken install reports the right version, so "already current" is
@@ -1089,6 +1394,7 @@ async function runInstallJob(manager, args, job) {
1089
1394
  installed: before.installed === true,
1090
1395
  order,
1091
1396
  platform: process.platform,
1397
+ configured: job.configured,
1092
1398
  })
1093
1399
  let triedClaudeUpdate = false
1094
1400
  if (plan === 'claude-update-then-registry') {
@@ -1101,12 +1407,14 @@ async function runInstallJob(manager, args, job) {
1101
1407
  })
1102
1408
  return
1103
1409
  }
1410
+ if (installPlan.binDir) await mkdir(installPlan.binDir, { recursive: true })
1104
1411
  job.phase = 'installing'
1105
1412
  const outcome = await runCapture(manager, args, {
1106
1413
  timeoutMs: INSTALL_TIMEOUT_MS,
1107
1414
  useShell: IS_WINDOWS,
1108
1415
  signal: job.controller.signal,
1109
1416
  onOutput: captureLines(job),
1417
+ env: installEnvironment(installPlan),
1110
1418
  })
1111
1419
  if (job.status !== 'running') return
1112
1420
  if (outcome.cancelled || job.cancelRequested) { markCancelled(job); return }
@@ -1119,7 +1427,7 @@ async function runInstallJob(manager, args, job) {
1119
1427
  return
1120
1428
  }
1121
1429
  if (!outcome.ok) {
1122
- if (IS_WINDOWS && job.tool.id === 'minimax-code') {
1430
+ if (IS_WINDOWS && job.tool.id === 'minimax-code' && !job.configured) {
1123
1431
  await runOfficialMiniMaxInstaller(job)
1124
1432
  return
1125
1433
  }
@@ -1131,19 +1439,19 @@ async function runInstallJob(manager, args, job) {
1131
1439
  job.phase = 'verifying'
1132
1440
  await ensureNpmPrefixOnPath()
1133
1441
  probeCache.delete(job.tool.id)
1134
- const probe = await probeToolWith(job.tool, defaultRunner)
1442
+ const probe = await probeToolWith(job.tool, defaultRunner, { plan: installPlan, preferences: job.preferences, cacheMs: 0 })
1135
1443
  if (job.cancelRequested) { markCancelled(job); return }
1136
1444
  job.postInstallProbe = probe
1137
1445
  const latestAfter = (await latestVersions.lookup(job.tool).catch(() => null)) ?? latest
1138
1446
  const stillOlder = Boolean(latestAfter?.version && versionStillOlder(probe.version, latestAfter.version))
1139
- if (stillOlder && job.tool.id === 'claude-code' && !triedClaudeUpdate) {
1447
+ if (stillOlder && job.tool.id === 'claude-code' && !triedClaudeUpdate && !job.configured) {
1140
1448
  triedClaudeUpdate = true
1141
1449
  const finished = await tryClaudeUpdate(job, latestAfter.version)
1142
1450
  if (finished || job.status !== 'running') return
1143
1451
  failBecauseStillOlder(job, job.postInstallProbe ?? probe, latestAfter.version)
1144
1452
  return
1145
1453
  }
1146
- if (stillOlder && IS_WINDOWS && job.tool.id === 'minimax-code') {
1454
+ if (stillOlder && IS_WINDOWS && job.tool.id === 'minimax-code' && !job.configured) {
1147
1455
  pushLine(job, `— npm 完成后 ${job.tool.label} 仍是 ${probe.version},低于最新 ${latestAfter.version};改用官方安装脚本 —`)
1148
1456
  await runOfficialMiniMaxInstaller(job)
1149
1457
  return