@ljwei-stak/dsh-model-router 0.16.4 → 0.17.1

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.
@@ -1,19 +1,33 @@
1
1
  /**
2
2
  * Host-side probe and install runtime for the official tool registry.
3
3
  *
4
- * The DeepSeek Harness plugin host runs as a full Node module, so probing and
5
- * installing official CLIs uses node:child_process directly. Every command is
6
- * built exclusively from the fixed registry: callers supply a tool id, never
7
- * a package name, executable or argument. Installs are serialized (npm global
8
- * state must not interleave), output is kept as a bounded tail, and success is
9
- * always confirmed by a fresh probe rather than the installer's exit code.
10
- * When a newer release is known, a probe that is still older fails the job
11
- * instead of reporting success.
4
+ * The DeepSeek Harness plugin host runs as a full Node module, so probing,
5
+ * installing, uninstalling and repairing official CLIs uses node:child_process
6
+ * directly. Every command is built exclusively from the fixed registry plus the
7
+ * user's own Host-side settings: callers supply a tool id and one of that tool's
8
+ * declared method ids — never a package name, executable or argument. Installs
9
+ * are serialized (npm global state must not interleave), output is kept as a
10
+ * bounded tail, and success is always confirmed by a fresh probe rather than the
11
+ * installer's exit code. When a newer release is known, a probe that is still
12
+ * older fails the job instead of reporting success.
12
13
  */
13
14
  import { spawn } from 'node:child_process'
14
- import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
15
- import { isAbsolute, join, relative, sep } from 'node:path'
16
- import { getOfficialTool, installCommandLine, OFFICIAL_TOOLS } from './official-tool-registry.mjs'
15
+ import { mkdir, mkdtemp, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises'
16
+ import { homedir, tmpdir } from 'node:os'
17
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path'
18
+ import {
19
+ defaultInstallMethod,
20
+ getOfficialTool,
21
+ installMethodFor,
22
+ installCommandLine,
23
+ OFFICIAL_TOOLS,
24
+ } from './official-tool-registry.mjs'
25
+ import {
26
+ minimaxScriptRegistry,
27
+ resolveInstallLocation,
28
+ resolveInstallPlan,
29
+ resolveUninstallPlan,
30
+ } from './tool-install-preferences.mjs'
17
31
  import { discoverZCodeBundle, unverifiedZCodeInstalls } from './zcode-bundle.mjs'
18
32
  import { openZCodeInstaller } from './zcode-installer.mjs'
19
33
  import { compareReleaseVersions, latestVersions } from './latest-versions.mjs'
@@ -21,11 +35,14 @@ import { versionStillOlder } from './version-order.mjs'
21
35
 
22
36
  const PROBE_TIMEOUT_MS = 8_000
23
37
  const INSTALL_TIMEOUT_MS = 900_000
38
+ const UNINSTALL_TIMEOUT_MS = 300_000
24
39
  const OUTPUT_LINE_CAP = 120
25
40
  const IS_WINDOWS = process.platform === 'win32'
26
41
  const CREATE_NO_WINDOW = 0x0800_0000
27
42
  const MINIMAX_INSTALLER_URL = 'https://filecdn.minimax.chat/public/install.ps1'
28
- const MAX_INSTALLER_BYTES = 200_000
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
29
46
  /** Windows ships where.exe in System32; POSIX which is ubiquitous. */
30
47
  const LOCATOR = IS_WINDOWS ? 'where' : 'which'
31
48
  /** Cached probe results keep plan generation fast; installs invalidate them. */
@@ -38,6 +55,32 @@ const CLAUDE_UPDATE_TIMEOUT_MS = 180_000
38
55
  /** Non-interactive updater for a native Claude Code install. npm does not move that copy. */
39
56
  export const CLAUDE_UPDATE_ARGS = Object.freeze(['update'])
40
57
 
58
+ /**
59
+ * Host-side install preferences (install directory, npm source, per-tool method
60
+ * and script source). Refreshed from plugin config on every apply, so a client
61
+ * can only ever pick a method id — these addresses never arrive over the wire.
62
+ */
63
+ let preferences = { installDir: '', registry: '', methods: {}, scriptUrls: {}, allowScriptInstall: true }
64
+
65
+ export function setInstallPreferences(next) {
66
+ if (next && typeof next === 'object') {
67
+ const changedDirectory = preferences.installDir !== next.installDir
68
+ preferences = { ...preferences, ...next }
69
+ // A new directory needs its own PATH entry before the next probe runs.
70
+ if (changedDirectory) prefixPromise = null
71
+ }
72
+ return preferences
73
+ }
74
+
75
+ export function installPreferences() {
76
+ return { ...preferences }
77
+ }
78
+
79
+ /** The machine this Host runs on; the plan resolver needs both to be explicit. */
80
+ export function currentInstallOptions() {
81
+ return { platform: process.platform, home: homedir() }
82
+ }
83
+
41
84
  /**
42
85
  * Spawn shape for a fixed registry command.
43
86
  *
@@ -88,11 +131,16 @@ export function staleInstallMessage(label, installedVersion, latestVersion) {
88
131
  * Which updater to run. `order` is compareReleaseVersions(installed, latest):
89
132
  * -1 older, 0 same, 1 newer, null when either version is unknown.
90
133
  */
91
- export function officialUpdatePlan({ toolId, installed, order, platform }) {
134
+ export function officialUpdatePlan({ toolId, installed, order, platform, methodId }) {
92
135
  if (installed && order === 1) return 'refuse-downgrade'
93
136
  if (installed && order === 0) return 'already-current'
94
137
  if (installed && order === -1 && toolId === 'claude-code') return 'claude-update-then-registry'
95
138
  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'
96
144
  return 'minimax-official-installer'
97
145
  }
98
146
  return 'registry-install'
@@ -197,20 +245,62 @@ function runCapture(executable, args, { timeoutMs, onOutput, useShell = false, s
197
245
  })
198
246
  }
199
247
 
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
+ export function installerScriptText(source) {
254
+ return decodeProcessOutput(Buffer.isBuffer(source) ? source : Buffer.from(source ?? '')).replace(/^\uFEFF/, '')
255
+ }
256
+
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
+ export function isMarkupDocument(text) {
264
+ const head = String(text ?? '').replace(/^\uFEFF/, '').trimStart()
265
+ if (!head.startsWith('<')) return false
266
+ if (head.startsWith('<#')) return false
267
+ return /^<(?:!|\?|[A-Za-z])/.test(head)
268
+ }
269
+
270
+ const MINIMAX_INSTALLER_MARKERS = Object.freeze(['@minimax-ai/code', 'MCODE_INSTALL_DIR', 'MiniMax Code'])
271
+
200
272
  /**
201
273
  * MiniMax's official Windows installer script is run as published (it installs
202
- * `@minimax-ai/code@latest`). Only structural sanity is checked: it must be the
203
- * MiniMax Code installer, not an HTML error page or another script.
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`.
204
277
  */
205
278
  export function officialMiniMaxInstaller(source) {
206
- const bytes = Buffer.isBuffer(source) ? source : Buffer.from(source)
207
- const text = bytes.toString('utf8').replace(/^\uFEFF/, '')
208
- if (!text.includes('@minimax-ai/code') || /^\s*</.test(text)) {
209
- throw new Error('下载内容不是 MiniMax Code 官方 Windows 安装脚本,已拒绝执行。')
279
+ 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('、')}),已拒绝执行。`)
210
287
  }
211
288
  return text
212
289
  }
213
290
 
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')
302
+ }
303
+
214
304
  async function fetchOfficialMiniMaxInstaller(signal) {
215
305
  const response = await fetch(MINIMAX_INSTALLER_URL, {
216
306
  signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000),
@@ -226,30 +316,50 @@ async function fetchOfficialMiniMaxInstaller(signal) {
226
316
  return officialMiniMaxInstaller(Buffer.concat(chunks))
227
317
  }
228
318
 
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 ''
336
+ }
337
+
229
338
  async function runOfficialMiniMaxInstaller(job, { reason } = {}) {
230
339
  let staging = null
231
- let prefixRoot = null
340
+ let stagingParent = null
232
341
  try {
233
342
  job.finishedAt = null
234
343
  job.error = null
235
344
  job.phase = 'downloading-and-verifying'
236
345
  pushLine(job, reason || '— npm 原生依赖未就绪;改用 MiniMax 官方 Windows 安装脚本(安装最新版)—')
237
- const prefixResult = await runCapture('npm', ['config', 'get', 'prefix'], {
238
- timeoutMs: 5_000, useShell: true, signal: job.controller.signal,
239
- })
240
- const rawPrefix = prefixResult.stdout.trim().replace(/^\\\\\?\\/, '')
241
- if (!prefixResult.ok || !isAbsolute(rawPrefix)) throw new Error('无法确定 npm 全局安装目录。')
242
- await mkdir(rawPrefix, { recursive: true })
243
- prefixRoot = await realpath(rawPrefix)
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} —`)
353
+ await mkdir(installDir, { recursive: true })
354
+ stagingParent = await realpath(installDir)
244
355
  const script = await fetchOfficialMiniMaxInstaller(job.controller.signal)
