@ljwei-stak/dsh-model-router 0.16.3 → 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
  *
@@ -173,11 +213,16 @@ function runCapture(executable, args, { timeoutMs, onOutput, useShell = false, s
173
213
  if (signal?.aborted) onAbort()
174
214
  child.stdout?.on('data', data => {
175
215
  if (settled || stopReason) return
176
- onOutput?.(pushChunk(stdoutChunks, data))
216
+ // Always keep the bytes. Optional chaining must not guard pushChunk:
217
+ // version and PATH probes omit onOutput, and a skipped push leaves
218
+ // stdout empty so every installed CLI looks not-installed.
219
+ const text = pushChunk(stdoutChunks, data)
220
+ onOutput?.(text)
177
221
  })
178
222
  child.stderr?.on('data', data => {
179
223
  if (settled || stopReason) return
180
- onOutput?.(pushChunk(stderrChunks, data))
224
+ const text = pushChunk(stderrChunks, data)
225
+ onOutput?.(text)
181
226
  })
182
227
  child.on('error', error => {
183
228
  const stdout = decoded(stdoutChunks)
@@ -306,8 +351,241 @@ async function runOfficialMiniMaxInstaller(job, { reason } = {}) {
306
351
  }
307
352
  }
308
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
+
309
585
  /** Tools whose ready-check failure can be repaired by reinstalling the same version. */
310
- 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
+ ])
311
589
 
312
590
  /** Extract the first version-looking token from a CLI banner. */
313
591
  export function versionFromBanner(banner) {
@@ -407,12 +685,25 @@ export const defaultRunner = (executable, args, options = {}) =>
407
685
 
408
686
  let prefixPromise = null
409
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
+
410
700
  /**
411
701
  * Users frequently move the npm global prefix off the default location (for
412
702
  * example to save C-drive space), so globally installed CLIs are missing from
413
- * PATH. Resolve the real prefix once and append it to this process's PATH so
414
- * probes and installs see the user's actual tools. Best effort: any failure
415
- * 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.
416
707
  */
417
708
  export function ensureNpmPrefixOnPath() {
418
709
  if (prefixPromise === null) {
@@ -432,10 +723,11 @@ export function ensureNpmPrefixOnPath() {
432
723
  // the same non-C drive as this user's npm global prefix.
433
724
  process.env.GROK_HOME = join(prefix, '.model-router-grok')
434
725
  }
435
- const current = process.env.PATH ?? ''
436
- if (current.split(IS_WINDOWS ? ';' : ':').includes(prefix)) return
437
- process.env.PATH = current ? `${current}${IS_WINDOWS ? ';' : ':'}${prefix}` : prefix
726
+ appendToProcessPath(prefix)
438
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 */ }
439
731
  })()
440
732
  }
441
733
  return prefixPromise
