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