245
- staging = await mkdtemp(join(prefixRoot, '.model-router-minimax-installer-'))
356
+ staging = await mkdtemp(join(stagingParent, '.model-router-minimax-installer-'))
246
357
  const scriptPath = join(staging, 'install.ps1')
247
358
  const tempPath = join(staging, 'temp')
248
359
  await mkdir(tempPath)
249
360
  // Windows PowerShell 5 reads a BOM-less .ps1 using the system ANSI page.
250
361
  // The upstream installer contains Unicode art, so write a UTF-8 BOM.
251
362
  await writeFile(scriptPath, `\uFEFF${script}`, 'utf8')
252
- const installDir = join(prefixRoot, '.minimax-code')
253
363
  const powershell = join(process.env.SystemRoot ?? 'C:\\Windows', 'System32',
254
364
  'WindowsPowerShell', 'v1.0', 'powershell.exe')
255
365
  const utilityModule = join(process.env.SystemRoot ?? 'C:\\Windows', 'System32',
@@ -259,13 +369,17 @@ async function runOfficialMiniMaxInstaller(job, { reason } = {}) {
259
369
  + 'Import-Module -Name $env:MODEL_ROUTER_UTILITY_MODULE -ErrorAction Stop; '
260
370
  + '& $env:MODEL_ROUTER_MINIMAX_INSTALL_SCRIPT'
261
371
  job.phase = 'installing'
372
+ const scriptRegistry = minimaxScriptRegistry(preferences.registry || 'https://registry.npmjs.org/')
373
+ if (!scriptRegistry && preferences.registry) {
374
+ pushLine(job, '— MiniMax 官方脚本不接受当前 npm 源,本次使用脚本自己的默认源 —')
375
+ }
262
376
  const outcome = await runCapture(powershell,
263
377
  ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', command], {
264
378
  timeoutMs: INSTALL_TIMEOUT_MS, signal: job.controller.signal,
265
379
  env: { ...process.env, MCODE_INSTALL_DIR: installDir,
266
- MCODE_NO_MODIFY_PATH: '1', MCODE_DOWNLOAD_MIRROR: 'global',
267
- MCODE_NPM_REGISTRY: 'https://registry.npmjs.org/',
268
- npm_config_cache: join(prefixRoot, '.model-router-npm-cache'),
380
+ MCODE_NO_MODIFY_PATH: '1',
381
+ ...(scriptRegistry ? { MCODE_NPM_REGISTRY: scriptRegistry } : {}),
382
+ npm_config_cache: join(staging, 'npm-cache'),
269
383
  TEMP: tempPath, TMP: tempPath,
270
384
  MODEL_ROUTER_UTILITY_MODULE: utilityModule,
271
385
  MODEL_ROUTER_MINIMAX_INSTALL_SCRIPT: scriptPath },
@@ -279,8 +393,7 @@ async function runOfficialMiniMaxInstaller(job, { reason } = {}) {
279
393
  if (!outcome.ok) throw new Error(outcome.timedOut
280
394
  ? '官方安装脚本超时并已终止。'
281
395
  : `官方安装脚本退出码 ${outcome.code ?? '信号终止'}。${outcome.stderr.split('\n').find(Boolean)?.slice(0, 180) ?? ''}`)
282
- const currentPath = process.env.PATH ?? ''
283
- if (!currentPath.split(';').includes(installDir)) process.env.PATH = `${currentPath};${installDir}`
396
+ appendToProcessPath(installDir)
284
397
  job.phase = 'verifying'
285
398
  probeCache.delete(job.tool.id)
286
399
  const probe = await probeToolWith(job.tool, defaultRunner)
@@ -302,17 +415,265 @@ async function runOfficialMiniMaxInstaller(job, { reason } = {}) {
302
415
  job.error = `MiniMax 官方安装兜底失败:${String(error?.message ?? error).slice(0, 300)}`
303
416
  job.finishedAt = new Date().toISOString()
304
417
  } finally {
305
- if (staging && prefixRoot) {
306
- const part = relative(prefixRoot, staging)
307
- if (part.startsWith('.model-router-minimax-installer-') && !part.includes(sep)) {
418
+ if (staging && stagingParent) {
419
+ const part = relative(stagingParent, staging)
420
+ if (part.startsWith('.model-router-minimax-installer-') && !part.includes(sep) && !part.startsWith('..')) {
308
421
  await rm(staging, { recursive: true, force: true }).catch(() => {})
309
422
  }
310
423
  }
311
424
  }
312
425
  }
313
426
 
427
+ /**
428
+ * The downloaded bytes must still be the vendor script this registry entry
429
+ * names — never an HTML error page, never another vendor's installer that a
430
+ * mirror substituted. Markers are the same ones the vendor's own docs publish
431
+ * inside the script, so a mirror may rehost the file but not rewrite it into a
432
+ * different program.
433
+ */
434
+ export function verifiedVendorScript(source, verify = {}) {
435
+ const text = installerScriptText(source)
436
+ if (!text.trim()) throw new Error('下载内容为空,已拒绝执行。')
437
+ if (isMarkupDocument(text)) throw new Error('下载内容不是安装脚本(收到 HTML 页面),已拒绝执行。')
438
+ // Check "wrong vendor" first: a mirror that substituted another installer is
439
+ // a different diagnosis from a truncated download, and the clearer one.
440
+ const foreign = (verify.mustNotInclude ?? []).filter(marker => text.includes(marker))
441
+ if (foreign.length) throw new Error('下载内容属于其他厂商的安装脚本,已拒绝执行。')
442
+ const missing = (verify.mustInclude ?? []).filter(marker => !text.includes(marker))
443
+ if (missing.length) {
444
+ throw new Error(`下载内容不是该厂商的官方安装脚本(缺少 ${missing.join('、')}),已拒绝执行。`)
445
+ }
446
+ return text
447
+ }
448
+
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)
460
+ }
461
+ return Buffer.concat(chunks)
462
+ }
463
+
464
+ const POWERSHELL = join(process.env.SystemRoot ?? 'C:\\Windows', 'System32',
465
+ 'WindowsPowerShell', 'v1.0', 'powershell.exe')
466
+
467
+ /** argv that runs one staged vendor script; no remote content ever reaches the interpreter. */
468
+ export function vendorScriptInvocation(plan, scriptPath) {
469
+ // `installArg` is the vendor's own single flag (`--install-dir` / `-InstallDir`).
470
+ const directoryArgs = plan.installArg?.[0] && plan.installDir ? [plan.installArg[0], plan.installDir] : []
471
+ if (plan.shell === 'powershell') {
472
+ return {
473
+ file: POWERSHELL,
474
+ args: ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...directoryArgs],
475
+ }
476
+ }
477
+ return { file: 'bash', args: [scriptPath, ...directoryArgs] }
478
+ }
479
+
480
+ /**
481
+ * Install one tool from its vendor script: download, verify, stage, run the
482
+ * staged copy. The binary lands in a directory the plugin appends to its own
483
+ * PATH, so the fresh probe below sees the copy this job just installed rather
484
+ * than an older one that happened to be on PATH first.
485
+ */
486
+ async function runVendorScriptInstallJob(job, plan) {
487
+ let staging = null
488
+ try {
489
+ job.phase = 'downloading-and-verifying'
490
+ for (const notice of plan.notices ?? []) pushLine(job, notice)
491
+ pushLine(job, `— 下载官方安装脚本:${plan.scriptUrl}`)
492
+ const source = verifiedVendorScript(await fetchVendorScript(plan.scriptUrl, job.controller.signal), plan.verify)
493
+ staging = await mkdtemp(join(tmpdir(), '.model-router-tool-install-'))
494
+ const isPowerShell = plan.shell === 'powershell'
495
+ const scriptPath = join(staging, isPowerShell ? 'install.ps1' : 'install.sh')
496
+ // Windows PowerShell 5 reads a BOM-less .ps1 using the system ANSI page;
497
+ // both vendor scripts contain non-ASCII text, so write a UTF-8 BOM.
498
+ await writeFile(scriptPath, isPowerShell ? `\uFEFF${source}` : source, 'utf8')
499
+ const invocation = vendorScriptInvocation(plan, scriptPath)
500
+ job.phase = 'installing'
501
+ pushLine(job, `$ ${plan.label}${invocation.args.length > 1 ? ` ${invocation.args.slice(1).join(' ')}` : ''}`)
502
+ const outcome = await runCapture(invocation.file, invocation.args, {
503
+ timeoutMs: INSTALL_TIMEOUT_MS,
504
+ signal: job.controller.signal,
505
+ env: { ...process.env, ...plan.env },
506
+ onOutput: captureLines(job),
507
+ })
508
+ if (outcome.cancelled || job.cancelRequested) { markCancelled(job); return }
509
+ job.exitCode = outcome.code
510
+ job.timedOut = outcome.timedOut
511
+ if (!outcome.ok) {
512
+ throw new Error(outcome.timedOut ? '官方安装脚本超时并已终止。'
513
+ : `官方安装脚本退出码 ${outcome.code ?? '信号终止'}。${String(outcome.stderr ?? '').split('\n').find(Boolean)?.slice(0, 200) ?? ''}`)
514
+ }
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
+ }
523
+ job.phase = 'verifying'
524
+ probeCache.delete(job.tool.id)
525
+ const probe = await probeToolWith(job.tool, defaultRunner, { cacheMs: 0 })
526
+ job.postInstallProbe = probe
527
+ if (!probe.installed) {
528
+ throw new Error(`官方安装脚本已完成,但 PATH 上仍找不到 ${job.tool.probeExecutables?.[0] ?? job.tool.label}:${probe.detail}`)
529
+ }
530
+ const latest = await latestVersions.lookup(job.tool).catch(() => null)
531
+ if (latest?.version && versionStillOlder(probe.version, latest.version)) {
532
+ throw new Error(staleInstallMessage(job.tool.label, probe.version, latest.version))
533
+ }
534
+ job.status = 'succeeded'
535
+ job.finishedAt = new Date().toISOString()
536
+ pushLine(job, `— 已通过 ${plan.label} 安装 ${job.tool.label} ${probe.version ?? ''} —`)
537
+ } catch (error) {
538
+ if (job.cancelRequested || job.controller.signal.aborted) { markCancelled(job); return }
539
+ job.status = 'failed'
540
+ job.error = `${job.tool.label} 安装失败:${String(error?.message ?? error).slice(0, 300)}`
541
+ job.finishedAt = new Date().toISOString()
542
+ } finally {
543
+ if (staging) await rm(staging, { recursive: true, force: true }).catch(() => {})
544
+ }
545
+ }
546
+
547
+ /**
548
+ * Guard rail for the one destructive operation: deleting a vendor binary. Every
549
+ * part of the path came from the fixed registry and the user's own settings,
550
+ * and this re-checks all of it — the basename must be one the registry names,
551
+ * the file must sit inside that tool's install directory, and it can never be a
552
+ * home directory or a filesystem root.
553
+ */
554
+ /** One relative path under an install directory. `..` never leaves that directory. */
555
+ export function scriptBinaryCandidate(installDir, relative) {
556
+ const parts = String(relative ?? '').split(/[\\/]/).filter(Boolean)
557
+ if (parts.length === 0 || parts.some(part => part === '.' || part === '..')) {
558
+ throw new Error('拒绝删除安装目录之外的文件。')
559
+ }
560
+ return join(installDir, ...parts)
561
+ }
562
+
563
+ export function checkedRemovableFile(candidate, { allowedNames, installDir, home = homedir() }) {
564
+ const target = resolve(candidate)
565
+ const base = target.split(sep).pop() ?? ''
566
+ if (!allowedNames.includes(base)) throw new Error(`拒绝删除 ${base}:不在该工具的固定可执行文件名列表中。`)
567
+ if (target === resolve(home) || target === resolve(target, '..')) {
568
+ throw new Error('拒绝删除用户主目录或文件系统根目录。')
569
+ }
570
+ if (installDir && !isInsideDirectory(installDir, target)) {
571
+ throw new Error(`拒绝删除 ${target}:不在该工具的安装目录 ${installDir} 内。`)
572
+ }
573
+ return target
574
+ }
575
+
576
+ function isInsideDirectory(parent, child) {
577
+ const canonical = resolve(parent)
578
+ return child === canonical || child.startsWith(canonical.endsWith(sep) ? canonical : `${canonical}${sep}`)
579
+ }
580
+
581
+ /**
582
+ * Remove one tool. Package-manager installs go back through the same manager;
583
+ * script installs delete the executable the vendor script placed, and only that
584
+ * file — the vendor's config, credentials and session history stay, so a
585
+ * reinstall keeps the user's login.
586
+ */
587
+ async function runVendorScriptUninstallJob(job, plan) {
588
+ try {
589
+ job.phase = 'removing'
590
+ const location = resolveInstallLocation(job.tool, plan.method, preferences, currentInstallOptions())
591
+ 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)
595
+ // checkedRemovableFile re-checks the basename, the install directory and
596
+ // the root/home guards even though every part came from the registry.
597
+ const target = checkedRemovableFile(candidate, {
598
+ allowedNames: location.binaries, installDir: location.dir, home: homedir(),
599
+ })
600
+ if (!(await stat(target).catch(() => null))?.isFile()) continue
601
+ await rm(target, { force: true })
602
+ removed.push(target)
603
+ }
604
+ const stripped = await stripPathProfileBlocks(job.tool).catch(error => `(未能清理 PATH 配置:${String(error?.message ?? error).slice(0, 120)})`)
605
+ if (removed.length === 0) {
606
+ throw new Error(`在 ${location?.dir || '安装目录'} 下没有找到 ${job.tool.label} 的可执行文件;它可能是用包管理器安装的,请改用该方式卸载。`)
607
+ }
608
+ for (const path of removed) pushLine(job, `已删除 ${path}`)
609
+ if (stripped.length) pushLine(job, `已清理 shell 配置中的 PATH 记录:${stripped.join('、')}`)
610
+ pushLine(job, `已保留 ${job.tool.label} 的配置与登录信息(${homedir()}/${String(job.tool.defaultScriptInstallDir ?? '').split('/')[0]})。`)
611
+ job.phase = 'verifying'
612
+ probeCache.delete(job.tool.id)
613
+ const probe = await probeToolWith(job.tool, defaultRunner, { cacheMs: 0 })
614
+ job.postInstallProbe = probe
615
+ job.status = 'succeeded'
616
+ job.finishedAt = new Date().toISOString()
617
+ job.error = probe.installed
618
+ ? `已删除本次安装的可执行文件,但 PATH 上仍有另一份 ${job.tool.label},请检查其他安装来源。`
619
+ : null
620
+ } catch (error) {
621
+ if (job.cancelRequested || job.controller.signal.aborted) { markCancelled(job); return }
622
+ job.status = 'failed'
623
+ job.error = `${job.tool.label} 卸载失败:${String(error?.message ?? error).slice(0, 300)}`
624
+ job.finishedAt = new Date().toISOString()
625
+ }
626
+ }
627
+
628
+ /**
629
+ * Remove the vendor installer's own marked block from the shell profiles it
630
+ * writes. Only a well-formed `# stepcode … # stepcode end` block is removed; a
631
+ * bare mention is left alone, exactly as the vendor's installer decides.
632
+ */
633
+ export function stripPathBlock(content, marker) {
634
+ const text = String(content ?? '')
635
+ if (!text.includes(marker)) return null
636
+ const lines = text.split(/\r?\n/)
637
+ const kept = []
638
+ 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
646
+ }
647
+ kept.push(line)
648
+ }
649
+ return removed > 0 ? kept.join('\n') : null
650
+ }
651
+
652
+ const SHELL_PROFILES = ['.bashrc', '.bash_profile', '.zshrc', '.profile']
653
+
654
+ async function stripPathProfileBlocks(tool) {
655
+ const markers = tool?.pathProfileMarkers ?? []
656
+ if (markers.length === 0) return []
657
+ const touched = []
658
+ for (const profile of SHELL_PROFILES) {
659
+ const path = join(homedir(), profile)
660
+ let content
661
+ try { content = await readFile(path, 'utf8') } catch { continue }
662
+ let next = content
663
+ for (const marker of markers) next = stripPathBlock(next, marker) ?? next
664
+ if (next === content) continue
665
+ const temporary = `${path}.model-router-${process.pid}.tmp`
666
+ await writeFile(temporary, next, 'utf8')
667
+ await rename(temporary, path)
668
+ touched.push(profile)
669
+ }
670
+ return touched
671
+ }
672
+
314
673
  /** Tools whose ready-check failure can be repaired by reinstalling the same version. */
