@ljwei-stak/dsh-model-router 0.16.4 → 0.17.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.
@@ -1,19 +1,32 @@
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
+ resolveInstallLocation,
27
+ resolveInstallPlan,
28
+ resolveUninstallPlan,
29
+ } from './tool-install-preferences.mjs'
17
30
  import { discoverZCodeBundle, unverifiedZCodeInstalls } from './zcode-bundle.mjs'
18
31
  import { openZCodeInstaller } from './zcode-installer.mjs'
19
32
  import { compareReleaseVersions, latestVersions } from './latest-versions.mjs'
@@ -21,6 +34,7 @@ import { versionStillOlder } from './version-order.mjs'
21
34
 
22
35
  const PROBE_TIMEOUT_MS = 8_000
23
36
  const INSTALL_TIMEOUT_MS = 900_000
37
+ const UNINSTALL_TIMEOUT_MS = 300_000
24
38
  const OUTPUT_LINE_CAP = 120
25
39
  const IS_WINDOWS = process.platform === 'win32'
26
40
  const CREATE_NO_WINDOW = 0x0800_0000
@@ -38,6 +52,32 @@ const CLAUDE_UPDATE_TIMEOUT_MS = 180_000
38
52
  /** Non-interactive updater for a native Claude Code install. npm does not move that copy. */
39
53
  export const CLAUDE_UPDATE_ARGS = Object.freeze(['update'])
40
54
 
