@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.
- package/.dsh-plugin/client.js +737 -99
- package/.dsh-plugin/index.mjs +133 -27
- package/.dsh-plugin/official-tools-remote-service.mjs +82 -10
- package/.dsh-plugin/shared/latest-versions.mjs +19 -0
- package/.dsh-plugin/shared/official-tool-executor.mjs +8 -0
- package/.dsh-plugin/shared/official-tool-registry.mjs +223 -12
- package/.dsh-plugin/shared/official-tools-remote.mjs +38 -2
- package/.dsh-plugin/shared/official-tools-runtime.mjs +460 -40
- package/.dsh-plugin/shared/tool-health.mjs +7 -0
- package/.dsh-plugin/shared/tool-install-preferences.mjs +294 -0
- package/CHANGELOG.md +14 -0
- package/README.md +11 -1
- package/README.zh.md +11 -1
- package/docs/WORKBENCH_USER_GUIDE.zh.md +35 -4
- package/package.json +3 -2
|
@@ -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
|
|
5
|
-
* installing official CLIs uses node:child_process
|
|
6
|
-
* built exclusively from the fixed registry
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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 {
|
|
16
|
-
import {
|
|
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([
|
|
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
|
|
419
|
-
*
|
|
420
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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(() =>
|
|
549
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
/**
|