315
- const CAPABILITY_REPAIRABLE = new Set(['claude-code', 'codex', 'kimi-code', 'minimax-code', 'mimo-code', 'grok-build'])
674
+ const CAPABILITY_REPAIRABLE = new Set([
675
+ 'claude-code', 'codex', 'kimi-code', 'minimax-code', 'mimo-code', 'grok-build', 'opencode', 'stepcode',
676
+ ])
316
677
 
317
678
  /** Extract the first version-looking token from a CLI banner. */
318
679
  export function versionFromBanner(banner) {
@@ -351,9 +712,18 @@ async function probeUncached(tool, runner) {
351
712
  if (tool.id === 'minimax-code' && IS_WINDOWS) {
352
713
  // The official Windows installer keeps mcode under a versioned releases
353
714
  // directory. A partial npm install can leave a broken shim earlier on PATH;
354
- // prefer the checksum-verified installer release in that case.
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.
355
718
  const { findManagedMiniMaxEntry } = await import('./official-tool-executor.mjs')
356
- const managed = await findManagedMiniMaxEntry(process.cwd())
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')])
357
727
  if (managed) return { id: tool.id, installed: true, version: managed.version,
358
728
  status: 'installed', detail: '已按官方 npm registry 摘要核验 MiniMax 官方 Windows 安装器中的 CLI 文件。',
359
729
  bannerLine: `MiniMax Code ${managed.version}` }
@@ -412,12 +782,27 @@ export const defaultRunner = (executable, args, options = {}) =>
412
782
 
413
783
  let prefixPromise = null
414
784
 
785
+ /** Add one directory to this process's PATH unless it is already there. */
786
+ function appendToProcessPath(directory, { includeBin = true } = {}) {
787
+ const target = String(directory ?? '').trim()
788
+ 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 })
797
+ }
798
+
415
799
  /**
416
800
  * Users frequently move the npm global prefix off the default location (for
417
801
  * example to save C-drive space), so globally installed CLIs are missing from
418
- * PATH. Resolve the real prefix once and append it to this process's PATH so
419
- * probes and installs see the user's actual tools. Best effort: any failure
420
- * leaves the environment untouched.
802
+ * PATH. Resolve the real prefix once and append it — plus the directory the
803
+ * user configured for this plugin — to this process's PATH so probes and
804
+ * installs see the user's actual tools. Best effort: any failure leaves the
805
+ * environment untouched.
421
806
  */
422
807
  export function ensureNpmPrefixOnPath() {
423
808
  if (prefixPromise === null) {
@@ -437,10 +822,19 @@ export function ensureNpmPrefixOnPath() {
437
822
  // the same non-C drive as this user's npm global prefix.
438
823
  process.env.GROK_HOME = join(prefix, '.model-router-grok')
439
824
  }
440
- const current = process.env.PATH ?? ''
441
- if (current.split(IS_WINDOWS ? ';' : ':').includes(prefix)) return
442
- process.env.PATH = current ? `${current}${IS_WINDOWS ? ';' : ':'}${prefix}` : prefix
825
+ appendToProcessPath(prefix)
443
826
  } catch { /* environment stays untouched */ }
827
+ // A --prefix install lands outside the npm prefix, so it needs its own
828
+ // 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 */ }
837
+ }
444
838
  })()