55
+ /**
56
+ * Host-side install preferences (install directory, npm source, per-tool method
57
+ * and script source). Refreshed from plugin config on every apply, so a client
58
+ * can only ever pick a method id — these addresses never arrive over the wire.
59
+ */
60
+ let preferences = { installDir: '', registry: '', methods: {}, scriptUrls: {}, allowScriptInstall: true }
61
+
62
+ export function setInstallPreferences(next) {
63
+ if (next && typeof next === 'object') {
64
+ const changedDirectory = preferences.installDir !== next.installDir
65
+ preferences = { ...preferences, ...next }
66
+ // A new directory needs its own PATH entry before the next probe runs.
67
+ if (changedDirectory) prefixPromise = null
68
+ }
69
+ return preferences
70
+ }
71
+
72
+ export function installPreferences() {
73
+ return { ...preferences }
74
+ }
75
+
76
+ /** The machine this Host runs on; the plan resolver needs both to be explicit. */
77
+ export function currentInstallOptions() {
78
+ return { platform: process.platform, home: homedir() }
79
+ }
80
+
41
81
  /**
42
82
  * Spawn shape for a fixed registry command.
43
83
  *
@@ -311,8 +351,241 @@ async function runOfficialMiniMaxInstaller(job, { reason } = {}) {
311
351
  }
312
352
  }
313
353
 
354
+ /**
355
+ * The downloaded bytes must still be the vendor script this registry entry
356
+ * names — never an HTML error page, never another vendor's installer that a
357
+ * mirror substituted. Markers are the same ones the vendor's own docs publish
358
+ * inside the script, so a mirror may rehost the file but not rewrite it into a
359
+ * different program.
360
+ */
361
+ export function verifiedVendorScript(source, verify = {}) {
362
+ const bytes = Buffer.isBuffer(source) ? source : Buffer.from(source)
363
+ const text = bytes.toString('utf8').replace(/^\uFEFF/, '')
364
+ if (!text.trim()) throw new Error('下载内容为空,已拒绝执行。')
365
+ if (/^\s*</.test(text)) throw new Error('下载内容不是安装脚本(收到 HTML 页面),已拒绝执行。')
366
+ // Check "wrong vendor" first: a mirror that substituted another installer is
367
+ // a different diagnosis from a truncated download, and the clearer one.
368
+ const foreign = (verify.mustNotInclude ?? []).filter(marker => text.includes(marker))
369
+ if (foreign.length) throw new Error('下载内容属于其他厂商的安装脚本,已拒绝执行。')
370
+ const missing = (verify.mustInclude ?? []).filter(marker => !text.includes(marker))
371
+ if (missing.length) {
372
+ throw new Error(`下载内容不是该厂商的官方安装脚本(缺少 ${missing.join('、')}),已拒绝执行。`)
373
+ }
374
+ return text
375
+ }
376
+
377
+ async function fetchVendorScript(url, signal) {
378
+ const response = await fetch(url, {
379
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000),
380
+ })
381
+ if (!response.ok || !response.body) throw new Error(`官方脚本下载失败:HTTP ${response.status}`)
382
+ const chunks = []
383
+ let length = 0
384
+ for await (const chunk of response.body) {
385
+ length += chunk.length
386
+ if (length > MAX_INSTALLER_BYTES) throw new Error('官方安装脚本超过大小上限。')
387
+ chunks.push(chunk)
388
+ }
389
+ return Buffer.concat(chunks)
390
+ }
391
+
392
+ const POWERSHELL = join(process.env.SystemRoot ?? 'C:\\Windows', 'System32',
393
+ 'WindowsPowerShell', 'v1.0', 'powershell.exe')
394
+
395
+ /** argv that runs one staged vendor script; no remote content ever reaches the interpreter. */
396
+ export function vendorScriptInvocation(plan, scriptPath) {
397
+ // `installArg` is the vendor's own single flag (`--install-dir` / `-InstallDir`).
398
+ const directoryArgs = plan.installArg?.[0] && plan.installDir ? [plan.installArg[0], plan.installDir] : []
399
+ if (plan.shell === 'powershell') {
400
+ return {
401
+ file: POWERSHELL,
402
+ args: ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...directoryArgs],
403
+ }
404
+ }
405
+ return { file: 'bash', args: [scriptPath, ...directoryArgs] }
406
+ }
407
+
408
+ /**
409
+ * Install one tool from its vendor script: download, verify, stage, run the
410
+ * staged copy. The binary lands in a directory the plugin appends to its own
411
+ * PATH, so the fresh probe below sees the copy this job just installed rather
412
+ * than an older one that happened to be on PATH first.
413
+ */
414
+ async function runVendorScriptInstallJob(job, plan) {
415
+ let staging = null
416
+ try {
417
+ job.phase = 'downloading-and-verifying'
418
+ for (const notice of plan.notices ?? []) pushLine(job, notice)
419
+ pushLine(job, `— 下载官方安装脚本:${plan.scriptUrl}`)
420
+ const source = verifiedVendorScript(await fetchVendorScript(plan.scriptUrl, job.controller.signal), plan.verify)
421
+ staging = await mkdtemp(join(tmpdir(), '.model-router-tool-install-'))
422
+ const isPowerShell = plan.shell === 'powershell'
423
+ const scriptPath = join(staging, isPowerShell ? 'install.ps1' : 'install.sh')
424
+ // Windows PowerShell 5 reads a BOM-less .ps1 using the system ANSI page;
425
+ // both vendor scripts contain non-ASCII text, so write a UTF-8 BOM.
426
+ await writeFile(scriptPath, isPowerShell ? `\uFEFF${source}` : source, 'utf8')
427
+ const invocation = vendorScriptInvocation(plan, scriptPath)
428
+ job.phase = 'installing'
429
+ pushLine(job, `$ ${plan.label}${invocation.args.length > 1 ? ` ${invocation.args.slice(1).join(' ')}` : ''}`)
430
+ const outcome = await runCapture(invocation.file, invocation.args, {
431
+ timeoutMs: INSTALL_TIMEOUT_MS,
432
+ signal: job.controller.signal,
433
+ env: { ...process.env, ...plan.env },
434
+ onOutput: captureLines(job),
435
+ })
436
+ if (outcome.cancelled || job.cancelRequested) { markCancelled(job); return }
437
+ job.exitCode = outcome.code
438
+ job.timedOut = outcome.timedOut
439
+ if (!outcome.ok) {
440
+ throw new Error(outcome.timedOut ? '官方安装脚本超时并已终止。'
441
+ : `官方安装脚本退出码 ${outcome.code ?? '信号终止'}。${String(outcome.stderr ?? '').split('\n').find(Boolean)?.slice(0, 200) ?? ''}`)
442
+ }
443
+ const location = resolveInstallLocation(job.tool, plan.method, preferences, currentInstallOptions())
444
+ if (location?.dir) appendToProcessPath(location.dir)
445
+ job.phase = 'verifying'
446
+ probeCache.delete(job.tool.id)
447
+ const probe = await probeToolWith(job.tool, defaultRunner, { cacheMs: 0 })
448
+ job.postInstallProbe = probe
449
+ if (!probe.installed) {
450
+ throw new Error(`官方安装脚本已完成,但 PATH 上仍找不到 ${job.tool.probeExecutables?.[0] ?? job.tool.label}:${probe.detail}`)
451
+ }
452
+ const latest = await latestVersions.lookup(job.tool).catch(() => null)
453
+ if (latest?.version && versionStillOlder(probe.version, latest.version)) {
454
+ throw new Error(staleInstallMessage(job.tool.label, probe.version, latest.version))
455
+ }
456
+ job.status = 'succeeded'
457
+ job.finishedAt = new Date().toISOString()
458
+ pushLine(job, `— 已通过 ${plan.label} 安装 ${job.tool.label} ${probe.version ?? ''} —`)
459
+ } catch (error) {
460
+ if (job.cancelRequested || job.controller.signal.aborted) { markCancelled(job); return }
461
+ job.status = 'failed'
462
+ job.error = `${job.tool.label} 安装失败:${String(error?.message ?? error).slice(0, 300)}`
463
+ job.finishedAt = new Date().toISOString()
464
+ } finally {
465
+ if (staging) await rm(staging, { recursive: true, force: true }).catch(() => {})
466
+ }
467
+ }
468
+
469
+ /**
470
+ * Guard rail for the one destructive operation: deleting a vendor binary. Every
471
+ * part of the path came from the fixed registry and the user's own settings,
472
+ * and this re-checks all of it — the basename must be one the registry names,
473
+ * the file must sit inside that tool's install directory, and it can never be a
474
+ * home directory or a filesystem root.
475
+ */
476
+ export function checkedRemovableFile(candidate, { allowedNames, installDir, home = homedir() }) {
477
+ const target = resolve(candidate)
478
+ const base = target.split(sep).pop() ?? ''
479
+ if (!allowedNames.includes(base)) throw new Error(`拒绝删除 ${base}:不在该工具的固定可执行文件名列表中。`)
480
+ if (target === resolve(home) || target === resolve(target, '..')) {
481
+ throw new Error('拒绝删除用户主目录或文件系统根目录。')
482
+ }
483
+ if (installDir && !isInsideDirectory(installDir, target)) {
484
+ throw new Error(`拒绝删除 ${target}:不在该工具的安装目录 ${installDir} 内。`)
485
+ }
486
+ return target
487
+ }
488
+
489
+ function isInsideDirectory(parent, child) {
490
+ const canonical = resolve(parent)
491
+ return child === canonical || child.startsWith(canonical.endsWith(sep) ? canonical : `${canonical}${sep}`)
492
+ }
493
+
494
+ /**
495
+ * Remove one tool. Package-manager installs go back through the same manager;
496
+ * script installs delete the executable the vendor script placed, and only that
497
+ * file — the vendor's config, credentials and session history stay, so a
498
+ * reinstall keeps the user's login.
499
+ */
500
+ async function runVendorScriptUninstallJob(job, plan) {
501
+ try {
502
+ job.phase = 'removing'
503
+ const location = resolveInstallLocation(job.tool, plan.method, preferences, currentInstallOptions())
504
+ const removed = []
505
+ for (const binary of location?.binaries ?? []) {
506
+ const candidate = join(location.dir, binary)
507
+ // checkedRemovableFile re-checks the basename, the install directory and
508
+ // the root/home guards even though every part came from the registry.
509
+ const target = checkedRemovableFile(candidate, {
510
+ allowedNames: location.binaries, installDir: location.dir, home: homedir(),
511
+ })
512
+ if (!(await stat(target).catch(() => null))?.isFile()) continue
513
+ await rm(target, { force: true })
514
+ removed.push(target)
515
+ }
516
+ const stripped = await stripPathProfileBlocks(job.tool).catch(error => `(未能清理 PATH 配置:${String(error?.message ?? error).slice(0, 120)})`)
517
+ if (removed.length === 0) {
518
+ throw new Error(`在 ${location?.dir || '安装目录'} 下没有找到 ${job.tool.label} 的可执行文件;它可能是用包管理器安装的,请改用该方式卸载。`)
519
+ }
520
+ for (const path of removed) pushLine(job, `已删除 ${path}`)
521
+ if (stripped.length) pushLine(job, `已清理 shell 配置中的 PATH 记录:${stripped.join('、')}`)
522
+ pushLine(job, `已保留 ${job.tool.label} 的配置与登录信息(${homedir()}/${String(job.tool.defaultScriptInstallDir ?? '').split('/')[0]})。`)
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
+ job.status = 'succeeded'
528
+ job.finishedAt = new Date().toISOString()
529
+ job.error = probe.installed
530
+ ? `已删除本次安装的可执行文件,但 PATH 上仍有另一份 ${job.tool.label},请检查其他安装来源。`
531
+ : null
532
+ } catch (error) {
533
+ if (job.cancelRequested || job.controller.signal.aborted) { markCancelled(job); return }
534
+ job.status = 'failed'
535
+ job.error = `${job.tool.label} 卸载失败:${String(error?.message ?? error).slice(0, 300)}`
536
+ job.finishedAt = new Date().toISOString()
537
+ }
538
+ }
539
+
540
+ /**
541
+ * Remove the vendor installer's own marked block from the shell profiles it
542
+ * writes. Only a well-formed `# stepcode … # stepcode end` block is removed; a
543
+ * bare mention is left alone, exactly as the vendor's installer decides.
544
+ */
545
+ export function stripPathBlock(content, marker) {
546
+ const text = String(content ?? '')
547
+ if (!text.includes(marker)) return null
548
+ const lines = text.split(/\r?\n/)
549
+ const kept = []
550
+ let removed = 0
551
+ let inside = false
552
+ for (const line of lines) {
553
+ if (!inside && line.trim() === marker) { inside = true; removed += 1; continue }
554
+ if (inside) {
555
+ removed += 1
556
+ if (line.trim() === `${marker} end`) inside = false
557
+ continue
558
+ }
559
+ kept.push(line)
560
+ }
561
+ return removed > 0 ? kept.join('\n') : null
562
+ }
563
+
564
+ const SHELL_PROFILES = ['.bashrc', '.bash_profile', '.zshrc', '.profile']
565
+
566
+ async function stripPathProfileBlocks(tool) {
567
+ const markers = tool?.pathProfileMarkers ?? []
568
+ if (markers.length === 0) return []
569
+ const touched = []
570
+ for (const profile of SHELL_PROFILES) {
571
+ const path = join(homedir(), profile)
572
+ let content
573
+ try { content = await readFile(path, 'utf8') } catch { continue }
574
+ let next = content
575
+ for (const marker of markers) next = stripPathBlock(next, marker) ?? next
576
+ if (next === content) continue
577
+ const temporary = `${path}.model-router-${process.pid}.tmp`
578
+ await writeFile(temporary, next, 'utf8')
579
+ await rename(temporary, path)
580
+ touched.push(profile)
581
+ }
582
+ return touched
583
+ }
584
+
314
585
  /** 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'])
586
+ const CAPABILITY_REPAIRABLE = new Set([
587
+ 'claude-code', 'codex', 'kimi-code', 'minimax-code', 'mimo-code', 'grok-build', 'opencode', 'stepcode',
588
+ ])
316
589
 
317
590
  /** Extract the first version-looking token from a CLI banner. */
