@ljwei-stak/dsh-model-router 0.17.1 → 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,7 +24,7 @@ import {
23
24
  OFFICIAL_TOOLS,
24
25
  } from './official-tool-registry.mjs'
25
26
  import {
26
- minimaxScriptRegistry,
27
+ expandInstallDir,
27
28
  resolveInstallLocation,
28
29
  resolveInstallPlan,
29
30
  resolveUninstallPlan,
@@ -40,9 +41,7 @@ const OUTPUT_LINE_CAP = 120
40
41
  const IS_WINDOWS = process.platform === 'win32'
41
42
  const CREATE_NO_WINDOW = 0x0800_0000
42
43
  const MINIMAX_INSTALLER_URL = 'https://filecdn.minimax.chat/public/install.ps1'
43
- // Codex's Windows installer is already ~70KB and MiniMax's is ~57KB. 1MiB still
44
- // rejects an HTML error page or a substituted archive.
45
- const MAX_INSTALLER_BYTES = 1_048_576
44
+ const MAX_INSTALLER_BYTES = 200_000
46
45
  /** Windows ships where.exe in System32; POSIX which is ubiquitous. */
47
46
  const LOCATOR = IS_WINDOWS ? 'where' : 'which'
48
47
  /** Cached probe results keep plan generation fast; installs invalidate them. */
@@ -64,21 +63,49 @@ let preferences = { installDir: '', registry: '', methods: {}, scriptUrls: {}, a
64
63
 
65
64
  export function setInstallPreferences(next) {
66
65
  if (next && typeof next === 'object') {
67
- const changedDirectory = preferences.installDir !== next.installDir
68
- 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
+ }
69
71
  // A new directory needs its own PATH entry before the next probe runs.
70
- if (changedDirectory) prefixPromise = null
72
+ if (previous !== JSON.stringify(preferences)) {
73
+ prefixPromise = null
74
+ probeCache.clear()
75
+ }
71
76
  }
72
77
  return preferences
73
78
  }
74
79
 
75
80
  export function installPreferences() {
76
- return { ...preferences }
81
+ return { ...preferences, methods: { ...preferences.methods }, scriptUrls: { ...preferences.scriptUrls } }
77
82
  }
78
83
 
79
84
  /** The machine this Host runs on; the plan resolver needs both to be explicit. */
80
85
  export function currentInstallOptions() {
81
- 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))
82
109
  }
83
110
 
84
111
  /**
@@ -89,10 +116,20 @@ export function currentInstallOptions() {
89
116
  * cmd strip the wrong quotes and launch `node` against a missing script:
90
117
  * `MODULE_NOT_FOUND` at `executeUserEntryPoint` with an empty requireStack.
91
118
  * That failed `npm config get prefix`, `--version`, and `npm install -g` for
92
- * 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.
93
121
  */
94
122
  export function shellSpawnSpec(executable, args = [], { useShell = false } = {}) {
95
- 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
+ }
96
133
  }
97
134
 
98
135
  /** Decode a CLI banner. Native Windows exes often write UTF-16 to a pipe. */