445
839
  }
446
840
  return prefixPromise
@@ -490,6 +884,9 @@ function jobView(job) {
490
884
  if (!job) return null
491
885
  return {
492
886
  tool: job.tool.id,
887
+ operation: job.operation,
888
+ methodId: job.methodId,
889
+ installDir: job.installDir,
493
890
  command: job.command,
494
891
  status: job.status,
495
892
  phase: job.phase,
@@ -513,23 +910,16 @@ function pushLine(job, line) {
513
910
  job.outputTail.push(line)
514
911
  }
515
912
 
516
- /**
517
- * Start the fixed install for one registry tool id. Returns the job snapshot
518
- * synchronously; poll installStatus until status is succeeded/failed.
519
- */
520
- export function startInstall(toolId) {
521
- const tool = getOfficialTool(toolId)
522
- if (!tool) throw new Error(`未知工具:${toolId}。只允许安装注册表中的官方工具。`)
523
- if (tool.unsupported) throw new Error(tool.unsupportedReason)
913
+ /** Reserve the single per-tool job slot and serialise against every other tool. */
914
+ function beginJob(tool, { operation, methodId, command, installDir = '', run }) {
524
915
  const existing = installJobs.get(tool.id)
525
- if (existing?.status === 'running') throw new Error(`${tool.label} 已有安装任务正在执行。`)
526
- const command = installCommandLine(tool)
527
- if (!command) throw new Error(tool.unsupportedReason)
528
- // With shell mode on Windows the bare name resolves npm.cmd/uv.exe.
529
- const manager = tool.manager === 'npm' ? 'npm' : tool.manager === 'signed-windows-installer' ? 'signed-windows-installer' : 'uv'
916
+ if (existing?.status === 'running') throw new Error(`${tool.label} 已有${operation === 'uninstall' ? '卸载' : '安装'}任务正在执行。`)
530
917
  const job = {
531
918
  tool,
919
+ operation,
920
+ methodId,
532
921
  command,
922
+ installDir,
533
923
  status: 'running',
534
924
  phase: 'queued',
535
925
  cancelRequested: false,
@@ -545,16 +935,145 @@ export function startInstall(toolId) {
545
935
  }
546
936
  installJobs.set(tool.id, job)
547
937
  probeCache.delete(tool.id)
548
- installChain = installChain.then(() => manager === 'signed-windows-installer'
549
- ? runZCodeInstallJob(job) : runInstallJob(manager, tool.installArgs, job)).catch(error => {
550
- if (job.status === 'cancelled') return
938
+ installChain = installChain.then(() => run(job)).catch(error => {
939
+ if (job.status !== 'running') return
551
940
  job.status = 'failed'
552
941
  job.finishedAt = new Date().toISOString()
553
- job.error = `安装流程失败:${String(error?.message ?? error).slice(0, 200)}`
942
+ job.error = `${operation === 'uninstall' ? '卸载' : '安装'}流程失败:${String(error?.message ?? error).slice(0, 200)}`
554
943
  })
555
944
  return jobView(job)
556
945
  }
557
946
 
947
+ /** One-line summary of a resolved plan, for the job log and the UI. */
948
+ export function planCommandLine(plan, operation = 'install') {
949
+ 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
957
+ }
958
+ return `${plan.manager} ${plan.args.join(' ')}`
959
+ }
960
+
961
+ /**
962
+ * Start the fixed install for one registry tool id. `method` names one of that
963
+ * tool's declared install methods; the default comes from the user's settings
964
+ * and then from the registry. Returns the job snapshot synchronously; poll
965
+ * installStatus until status is succeeded/failed.
966
+ */
967
+ export function startInstall(toolId, { method } = {}) {
968
+ const tool = getOfficialTool(toolId)
969
+ if (!tool) throw new Error(`未知工具:${toolId}。只允许安装注册表中的官方工具。`)
970
+ if (tool.unsupported) throw new Error(tool.unsupportedReason)
971
+ if (tool.manager === 'signed-windows-installer') {
972
+ const command = installCommandLine(tool)
973
+ if (!command) throw new Error(tool.unsupportedReason)
974
+ return beginJob(tool, { operation: 'install', methodId: 'signed-windows-installer', command, run: runZCodeInstallJob })
975
+ }
976
+ const plan = resolveInstallPlan(tool, method, preferences, currentInstallOptions())
977
+ return beginJob(tool, {
978
+ operation: 'install',
979
+ methodId: plan.methodId,
980
+ command: planCommandLine(plan),
981
+ installDir: plan.installDir ?? '',
982
+ run: job => (plan.kind === 'script'
983
+ ? runVendorScriptInstallJob(job, plan)
984
+ : runInstallJob(plan.manager, plan.args, job)),
985
+ })
986
+ }
987
+
988
+ /** Reinstall the tool even when it is already the latest release. */
989
+ export function startRepair(toolId, { method } = {}) {
990
+ const tool = getOfficialTool(toolId)
991
+ if (!tool) throw new Error(`未知工具:${toolId}。`)
992
+ if (tool.manager === 'signed-windows-installer') {
993
+ return beginJob(tool, {
994
+ operation: 'repair', methodId: 'signed-windows-installer',
995
+ command: installCommandLine(tool), run: runZCodeInstallJob,
996
+ })
997
+ }
998
+ const plan = resolveInstallPlan(tool, method, preferences, currentInstallOptions())
999
+ return beginJob(tool, {
1000
+ operation: 'repair',
1001
+ methodId: plan.methodId,
1002
+ command: `修复:${planCommandLine(plan)}`,
1003
+ installDir: plan.installDir ?? '',
1004
+ run: async job => {
1005
+ job.repair = true
1006
+ if (plan.kind === 'script') return runVendorScriptInstallJob(job, plan)
1007
+ return runInstallJob(plan.manager, plan.args, job)
1008
+ },
1009
+ })
1010
+ }
1011
+
1012
+ /**
1013
+ * Remove one tool: through its package manager, or by deleting the single file
1014
+ * the vendor installer placed. Configuration and credentials are never touched.
1015
+ */
1016
+ export function startUninstall(toolId, { method } = {}) {
1017
+ const tool = getOfficialTool(toolId)
1018
+ if (!tool) throw new Error(`未知工具:${toolId}。只允许卸载注册表中的官方工具。`)
1019
+ const plan = resolveUninstallPlan(tool, method, preferences, currentInstallOptions())
1020
+ return beginJob(tool, {
1021
+ operation: 'uninstall',
1022
+ methodId: plan.methodId,
1023
+ command: planCommandLine(plan, 'uninstall'),
1024
+ installDir: plan.dir ?? plan.installDir ?? '',
1025
+ run: job => (plan.kind === 'script'
1026
+ ? runVendorScriptUninstallJob(job, plan)
1027
+ : runPackageManagerUninstallJob(job, plan)),
1028
+ })
1029
+ }
1030
+
1031
+ async function runPackageManagerUninstallJob(job, plan) {
1032
+ try {
1033
+ job.phase = 'preflight'
1034
+ await ensureNpmPrefixOnPath()
1035
+ const before = await probeToolWith(job.tool, defaultRunner)
1036
+ if (job.cancelRequested) { markCancelled(job); return }
1037
+ if (!before.installed) {
1038
+ job.status = 'succeeded'
1039
+ job.finishedAt = new Date().toISOString()
1040
+ job.postInstallProbe = before
1041
+ pushLine(job, `— ${job.tool.label} 本就未安装,无需卸载 —`)
1042
+ return
1043
+ }
1044
+ job.phase = 'uninstalling'
1045
+ const outcome = await runCapture(plan.manager, plan.args, {
1046
+ timeoutMs: UNINSTALL_TIMEOUT_MS,
1047
+ useShell: IS_WINDOWS,
1048
+ signal: job.controller.signal,
1049
+ onOutput: captureLines(job),
1050
+ })
1051
+ if (outcome.cancelled || job.cancelRequested) { markCancelled(job); return }
1052
+ job.exitCode = outcome.code
1053
+ job.timedOut = outcome.timedOut
1054
+ if (!outcome.ok) {
1055
+ throw new Error(outcome.timedOut ? '卸载超时并已终止。'
1056
+ : `卸载命令失败(退出码 ${outcome.code ?? '信号终止'})。${String(outcome.stderr ?? '').split('\n').find(Boolean)?.slice(0, 200) ?? ''}`)
1057
+ }
1058
+ job.phase = 'verifying'
1059
+ probeCache.delete(job.tool.id)
1060
+ const probe = await probeToolWith(job.tool, defaultRunner, { cacheMs: 0 })
1061
+ job.postInstallProbe = probe
1062
+ job.status = 'succeeded'
1063
+ job.finishedAt = new Date().toISOString()
1064
+ if (probe.installed) {
1065
+ job.error = `${plan.manager} 已卸载 ${job.tool.label},但 PATH 上仍有另一份;请检查其他安装来源。`
1066
+ } else {
1067
+ pushLine(job, `— 已卸载 ${job.tool.label},PATH 上已找不到 ${job.tool.probeExecutables?.[0] ?? ''} —`)
1068
+ }
1069
+ } catch (error) {
1070
+ if (job.cancelRequested || job.controller.signal.aborted) { markCancelled(job); return }
1071
+ job.status = 'failed'
1072
+ job.error = `${job.tool.label} 卸载失败:${String(error?.message ?? error).slice(0, 300)}`
1073
+ job.finishedAt = new Date().toISOString()
1074
+ }
1075
+ }
1076
+
558
1077
  function captureLines(job) {
559
1078
  return chunk => {
560
1079
  for (const line of String(chunk).split(/\r?\n/)) {
@@ -659,14 +1178,20 @@ async function runInstallJob(manager, args, job) {
659
1178
  if (order === 0) {
660
1179
  const readiness = await trustedExecutionReadiness(job.tool.id)
661
1180
  if (job.cancelRequested) { markCancelled(job); return }
662
- if (readiness.ready || !CAPABILITY_REPAIRABLE.has(job.tool.id)) {
1181
+ // A repair re-runs the same command even when nothing looks stale: a
1182
+ // broken install reports the right version, so "already current" is
1183
+ // exactly the case the user pressed 修复 for.
1184
+ if (job.repair) {
1185
+ pushLine(job, `— ${job.tool.label} ${before.version} 已是最新版本,按修复流程重新安装 —`)
1186
+ } else if (readiness.ready || !CAPABILITY_REPAIRABLE.has(job.tool.id)) {
663
1187
  job.status = 'succeeded'
664
1188
  job.finishedAt = new Date().toISOString()
665
1189
  job.postInstallProbe = before
666
1190
  pushLine(job, `— ${job.tool.label} ${before.version} 已是最新版本,无需重复安装 —`)
667
1191
  return
1192
+ } else {
1193
+ pushLine(job, `— 已是最新版本 ${before.version},但执行入口未就绪:${readiness.reason};重新安装修复 —`)
668
1194
  }
669
- pushLine(job, `— 已是最新版本 ${before.version},但执行入口未就绪:${readiness.reason};重新安装修复 —`)
670
1195
  }
671
1196
  }
672
1197
  const plan = officialUpdatePlan({
@@ -674,6 +1199,7 @@ async function runInstallJob(manager, args, job) {
674
1199
  installed: before.installed === true,
675
1200
  order,
676
1201
  platform: process.platform,
1202
+ methodId: job.methodId,
677
1203
  })
678
1204
  let triedClaudeUpdate = false
679
1205
  if (plan === 'claude-update-then-registry') {
@@ -792,12 +1318,15 @@ async function runZCodeInstallJob(job) {
792
1318
  }
793
1319
 
794
1320
  function markCancelled(job) {
1321
+ const uninstalling = job.operation === 'uninstall'
795
1322
  job.status = 'cancelled'
796
1323
  job.phase = 'done'
797
1324
  job.finishedAt = new Date().toISOString()
798
- job.error = '已取消安装。若 npm 已开始写入,可能需要重新检测或修复该工具。'
1325
+ job.error = uninstalling
1326
+ ? '已取消卸载。若删除已经开始,请重新检测实际状态。'
1327
+ : '已取消安装。若包管理器已开始写入,可能需要重新检测或修复该工具。'
799
1328
  probeCache.delete(job.tool.id)
800
- pushLine(job, '— 安装已取消;请重新检测实际状态 —')
1329
+ pushLine(job, uninstalling ? '— 卸载已取消;请重新检测实际状态 —' : '— 安装已取消;请重新检测实际状态 —')
801
1330
  }
802
1331
 
803
1332
  /** Cancel a queued or running install; cancellation is reflected after its process exits. */
@@ -843,9 +1372,11 @@ export async function executionChannels(providers) {
843
1372
  return resolved
844
1373
  }
845
1374
 
846
- /** Test-only: drop caches and jobs. */
1375
+ /** Test-only: drop caches, jobs and any preferences a previous test left behind. */
847
1376
  export function resetForTests() {
848
1377
  probeCache.clear()
849
1378
  installJobs.clear()
850
1379
  installChain = Promise.resolve()
1380
+ prefixPromise = null
1381
+ preferences = { installDir: '', registry: '', methods: {}, scriptUrls: {}, allowScriptInstall: true }
851
1382
  }