318
591
  export function versionFromBanner(banner) {
@@ -412,12 +685,25 @@ export const defaultRunner = (executable, args, options = {}) =>
412
685
 
413
686
  let prefixPromise = null
414
687
 
688
+ /** Add one directory to this process's PATH unless it is already there. */
689
+ function appendToProcessPath(directory) {
690
+ const target = String(directory ?? '').trim()
691
+ if (!target || !isAbsolute(target)) return
692
+ const separator = IS_WINDOWS ? ';' : ':'
693
+ const current = process.env.PATH ?? ''
694
+ if (current.split(separator).some(entry => entry.toLowerCase() === target.toLowerCase())) return
695
+ process.env.PATH = current ? `${current}${separator}${target}` : target
696
+ // POSIX package managers put global binaries in <prefix>/bin.
697
+ if (!IS_WINDOWS) appendToProcessPath(join(target, 'bin'))
698
+ }
699
+
415
700
  /**
416
701
  * Users frequently move the npm global prefix off the default location (for
417
702
  * 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.
703
+ * PATH. Resolve the real prefix once and append it — plus the directory the
704
+ * user configured for this plugin — to this process's PATH so probes and
705
+ * installs see the user's actual tools. Best effort: any failure leaves the
706
+ * environment untouched.
421
707
  */
422
708
  export function ensureNpmPrefixOnPath() {
423
709
  if (prefixPromise === null) {
@@ -437,10 +723,11 @@ export function ensureNpmPrefixOnPath() {
437
723
  // the same non-C drive as this user's npm global prefix.
438
724
  process.env.GROK_HOME = join(prefix, '.model-router-grok')
439
725
  }
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
726
+ appendToProcessPath(prefix)
443
727
  } catch { /* environment stays untouched */ }
728
+ // A --prefix install lands outside the npm prefix, so it needs its own
729
+ // PATH entry or every later probe reports the tool as missing.
730
+ try { appendToProcessPath(preferences.installDir) } catch { /* environment stays untouched */ }
444
731
  })()