@@ -131,26 +168,22 @@ export function staleInstallMessage(label, installedVersion, latestVersion) {
131
168
  * Which updater to run. `order` is compareReleaseVersions(installed, latest):
132
169
  * -1 older, 0 same, 1 newer, null when either version is unknown.
133
170
  */
134
- export function officialUpdatePlan({ toolId, installed, order, platform, methodId }) {
171
+ export function officialUpdatePlan({ toolId, installed, order, platform, configured = false }) {
135
172
  if (installed && order === 1) return 'refuse-downgrade'
136
173
  if (installed && order === 0) return 'already-current'
174
+ if (configured) return 'registry-install'
137
175
  if (installed && order === -1 && toolId === 'claude-code') return 'claude-update-then-registry'
138
176
  if (installed && (order === -1 || order === null) && toolId === 'minimax-code' && platform === 'win32') {
139
- // npm/pnpm is the method the user picked, so that command runs first. The
140
- // official Windows installer stays the fallback when that copy does not
141
- // advance, and it is the direct plan when no package manager was named
142
- // (the historical one-click update of an official-layout install).
143
- if (methodId === 'npm' || methodId === 'pnpm') return 'registry-install'
144
177
  return 'minimax-official-installer'
145
178
  }
146
179
  return 'registry-install'
147
180
  }
148
181
 
149
- async function trustedExecutionReadiness(toolId) {
182
+ async function trustedExecutionReadiness(toolId, options) {
150
183
  // Loaded only during an install to avoid coupling fast version probes to
151
184
  // the executor. The executor itself uses this runtime for PATH discovery.
152
185
  const { officialToolReadiness } = await import('./official-tool-executor.mjs')
153
- return officialToolReadiness(toolId)
186
+ return officialToolReadiness(toolId, process.cwd(), options)
154
187
  }
155
188
 
156
189
  function runCapture(executable, args, { timeoutMs, onOutput, useShell = false, signal, env } = {}) {
@@ -245,121 +278,73 @@ function runCapture(executable, args, { timeoutMs, onOutput, useShell = false, s
245
278
  })
246
279
  }
247
280
 
248
- /**
249
- * Decode installer bytes the way a terminal would. The MiniMax CDN serves the
250
- * PowerShell script as `application/octet-stream` UTF-8; a UTF-16 copy (BOM or
251
- * null-padded) is the same script and must not be rejected as a mismatch.
252
- */
253
281
  export function installerScriptText(source) {
254
282
  return decodeProcessOutput(Buffer.isBuffer(source) ? source : Buffer.from(source ?? '')).replace(/^\uFEFF/, '')
255
283
  }
256
284
 
257
- /**
258
- * An HTML/XML document is not an installer. PowerShell comment-based help
259
- * starts with `<#`, which is not a tag: the published MiniMax, Kimi and MiMo
260
- * Windows scripts all open that way. Treating every leading `<` as HTML
261
- * rejected those official scripts.
262
- */
263
285
  export function isMarkupDocument(text) {
264
286
  const head = String(text ?? '').replace(/^\uFEFF/, '').trimStart()
265
- if (!head.startsWith('<')) return false
266
- if (head.startsWith('<#')) return false
287
+ if (!head.startsWith('<') || head.startsWith('<#')) return false
267
288
  return /^<(?:!|\?|[A-Za-z])/.test(head)
268
289
  }
269
290
 
270
- const MINIMAX_INSTALLER_MARKERS = Object.freeze(['@minimax-ai/code', 'MCODE_INSTALL_DIR', 'MiniMax Code'])
271
-
272
291
  /**
273
292
  * MiniMax's official Windows installer script is run as published (it installs
274
- * `@minimax-ai/code@latest`). Structural checks only: the vendor's own markers,
275
- * and a refusal of HTML/XML. Content-Type is not consulted — the CDN labels
276
- * the real script `application/octet-stream`.
293
+ * `@minimax-ai/code@latest`). Only structural sanity is checked: it must be the
294
+ * MiniMax Code installer, not an HTML error page or another script.
277
295
  */
278
296
  export function officialMiniMaxInstaller(source) {
279
297
  const text = installerScriptText(source)
280
- if (!text.trim()) throw new Error('下载内容为空,已拒绝执行。')
281
- if (isMarkupDocument(text)) {
282
- throw new Error('下载内容不是 MiniMax Code 官方 Windows 安装脚本(收到 HTML 页面),已拒绝执行。')
283
- }
284
- const missing = MINIMAX_INSTALLER_MARKERS.filter(marker => !text.includes(marker))
285
- if (missing.length) {
286
- throw new Error(`下载内容不是 MiniMax Code 官方 Windows 安装脚本(缺少 ${missing.join('、')}),已拒绝执行。`)
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))) {
301
+ throw new Error('下载内容不是 MiniMax Code 官方 Windows 安装脚本,已拒绝执行。')
287
302
  }
288
303
  return text
289
304
  }
290
305
 
291
- /**
292
- * Directory the Windows installer must use. A configured path wins. Otherwise
293
- * an already-installed official layout is updated in place. With neither, the
294
- * vendor default is `%USERPROFILE%\.minimax-code` — not the npm prefix.
295
- */
296
- export function minimaxOfficialInstallDir(preferences = {}, home = homedir(), existingRoot = '') {
297
- const configured = String(preferences?.installDir ?? '').trim()
298
- if (configured) return configured
299
- const existing = String(existingRoot ?? '').trim()
300
- if (existing) return existing
301
- return join(home, '.minimax-code')
306
+ async function fetchOfficialMiniMaxInstaller(signal) {
307
+ return officialMiniMaxInstaller(await fetchVendorScript(MINIMAX_INSTALLER_URL, signal, { verify: { mustInclude: ['@minimax-ai/code', 'MiniMax Code CLI'] } }))
302
308
  }
303
309
 
304
- async function fetchOfficialMiniMaxInstaller(signal) {
305
- const response = await fetch(MINIMAX_INSTALLER_URL, {
306
- signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000),
307
- })
308
- if (!response.ok || !response.body) throw new Error(`官方脚本下载失败:HTTP ${response.status}`)
309
- const chunks = []
310
- let length = 0
311
- for await (const chunk of response.body) {
312
- length += chunk.length
313
- if (length > MAX_INSTALLER_BYTES) throw new Error('官方安装脚本超过大小上限。')
314
- chunks.push(chunk)
315
- }
316
- return officialMiniMaxInstaller(Buffer.concat(chunks))
310
+ export function minimaxOfficialInstallDir(selectedPreferences = {}, home = homedir(), existingRoot = '') {
311
+ return selectedPreferences.installDir || existingRoot || join(home, '.minimax-code')
317
312
  }
318
313
 
319
- async function existingMiniMaxInstallRoot() {
320
- const paths = (process.env.PATH ?? '').split(IS_WINDOWS ? ';' : ':').filter(Boolean)
321
- const candidates = []
322
- for (const directory of paths) candidates.push(directory, join(directory, '.minimax-code'))
323
- candidates.push(join(homedir(), '.minimax-code'))
324
- const seen = new Set()
325
- for (const directory of candidates) {
326
- const key = directory.toLowerCase()
327
- if (seen.has(key)) continue
328
- seen.add(key)
329
- try {
330
- if (!(await stat(join(directory, 'mcode.cmd'))).isFile()) continue
331
- if (!(await stat(join(directory, 'current'))).isFile()) continue
332
- return await realpath(directory)
333
- } catch { /* try the next candidate */ }
334
- }
335
- return ''
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 }
336
321
  }
337
322
 
338
323
  async function runOfficialMiniMaxInstaller(job, { reason } = {}) {
339
324
  let staging = null
340
- let stagingParent = null
325
+ let prefixRoot = null
341
326
  try {
342
327
  job.finishedAt = null
343
328
  job.error = null
344
329
  job.phase = 'downloading-and-verifying'
345
330
  pushLine(job, reason || '— npm 原生依赖未就绪;改用 MiniMax 官方 Windows 安装脚本(安装最新版)—')
346
- const existingRoot = preferences.installDir ? '' : await existingMiniMaxInstallRoot()
347
- const installDir = minimaxOfficialInstallDir(preferences, homedir(), existingRoot)
348
- pushLine(job, preferences.installDir
349
- ? `— 使用已配置的安装目录:${installDir} —`
350
- : existingRoot
351
- ? `— 未配置统一安装目录,沿用已安装的官方目录:${installDir} —`
352
- : `— 未配置统一安装目录,使用 MiniMax 官方默认位置:${installDir} —`)
331
+ const { plan, executionPreferences } = miniMaxFallbackPlan(job.preferences, job.beforeProbe, '')
332
+ const installDir = plan.installDir
353
333
  await mkdir(installDir, { recursive: true })
354
- stagingParent = await realpath(installDir)
334
+ prefixRoot = await realpath(installDir)
355
335
  const script = await fetchOfficialMiniMaxInstaller(job.controller.signal)
356
- staging = await mkdtemp(join(stagingParent, '.model-router-minimax-installer-'))
336
+ staging = await mkdtemp(join(prefixRoot, '.model-router-minimax-installer-'))
357
337
  const scriptPath = join(staging, 'install.ps1')
358
338
  const tempPath = join(staging, 'temp')
359
339
  await mkdir(tempPath)
360
340
  // Windows PowerShell 5 reads a BOM-less .ps1 using the system ANSI page.
361
341
  // The upstream installer contains Unicode art, so write a UTF-8 BOM.
362
342
  await writeFile(scriptPath, `\uFEFF${script}`, 'utf8')
343
+ job.plan = plan
344
+ job.methodId = plan.methodId
345
+ job.installDir = installDir
346
+ job.command = planCommandLine(plan)
347
+ pushLine(job, `— 本次更新目录:${installDir} —`)
363
348
  const powershell = join(process.env.SystemRoot ?? 'C:\\Windows', 'System32',
364
349
  'WindowsPowerShell', 'v1.0', 'powershell.exe')
365
350
  const utilityModule = join(process.env.SystemRoot ?? 'C:\\Windows', 'System32',
@@ -369,17 +354,12 @@ async function runOfficialMiniMaxInstaller(job, { reason } = {}) {
369
354
  + 'Import-Module -Name $env:MODEL_ROUTER_UTILITY_MODULE -ErrorAction Stop; '
370
355
  + '& $env:MODEL_ROUTER_MINIMAX_INSTALL_SCRIPT'
371
356
  job.phase = 'installing'
372
- const scriptRegistry = minimaxScriptRegistry(preferences.registry || 'https://registry.npmjs.org/')
373
- if (!scriptRegistry && preferences.registry) {
374
- pushLine(job, '— MiniMax 官方脚本不接受当前 npm 源,本次使用脚本自己的默认源 —')
375
- }
376
357
  const outcome = await runCapture(powershell,
377
358
  ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', command], {
378
359
  timeoutMs: INSTALL_TIMEOUT_MS, signal: job.controller.signal,
379
- env: { ...process.env, MCODE_INSTALL_DIR: installDir,
380
- MCODE_NO_MODIFY_PATH: '1',
381
- ...(scriptRegistry ? { MCODE_NPM_REGISTRY: scriptRegistry } : {}),
382
- npm_config_cache: join(staging, 'npm-cache'),
360
+ env: { ...process.env, ...plan.env, MCODE_INSTALL_DIR: installDir,
361
+ MCODE_NO_MODIFY_PATH: '1', MCODE_DOWNLOAD_MIRROR: 'global',
362
+ npm_config_cache: join(prefixRoot, '.model-router-npm-cache'),
383
363
  TEMP: tempPath, TMP: tempPath,
384
364
  MODEL_ROUTER_UTILITY_MODULE: utilityModule,
385
365
  MODEL_ROUTER_MINIMAX_INSTALL_SCRIPT: scriptPath },
@@ -393,12 +373,12 @@ async function runOfficialMiniMaxInstaller(job, { reason } = {}) {
393
373
  if (!outcome.ok) throw new Error(outcome.timedOut
394
374
  ? '官方安装脚本超时并已终止。'
395
375
  : `官方安装脚本退出码 ${outcome.code ?? '信号终止'}。${outcome.stderr.split('\n').find(Boolean)?.slice(0, 180) ?? ''}`)
396
- appendToProcessPath(installDir)
376
+ appendToProcessPath(installDir, { prepend: true })
397
377
  job.phase = 'verifying'
398
378
  probeCache.delete(job.tool.id)
399
- const probe = await probeToolWith(job.tool, defaultRunner)
379
+ const probe = await probeToolWith(job.tool, defaultRunner, { preferences: executionPreferences, plan, cacheMs: 0 })
400
380
  job.postInstallProbe = probe
401
- const readiness = await trustedExecutionReadiness(job.tool.id)
381
+ const readiness = await trustedExecutionReadiness(job.tool.id, { preferences: executionPreferences })
402
382
  if (!probe.installed || !readiness.ready) {
403
383
  throw new Error(`官方安装器完成,但执行入口未通过 npm registry 摘要核验:${readiness.reason ?? probe.detail}`)
404
384
  }
@@ -415,9 +395,9 @@ async function runOfficialMiniMaxInstaller(job, { reason } = {}) {
415
395
  job.error = `MiniMax 官方安装兜底失败:${String(error?.message ?? error).slice(0, 300)}`
416
396
  job.finishedAt = new Date().toISOString()
417
397
  } finally {
418
- if (staging && stagingParent) {
419
- const part = relative(stagingParent, staging)
420
- if (part.startsWith('.model-router-minimax-installer-') && !part.includes(sep) && !part.startsWith('..')) {
398
+ if (staging && prefixRoot) {
399
+ const part = relative(prefixRoot, staging)
400
+ if (part.startsWith('.model-router-minimax-installer-') && !part.includes(sep)) {
421
401
  await rm(staging, { recursive: true, force: true }).catch(() => {})
422
402
  }
423
403
  }
@@ -446,19 +426,78 @@ export function verifiedVendorScript(source, verify = {}) {
446
426
  return text
447
427
  }
448
428
 
449
- async function fetchVendorScript(url, signal) {
450
- const response = await fetch(url, {
451
- signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000),
452
- })
453
- if (!response.ok || !response.body) throw new Error(`官方脚本下载失败:HTTP ${response.status}`)
454
- const chunks = []
455
- let length = 0
456
- for await (const chunk of response.body) {
457
- length += chunk.length
458
- if (length > MAX_INSTALLER_BYTES) throw new Error('官方安装脚本超过大小上限。')
459
- 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(() => {})
460
500
  }
461
- return Buffer.concat(chunks)
462
501
  }
463
502
 
464
503
  const POWERSHELL = join(process.env.SystemRoot ?? 'C:\\Windows', 'System32',
@@ -474,7 +513,7 @@ export function vendorScriptInvocation(plan, scriptPath) {
474
513
  args: ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...directoryArgs],
475
514
  }
476
515
  }
477
- return { file: 'bash', args: [scriptPath, ...directoryArgs] }
516
+ return { file: plan.shell === 'sh' ? 'sh' : 'bash', args: [scriptPath, ...directoryArgs] }
478
517
  }
479
518
 
480
519
  /**
@@ -484,12 +523,23 @@ export function vendorScriptInvocation(plan, scriptPath) {
484
523
  * than an older one that happened to be on PATH first.
485
524
  */
486
525
  async function runVendorScriptInstallJob(job, plan) {
526
+ if (job.status !== 'running') return
487
527
  let staging = null
488
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
+ }
489
537
  job.phase = 'downloading-and-verifying'
490
538
  for (const notice of plan.notices ?? []) pushLine(job, notice)
491
539
  pushLine(job, `— 下载官方安装脚本:${plan.scriptUrl}`)
492
- 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)
493
543
  staging = await mkdtemp(join(tmpdir(), '.model-router-tool-install-'))
494
544
  const isPowerShell = plan.shell === 'powershell'
495
545
  const scriptPath = join(staging, isPowerShell ? 'install.ps1' : 'install.sh')
@@ -502,7 +552,7 @@ async function runVendorScriptInstallJob(job, plan) {
502
552
  const outcome = await runCapture(invocation.file, invocation.args, {
503
553
  timeoutMs: INSTALL_TIMEOUT_MS,
504
554
  signal: job.controller.signal,
505
- env: { ...process.env, ...plan.env },
555
+ env: installEnvironment(plan),
506
556
  onOutput: captureLines(job),
507
557
  })
508
558
  if (outcome.cancelled || job.cancelRequested) { markCancelled(job); return }
@@ -512,17 +562,10 @@ async function runVendorScriptInstallJob(job, plan) {
512
562
  throw new Error(outcome.timedOut ? '官方安装脚本超时并已终止。'
513
563
  : `官方安装脚本退出码 ${outcome.code ?? '信号终止'}。${String(outcome.stderr ?? '').split('\n').find(Boolean)?.slice(0, 200) ?? ''}`)
514
564
  }
515
- const location = resolveInstallLocation(job.tool, plan.method, preferences, currentInstallOptions())
516
- if (location?.dir) {
517
- appendToProcessPath(location.dir)
518
- for (const relative of location.relatives ?? []) {
519
- const folder = String(relative).split(/[\\/]/).slice(0, -1).filter(Boolean).join(sep)
520
- if (folder) appendToProcessPath(join(location.dir, folder))
521
- }
522
- }
565
+ if (plan.binDir) appendToProcessPath(plan.binDir, { prepend: true })
523
566
  job.phase = 'verifying'
524
567
  probeCache.delete(job.tool.id)
525
- const probe = await probeToolWith(job.tool, defaultRunner, { cacheMs: 0 })
568
+ const probe = await probeToolWith(job.tool, defaultRunner, { cacheMs: 0, plan, preferences: job.preferences })
526
569
  job.postInstallProbe = probe
527
570
  if (!probe.installed) {
528
571
  throw new Error(`官方安装脚本已完成,但 PATH 上仍找不到 ${job.tool.probeExecutables?.[0] ?? job.tool.label}:${probe.detail}`)
@@ -551,10 +594,10 @@ async function runVendorScriptInstallJob(job, plan) {
551
594
  * the file must sit inside that tool's install directory, and it can never be a
552
595
  * home directory or a filesystem root.
553
596
  */
554
- /** One relative path under an install directory. `..` never leaves that directory. */
555
597
  export function scriptBinaryCandidate(installDir, relative) {
556
- const parts = String(relative ?? '').split(/[\\/]/).filter(Boolean)
557
- if (parts.length === 0 || parts.some(part => part === '.' || part === '..')) {
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 === '..')) {
558
601
  throw new Error('拒绝删除安装目录之外的文件。')
559
602
  }
560
603
  return join(installDir, ...parts)
@@ -585,13 +628,13 @@ function isInsideDirectory(parent, child) {
585
628
  * reinstall keeps the user's login.
586
629
  */
587
630
  async function runVendorScriptUninstallJob(job, plan) {
631
+ if (job.status !== 'running') return
588
632
  try {
589
633
  job.phase = 'removing'
590
- const location = resolveInstallLocation(job.tool, plan.method, preferences, currentInstallOptions())
634
+ const location = { dir: plan.dir, binaries: plan.binaries }
591
635
  const removed = []
592
- const relatives = location?.relatives?.length ? location.relatives : (location?.binaries ?? [])
593
- for (const relative of relatives) {
594
- const candidate = scriptBinaryCandidate(location.dir, relative)
636
+ for (const binary of location?.binaries ?? []) {
637
+ const candidate = scriptBinaryCandidate(location.dir, binary)
595
638
  // checkedRemovableFile re-checks the basename, the install directory and
596
639
  // the root/home guards even though every part came from the registry.
597
640
  const target = checkedRemovableFile(candidate, {
@@ -601,16 +644,16 @@ async function runVendorScriptUninstallJob(job, plan) {
601
644
  await rm(target, { force: true })
602
645
  removed.push(target)
603
646
  }
604
- const stripped = await stripPathProfileBlocks(job.tool).catch(error => `(未能清理 PATH 配置:${String(error?.message ?? error).slice(0, 120)})`)
605
647
  if (removed.length === 0) {
606
648
  throw new Error(`在 ${location?.dir || '安装目录'} 下没有找到 ${job.tool.label} 的可执行文件;它可能是用包管理器安装的,请改用该方式卸载。`)
607
649
  }
650
+ const stripped = await stripPathProfileBlocks(job.tool).catch(error => [`(未能清理 PATH 配置:${String(error?.message ?? error).slice(0, 120)})`])
608
651
  for (const path of removed) pushLine(job, `已删除 ${path}`)
609
652
  if (stripped.length) pushLine(job, `已清理 shell 配置中的 PATH 记录:${stripped.join('、')}`)
610
653
  pushLine(job, `已保留 ${job.tool.label} 的配置与登录信息(${homedir()}/${String(job.tool.defaultScriptInstallDir ?? '').split('/')[0]})。`)
611
654
  job.phase = 'verifying'
612
655
  probeCache.delete(job.tool.id)
613
- 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 } })
614
657
  job.postInstallProbe = probe
615
658
  job.status = 'succeeded'
616
659
  job.finishedAt = new Date().toISOString()
@@ -630,23 +673,25 @@ async function runVendorScriptUninstallJob(job, plan) {
630
673
  * writes. Only a well-formed `# stepcode … # stepcode end` block is removed; a
631
674
  * bare mention is left alone, exactly as the vendor's installer decides.
632
675
  */
633
- export function stripPathBlock(content, marker) {
676
+ export function stripPathBlock(content, marker, closingMarker = `${marker} end`) {
634
677
  const text = String(content ?? '')
635
678
  if (!text.includes(marker)) return null
636
- const lines = text.split(/\r?\n/)
679
+ const lines = text.match(/[^\r\n]*(?:\r\n|\n|$)/g)?.filter(Boolean) ?? []
637
680
  const kept = []
638
681
  let removed = 0
639
- let inside = false
640
- for (const line of lines) {
641
- if (!inside && line.trim() === marker) { inside = true; removed += 1; continue }
642
- if (inside) {
643
- removed += 1
644
- if (line.trim() === `${marker} end`) inside = false
645
- 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
646
688
  }
647
- 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
648
693
  }
649
- return removed > 0 ? kept.join('\n') : null
694
+ return removed > 0 ? kept.join('') : null
650
695
  }
651
696
 
652
697
  const SHELL_PROFILES = ['.bashrc', '.bash_profile', '.zshrc', '.profile']
@@ -660,7 +705,11 @@ async function stripPathProfileBlocks(tool) {
660
705
  let content
661
706
  try { content = await readFile(path, 'utf8') } catch { continue }
662
707
  let next = content
663
- 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
+ }
664
713
  if (next === content) continue
665
714
  const temporary = `${path}.model-router-${process.pid}.tmp`
666
715
  await writeFile(temporary, next, 'utf8')
@@ -683,17 +732,37 @@ export function versionFromBanner(banner) {
683
732
 
684
733
  /**
685
734
  * Probe one registry tool. `runner` is injectable for offline tests and must
686
- * 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.
687
738
  */
688
- 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])
689
746
  const cached = cache.get(tool.id)
690
- if (cached && now() - cached.at < cacheMs) return cached.value
691
- const probe = await probeUncached(tool, runner)
692
- 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 })
693
750
  return probe
694
751
  }
695
752
 
696
- 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 }) {
697
766
  if (tool.unsupported) {
698
767
  return { id: tool.id, installed: false, version: null, status: 'unsupported', detail: tool.unsupportedReason }
699
768
  }
@@ -709,23 +778,68 @@ async function probeUncached(tool, runner) {
709
778
  ? `检测到已安装的 ZCode ${other.version ?? '(版本未知)'}${other.root ? `(${other.root})` : ''},但 ZCode.exe 未通过智谱发布者签名或 GLM 组件检查,因此不通过插件调用;可以继续直接使用 ZCode 桌面版。`
710
779
  : '未找到具有智谱有效签名的 ZCode 桌面版。' }
711
780
  }
712
- 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)) {
713
835
  // The official Windows installer keeps mcode under a versioned releases
714
836
  // directory. A partial npm install can leave a broken shim earlier on PATH;
715
- // prefer the checksum-verified installer release in that case. Also look at
716
- // the configured directory and the vendor default, which may not be on PATH
717
- // yet when the installer was told not to edit the user PATH.
837
+ // prefer the checksum-verified installer release in that case.
718
838
  const { findManagedMiniMaxEntry } = await import('./official-tool-executor.mjs')
719
- const directed = minimaxOfficialInstallDir(preferences, homedir())
720
- // The configured directory is the copy updates write, so it wins over an
721
- // older install that happens to sit earlier on PATH.
722
- let managed = preferences.installDir
723
- ? await findManagedMiniMaxEntry(process.cwd(), [directed, join(directed, '.minimax-code')])
724
- : null
725
- if (!managed) managed = await findManagedMiniMaxEntry(process.cwd())
726
- if (!managed) managed = await findManagedMiniMaxEntry(process.cwd(), [directed, join(directed, '.minimax-code')])
839
+ const managed = await findManagedMiniMaxEntry(process.cwd())
727
840
  if (managed) return { id: tool.id, installed: true, version: managed.version,
728
841
  status: 'installed', detail: '已按官方 npm registry 摘要核验 MiniMax 官方 Windows 安装器中的 CLI 文件。',
842
+ installDir: managed.installDir, binDir: managed.installDir,
729
843
  bannerLine: `MiniMax Code ${managed.version}` }
730
844
  }
731
845
  for (const executable of tool.probeExecutables) {
@@ -742,12 +856,12 @@ async function probeUncached(tool, runner) {
742
856
  if (attempt.timedOut) {
743
857
  return { id: tool.id, installed: false, version: null, status: 'probe-timeout', detail: `${executable} --version 超时(${PROBE_TIMEOUT_MS / 1000}s)` }
744
858
  }
745
- const attempts = [attempt]
859
+ const attempts = [{ ...attempt, executablePath: locatorPaths(located.stdout)[0] }]
746
860
  if (!versionFromBanner(probeBanner(attempt))) {
747
861
  for (const target of locatorPaths(located.stdout)) {
748
862
  if (target === executable) continue
749
863
  const direct = await runner(target, ['--version'], { timeoutMs: PROBE_TIMEOUT_MS, useShell: false })
750
- attempts.push(direct)
864
+ attempts.push({ ...direct, executablePath: target })
751
865
  if (direct.timedOut) break
752
866
  if (direct.ok || versionFromBanner(probeBanner(direct))) break
753
867
  }
@@ -762,6 +876,7 @@ async function probeUncached(tool, runner) {
762
876
  version,
763
877
  status: 'installed',
764
878
  detail: tool.probeNote ?? '',
879
+ executablePath: chosen.executablePath,
765
880
  bannerLine: banner.split('\n').find(Boolean)?.slice(0, 200) ?? '',
766
881
  }
767
882
  }
@@ -783,17 +898,16 @@ export const defaultRunner = (executable, args, options = {}) =>
783
898
  let prefixPromise = null
784
899
 
785
900
  /** Add one directory to this process's PATH unless it is already there. */
786
- function appendToProcessPath(directory, { includeBin = true } = {}) {
901
+ export function appendToProcessPath(directory, { prepend = false, env = process.env, platform = process.platform } = {}) {
787
902
  const target = String(directory ?? '').trim()
788
903
  if (!target || !isAbsolute(target)) return
789
- const separator = IS_WINDOWS ? ';' : ':'
790
- const current = process.env.PATH ?? ''
791
- if (!current.split(separator).some(entry => entry.toLowerCase() === target.toLowerCase())) {
792
- process.env.PATH = current ? `${current}${separator}${target}` : target
793
- }
794
- // POSIX package managers put global binaries in <prefix>/bin. One level only:
795
- // recursing would append bin/bin/bin until the process runs out of memory.
796
- if (includeBin && !IS_WINDOWS) appendToProcessPath(join(target, 'bin'), { includeBin: false })
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)
797
911
  }
798
912
 
799
913
  /**
@@ -812,9 +926,9 @@ export function ensureNpmPrefixOnPath() {
812
926
  timeoutMs: 5_000,
813
927
  useShell: IS_WINDOWS,
814
928
  })
815
- if (!outcome.ok) return
929
+ if (!outcome.ok) throw new Error('npm prefix is unavailable')
816
930
  let prefix = outcome.stdout.trim()
817
- if (!prefix) return
931
+ if (!prefix) throw new Error('npm prefix is empty')
818
932
  if (prefix.startsWith('\\\\?\\')) prefix = prefix.slice(4)
819
933
  if (IS_WINDOWS && !process.env.GROK_HOME && /^[D-Z]:[\\/]/i.test(prefix)) {
820
934
  // Grok's npm bootstrap otherwise expands its native binary under
@@ -822,18 +936,14 @@ export function ensureNpmPrefixOnPath() {
822
936
  // the same non-C drive as this user's npm global prefix.
823
937
  process.env.GROK_HOME = join(prefix, '.model-router-grok')
824
938
  }
825
- appendToProcessPath(prefix)
939
+ appendToProcessPath(IS_WINDOWS ? prefix : join(prefix, 'bin'))
826
940
  } catch { /* environment stays untouched */ }
827
941
  // A --prefix install lands outside the npm prefix, so it needs its own
828
942
  // PATH entry or every later probe reports the tool as missing.
829
- try { appendToProcessPath(preferences.installDir) } catch { /* environment stays untouched */ }
830
- // Vendor script defaults, so a one-click install that did not edit the
831
- // user PATH is still visible to the next probe in this process.
832
- for (const relative of [
833
- '.minimax-code', '.local/bin', '.kimi-code/bin', '.grok/bin', '.mimocode/bin',
834
- '.opencode/bin', '.stepcode/bin', 'AppData/Local/Programs/OpenAI/Codex/bin',
835
- ]) {
836
- try { appendToProcessPath(join(homedir(), ...relative.split('/'))) } catch { /* skip */ }
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
+ }
837
947
  }
838
948
  })()
839
949
  }
@@ -910,8 +1020,46 @@ function pushLine(job, line) {
910
1020
  job.outputTail.push(line)
911
1021
  }
912
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
+
913
1061
  /** Reserve the single per-tool job slot and serialise against every other tool. */
914
- function beginJob(tool, { operation, methodId, command, installDir = '', run }) {
1062
+ function beginJob(tool, { operation, methodId, command, installDir = '', plan, configured = false, run }) {
915
1063
  const existing = installJobs.get(tool.id)
916
1064
  if (existing?.status === 'running') throw new Error(`${tool.label} 已有${operation === 'uninstall' ? '卸载' : '安装'}任务正在执行。`)
917
1065
  const job = {
@@ -920,6 +1068,9 @@ function beginJob(tool, { operation, methodId, command, installDir = '', run })
920
1068
  methodId,
921
1069
  command,
922
1070
  installDir,
1071
+ plan,
1072
+ preferences: installPreferences(),
1073
+ configured: configured || plan?.kind === 'package-manager',
923
1074
  status: 'running',
924
1075
  phase: 'queued',
925
1076
  cancelRequested: false,
@@ -935,8 +1086,28 @@ function beginJob(tool, { operation, methodId, command, installDir = '', run })
935
1086
  }
936
1087
  installJobs.set(tool.id, job)
937
1088
  probeCache.delete(tool.id)
938
- installChain = installChain.then(() => run(job)).catch(error => {
1089
+ installChain = installChain.then(async () => {
939
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 }
940
1111
  job.status = 'failed'
941
1112
  job.finishedAt = new Date().toISOString()
942
1113
  job.error = `${operation === 'uninstall' ? '卸载' : '安装'}流程失败:${String(error?.message ?? error).slice(0, 200)}`
@@ -947,15 +1118,19 @@ function beginJob(tool, { operation, methodId, command, installDir = '', run })
947
1118
  /** One-line summary of a resolved plan, for the job log and the UI. */
948
1119
  export function planCommandLine(plan, operation = 'install') {
949
1120
  if (plan.kind === 'script') {
950
- const piped = plan.shell === 'powershell' ? `irm ${plan.scriptUrl} | iex` : `curl -fsSL ${plan.scriptUrl} | bash`
951
- if (operation === 'uninstall') {
952
- return `删除 ${plan.dir} 下的 ${(plan.binaries ?? []).join('、') || '可执行文件'}`
953
- }
954
- if (plan.installDir && plan.installArg?.[0]) return `${piped} ${plan.installArg[0]} ${plan.installDir}`
955
- if (plan.installDir && plan.installDirEnv) return `${piped}(${plan.installDirEnv}=${plan.installDir})`
956
- return piped
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}` : ''}`
1128
+ return operation === 'uninstall'
1129
+ ? `删除 ${plan.dir} 下的 ${(plan.binaries ?? []).join('、') || '可执行文件'}`
1130
+ : plan.shell === 'powershell' && directoryArgs
1131
+ ? `${environment}& ([scriptblock]::Create((irm ${plan.scriptUrl})))${directoryArgs}` : piped
957
1132
  }
958
- return `${plan.manager} ${plan.args.join(' ')}`
1133
+ return `${plan.manager} ${plan.args.map(arg => /\s/.test(arg) ? JSON.stringify(arg) : arg).join(' ')}`
959
1134
  }
960
1135
 
961
1136
  /**
@@ -979,9 +1154,11 @@ export function startInstall(toolId, { method } = {}) {
979
1154
  methodId: plan.methodId,
980
1155
  command: planCommandLine(plan),
981
1156
  installDir: plan.installDir ?? '',
1157
+ plan,
1158
+ configured: usesConfiguredInstallPlan(tool.id, preferences, method),
982
1159
  run: job => (plan.kind === 'script'
983
1160
  ? runVendorScriptInstallJob(job, plan)
984
- : runInstallJob(plan.manager, plan.args, job)),
1161
+ : runInstallJob(job.plan, job)),
985
1162
  })
986
1163
  }
987
1164
 
@@ -1001,10 +1178,12 @@ export function startRepair(toolId, { method } = {}) {
1001
1178
  methodId: plan.methodId,
1002
1179
  command: `修复:${planCommandLine(plan)}`,
1003
1180
  installDir: plan.installDir ?? '',
1181
+ plan,
1182
+ configured: usesConfiguredInstallPlan(tool.id, preferences, method),
1004
1183
  run: async job => {
1005
1184
  job.repair = true
1006
1185
  if (plan.kind === 'script') return runVendorScriptInstallJob(job, plan)
1007
- return runInstallJob(plan.manager, plan.args, job)
1186
+ return runInstallJob(job.plan, job)
1008
1187
  },
1009
1188
  })
1010
1189
  }
@@ -1022,17 +1201,20 @@ export function startUninstall(toolId, { method } = {}) {
1022
1201
  methodId: plan.methodId,
1023
1202
  command: planCommandLine(plan, 'uninstall'),
1024
1203
  installDir: plan.dir ?? plan.installDir ?? '',
1204
+ plan,
1205
+ configured: usesConfiguredInstallPlan(tool.id, preferences, method),
1025
1206
  run: job => (plan.kind === 'script'
1026
1207
  ? runVendorScriptUninstallJob(job, plan)
1027
- : runPackageManagerUninstallJob(job, plan)),
1208
+ : runPackageManagerUninstallJob(job, job.plan)),
1028
1209
  })
1029
1210
  }
1030
1211
 
1031
1212
  async function runPackageManagerUninstallJob(job, plan) {
1213
+ if (job.status !== 'running') return
1032
1214
  try {
1033
1215
  job.phase = 'preflight'
1034
1216
  await ensureNpmPrefixOnPath()
1035
- const before = await probeToolWith(job.tool, defaultRunner)
1217
+ const before = await probeToolWith(job.tool, defaultRunner, { plan, preferences: job.preferences, cacheMs: 0 })
1036
1218
  if (job.cancelRequested) { markCancelled(job); return }
1037
1219
  if (!before.installed) {
1038
1220
  job.status = 'succeeded'
@@ -1047,6 +1229,7 @@ async function runPackageManagerUninstallJob(job, plan) {
1047
1229
  useShell: IS_WINDOWS,
1048
1230
  signal: job.controller.signal,
1049
1231
  onOutput: captureLines(job),
1232
+ env: installEnvironment(plan),
1050
1233
  })
1051
1234
  if (outcome.cancelled || job.cancelRequested) { markCancelled(job); return }
1052
1235
  job.exitCode = outcome.code
@@ -1057,7 +1240,7 @@ async function runPackageManagerUninstallJob(job, plan) {
1057
1240
  }
1058
1241
  job.phase = 'verifying'
1059
1242
  probeCache.delete(job.tool.id)
1060
- const probe = await probeToolWith(job.tool, defaultRunner, { cacheMs: 0 })
1243
+ const probe = await probeToolWith(job.tool, defaultRunner, { cacheMs: 0, preferences: job.preferences, plan })
1061
1244
  job.postInstallProbe = probe
1062
1245
  job.status = 'succeeded'
1063
1246
  job.finishedAt = new Date().toISOString()
@@ -1082,6 +1265,13 @@ function captureLines(job) {
1082
1265
  }
1083
1266
  }
1084
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
+
1085
1275
  /** Mark the job succeeded only when the fresh probe is installed, ready, and not still older. */
1086
1276
  async function finishVerifiedInstall(job, probe) {
1087
1277
  job.postInstallProbe = probe
@@ -1097,10 +1287,10 @@ async function finishVerifiedInstall(job, probe) {
1097
1287
  failBecauseStillOlder(job, probe, latest.version)
1098
1288
  return true
1099
1289
  }
1100
- const readiness = await trustedExecutionReadiness(job.tool.id)
1290
+ const readiness = await trustedExecutionReadiness(job.tool.id, { preferences: job.preferences, plan: job.plan })
1101
1291
  if (job.cancelRequested) { markCancelled(job); return true }
1102
1292
  if (!readiness.ready) {
1103
- if (IS_WINDOWS && job.tool.id === 'minimax-code') {
1293
+ if (IS_WINDOWS && job.tool.id === 'minimax-code' && !job.configured) {
1104
1294
  await runOfficialMiniMaxInstaller(job)
1105
1295
  return true
1106
1296
  }
@@ -1130,7 +1320,7 @@ async function tryClaudeUpdate(job, latestVersion) {
1130
1320
  if (job.cancelRequested) { markCancelled(job); return true }
1131
1321
  pushLine(job, '$ claude update')
1132
1322
  job.phase = 'installing'
1133
- const outcome = await runCapture('claude', CLAUDE_UPDATE_ARGS, {
1323
+ const outcome = await runCapture(job.beforeProbe?.executablePath || 'claude', CLAUDE_UPDATE_ARGS, {
1134
1324
  timeoutMs: CLAUDE_UPDATE_TIMEOUT_MS,
1135
1325
  useShell: IS_WINDOWS,
1136
1326
  signal: job.controller.signal,
@@ -1144,7 +1334,10 @@ async function tryClaudeUpdate(job, latestVersion) {
1144
1334
  }
1145
1335
  await ensureNpmPrefixOnPath()
1146
1336
  probeCache.delete(job.tool.id)
1147
- 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 })
1148
1341
  if (job.cancelRequested) { markCancelled(job); return true }
1149
1342
  if (latestVersion && versionStillOlder(probe.version, latestVersion)) {
1150
1343
  job.postInstallProbe = probe
@@ -1154,13 +1347,15 @@ async function tryClaudeUpdate(job, latestVersion) {
1154
1347
  return finishVerifiedInstall(job, probe)
1155
1348
  }
1156
1349
 
1157
- async function runInstallJob(manager, args, job) {
1350
+ async function runInstallJob(installPlan, job) {
1351
+ const { manager, args } = installPlan
1158
1352
  if (job.status !== 'running') return
1159
1353
  job.phase = 'preflight'
1160
1354
  await ensureNpmPrefixOnPath()
1161
1355
  if (job.cancelRequested) { markCancelled(job); return }
1162
1356
  probeCache.delete(job.tool.id)
1163
- 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
1164
1359
  if (job.cancelRequested) { markCancelled(job); return }
1165
1360
  let latest = null
1166
1361
  let order = null
@@ -1176,7 +1371,7 @@ async function runInstallJob(manager, args, job) {
1176
1371
  return
1177
1372
  }
1178
1373
  if (order === 0) {
1179
- const readiness = await trustedExecutionReadiness(job.tool.id)
1374
+ const readiness = await trustedExecutionReadiness(job.tool.id, { preferences: job.preferences, plan: job.plan })
1180
1375
  if (job.cancelRequested) { markCancelled(job); return }
1181
1376
  // A repair re-runs the same command even when nothing looks stale: a
1182
1377
  // broken install reports the right version, so "already current" is
@@ -1199,7 +1394,7 @@ async function runInstallJob(manager, args, job) {
1199
1394
  installed: before.installed === true,
1200
1395
  order,
1201
1396
  platform: process.platform,
1202
- methodId: job.methodId,
1397
+ configured: job.configured,
1203
1398
  })
1204
1399
  let triedClaudeUpdate = false
1205
1400
  if (plan === 'claude-update-then-registry') {
@@ -1212,12 +1407,14 @@ async function runInstallJob(manager, args, job) {
1212
1407
  })
1213
1408
  return
1214
1409
  }
1410
+ if (installPlan.binDir) await mkdir(installPlan.binDir, { recursive: true })
1215
1411
  job.phase = 'installing'
1216
1412
  const outcome = await runCapture(manager, args, {
1217
1413
  timeoutMs: INSTALL_TIMEOUT_MS,
1218
1414
  useShell: IS_WINDOWS,
1219
1415
  signal: job.controller.signal,
1220
1416
  onOutput: captureLines(job),
1417
+ env: installEnvironment(installPlan),
1221
1418
  })
1222
1419
  if (job.status !== 'running') return
1223
1420
  if (outcome.cancelled || job.cancelRequested) { markCancelled(job); return }
@@ -1230,7 +1427,7 @@ async function runInstallJob(manager, args, job) {
1230
1427
  return
1231
1428
  }
1232
1429
  if (!outcome.ok) {
1233
- if (IS_WINDOWS && job.tool.id === 'minimax-code') {
1430
+ if (IS_WINDOWS && job.tool.id === 'minimax-code' && !job.configured) {
1234
1431
  await runOfficialMiniMaxInstaller(job)
1235
1432
  return
1236
1433
  }
@@ -1242,19 +1439,19 @@ async function runInstallJob(manager, args, job) {
1242
1439
  job.phase = 'verifying'
1243
1440
  await ensureNpmPrefixOnPath()
1244
1441
  probeCache.delete(job.tool.id)
1245
- const probe = await probeToolWith(job.tool, defaultRunner)
1442
+ const probe = await probeToolWith(job.tool, defaultRunner, { plan: installPlan, preferences: job.preferences, cacheMs: 0 })
1246
1443
  if (job.cancelRequested) { markCancelled(job); return }
1247
1444
  job.postInstallProbe = probe
1248
1445
  const latestAfter = (await latestVersions.lookup(job.tool).catch(() => null)) ?? latest
1249
1446
  const stillOlder = Boolean(latestAfter?.version && versionStillOlder(probe.version, latestAfter.version))
1250
- if (stillOlder && job.tool.id === 'claude-code' && !triedClaudeUpdate) {
1447
+ if (stillOlder && job.tool.id === 'claude-code' && !triedClaudeUpdate && !job.configured) {
1251
1448
  triedClaudeUpdate = true
1252
1449
  const finished = await tryClaudeUpdate(job, latestAfter.version)
1253
1450
  if (finished || job.status !== 'running') return
1254
1451
  failBecauseStillOlder(job, job.postInstallProbe ?? probe, latestAfter.version)
1255
1452
  return
1256
1453
  }
1257
- if (stillOlder && IS_WINDOWS && job.tool.id === 'minimax-code') {
1454
+ if (stillOlder && IS_WINDOWS && job.tool.id === 'minimax-code' && !job.configured) {
1258
1455
  pushLine(job, `— npm 完成后 ${job.tool.label} 仍是 ${probe.version},低于最新 ${latestAfter.version};改用官方安装脚本 —`)
1259
1456
  await runOfficialMiniMaxInstaller(job)
1260
1457
  return