@@ -485,6 +777,9 @@ function jobView(job) {
485
777
  if (!job) return null
486
778
  return {
487
779
  tool: job.tool.id,
780
+ operation: job.operation,
781
+ methodId: job.methodId,
782
+ installDir: job.installDir,
488
783
  command: job.command,
489
784
  status: job.status,
490
785
  phase: job.phase,
@@ -508,23 +803,16 @@ function pushLine(job, line) {
508
803
  job.outputTail.push(line)
509
804
  }
510
805
 
511
- /**
512
- * Start the fixed install for one registry tool id. Returns the job snapshot
513
- * synchronously; poll installStatus until status is succeeded/failed.
514
- */
515
- export function startInstall(toolId) {
516
- const tool = getOfficialTool(toolId)
517
- if (!tool) throw new Error(`未知工具:${toolId}。只允许安装注册表中的官方工具。`)
518
- 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 }) {
519
808
  const existing = installJobs.get(tool.id)
520
- if (existing?.status === 'running') throw new Error(`${tool.label} 已有安装任务正在执行。`)
521
- const command = installCommandLine(tool)
522
- if (!command) throw new Error(tool.unsupportedReason)
523
- // With shell mode on Windows the bare name resolves npm.cmd/uv.exe.
524
- 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' ? '卸载' : '安装'}任务正在执行。`)
525
810
  const job = {
526
811
  tool,
812
+ operation,
813
+ methodId,
527
814
  command,
815
+ installDir,
528
816
  status: 'running',
529
817
  phase: 'queued',
530
818
  cancelRequested: false,
@@ -540,16 +828,142 @@ export function startInstall(toolId) {
540
828
  }
541
829
  installJobs.set(tool.id, job)
542
830
  probeCache.delete(tool.id)
543
- installChain = installChain.then(() => manager === 'signed-windows-installer'
544
- ? runZCodeInstallJob(job) : runInstallJob(manager, tool.installArgs, job)).catch(error => {
545
- if (job.status === 'cancelled') return
831
+ installChain = installChain.then(() => run(job)).catch(error => {
832
+ if (job.status !== 'running') return
546
833
  job.status = 'failed'
547
834
  job.finishedAt = new Date().toISOString()
548
- job.error = `安装流程失败:${String(error?.message ?? error).slice(0, 200)}`
835
+ job.error = `${operation === 'uninstall' ? '卸载' : '安装'}流程失败:${String(error?.message ?? error).slice(0, 200)}`
549
836
  })
550
837
  return jobView(job)
551
838
  }
552
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
+
553
967
  function captureLines(job) {
554
968
  return chunk => {
555
969
  for (const line of String(chunk).split(/\r?\n/)) {
@@ -654,14 +1068,20 @@ async function runInstallJob(manager, args, job) {
654
1068
  if (order === 0) {
655
1069
  const readiness = await trustedExecutionReadiness(job.tool.id)
656
1070
  if (job.cancelRequested) { markCancelled(job); return }
657
- 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)) {
658
1077
  job.status = 'succeeded'
659
1078
  job.finishedAt = new Date().toISOString()
660
1079
  job.postInstallProbe = before
661
1080
  pushLine(job, `— ${job.tool.label} ${before.version} 已是最新版本,无需重复安装 —`)
662
1081
  return
1082
+ } else {
1083
+ pushLine(job, `— 已是最新版本 ${before.version},但执行入口未就绪:${readiness.reason};重新安装修复 —`)
663
1084
  }
664
- pushLine(job, `— 已是最新版本 ${before.version},但执行入口未就绪:${readiness.reason};重新安装修复 —`)
665
1085
  }
666
1086
  }
667
1087
  const plan = officialUpdatePlan({
@@ -787,12 +1207,15 @@ async function runZCodeInstallJob(job) {
787
1207
  }
788
1208
 
789
1209
  function markCancelled(job) {
1210
+ const uninstalling = job.operation === 'uninstall'
790
1211
  job.status = 'cancelled'
791
1212
  job.phase = 'done'
792
1213
  job.finishedAt = new Date().toISOString()
793
- job.error = '已取消安装。若 npm 已开始写入,可能需要重新检测或修复该工具。'
1214
+ job.error = uninstalling
1215
+ ? '已取消卸载。若删除已经开始,请重新检测实际状态。'
1216
+ : '已取消安装。若包管理器已开始写入,可能需要重新检测或修复该工具。'
794
1217
  probeCache.delete(job.tool.id)
795
- pushLine(job, '— 安装已取消;请重新检测实际状态 —')
1218
+ pushLine(job, uninstalling ? '— 卸载已取消;请重新检测实际状态 —' : '— 安装已取消;请重新检测实际状态 —')
796
1219
  }
797
1220
 
798
1221
  /** Cancel a queued or running install; cancellation is reflected after its process exits. */
@@ -838,9 +1261,11 @@ export async function executionChannels(providers) {
838
1261
  return resolved
839
1262
  }
840
1263
 
841
- /** Test-only: drop caches and jobs. */
1264
+ /** Test-only: drop caches, jobs and any preferences a previous test left behind. */
842
1265
  export function resetForTests() {
843
1266
  probeCache.clear()
844
1267
  installJobs.clear()
845
1268
  installChain = Promise.resolve()
1269
+ prefixPromise = null
1270
+ preferences = { installDir: '', registry: '', methods: {}, scriptUrls: {}, allowScriptInstall: true }
846
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
  /**