445
732
  }
446
733
  return prefixPromise
@@ -490,6 +777,9 @@ function jobView(job) {
490
777
  if (!job) return null
491
778
  return {
492
779
  tool: job.tool.id,
780
+ operation: job.operation,
781
+ methodId: job.methodId,
782
+ installDir: job.installDir,
493
783
  command: job.command,
494
784
  status: job.status,
495
785
  phase: job.phase,
@@ -513,23 +803,16 @@ function pushLine(job, line) {
513
803
  job.outputTail.push(line)
514
804
  }
515
805
 
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)
806
+ /** Reserve the single per-tool job slot and serialise against every other tool. */
807
+ function beginJob(tool, { operation, methodId, command, installDir = '', run }) {
524
808
  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'
809
+ if (existing?.status === 'running') throw new Error(`${tool.label} 已有${operation === 'uninstall' ? '卸载' : '安装'}任务正在执行。`)
530
810
  const job = {
531
811
  tool,
812
+ operation,
813
+ methodId,
532
814
  command,
815
+ installDir,
533
816
  status: 'running',
534
817
  phase: 'queued',
535
818
  cancelRequested: false,
@@ -545,16 +828,142 @@ export function startInstall(toolId) {
545
828
  }
546
829
  installJobs.set(tool.id, job)
547
830
  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
831
+ installChain = installChain.then(() => run(job)).catch(error => {
832
+ if (job.status !== 'running') return
551
833
  job.status = 'failed'
552
834
  job.finishedAt = new Date().toISOString()
553
- job.error = `安装流程失败:${String(error?.message ?? error).slice(0, 200)}`
835
+ job.error = `${operation === 'uninstall' ? '卸载' : '安装'}流程失败:${String(error?.message ?? error).slice(0, 200)}`
554
836
  })
555
837
  return jobView(job)
556
838
  }
557
839
 
840
+ /** One-line summary of a resolved plan, for the job log and the UI. */
841
+ export function planCommandLine(plan, operation = 'install') {
842
+ if (plan.kind === 'script') {
843
+ const piped = plan.shell === 'powershell' ? `irm ${plan.scriptUrl} | iex` : `curl -fsSL ${plan.scriptUrl} | bash`
844
+ return operation === 'uninstall'
845
+ ? `删除 ${plan.dir} 下的 ${(plan.binaries ?? []).join('、') || '可执行文件'}`
846
+ : `${piped}${plan.installDir ? ` ${plan.installArg} ${plan.installDir}` : ''}`
847
+ }
848
+ return `${plan.manager} ${plan.args.join(' ')}`
849
+ }
850
+
851
+ /**
852
+ * Start the fixed install for one registry tool id. `method` names one of that
853
+ * tool's declared install methods; the default comes from the user's settings
854
+ * and then from the registry. Returns the job snapshot synchronously; poll
855
+ * installStatus until status is succeeded/failed.
856
+ */
857
+ export function startInstall(toolId, { method } = {}) {
858
+ const tool = getOfficialTool(toolId)
859
+ if (!tool) throw new Error(`未知工具:${toolId}。只允许安装注册表中的官方工具。`)
860
+ if (tool.unsupported) throw new Error(tool.unsupportedReason)
861
+ if (tool.manager === 'signed-windows-installer') {
862
+ const command = installCommandLine(tool)
863
+ if (!command) throw new Error(tool.unsupportedReason)
864
+ return beginJob(tool, { operation: 'install', methodId: 'signed-windows-installer', command, run: runZCodeInstallJob })
865
+ }
866
+ const plan = resolveInstallPlan(tool, method, preferences, currentInstallOptions())
867
+ return beginJob(tool, {
868
+ operation: 'install',
869
+ methodId: plan.methodId,
870
+ command: planCommandLine(plan),
871
+ installDir: plan.installDir ?? '',
872
+ run: job => (plan.kind === 'script'
873
+ ? runVendorScriptInstallJob(job, plan)
874
+ : runInstallJob(plan.manager, plan.args, job)),
875
+ })
876
+ }
877
+
878
+ /** Reinstall the tool even when it is already the latest release. */
879
+ export function startRepair(toolId, { method } = {}) {
880
+ const tool = getOfficialTool(toolId)
881
+ if (!tool) throw new Error(`未知工具:${toolId}。`)
882
+ if (tool.manager === 'signed-windows-installer') {
883
+ return beginJob(tool, {
884
+ operation: 'repair', methodId: 'signed-windows-installer',
885
+ command: installCommandLine(tool), run: runZCodeInstallJob,
886
+ })
887
+ }
888
+ const plan = resolveInstallPlan(tool, method, preferences, currentInstallOptions())
889
+ return beginJob(tool, {
890
+ operation: 'repair',
891
+ methodId: plan.methodId,
892
+ command: `修复:${planCommandLine(plan)}`,
893
+ installDir: plan.installDir ?? '',
894
+ run: async job => {
895
+ job.repair = true
896
+ if (plan.kind === 'script') return runVendorScriptInstallJob(job, plan)
897
+ return runInstallJob(plan.manager, plan.args, job)
898
+ },
899
+ })
900
+ }
901
+
902
+ /**
903
+ * Remove one tool: through its package manager, or by deleting the single file
904
+ * the vendor installer placed. Configuration and credentials are never touched.
905
+ */
906
+ export function startUninstall(toolId, { method } = {}) {
907
+ const tool = getOfficialTool(toolId)
908
+ if (!tool) throw new Error(`未知工具:${toolId}。只允许卸载注册表中的官方工具。`)
909
+ const plan = resolveUninstallPlan(tool, method, preferences, currentInstallOptions())
910
+ return beginJob(tool, {
911
+ operation: 'uninstall',
912
+ methodId: plan.methodId,
913
+ command: planCommandLine(plan, 'uninstall'),
914
+ installDir: plan.dir ?? plan.installDir ?? '',
915
+ run: job => (plan.kind === 'script'
916
+ ? runVendorScriptUninstallJob(job, plan)
917
+ : runPackageManagerUninstallJob(job, plan)),
918
+ })
919
+ }
920
+
921
+ async function runPackageManagerUninstallJob(job, plan) {
922
+ try {
923
+ job.phase = 'preflight'
924
+ await ensureNpmPrefixOnPath()
925
+ const before = await probeToolWith(job.tool, defaultRunner)
926
+ if (job.cancelRequested) { markCancelled(job); return }
927
+ if (!before.installed) {
928
+ job.status = 'succeeded'
929
+ job.finishedAt = new Date().toISOString()
930
+ job.postInstallProbe = before
931
+ pushLine(job, `— ${job.tool.label} 本就未安装,无需卸载 —`)
932
+ return
933
+ }
934
+ job.phase = 'uninstalling'
935
+ const outcome = await runCapture(plan.manager, plan.args, {
936
+ timeoutMs: UNINSTALL_TIMEOUT_MS,
937
+ useShell: IS_WINDOWS,
938
+ signal: job.controller.signal,
939
+ onOutput: captureLines(job),
940
+ })
941
+ if (outcome.cancelled || job.cancelRequested) { markCancelled(job); return }
942
+ job.exitCode = outcome.code
943
+ job.timedOut = outcome.timedOut
944
+ if (!outcome.ok) {
945
+ throw new Error(outcome.timedOut ? '卸载超时并已终止。'
946
+ : `卸载命令失败(退出码 ${outcome.code ?? '信号终止'})。${String(outcome.stderr ?? '').split('\n').find(Boolean)?.slice(0, 200) ?? ''}`)
947
+ }
948
+ job.phase = 'verifying'
949
+ probeCache.delete(job.tool.id)
950
+ const probe = await probeToolWith(job.tool, defaultRunner, { cacheMs: 0 })
951
+ job.postInstallProbe = probe
952
+ job.status = 'succeeded'
953
+ job.finishedAt = new Date().toISOString()
954
+ if (probe.installed) {
955
+ job.error = `${plan.manager} 已卸载 ${job.tool.label},但 PATH 上仍有另一份;请检查其他安装来源。`
956
+ } else {
957
+ pushLine(job, `— 已卸载 ${job.tool.label},PATH 上已找不到 ${job.tool.probeExecutables?.[0] ?? ''} —`)
958
+ }
959
+ } catch (error) {
960
+ if (job.cancelRequested || job.controller.signal.aborted) { markCancelled(job); return }
961
+ job.status = 'failed'
962
+ job.error = `${job.tool.label} 卸载失败:${String(error?.message ?? error).slice(0, 300)}`
963
+ job.finishedAt = new Date().toISOString()
964
+ }
965
+ }
966
+
558
967
  function captureLines(job) {
559
968
  return chunk => {
560
969
  for (const line of String(chunk).split(/\r?\n/)) {
@@ -659,14 +1068,20 @@ async function runInstallJob(manager, args, job) {
659
1068
  if (order === 0) {
660
1069
  const readiness = await trustedExecutionReadiness(job.tool.id)
661
1070
  if (job.cancelRequested) { markCancelled(job); return }
662
- if (readiness.ready || !CAPABILITY_REPAIRABLE.has(job.tool.id)) {
1071
+ // A repair re-runs the same command even when nothing looks stale: a
1072
+ // broken install reports the right version, so "already current" is
1073
+ // exactly the case the user pressed 修复 for.
1074
+ if (job.repair) {
1075
+ pushLine(job, `— ${job.tool.label} ${before.version} 已是最新版本,按修复流程重新安装 —`)
1076
+ } else if (readiness.ready || !CAPABILITY_REPAIRABLE.has(job.tool.id)) {
663
1077
  job.status = 'succeeded'
664
1078
  job.finishedAt = new Date().toISOString()
665
1079
  job.postInstallProbe = before
666
1080
  pushLine(job, `— ${job.tool.label} ${before.version} 已是最新版本,无需重复安装 —`)
667
1081
  return
1082
+ } else {
1083
+ pushLine(job, `— 已是最新版本 ${before.version},但执行入口未就绪:${readiness.reason};重新安装修复 —`)
668
1084
  }
669
- pushLine(job, `— 已是最新版本 ${before.version},但执行入口未就绪:${readiness.reason};重新安装修复 —`)
670
1085
  }
671
1086
  }
672
1087
  const plan = officialUpdatePlan({
@@ -792,12 +1207,15 @@ async function runZCodeInstallJob(job) {
792
1207
  }
793
1208
 
794
1209
  function markCancelled(job) {
1210
+ const uninstalling = job.operation === 'uninstall'
795
1211
  job.status = 'cancelled'
796
1212
  job.phase = 'done'
797
1213
  job.finishedAt = new Date().toISOString()
798
- job.error = '已取消安装。若 npm 已开始写入,可能需要重新检测或修复该工具。'
1214
+ job.error = uninstalling
1215
+ ? '已取消卸载。若删除已经开始,请重新检测实际状态。'
1216
+ : '已取消安装。若包管理器已开始写入,可能需要重新检测或修复该工具。'
799
1217
  probeCache.delete(job.tool.id)
800
- pushLine(job, '— 安装已取消;请重新检测实际状态 —')
1218
+ pushLine(job, uninstalling ? '— 卸载已取消;请重新检测实际状态 —' : '— 安装已取消;请重新检测实际状态 —')
801
1219
  }
802
1220
 
803
1221
  /** Cancel a queued or running install; cancellation is reflected after its process exits. */
@@ -843,9 +1261,11 @@ export async function executionChannels(providers) {
843
1261
  return resolved
844
1262
  }
845
1263
 
846
- /** Test-only: drop caches and jobs. */
1264
+ /** Test-only: drop caches, jobs and any preferences a previous test left behind. */
847
1265
  export function resetForTests() {
848
1266
  probeCache.clear()
849
1267
  installJobs.clear()
850
1268
  installChain = Promise.resolve()
1269
+ prefixPromise = null
1270
+ preferences = { installDir: '', registry: '', methods: {}, scriptUrls: {}, allowScriptInstall: true }
851
1271
  }
@@ -28,6 +28,8 @@ export const LOGIN_GUIDES = Object.freeze({
28
28
  'mimo-code': Object.freeze({ command: 'mimo auth login', steps: '在终端运行 mimo auth login,选择 Xiaomi MiMo Platform 登录小米账号或填入 API Key;mimo auth list 可查看已保存的凭据。未登录时 MiMo Auto 免费匿名通道可能仍可用。' }),
29
29
  'grok-build': Object.freeze({ command: 'grok login', steps: '在终端运行 grok login,按浏览器提示登录 xAI 账号;无浏览器环境用 grok login --device-auth,或设置 XAI_API_KEY 使用 API 计费。' }),
30
30
  zcode: Object.freeze({ command: null, steps: '打开 ZCode 桌面版,在欢迎页选择“连接 BigModel 继续使用”或“连接 Z.ai 继续使用”完成授权;已订阅 GLM Coding Plan 时在“模型设置 → BigModel”右上角选择“编程套餐”绑定。改用 API Key 时 OpenAI 地址须填 Coding 专用端点 https://open.bigmodel.cn/api/coding/paas/v4。ZCode 没有可调用的登录状态命令。' }),
31
+ opencode: Object.freeze({ command: 'opencode auth login', steps: '在终端运行 opencode auth login,按提示完成登录;OpenCode 支持多家供应商,用 /connect 在会话内选择并保存凭据。也可在 opencode.json 中配置 provider 的 API Key。' }),
32
+ stepcode: Object.freeze({ command: 'step login', steps: '在终端运行 step login(step login status --json 可查看状态),按提示完成阶跃账号登录;登录信息保存在 ~/.stepcode/auth.json。卸载本工具不会删除该文件。' }),
31
33
  })
32
34
 
33
35
  /** Environment variable names whose presence means a CLI can authenticate without a session. */
@@ -39,6 +41,8 @@ const KEY_ENV = Object.freeze({
39
41
  'minimax-code': ['MINIMAX_API_KEY'],
40
42
  'mimo-code': ['MIMO_API_KEY'],
41
43
  'grok-build': ['XAI_API_KEY'],
44
+ opencode: ['OPENCODE_API_KEY', 'ANTHROPIC_API_KEY', 'OPENAI_API_KEY'],
45
+ stepcode: ['STEP_API_KEY', 'STEPCODE_API_KEY'],
42
46
  })
43
47
 
44
48
  /**
@@ -54,6 +58,8 @@ export function credentialFileFor(toolId, { home = '', env = process.env } = {})
54
58
  if (toolId === 'mimo-code') { const root = value('XDG_DATA_HOME') || (base && `${base}/.local/share`); return root ? `${root}/mimocode/auth.json` : '' }
55
59
  if (toolId === 'grok-build') { const root = value('GROK_HOME') || (base && `${base}/.grok`); return root ? `${root}/auth.json` : '' }
56
60
  if (toolId === 'gemini') return base ? `${base}/.gemini/oauth_creds.json` : ''
61
+ if (toolId === 'stepcode') { const root = value('STEP_CODING_AGENT_DIR') || (base && `${base}/.stepcode`); return root ? `${root}/auth.json` : '' }
62
+ if (toolId === 'opencode') return base ? `${base}/.local/share/opencode/auth.json` : ''
57
63
  return ''
58
64
  }
59
65
 
@@ -61,6 +67,7 @@ const FILE_LOGIN = Object.freeze({
61
67
  'kimi-code': { found: path => `检测到 Kimi Code 登录凭据文件 ${path}(未验证是否过期)。`, missing: path => `未找到 Kimi Code 登录凭据文件 ${path}(也可能通过 config.toml 中的自定义供应商认证),登录状态未知;如未登录请运行 kimi login。`, missingState: 'unknown' },
62
68
  'mimo-code': { found: path => `检测到 MiMo Code 凭据文件 ${path}(未验证是否有效,也可能只含 API Key)。`, missing: path => `未找到 MiMo Code 凭据文件 ${path};MiMo Auto 免费匿名通道可能仍可用,订阅/账号状态未知。`, missingState: 'unknown' },
63
69
  'grok-build': { found: path => `检测到 Grok 登录凭据文件 ${path}(未验证是否过期)。`, missing: path => `未找到 Grok 登录凭据文件 ${path},登录状态未知;如未登录请运行 grok login。`, missingState: 'unknown' },
70
+ 'stepcode': { found: path => `检测到 Step Code 登录凭据文件 ${path}(未验证是否过期)。`, missing: path => `未找到 Step Code 凭据文件 ${path},登录状态未知;如未登录请运行 step login。`, missingState: 'unknown' },
64
71
  })
65
72
 
66
73
  /**