@ljwei-stak/dsh-model-router 0.17.0 → 0.18.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 +331 -85
- package/.dsh-plugin/index.mjs +1 -1
- package/.dsh-plugin/official-tools-remote-service.mjs +46 -10
- package/.dsh-plugin/shared/official-tool-executor.mjs +135 -47
- package/.dsh-plugin/shared/official-tool-registry.mjs +145 -12
- package/.dsh-plugin/shared/official-tools-runtime.mjs +426 -118
- package/.dsh-plugin/shared/tool-install-preferences.mjs +82 -26
- package/.dsh-plugin/shared/vendor-mimo-grok-adapter.mjs +41 -16
- package/CHANGELOG.md +22 -1
- package/README.md +19 -10
- package/README.zh.md +19 -10
- package/{INSTALLATION_GUIDE.zh.md → docs/INSTALLATION_GUIDE.zh.md} +16 -16
- package/docs/WORKBENCH_USER_GUIDE.zh.md +27 -5
- package/{MIGRATION.md → docs/archive/MIGRATION.md} +1 -1
- package/docs/archive/README.md +21 -0
- package/package.json +4 -4
|
@@ -14,8 +14,9 @@
|
|
|
14
14
|
import { spawn } from 'node:child_process'
|
|
15
15
|
import { mkdir, mkdtemp, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises'
|
|
16
16
|
import { homedir, tmpdir } from 'node:os'
|
|
17
|
-
import { isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
17
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
18
18
|
import {
|
|
19
|
+
DEFAULT_NPM_REGISTRY,
|
|
19
20
|
defaultInstallMethod,
|
|
20
21
|
getOfficialTool,
|
|
21
22
|
installMethodFor,
|
|
@@ -23,6 +24,7 @@ import {
|
|
|
23
24
|
OFFICIAL_TOOLS,
|
|
24
25
|
} from './official-tool-registry.mjs'
|
|
25
26
|
import {
|
|
27
|
+
expandInstallDir,
|
|
26
28
|
resolveInstallLocation,
|
|
27
29
|
resolveInstallPlan,
|
|
28
30
|
resolveUninstallPlan,
|
|
@@ -61,21 +63,49 @@ let preferences = { installDir: '', registry: '', methods: {}, scriptUrls: {}, a
|
|
|
61
63
|
|
|
62
64
|
export function setInstallPreferences(next) {
|
|
63
65
|
if (next && typeof next === 'object') {
|
|
64
|
-
const
|
|
65
|
-
preferences = { ...preferences, ...next
|
|
66
|
+
const previous = JSON.stringify(preferences)
|
|
67
|
+
preferences = { ...preferences, ...next,
|
|
68
|
+
methods: { ...(next.methods ?? preferences.methods) },
|
|
69
|
+
scriptUrls: { ...(next.scriptUrls ?? preferences.scriptUrls) },
|
|
70
|
+
}
|
|
66
71
|
// A new directory needs its own PATH entry before the next probe runs.
|
|
67
|
-
if (
|
|
72
|
+
if (previous !== JSON.stringify(preferences)) {
|
|
73
|
+
prefixPromise = null
|
|
74
|
+
probeCache.clear()
|
|
75
|
+
}
|
|
68
76
|
}
|
|
69
77
|
return preferences
|
|
70
78
|
}
|
|
71
79
|
|
|
72
80
|
export function installPreferences() {
|
|
73
|
-
return { ...preferences }
|
|
81
|
+
return { ...preferences, methods: { ...preferences.methods }, scriptUrls: { ...preferences.scriptUrls } }
|
|
74
82
|
}
|
|
75
83
|
|
|
76
84
|
/** The machine this Host runs on; the plan resolver needs both to be explicit. */
|
|
77
85
|
export function currentInstallOptions() {
|
|
78
|
-
return { platform: process.platform, home: homedir() }
|
|
86
|
+
return { platform: process.platform, home: homedir(), env: process.env }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The selected tool's command directory, ahead of unrelated copies on PATH. */
|
|
90
|
+
export function preferredToolDirectories(toolId, selectedPreferences = preferences, options = currentInstallOptions()) {
|
|
91
|
+
const tool = getOfficialTool(toolId)
|
|
92
|
+
if (!tool || tool.unsupported || tool.manager === 'signed-windows-installer') return []
|
|
93
|
+
try {
|
|
94
|
+
const plan = resolveInstallPlan(tool, undefined, {
|
|
95
|
+
...selectedPreferences,
|
|
96
|
+
// Keep a deliberately selected existing script copy visible, while the
|
|
97
|
+
// automatic method still honors the switch and can select npm instead.
|
|
98
|
+
allowScriptInstall: installMethodFor(tool, selectedPreferences.methods?.[tool.id])?.kind === 'script' ? true : selectedPreferences.allowScriptInstall,
|
|
99
|
+
}, options)
|
|
100
|
+
return plan.binDir ? [plan.binDir] : []
|
|
101
|
+
} catch { return [] }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A deliberate method/source/directory choice must never invoke a different updater. */
|
|
105
|
+
export function usesConfiguredInstallPlan(toolId, selectedPreferences = preferences, methodId) {
|
|
106
|
+
return Boolean(methodId || selectedPreferences.installDir || selectedPreferences.methods?.[toolId]
|
|
107
|
+
|| selectedPreferences.scriptUrls?.[toolId] || selectedPreferences.allowScriptInstall === false
|
|
108
|
+
|| (selectedPreferences.registry && selectedPreferences.registry !== DEFAULT_NPM_REGISTRY))
|
|
79
109
|
}
|
|
80
110
|
|
|
81
111
|
/**
|
|
@@ -86,10 +116,20 @@ export function currentInstallOptions() {
|
|
|
86
116
|
* cmd strip the wrong quotes and launch `node` against a missing script:
|
|
87
117
|
* `MODULE_NOT_FOUND` at `executeUserEntryPoint` with an empty requireStack.
|
|
88
118
|
* That failed `npm config get prefix`, `--version`, and `npm install -g` for
|
|
89
|
-
* every official tool. Keep
|
|
119
|
+
* every official tool. Keep bare tokens separate; quote only a path/value that
|
|
120
|
+
* cmd would split or interpret as an operator.
|
|
90
121
|
*/
|
|
91
122
|
export function shellSpawnSpec(executable, args = [], { useShell = false } = {}) {
|
|
92
|
-
|
|
123
|
+
const quote = value => {
|
|
124
|
+
const text = String(value)
|
|
125
|
+
if (!/[\s&|<>()^!%"]/.test(text)) return text
|
|
126
|
+
return `"${text.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, '$1$1')}"`
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
file: useShell && IS_WINDOWS ? quote(executable) : String(executable),
|
|
130
|
+
args: useShell && IS_WINDOWS ? (args ?? []).map(quote) : [...(args ?? [])],
|
|
131
|
+
shell: Boolean(useShell),
|
|
132
|
+
}
|
|
93
133
|
}
|
|
94
134
|
|
|
95
135
|
/** Decode a CLI banner. Native Windows exes often write UTF-16 to a pipe. */
|
|
@@ -128,9 +168,10 @@ export function staleInstallMessage(label, installedVersion, latestVersion) {
|
|
|
128
168
|
* Which updater to run. `order` is compareReleaseVersions(installed, latest):
|
|
129
169
|
* -1 older, 0 same, 1 newer, null when either version is unknown.
|
|
130
170
|
*/
|
|
131
|
-
export function officialUpdatePlan({ toolId, installed, order, platform }) {
|
|
171
|
+
export function officialUpdatePlan({ toolId, installed, order, platform, configured = false }) {
|
|
132
172
|
if (installed && order === 1) return 'refuse-downgrade'
|
|
133
173
|
if (installed && order === 0) return 'already-current'
|
|
174
|
+
if (configured) return 'registry-install'
|
|
134
175
|
if (installed && order === -1 && toolId === 'claude-code') return 'claude-update-then-registry'
|
|
135
176
|
if (installed && (order === -1 || order === null) && toolId === 'minimax-code' && platform === 'win32') {
|
|
136
177
|
return 'minimax-official-installer'
|
|
@@ -138,11 +179,11 @@ export function officialUpdatePlan({ toolId, installed, order, platform }) {
|
|
|
138
179
|
return 'registry-install'
|
|
139
180
|
}
|
|
140
181
|
|
|
141
|
-
async function trustedExecutionReadiness(toolId) {
|
|
182
|
+
async function trustedExecutionReadiness(toolId, options) {
|
|
142
183
|
// Loaded only during an install to avoid coupling fast version probes to
|
|
143
184
|
// the executor. The executor itself uses this runtime for PATH discovery.
|
|
144
185
|
const { officialToolReadiness } = await import('./official-tool-executor.mjs')
|
|
145
|
-
return officialToolReadiness(toolId)
|
|
186
|
+
return officialToolReadiness(toolId, process.cwd(), options)
|
|
146
187
|
}
|
|
147
188
|
|
|
148
189
|
function runCapture(executable, args, { timeoutMs, onOutput, useShell = false, signal, env } = {}) {
|
|
@@ -237,33 +278,46 @@ function runCapture(executable, args, { timeoutMs, onOutput, useShell = false, s
|
|
|
237
278
|
})
|
|
238
279
|
}
|
|
239
280
|
|
|
281
|
+
export function installerScriptText(source) {
|
|
282
|
+
return decodeProcessOutput(Buffer.isBuffer(source) ? source : Buffer.from(source ?? '')).replace(/^\uFEFF/, '')
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function isMarkupDocument(text) {
|
|
286
|
+
const head = String(text ?? '').replace(/^\uFEFF/, '').trimStart()
|
|
287
|
+
if (!head.startsWith('<') || head.startsWith('<#')) return false
|
|
288
|
+
return /^<(?:!|\?|[A-Za-z])/.test(head)
|
|
289
|
+
}
|
|
290
|
+
|
|
240
291
|
/**
|
|
241
292
|
* MiniMax's official Windows installer script is run as published (it installs
|
|
242
293
|
* `@minimax-ai/code@latest`). Only structural sanity is checked: it must be the
|
|
243
294
|
* MiniMax Code installer, not an HTML error page or another script.
|
|
244
295
|
*/
|
|
245
296
|
export function officialMiniMaxInstaller(source) {
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
297
|
+
const text = installerScriptText(source)
|
|
298
|
+
// PowerShell help starts with `<# ... #>`, including the current official
|
|
299
|
+
// installer. It is a script comment, not an HTML response.
|
|
300
|
+
if (isMarkupDocument(text) || !['@minimax-ai/code', 'MCODE_INSTALL_DIR', 'MiniMax Code'].every(marker => text.includes(marker))) {
|
|
249
301
|
throw new Error('下载内容不是 MiniMax Code 官方 Windows 安装脚本,已拒绝执行。')
|
|
250
302
|
}
|
|
251
303
|
return text
|
|
252
304
|
}
|
|
253
305
|
|
|
254
306
|
async function fetchOfficialMiniMaxInstaller(signal) {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
307
|
+
return officialMiniMaxInstaller(await fetchVendorScript(MINIMAX_INSTALLER_URL, signal, { verify: { mustInclude: ['@minimax-ai/code', 'MiniMax Code CLI'] } }))
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function minimaxOfficialInstallDir(selectedPreferences = {}, home = homedir(), existingRoot = '') {
|
|
311
|
+
return selectedPreferences.installDir || existingRoot || join(home, '.minimax-code')
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Keep a discovered native installation in place when its legacy updater runs. */
|
|
315
|
+
export function miniMaxFallbackPlan(selectedPreferences, beforeProbe, prefix, options = currentInstallOptions()) {
|
|
316
|
+
const installDir = minimaxOfficialInstallDir(selectedPreferences, options.home,
|
|
317
|
+
beforeProbe?.installed ? beforeProbe.installDir : '')
|
|
318
|
+
const executionPreferences = { ...selectedPreferences, installDir,
|
|
319
|
+
methods: { ...selectedPreferences.methods, 'minimax-code': 'script-powershell' } }
|
|
320
|
+
return { plan: resolveInstallPlan(getOfficialTool('minimax-code'), 'script-powershell', executionPreferences, options), executionPreferences }
|
|
267
321
|
}
|
|
268
322
|
|
|
269
323
|
async function runOfficialMiniMaxInstaller(job, { reason } = {}) {
|
|
@@ -274,13 +328,10 @@ async function runOfficialMiniMaxInstaller(job, { reason } = {}) {
|
|
|
274
328
|
job.error = null
|
|
275
329
|
job.phase = 'downloading-and-verifying'
|
|
276
330
|
pushLine(job, reason || '— npm 原生依赖未就绪;改用 MiniMax 官方 Windows 安装脚本(安装最新版)—')
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
})
|
|
280
|
-
|
|
281
|
-
if (!prefixResult.ok || !isAbsolute(rawPrefix)) throw new Error('无法确定 npm 全局安装目录。')
|
|
282
|
-
await mkdir(rawPrefix, { recursive: true })
|
|
283
|
-
prefixRoot = await realpath(rawPrefix)
|
|
331
|
+
const { plan, executionPreferences } = miniMaxFallbackPlan(job.preferences, job.beforeProbe, '')
|
|
332
|
+
const installDir = plan.installDir
|
|
333
|
+
await mkdir(installDir, { recursive: true })
|
|
334
|
+
prefixRoot = await realpath(installDir)
|
|
284
335
|
const script = await fetchOfficialMiniMaxInstaller(job.controller.signal)
|
|
285
336
|
staging = await mkdtemp(join(prefixRoot, '.model-router-minimax-installer-'))
|
|
286
337
|
const scriptPath = join(staging, 'install.ps1')
|
|
@@ -289,7 +340,11 @@ async function runOfficialMiniMaxInstaller(job, { reason } = {}) {
|
|
|
289
340
|
// Windows PowerShell 5 reads a BOM-less .ps1 using the system ANSI page.
|
|
290
341
|
// The upstream installer contains Unicode art, so write a UTF-8 BOM.
|
|
291
342
|
await writeFile(scriptPath, `\uFEFF${script}`, 'utf8')
|
|
292
|
-
|
|
343
|
+
job.plan = plan
|
|
344
|
+
job.methodId = plan.methodId
|
|
345
|
+
job.installDir = installDir
|
|
346
|
+
job.command = planCommandLine(plan)
|
|
347
|
+
pushLine(job, `— 本次更新目录:${installDir} —`)
|
|
293
348
|
const powershell = join(process.env.SystemRoot ?? 'C:\\Windows', 'System32',
|
|
294
349
|
'WindowsPowerShell', 'v1.0', 'powershell.exe')
|
|
295
350
|
const utilityModule = join(process.env.SystemRoot ?? 'C:\\Windows', 'System32',
|
|
@@ -302,9 +357,8 @@ async function runOfficialMiniMaxInstaller(job, { reason } = {}) {
|
|
|
302
357
|
const outcome = await runCapture(powershell,
|
|
303
358
|
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', command], {
|
|
304
359
|
timeoutMs: INSTALL_TIMEOUT_MS, signal: job.controller.signal,
|
|
305
|
-
env: { ...process.env, MCODE_INSTALL_DIR: installDir,
|
|
360
|
+
env: { ...process.env, ...plan.env, MCODE_INSTALL_DIR: installDir,
|
|
306
361
|
MCODE_NO_MODIFY_PATH: '1', MCODE_DOWNLOAD_MIRROR: 'global',
|
|
307
|
-
MCODE_NPM_REGISTRY: 'https://registry.npmjs.org/',
|
|
308
362
|
npm_config_cache: join(prefixRoot, '.model-router-npm-cache'),
|
|
309
363
|
TEMP: tempPath, TMP: tempPath,
|
|
310
364
|
MODEL_ROUTER_UTILITY_MODULE: utilityModule,
|
|
@@ -319,13 +373,12 @@ async function runOfficialMiniMaxInstaller(job, { reason } = {}) {
|
|
|
319
373
|
if (!outcome.ok) throw new Error(outcome.timedOut
|
|
320
374
|
? '官方安装脚本超时并已终止。'
|
|
321
375
|
: `官方安装脚本退出码 ${outcome.code ?? '信号终止'}。${outcome.stderr.split('\n').find(Boolean)?.slice(0, 180) ?? ''}`)
|
|
322
|
-
|
|
323
|
-
if (!currentPath.split(';').includes(installDir)) process.env.PATH = `${currentPath};${installDir}`
|
|
376
|
+
appendToProcessPath(installDir, { prepend: true })
|
|
324
377
|
job.phase = 'verifying'
|
|
325
378
|
probeCache.delete(job.tool.id)
|
|
326
|
-
const probe = await probeToolWith(job.tool, defaultRunner)
|
|
379
|
+
const probe = await probeToolWith(job.tool, defaultRunner, { preferences: executionPreferences, plan, cacheMs: 0 })
|
|
327
380
|
job.postInstallProbe = probe
|
|
328
|
-
const readiness = await trustedExecutionReadiness(job.tool.id)
|
|
381
|
+
const readiness = await trustedExecutionReadiness(job.tool.id, { preferences: executionPreferences })
|
|
329
382
|
if (!probe.installed || !readiness.ready) {
|
|
330
383
|
throw new Error(`官方安装器完成,但执行入口未通过 npm registry 摘要核验:${readiness.reason ?? probe.detail}`)
|
|
331
384
|
}
|
|
@@ -359,10 +412,9 @@ async function runOfficialMiniMaxInstaller(job, { reason } = {}) {
|
|
|
359
412
|
* different program.
|
|
360
413
|
*/
|
|
361
414
|
export function verifiedVendorScript(source, verify = {}) {
|
|
362
|
-
const
|
|
363
|
-
const text = bytes.toString('utf8').replace(/^\uFEFF/, '')
|
|
415
|
+
const text = installerScriptText(source)
|
|
364
416
|
if (!text.trim()) throw new Error('下载内容为空,已拒绝执行。')
|
|
365
|
-
if (
|
|
417
|
+
if (isMarkupDocument(text)) throw new Error('下载内容不是安装脚本(收到 HTML 页面),已拒绝执行。')
|
|
366
418
|
// Check "wrong vendor" first: a mirror that substituted another installer is
|
|
367
419
|
// a different diagnosis from a truncated download, and the clearer one.
|
|
368
420
|
const foreign = (verify.mustNotInclude ?? []).filter(marker => text.includes(marker))
|
|
@@ -374,19 +426,78 @@ export function verifiedVendorScript(source, verify = {}) {
|
|
|
374
426
|
return text
|
|
375
427
|
}
|
|
376
428
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
429
|
+
/** Windows WebRequest follows the system proxy, which Node fetch may not use. */
|
|
430
|
+
export const WINDOWS_SCRIPT_DOWNLOAD = [
|
|
431
|
+
'$ErrorActionPreference="Stop"',
|
|
432
|
+
'[Net.ServicePointManager]::SecurityProtocol=[Net.SecurityProtocolType]::Tls12',
|
|
433
|
+
'$request=[Net.HttpWebRequest]::Create($env:MODEL_ROUTER_DOWNLOAD_URL)',
|
|
434
|
+
'$request.UserAgent="curl/8.0"',
|
|
435
|
+
'$request.Timeout=30000',
|
|
436
|
+
'$request.ReadWriteTimeout=30000',
|
|
437
|
+
'$response=$request.GetResponse()',
|
|
438
|
+
`if ($response.ContentLength -gt ${MAX_INSTALLER_BYTES}) { $response.Close(); throw "Installer exceeds size limit" }`,
|
|
439
|
+
'$stream=$response.GetResponseStream()',
|
|
440
|
+
'$output=New-Object IO.MemoryStream',
|
|
441
|
+
'$buffer=New-Object byte[] 8192',
|
|
442
|
+
'try { while (($count=$stream.Read($buffer,0,$buffer.Length)) -gt 0) { '
|
|
443
|
+
+ `$output.Write($buffer,0,$count); if ($output.Length -gt ${MAX_INSTALLER_BYTES}) { throw "Installer exceeds size limit" } }; `
|
|
444
|
+
+ '[IO.File]::WriteAllBytes($env:MODEL_ROUTER_DOWNLOAD_FILE,$output.ToArray()) '
|
|
445
|
+
+ '} finally { $stream.Close(); $response.Close(); $output.Dispose() }',
|
|
446
|
+
].join('; ')
|
|
447
|
+
|
|
448
|
+
/** Download bytes only; every transport still checks the same vendor markers. */
|
|
449
|
+
export async function fetchVendorScript(url, signal, {
|
|
450
|
+
verify = {}, fetcher = globalThis.fetch, platform = process.platform, powershellRunner = runCapture, fallbackUrls = [],
|
|
451
|
+
} = {}) {
|
|
452
|
+
if (fallbackUrls.length) {
|
|
453
|
+
let lastError
|
|
454
|
+
for (const candidate of [...new Set([url, ...fallbackUrls])]) {
|
|
455
|
+
try { return await fetchVendorScript(candidate, signal, { verify, fetcher, platform, powershellRunner }) }
|
|
456
|
+
catch (error) { if (signal?.aborted) throw error; lastError = error }
|
|
457
|
+
}
|
|
458
|
+
throw lastError
|
|
459
|
+
}
|
|
460
|
+
const check = bytes => { verifiedVendorScript(bytes, verify); return bytes }
|
|
461
|
+
let firstError
|
|
462
|
+
try {
|
|
463
|
+
const response = await fetcher(url, {
|
|
464
|
+
headers: { 'User-Agent': 'curl/8.0', Accept: 'text/plain, application/octet-stream;q=0.9, */*;q=0.8' },
|
|
465
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000),
|
|
466
|
+
})
|
|
467
|
+
if (!response.ok || !response.body) throw new Error(`官方脚本下载失败:HTTP ${response.status}`)
|
|
468
|
+
const declared = Number(response.headers?.get?.('content-length') ?? 0)
|
|
469
|
+
if (declared > MAX_INSTALLER_BYTES) throw new Error('官方安装脚本超过大小上限。')
|
|
470
|
+
const chunks = []
|
|
471
|
+
let length = 0
|
|
472
|
+
for await (const chunk of response.body) {
|
|
473
|
+
length += chunk.length
|
|
474
|
+
if (length > MAX_INSTALLER_BYTES) throw new Error('官方安装脚本超过大小上限。')
|
|
475
|
+
chunks.push(chunk)
|
|
476
|
+
}
|
|
477
|
+
return check(Buffer.concat(chunks))
|
|
478
|
+
} catch (error) {
|
|
479
|
+
if (signal?.aborted || platform !== 'win32') throw error
|
|
480
|
+
firstError = error
|
|
481
|
+
}
|
|
482
|
+
// This fixed PowerShell program only downloads to a private temporary file.
|
|
483
|
+
// Neither the URL nor response bytes become executable command text.
|
|
484
|
+
const staging = await mkdtemp(join(tmpdir(), '.model-router-script-download-'))
|
|
485
|
+
try {
|
|
486
|
+
const outputPath = join(staging, 'installer.bin')
|
|
487
|
+
const outcome = await powershellRunner(POWERSHELL, [
|
|
488
|
+
'-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(WINDOWS_SCRIPT_DOWNLOAD, 'utf16le').toString('base64'),
|
|
489
|
+
], { timeoutMs: 40_000, signal,
|
|
490
|
+
env: { ...process.env, MODEL_ROUTER_DOWNLOAD_URL: url, MODEL_ROUTER_DOWNLOAD_FILE: outputPath },
|
|
491
|
+
})
|
|
492
|
+
if (!outcome.ok) {
|
|
493
|
+
throw new Error(`官方脚本下载失败:${String(firstError?.message ?? firstError).slice(0, 150)};系统下载通道${outcome.cancelled ? '已取消' : outcome.timedOut ? '超时' : `退出码 ${outcome.code ?? '未知'}`}。${String(outcome.stderr ?? '').slice(0, 150)}`)
|
|
494
|
+
}
|
|
495
|
+
const size = (await stat(outputPath)).size
|
|
496
|
+
if (size > MAX_INSTALLER_BYTES) throw new Error('官方安装脚本超过大小上限。')
|
|
497
|
+
return check(await readFile(outputPath))
|
|
498
|
+
} finally {
|
|
499
|
+
await rm(staging, { recursive: true, force: true }).catch(() => {})
|
|
388
500
|
}
|
|
389
|
-
return Buffer.concat(chunks)
|
|
390
501
|
}
|
|
391
502
|
|
|
392
503
|
const POWERSHELL = join(process.env.SystemRoot ?? 'C:\\Windows', 'System32',
|
|
@@ -402,7 +513,7 @@ export function vendorScriptInvocation(plan, scriptPath) {
|
|
|
402
513
|
args: ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...directoryArgs],
|
|
403
514
|
}
|
|
404
515
|
}
|
|
405
|
-
return { file: 'bash', args: [scriptPath, ...directoryArgs] }
|
|
516
|
+
return { file: plan.shell === 'sh' ? 'sh' : 'bash', args: [scriptPath, ...directoryArgs] }
|
|
406
517
|
}
|
|
407
518
|
|
|
408
519
|
/**
|
|
@@ -412,12 +523,23 @@ export function vendorScriptInvocation(plan, scriptPath) {
|
|
|
412
523
|
* than an older one that happened to be on PATH first.
|
|
413
524
|
*/
|
|
414
525
|
async function runVendorScriptInstallJob(job, plan) {
|
|
526
|
+
if (job.status !== 'running') return
|
|
415
527
|
let staging = null
|
|
416
528
|
try {
|
|
529
|
+
job.phase = 'preflight'
|
|
530
|
+
const before = await probeToolWith(job.tool, defaultRunner, { plan, preferences: job.preferences, cacheMs: 0 })
|
|
531
|
+
job.beforeProbe = before
|
|
532
|
+
const latestBefore = before.installed ? await latestVersions.lookup(job.tool, { fresh: true }).catch(() => null) : null
|
|
533
|
+
if (job.cancelRequested) { markCancelled(job); return }
|
|
534
|
+
if (latestBefore?.version && compareReleaseVersions(before.version, latestBefore.version) === 1) {
|
|
535
|
+
throw new Error(`已安装 ${before.version},高于最新正式版 ${latestBefore.version};不会自动降级。`)
|
|
536
|
+
}
|
|
417
537
|
job.phase = 'downloading-and-verifying'
|
|
418
538
|
for (const notice of plan.notices ?? []) pushLine(job, notice)
|
|
419
539
|
pushLine(job, `— 下载官方安装脚本:${plan.scriptUrl}`)
|
|
420
|
-
const source = verifiedVendorScript(await fetchVendorScript(plan.scriptUrl, job.controller.signal
|
|
540
|
+
const source = verifiedVendorScript(await fetchVendorScript(plan.scriptUrl, job.controller.signal, {
|
|
541
|
+
verify: plan.verify, fallbackUrls: (plan.downloadUrls ?? []).slice(1),
|
|
542
|
+
}), plan.verify)
|
|
421
543
|
staging = await mkdtemp(join(tmpdir(), '.model-router-tool-install-'))
|
|
422
544
|
const isPowerShell = plan.shell === 'powershell'
|
|
423
545
|
const scriptPath = join(staging, isPowerShell ? 'install.ps1' : 'install.sh')
|
|
@@ -430,7 +552,7 @@ async function runVendorScriptInstallJob(job, plan) {
|
|
|
430
552
|
const outcome = await runCapture(invocation.file, invocation.args, {
|
|
431
553
|
timeoutMs: INSTALL_TIMEOUT_MS,
|
|
432
554
|
signal: job.controller.signal,
|
|
433
|
-
env:
|
|
555
|
+
env: installEnvironment(plan),
|
|
434
556
|
onOutput: captureLines(job),
|
|
435
557
|
})
|
|
436
558
|
if (outcome.cancelled || job.cancelRequested) { markCancelled(job); return }
|
|
@@ -440,11 +562,10 @@ async function runVendorScriptInstallJob(job, plan) {
|
|
|
440
562
|
throw new Error(outcome.timedOut ? '官方安装脚本超时并已终止。'
|
|
441
563
|
: `官方安装脚本退出码 ${outcome.code ?? '信号终止'}。${String(outcome.stderr ?? '').split('\n').find(Boolean)?.slice(0, 200) ?? ''}`)
|
|
442
564
|
}
|
|
443
|
-
|
|
444
|
-
if (location?.dir) appendToProcessPath(location.dir)
|
|
565
|
+
if (plan.binDir) appendToProcessPath(plan.binDir, { prepend: true })
|
|
445
566
|
job.phase = 'verifying'
|
|
446
567
|
probeCache.delete(job.tool.id)
|
|
447
|
-
const probe = await probeToolWith(job.tool, defaultRunner, { cacheMs: 0 })
|
|
568
|
+
const probe = await probeToolWith(job.tool, defaultRunner, { cacheMs: 0, plan, preferences: job.preferences })
|
|
448
569
|
job.postInstallProbe = probe
|
|
449
570
|
if (!probe.installed) {
|
|
450
571
|
throw new Error(`官方安装脚本已完成,但 PATH 上仍找不到 ${job.tool.probeExecutables?.[0] ?? job.tool.label}:${probe.detail}`)
|
|
@@ -473,6 +594,15 @@ async function runVendorScriptInstallJob(job, plan) {
|
|
|
473
594
|
* the file must sit inside that tool's install directory, and it can never be a
|
|
474
595
|
* home directory or a filesystem root.
|
|
475
596
|
*/
|
|
597
|
+
export function scriptBinaryCandidate(installDir, relative) {
|
|
598
|
+
const raw = String(relative ?? '')
|
|
599
|
+
const parts = raw.split(/[\\/]/)
|
|
600
|
+
if (!raw || isAbsolute(raw) || /^[A-Za-z]:/.test(raw) || parts.some(part => !part || part === '.' || part === '..')) {
|
|
601
|
+
throw new Error('拒绝删除安装目录之外的文件。')
|
|
602
|
+
}
|
|
603
|
+
return join(installDir, ...parts)
|
|
604
|
+
}
|
|
605
|
+
|
|
476
606
|
export function checkedRemovableFile(candidate, { allowedNames, installDir, home = homedir() }) {
|
|
477
607
|
const target = resolve(candidate)
|
|
478
608
|
const base = target.split(sep).pop() ?? ''
|
|
@@ -498,12 +628,13 @@ function isInsideDirectory(parent, child) {
|
|
|
498
628
|
* reinstall keeps the user's login.
|
|
499
629
|
*/
|
|
500
630
|
async function runVendorScriptUninstallJob(job, plan) {
|
|
631
|
+
if (job.status !== 'running') return
|
|
501
632
|
try {
|
|
502
633
|
job.phase = 'removing'
|
|
503
|
-
const location =
|
|
634
|
+
const location = { dir: plan.dir, binaries: plan.binaries }
|
|
504
635
|
const removed = []
|
|
505
636
|
for (const binary of location?.binaries ?? []) {
|
|
506
|
-
const candidate =
|
|
637
|
+
const candidate = scriptBinaryCandidate(location.dir, binary)
|
|
507
638
|
// checkedRemovableFile re-checks the basename, the install directory and
|
|
508
639
|
// the root/home guards even though every part came from the registry.
|
|
509
640
|
const target = checkedRemovableFile(candidate, {
|
|
@@ -513,16 +644,16 @@ async function runVendorScriptUninstallJob(job, plan) {
|
|
|
513
644
|
await rm(target, { force: true })
|
|
514
645
|
removed.push(target)
|
|
515
646
|
}
|
|
516
|
-
const stripped = await stripPathProfileBlocks(job.tool).catch(error => `(未能清理 PATH 配置:${String(error?.message ?? error).slice(0, 120)})`)
|
|
517
647
|
if (removed.length === 0) {
|
|
518
648
|
throw new Error(`在 ${location?.dir || '安装目录'} 下没有找到 ${job.tool.label} 的可执行文件;它可能是用包管理器安装的,请改用该方式卸载。`)
|
|
519
649
|
}
|
|
650
|
+
const stripped = await stripPathProfileBlocks(job.tool).catch(error => [`(未能清理 PATH 配置:${String(error?.message ?? error).slice(0, 120)})`])
|
|
520
651
|
for (const path of removed) pushLine(job, `已删除 ${path}`)
|
|
521
652
|
if (stripped.length) pushLine(job, `已清理 shell 配置中的 PATH 记录:${stripped.join('、')}`)
|
|
522
653
|
pushLine(job, `已保留 ${job.tool.label} 的配置与登录信息(${homedir()}/${String(job.tool.defaultScriptInstallDir ?? '').split('/')[0]})。`)
|
|
523
654
|
job.phase = 'verifying'
|
|
524
655
|
probeCache.delete(job.tool.id)
|
|
525
|
-
const probe = await probeToolWith(job.tool, defaultRunner, { cacheMs: 0 })
|
|
656
|
+
const probe = await probeToolWith(job.tool, defaultRunner, { cacheMs: 0, preferences: job.preferences, plan: { ...plan, binDir: plan.dir } })
|
|
526
657
|
job.postInstallProbe = probe
|
|
527
658
|
job.status = 'succeeded'
|
|
528
659
|
job.finishedAt = new Date().toISOString()
|
|
@@ -542,23 +673,25 @@ async function runVendorScriptUninstallJob(job, plan) {
|
|
|
542
673
|
* writes. Only a well-formed `# stepcode … # stepcode end` block is removed; a
|
|
543
674
|
* bare mention is left alone, exactly as the vendor's installer decides.
|
|
544
675
|
*/
|
|
545
|
-
export function stripPathBlock(content, marker) {
|
|
676
|
+
export function stripPathBlock(content, marker, closingMarker = `${marker} end`) {
|
|
546
677
|
const text = String(content ?? '')
|
|
547
678
|
if (!text.includes(marker)) return null
|
|
548
|
-
const lines = text.
|
|
679
|
+
const lines = text.match(/[^\r\n]*(?:\r\n|\n|$)/g)?.filter(Boolean) ?? []
|
|
549
680
|
const kept = []
|
|
550
681
|
let removed = 0
|
|
551
|
-
let
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
continue
|
|
682
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
683
|
+
if (lines[index].trim() !== marker) { kept.push(lines[index]); continue }
|
|
684
|
+
let closing = index + 1
|
|
685
|
+
while (closing < lines.length && lines[closing].trim() !== closingMarker) {
|
|
686
|
+
if (lines[closing].trim() === marker) return null
|
|
687
|
+
closing += 1
|
|
558
688
|
}
|
|
559
|
-
|
|
689
|
+
// A truncated block never authorizes deleting the rest of the user's file.
|
|
690
|
+
if (closing === lines.length) return null
|
|
691
|
+
removed += closing - index + 1
|
|
692
|
+
index = closing
|
|
560
693
|
}
|
|
561
|
-
return removed > 0 ? kept.join('
|
|
694
|
+
return removed > 0 ? kept.join('') : null
|
|
562
695
|
}
|
|
563
696
|
|
|
564
697
|
const SHELL_PROFILES = ['.bashrc', '.bash_profile', '.zshrc', '.profile']
|
|
@@ -572,7 +705,11 @@ async function stripPathProfileBlocks(tool) {
|
|
|
572
705
|
let content
|
|
573
706
|
try { content = await readFile(path, 'utf8') } catch { continue }
|
|
574
707
|
let next = content
|
|
575
|
-
|
|
708
|
+
if (tool.id === 'grok-build' && markers.length === 2) {
|
|
709
|
+
next = stripPathBlock(next, markers[0], markers[1]) ?? next
|
|
710
|
+
} else {
|
|
711
|
+
for (const marker of markers) next = stripPathBlock(next, marker) ?? next
|
|
712
|
+
}
|
|
576
713
|
if (next === content) continue
|
|
577
714
|
const temporary = `${path}.model-router-${process.pid}.tmp`
|
|
578
715
|
await writeFile(temporary, next, 'utf8')
|
|
@@ -595,17 +732,37 @@ export function versionFromBanner(banner) {
|
|
|
595
732
|
|
|
596
733
|
/**
|
|
597
734
|
* Probe one registry tool. `runner` is injectable for offline tests and must
|
|
598
|
-
* return { ok, code, stdout, stderr, timedOut }.
|
|
735
|
+
* return { ok, code, stdout, stderr, timedOut }. Package-manager probes inspect
|
|
736
|
+
* their actual package manifest and declared entry; they never execute a shared
|
|
737
|
+
* npm/pnpm command shim. `packageEntries` keeps those inspections injectable.
|
|
599
738
|
*/
|
|
600
|
-
export async function probeToolWith(tool, runner, {
|
|
739
|
+
export async function probeToolWith(tool, runner, {
|
|
740
|
+
cache = probeCache, cacheMs = PROBE_CACHE_MS, now = Date.now,
|
|
741
|
+
preferences: selectedPreferences = preferences, plan,
|
|
742
|
+
fileExists = async path => (await stat(path).catch(() => null))?.isFile() === true,
|
|
743
|
+
packageEntries = selectedPackageEntries,
|
|
744
|
+
} = {}) {
|
|
745
|
+
const scope = JSON.stringify([selectedPreferences, plan?.methodId, plan?.binDir, plan?.packageModuleRoots])
|
|
601
746
|
const cached = cache.get(tool.id)
|
|
602
|
-
if (cached && now() - cached.at < cacheMs) return cached.value
|
|
603
|
-
const probe = await probeUncached(tool, runner)
|
|
604
|
-
cache.set(tool.id, { at: now(), value: probe })
|
|
747
|
+
if (cached && cached.scope === scope && now() - cached.at < cacheMs) return cached.value
|
|
748
|
+
const probe = await probeUncached(tool, runner, { selectedPreferences, plan, fileExists, packageEntries })
|
|
749
|
+
cache.set(tool.id, { at: now(), value: probe, scope })
|
|
605
750
|
return probe
|
|
606
751
|
}
|
|
607
752
|
|
|
608
|
-
async function
|
|
753
|
+
async function selectedPackageEntries(tool, { plan, preferences: selectedPreferences }) {
|
|
754
|
+
const { findGlobalPackageEntries, officialToolEntrySelection } = await import('./official-tool-executor.mjs')
|
|
755
|
+
const selection = officialToolEntrySelection(tool.id, { preferences: selectedPreferences, plan })
|
|
756
|
+
const entries = []
|
|
757
|
+
for (const executable of tool.probeExecutables) {
|
|
758
|
+
entries.push(...await findGlobalPackageEntries(tool.package, executable, '', process.cwd(), {
|
|
759
|
+
selection, directories: selection.directories, manager: plan.manager, moduleRoots: plan.packageModuleRoots,
|
|
760
|
+
}))
|
|
761
|
+
}
|
|
762
|
+
return entries
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
async function probeUncached(tool, runner, { selectedPreferences, plan, fileExists, packageEntries }) {
|
|
609
766
|
if (tool.unsupported) {
|
|
610
767
|
return { id: tool.id, installed: false, version: null, status: 'unsupported', detail: tool.unsupportedReason }
|
|
611
768
|
}
|
|
@@ -621,7 +778,60 @@ async function probeUncached(tool, runner) {
|
|
|
621
778
|
? `检测到已安装的 ZCode ${other.version ?? '(版本未知)'}${other.root ? `(${other.root})` : ''},但 ZCode.exe 未通过智谱发布者签名或 GLM 组件检查,因此不通过插件调用;可以继续直接使用 ZCode 桌面版。`
|
|
622
779
|
: '未找到具有智谱有效签名的 ZCode 桌面版。' }
|
|
623
780
|
}
|
|
624
|
-
|
|
781
|
+
let selectedPlan = plan
|
|
782
|
+
if (!selectedPlan) {
|
|
783
|
+
try {
|
|
784
|
+
selectedPlan = resolveInstallPlan(tool, undefined, {
|
|
785
|
+
...selectedPreferences,
|
|
786
|
+
allowScriptInstall: installMethodFor(tool, selectedPreferences.methods?.[tool.id])?.kind === 'script' ? true : selectedPreferences.allowScriptInstall,
|
|
787
|
+
}, currentInstallOptions())
|
|
788
|
+
} catch { /* a blocked saved method still allows normal detection */ }
|
|
789
|
+
}
|
|
790
|
+
// npm and pnpm can write the same shim into a unified bin directory. Its
|
|
791
|
+
// banner belongs to whichever manager wrote it last, not the selected copy.
|
|
792
|
+
// Bind version detection to the selected manager's package tree and declared
|
|
793
|
+
// bin. Execution readiness independently verifies whether that entry runs.
|
|
794
|
+
if (selectedPlan?.kind === 'package-manager' && tool.package) {
|
|
795
|
+
const entries = await packageEntries(tool, { plan: selectedPlan, preferences: selectedPreferences })
|
|
796
|
+
const selected = [...entries].sort((left, right) => compareReleaseVersions(right.version, left.version) ?? 0)[0]
|
|
797
|
+
const location = { installDir: selectedPlan.installDir, binDir: selectedPlan.binDir,
|
|
798
|
+
methodId: selectedPlan.methodId, manager: selectedPlan.manager }
|
|
799
|
+
if (selected) return { id: tool.id, installed: true, version: selected.version ?? null,
|
|
800
|
+
status: 'installed', ...location, packageRoot: selected.packageRoot,
|
|
801
|
+
packageEntry: selected.entry, source: selected.source,
|
|
802
|
+
detail: `${tool.probeNote ?? ''} ${selectedPlan.manager} 安装包:${selected.packageRoot}`,
|
|
803
|
+
bannerLine: `${tool.label} ${selected.version ?? '(版本未知)'}` }
|
|
804
|
+
return { id: tool.id, installed: false, version: null, status: 'not-installed', ...location,
|
|
805
|
+
detail: `未在所选${selectedPlan.binDir ? `安装目录 ${selectedPlan.binDir}` : '全局安装目录'}中找到 ${selectedPlan.manager} 安装的 ${tool.package} 及其声明的命令入口。` }
|
|
806
|
+
}
|
|
807
|
+
// Native script locations are also concrete targets. A different PATH copy
|
|
808
|
+
// must not satisfy installation or conceal an unsuccessful update.
|
|
809
|
+
if (selectedPlan?.binDir) {
|
|
810
|
+
for (const executable of tool.probeExecutables) {
|
|
811
|
+
const names = IS_WINDOWS
|
|
812
|
+
? selectedPlan.kind === 'package-manager'
|
|
813
|
+
? [`${executable}.cmd`, executable, `${executable}.exe`]
|
|
814
|
+
: [`${executable}.exe`, `${executable}.cmd`, executable]
|
|
815
|
+
: [executable]
|
|
816
|
+
for (const name of names) {
|
|
817
|
+
const target = join(selectedPlan.binDir, name)
|
|
818
|
+
if (!(await fileExists(target))) continue
|
|
819
|
+
const attempt = await runner(target, ['--version'], { timeoutMs: PROBE_TIMEOUT_MS, useShell: IS_WINDOWS && /\.(cmd|bat)$/i.test(target) })
|
|
820
|
+
const banner = probeBanner(attempt)
|
|
821
|
+
const version = versionFromBanner(banner)
|
|
822
|
+
return { id: tool.id, installed: Boolean(attempt.ok || version), version,
|
|
823
|
+
status: attempt.timedOut ? 'probe-timeout' : attempt.ok || version ? 'installed' : 'probe-failed',
|
|
824
|
+
detail: attempt.ok || version ? `${tool.probeNote ?? ''} 安装目录:${selectedPlan.binDir}`
|
|
825
|
+
: `${target} --version ${attempt.timedOut ? '超时' : `退出码 ${attempt.code ?? '信号终止'}`}。${String(attempt.stderr ?? '').slice(0, 120)}`,
|
|
826
|
+
executablePath: target, installDir: selectedPlan.installDir, binDir: selectedPlan.binDir,
|
|
827
|
+
bannerLine: banner.split('\n').find(Boolean)?.slice(0, 200) ?? '' }
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
return { id: tool.id, installed: false, version: null, status: 'not-installed',
|
|
831
|
+
installDir: selectedPlan.installDir, binDir: selectedPlan.binDir,
|
|
832
|
+
detail: `未在所选安装目录 ${selectedPlan.binDir} 中找到官方命令。` }
|
|
833
|
+
}
|
|
834
|
+
if (tool.id === 'minimax-code' && IS_WINDOWS && !usesConfiguredInstallPlan(tool.id, selectedPreferences)) {
|
|
625
835
|
// The official Windows installer keeps mcode under a versioned releases
|
|
626
836
|
// directory. A partial npm install can leave a broken shim earlier on PATH;
|
|
627
837
|
// prefer the checksum-verified installer release in that case.
|
|
@@ -629,6 +839,7 @@ async function probeUncached(tool, runner) {
|
|
|
629
839
|
const managed = await findManagedMiniMaxEntry(process.cwd())
|
|
630
840
|
if (managed) return { id: tool.id, installed: true, version: managed.version,
|
|
631
841
|
status: 'installed', detail: '已按官方 npm registry 摘要核验 MiniMax 官方 Windows 安装器中的 CLI 文件。',
|
|
842
|
+
installDir: managed.installDir, binDir: managed.installDir,
|
|
632
843
|
bannerLine: `MiniMax Code ${managed.version}` }
|
|
633
844
|
}
|
|
634
845
|
for (const executable of tool.probeExecutables) {
|
|
@@ -645,12 +856,12 @@ async function probeUncached(tool, runner) {
|
|
|
645
856
|
if (attempt.timedOut) {
|
|
646
857
|
return { id: tool.id, installed: false, version: null, status: 'probe-timeout', detail: `${executable} --version 超时(${PROBE_TIMEOUT_MS / 1000}s)` }
|
|
647
858
|
}
|
|
648
|
-
const attempts = [attempt]
|
|
859
|
+
const attempts = [{ ...attempt, executablePath: locatorPaths(located.stdout)[0] }]
|
|
649
860
|
if (!versionFromBanner(probeBanner(attempt))) {
|
|
650
861
|
for (const target of locatorPaths(located.stdout)) {
|
|
651
862
|
if (target === executable) continue
|
|
652
863
|
const direct = await runner(target, ['--version'], { timeoutMs: PROBE_TIMEOUT_MS, useShell: false })
|
|
653
|
-
attempts.push(direct)
|
|
864
|
+
attempts.push({ ...direct, executablePath: target })
|
|
654
865
|
if (direct.timedOut) break
|
|
655
866
|
if (direct.ok || versionFromBanner(probeBanner(direct))) break
|
|
656
867
|
}
|
|
@@ -665,6 +876,7 @@ async function probeUncached(tool, runner) {
|
|
|
665
876
|
version,
|
|
666
877
|
status: 'installed',
|
|
667
878
|
detail: tool.probeNote ?? '',
|
|
879
|
+
executablePath: chosen.executablePath,
|
|
668
880
|
bannerLine: banner.split('\n').find(Boolean)?.slice(0, 200) ?? '',
|
|
669
881
|
}
|
|
670
882
|
}
|
|
@@ -686,15 +898,16 @@ export const defaultRunner = (executable, args, options = {}) =>
|
|
|
686
898
|
let prefixPromise = null
|
|
687
899
|
|
|
688
900
|
/** Add one directory to this process's PATH unless it is already there. */
|
|
689
|
-
function appendToProcessPath(directory) {
|
|
901
|
+
export function appendToProcessPath(directory, { prepend = false, env = process.env, platform = process.platform } = {}) {
|
|
690
902
|
const target = String(directory ?? '').trim()
|
|
691
903
|
if (!target || !isAbsolute(target)) return
|
|
692
|
-
const separator =
|
|
693
|
-
const
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
904
|
+
const separator = platform === 'win32' ? ';' : ':'
|
|
905
|
+
const key = Object.keys(env).find(name => name.toUpperCase() === 'PATH') ?? 'PATH'
|
|
906
|
+
const parts = String(env[key] ?? '').split(separator).filter(Boolean)
|
|
907
|
+
const equal = entry => platform === 'win32' ? entry.toLowerCase() === target.toLowerCase() : entry === target
|
|
908
|
+
if (!prepend && parts.some(equal)) return
|
|
909
|
+
const remaining = parts.filter(entry => !equal(entry))
|
|
910
|
+
env[key] = (prepend ? [target, ...remaining] : [...remaining, target]).join(separator)
|
|
698
911
|
}
|
|
699
912
|
|
|
700
913
|
/**
|
|
@@ -713,9 +926,9 @@ export function ensureNpmPrefixOnPath() {
|
|
|
713
926
|
timeoutMs: 5_000,
|
|
714
927
|
useShell: IS_WINDOWS,
|
|
715
928
|
})
|
|
716
|
-
if (!outcome.ok)
|
|
929
|
+
if (!outcome.ok) throw new Error('npm prefix is unavailable')
|
|
717
930
|
let prefix = outcome.stdout.trim()
|
|
718
|
-
if (!prefix)
|
|
931
|
+
if (!prefix) throw new Error('npm prefix is empty')
|
|
719
932
|
if (prefix.startsWith('\\\\?\\')) prefix = prefix.slice(4)
|
|
720
933
|
if (IS_WINDOWS && !process.env.GROK_HOME && /^[D-Z]:[\\/]/i.test(prefix)) {
|
|
721
934
|
// Grok's npm bootstrap otherwise expands its native binary under
|
|
@@ -723,11 +936,15 @@ export function ensureNpmPrefixOnPath() {
|
|
|
723
936
|
// the same non-C drive as this user's npm global prefix.
|
|
724
937
|
process.env.GROK_HOME = join(prefix, '.model-router-grok')
|
|
725
938
|
}
|
|
726
|
-
appendToProcessPath(prefix)
|
|
939
|
+
appendToProcessPath(IS_WINDOWS ? prefix : join(prefix, 'bin'))
|
|
727
940
|
} catch { /* environment stays untouched */ }
|
|
728
941
|
// A --prefix install lands outside the npm prefix, so it needs its own
|
|
729
942
|
// PATH entry or every later probe reports the tool as missing.
|
|
730
|
-
|
|
943
|
+
for (const tool of OFFICIAL_TOOLS) {
|
|
944
|
+
for (const directory of preferredToolDirectories(tool.id)) {
|
|
945
|
+
try { appendToProcessPath(directory, { prepend: Boolean(preferences.installDir || preferences.methods[tool.id]) }) } catch { /* environment stays untouched */ }
|
|
946
|
+
}
|
|
947
|
+
}
|
|
731
948
|
})()
|
|
732
949
|
}
|
|
733
950
|
return prefixPromise
|
|
@@ -803,8 +1020,46 @@ function pushLine(job, line) {
|
|
|
803
1020
|
job.outputTail.push(line)
|
|
804
1021
|
}
|
|
805
1022
|
|
|
1023
|
+
/** Bind a mutating package-manager job to the directory its command will write. */
|
|
1024
|
+
export async function resolvePackageJobPlan(tool, plan, selectedPreferences, {
|
|
1025
|
+
operation = 'install', runner = defaultRunner, options = currentInstallOptions(), signal,
|
|
1026
|
+
} = {}) {
|
|
1027
|
+
if (plan?.kind !== 'package-manager' || plan.installDir) return plan
|
|
1028
|
+
const readDirectory = async args => {
|
|
1029
|
+
const outcome = await runner(plan.manager, args, {
|
|
1030
|
+
timeoutMs: PROBE_TIMEOUT_MS, useShell: options.platform === 'win32', signal,
|
|
1031
|
+
})
|
|
1032
|
+
if (!outcome.ok || outcome.timedOut || outcome.cancelled) throw new Error('query failed')
|
|
1033
|
+
const raw = String(outcome.stdout ?? '').trim().replace(/^\\\\\?\\/, '')
|
|
1034
|
+
const directory = expandInstallDir(raw, options)
|
|
1035
|
+
if (!directory || /[\r\n]/.test(directory)) throw new Error('invalid directory')
|
|
1036
|
+
return directory.replace(/[\\/]+$/, '')
|
|
1037
|
+
}
|
|
1038
|
+
if (plan.manager === 'pnpm') {
|
|
1039
|
+
try {
|
|
1040
|
+
const [binDir, moduleRoot] = await Promise.all([readDirectory(['bin', '-g']), readDirectory(['root', '-g'])])
|
|
1041
|
+
const layout = /^(.*)[\\/]\d+[\\/]node_modules$/.exec(moduleRoot)
|
|
1042
|
+
if (!layout || !layout[1]) throw new Error('unsupported global layout')
|
|
1043
|
+
const globalDir = expandInstallDir(layout[1], options)
|
|
1044
|
+
return { ...plan, binDir, installDir: '', globalDir, packageModuleRoots: [moduleRoot],
|
|
1045
|
+
args: [...plan.args, `--config.global-dir=${globalDir}`, `--config.global-bin-dir=${binDir}`] }
|
|
1046
|
+
} catch {
|
|
1047
|
+
throw new Error('无法安全绑定 pnpm 默认全局包目录与命令目录,已停止操作;请检查 pnpm 或在官方工具设置中配置统一安装目录。')
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
if (plan.manager !== 'npm') throw new Error('此包管理器的默认安装目录无法安全绑定,请配置统一安装目录。')
|
|
1051
|
+
let prefix
|
|
1052
|
+
try { prefix = await readDirectory(['config', 'get', 'prefix']) } catch {
|
|
1053
|
+
throw new Error('无法读取有效的 npm 默认全局安装目录,已停止操作;请检查 npm 或配置统一安装目录。')
|
|
1054
|
+
}
|
|
1055
|
+
const targetPreferences = { ...selectedPreferences, installDir: prefix }
|
|
1056
|
+
return operation === 'uninstall'
|
|
1057
|
+
? resolveUninstallPlan(tool, plan.methodId, targetPreferences, options)
|
|
1058
|
+
: resolveInstallPlan(tool, plan.methodId, targetPreferences, options)
|
|
1059
|
+
}
|
|
1060
|
+
|
|
806
1061
|
/** Reserve the single per-tool job slot and serialise against every other tool. */
|
|
807
|
-
function beginJob(tool, { operation, methodId, command, installDir = '', run }) {
|
|
1062
|
+
function beginJob(tool, { operation, methodId, command, installDir = '', plan, configured = false, run }) {
|
|
808
1063
|
const existing = installJobs.get(tool.id)
|
|
809
1064
|
if (existing?.status === 'running') throw new Error(`${tool.label} 已有${operation === 'uninstall' ? '卸载' : '安装'}任务正在执行。`)
|
|
810
1065
|
const job = {
|
|
@@ -813,6 +1068,9 @@ function beginJob(tool, { operation, methodId, command, installDir = '', run })
|
|
|
813
1068
|
methodId,
|
|
814
1069
|
command,
|
|
815
1070
|
installDir,
|
|
1071
|
+
plan,
|
|
1072
|
+
preferences: installPreferences(),
|
|
1073
|
+
configured: configured || plan?.kind === 'package-manager',
|
|
816
1074
|
status: 'running',
|
|
817
1075
|
phase: 'queued',
|
|
818
1076
|
cancelRequested: false,
|
|
@@ -828,8 +1086,28 @@ function beginJob(tool, { operation, methodId, command, installDir = '', run })
|
|
|
828
1086
|
}
|
|
829
1087
|
installJobs.set(tool.id, job)
|
|
830
1088
|
probeCache.delete(tool.id)
|
|
831
|
-
installChain = installChain.then(
|
|
1089
|
+
installChain = installChain.then(async () => {
|
|
832
1090
|
if (job.status !== 'running') return
|
|
1091
|
+
if (job.plan?.kind === 'package-manager') {
|
|
1092
|
+
job.phase = 'preflight'
|
|
1093
|
+
const target = await resolvePackageJobPlan(job.tool, job.plan, job.preferences, {
|
|
1094
|
+
operation: job.operation, signal: job.controller.signal,
|
|
1095
|
+
})
|
|
1096
|
+
if (job.cancelRequested) { markCancelled(job); return }
|
|
1097
|
+
job.plan = target
|
|
1098
|
+
job.installDir = target.installDir || target.globalDir || target.binDir
|
|
1099
|
+
const resolvedCommand = planCommandLine(target, job.operation)
|
|
1100
|
+
const command = job.operation === 'repair' ? `修复:${resolvedCommand}` : resolvedCommand
|
|
1101
|
+
if (job.command !== command) {
|
|
1102
|
+
job.command = command
|
|
1103
|
+
pushLine(job, `— 本次操作目录:${job.installDir} —`)
|
|
1104
|
+
pushLine(job, `$ ${job.command}`)
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
return run(job)
|
|
1108
|
+
}).catch(error => {
|
|
1109
|
+
if (job.status !== 'running') return
|
|
1110
|
+
if (job.cancelRequested || job.controller.signal.aborted) { markCancelled(job); return }
|
|
833
1111
|
job.status = 'failed'
|
|
834
1112
|
job.finishedAt = new Date().toISOString()
|
|
835
1113
|
job.error = `${operation === 'uninstall' ? '卸载' : '安装'}流程失败:${String(error?.message ?? error).slice(0, 200)}`
|
|
@@ -840,12 +1118,19 @@ function beginJob(tool, { operation, methodId, command, installDir = '', run })
|
|
|
840
1118
|
/** One-line summary of a resolved plan, for the job log and the UI. */
|
|
841
1119
|
export function planCommandLine(plan, operation = 'install') {
|
|
842
1120
|
if (plan.kind === 'script') {
|
|
843
|
-
const
|
|
1121
|
+
const quote = value => `'${String(value).replace(/'/g, plan.shell === 'powershell' ? "''" : "'\\''")}'`
|
|
1122
|
+
const environment = Object.entries(plan.env ?? {}).map(([name, value]) => plan.shell === 'powershell'
|
|
1123
|
+
? `$env:${name}=${quote(value)}; ` : `${name}=${quote(value)} `).join('')
|
|
1124
|
+
const directoryArgs = plan.installDir && plan.installArg?.length ? ` ${plan.installArg.join(' ')} ${quote(plan.installDir)}` : ''
|
|
1125
|
+
const piped = plan.shell === 'powershell'
|
|
1126
|
+
? `${environment}irm ${plan.scriptUrl} | iex`
|
|
1127
|
+
: `curl -fsSL ${plan.scriptUrl} | ${environment}${plan.shell === 'sh' ? 'sh' : 'bash'}${directoryArgs ? ` -s --${directoryArgs}` : ''}`
|
|
844
1128
|
return operation === 'uninstall'
|
|
845
1129
|
? `删除 ${plan.dir} 下的 ${(plan.binaries ?? []).join('、') || '可执行文件'}`
|
|
846
|
-
:
|
|
1130
|
+
: plan.shell === 'powershell' && directoryArgs
|
|
1131
|
+
? `${environment}& ([scriptblock]::Create((irm ${plan.scriptUrl})))${directoryArgs}` : piped
|
|
847
1132
|
}
|
|
848
|
-
return `${plan.manager} ${plan.args.join(' ')}`
|
|
1133
|
+
return `${plan.manager} ${plan.args.map(arg => /\s/.test(arg) ? JSON.stringify(arg) : arg).join(' ')}`
|
|
849
1134
|
}
|
|
850
1135
|
|
|
851
1136
|
/**
|
|
@@ -869,9 +1154,11 @@ export function startInstall(toolId, { method } = {}) {
|
|
|
869
1154
|
methodId: plan.methodId,
|
|
870
1155
|
command: planCommandLine(plan),
|
|
871
1156
|
installDir: plan.installDir ?? '',
|
|
1157
|
+
plan,
|
|
1158
|
+
configured: usesConfiguredInstallPlan(tool.id, preferences, method),
|
|
872
1159
|
run: job => (plan.kind === 'script'
|
|
873
1160
|
? runVendorScriptInstallJob(job, plan)
|
|
874
|
-
: runInstallJob(
|
|
1161
|
+
: runInstallJob(job.plan, job)),
|
|
875
1162
|
})
|
|
876
1163
|
}
|
|
877
1164
|
|
|
@@ -891,10 +1178,12 @@ export function startRepair(toolId, { method } = {}) {
|
|
|
891
1178
|
methodId: plan.methodId,
|
|
892
1179
|
command: `修复:${planCommandLine(plan)}`,
|
|
893
1180
|
installDir: plan.installDir ?? '',
|
|
1181
|
+
plan,
|
|
1182
|
+
configured: usesConfiguredInstallPlan(tool.id, preferences, method),
|
|
894
1183
|
run: async job => {
|
|
895
1184
|
job.repair = true
|
|
896
1185
|
if (plan.kind === 'script') return runVendorScriptInstallJob(job, plan)
|
|
897
|
-
return runInstallJob(
|
|
1186
|
+
return runInstallJob(job.plan, job)
|
|
898
1187
|
},
|
|
899
1188
|
})
|
|
900
1189
|
}
|
|
@@ -912,17 +1201,20 @@ export function startUninstall(toolId, { method } = {}) {
|
|
|
912
1201
|
methodId: plan.methodId,
|
|
913
1202
|
command: planCommandLine(plan, 'uninstall'),
|
|
914
1203
|
installDir: plan.dir ?? plan.installDir ?? '',
|
|
1204
|
+
plan,
|
|
1205
|
+
configured: usesConfiguredInstallPlan(tool.id, preferences, method),
|
|
915
1206
|
run: job => (plan.kind === 'script'
|
|
916
1207
|
? runVendorScriptUninstallJob(job, plan)
|
|
917
|
-
: runPackageManagerUninstallJob(job, plan)),
|
|
1208
|
+
: runPackageManagerUninstallJob(job, job.plan)),
|
|
918
1209
|
})
|
|
919
1210
|
}
|
|
920
1211
|
|
|
921
1212
|
async function runPackageManagerUninstallJob(job, plan) {
|
|
1213
|
+
if (job.status !== 'running') return
|
|
922
1214
|
try {
|
|
923
1215
|
job.phase = 'preflight'
|
|
924
1216
|
await ensureNpmPrefixOnPath()
|
|
925
|
-
const before = await probeToolWith(job.tool, defaultRunner)
|
|
1217
|
+
const before = await probeToolWith(job.tool, defaultRunner, { plan, preferences: job.preferences, cacheMs: 0 })
|
|
926
1218
|
if (job.cancelRequested) { markCancelled(job); return }
|
|
927
1219
|
if (!before.installed) {
|
|
928
1220
|
job.status = 'succeeded'
|
|
@@ -937,6 +1229,7 @@ async function runPackageManagerUninstallJob(job, plan) {
|
|
|
937
1229
|
useShell: IS_WINDOWS,
|
|
938
1230
|
signal: job.controller.signal,
|
|
939
1231
|
onOutput: captureLines(job),
|
|
1232
|
+
env: installEnvironment(plan),
|
|
940
1233
|
})
|
|
941
1234
|
if (outcome.cancelled || job.cancelRequested) { markCancelled(job); return }
|
|
942
1235
|
job.exitCode = outcome.code
|
|
@@ -947,7 +1240,7 @@ async function runPackageManagerUninstallJob(job, plan) {
|
|
|
947
1240
|
}
|
|
948
1241
|
job.phase = 'verifying'
|
|
949
1242
|
probeCache.delete(job.tool.id)
|
|
950
|
-
const probe = await probeToolWith(job.tool, defaultRunner, { cacheMs: 0 })
|
|
1243
|
+
const probe = await probeToolWith(job.tool, defaultRunner, { cacheMs: 0, preferences: job.preferences, plan })
|
|
951
1244
|
job.postInstallProbe = probe
|
|
952
1245
|
job.status = 'succeeded'
|
|
953
1246
|
job.finishedAt = new Date().toISOString()
|
|
@@ -972,6 +1265,13 @@ function captureLines(job) {
|
|
|
972
1265
|
}
|
|
973
1266
|
}
|
|
974
1267
|
|
|
1268
|
+
/** pnpm requires its global bin directory on PATH even during installation. */
|
|
1269
|
+
export function installEnvironment(plan, base = process.env) {
|
|
1270
|
+
const env = { ...base, ...(plan.env ?? {}) }
|
|
1271
|
+
if (plan.binDir) appendToProcessPath(plan.binDir, { prepend: true, env })
|
|
1272
|
+
return env
|
|
1273
|
+
}
|
|
1274
|
+
|
|
975
1275
|
/** Mark the job succeeded only when the fresh probe is installed, ready, and not still older. */
|
|
976
1276
|
async function finishVerifiedInstall(job, probe) {
|
|
977
1277
|
job.postInstallProbe = probe
|
|
@@ -987,10 +1287,10 @@ async function finishVerifiedInstall(job, probe) {
|
|
|
987
1287
|
failBecauseStillOlder(job, probe, latest.version)
|
|
988
1288
|
return true
|
|
989
1289
|
}
|
|
990
|
-
const readiness = await trustedExecutionReadiness(job.tool.id)
|
|
1290
|
+
const readiness = await trustedExecutionReadiness(job.tool.id, { preferences: job.preferences, plan: job.plan })
|
|
991
1291
|
if (job.cancelRequested) { markCancelled(job); return true }
|
|
992
1292
|
if (!readiness.ready) {
|
|
993
|
-
if (IS_WINDOWS && job.tool.id === 'minimax-code') {
|
|
1293
|
+
if (IS_WINDOWS && job.tool.id === 'minimax-code' && !job.configured) {
|
|
994
1294
|
await runOfficialMiniMaxInstaller(job)
|
|
995
1295
|
return true
|
|
996
1296
|
}
|
|
@@ -1020,7 +1320,7 @@ async function tryClaudeUpdate(job, latestVersion) {
|
|
|
1020
1320
|
if (job.cancelRequested) { markCancelled(job); return true }
|
|
1021
1321
|
pushLine(job, '$ claude update')
|
|
1022
1322
|
job.phase = 'installing'
|
|
1023
|
-
const outcome = await runCapture('claude', CLAUDE_UPDATE_ARGS, {
|
|
1323
|
+
const outcome = await runCapture(job.beforeProbe?.executablePath || 'claude', CLAUDE_UPDATE_ARGS, {
|
|
1024
1324
|
timeoutMs: CLAUDE_UPDATE_TIMEOUT_MS,
|
|
1025
1325
|
useShell: IS_WINDOWS,
|
|
1026
1326
|
signal: job.controller.signal,
|
|
@@ -1034,7 +1334,10 @@ async function tryClaudeUpdate(job, latestVersion) {
|
|
|
1034
1334
|
}
|
|
1035
1335
|
await ensureNpmPrefixOnPath()
|
|
1036
1336
|
probeCache.delete(job.tool.id)
|
|
1037
|
-
const
|
|
1337
|
+
const nativePlan = job.beforeProbe?.executablePath
|
|
1338
|
+
? { ...job.plan, binDir: dirname(job.beforeProbe.executablePath), installDir: dirname(job.beforeProbe.executablePath) }
|
|
1339
|
+
: job.plan
|
|
1340
|
+
const probe = await probeToolWith(job.tool, defaultRunner, { preferences: job.preferences, plan: nativePlan, cacheMs: 0 })
|
|
1038
1341
|
if (job.cancelRequested) { markCancelled(job); return true }
|
|
1039
1342
|
if (latestVersion && versionStillOlder(probe.version, latestVersion)) {
|
|
1040
1343
|
job.postInstallProbe = probe
|
|
@@ -1044,13 +1347,15 @@ async function tryClaudeUpdate(job, latestVersion) {
|
|
|
1044
1347
|
return finishVerifiedInstall(job, probe)
|
|
1045
1348
|
}
|
|
1046
1349
|
|
|
1047
|
-
async function runInstallJob(
|
|
1350
|
+
async function runInstallJob(installPlan, job) {
|
|
1351
|
+
const { manager, args } = installPlan
|
|
1048
1352
|
if (job.status !== 'running') return
|
|
1049
1353
|
job.phase = 'preflight'
|
|
1050
1354
|
await ensureNpmPrefixOnPath()
|
|
1051
1355
|
if (job.cancelRequested) { markCancelled(job); return }
|
|
1052
1356
|
probeCache.delete(job.tool.id)
|
|
1053
|
-
const before = await probeToolWith(job.tool, defaultRunner)
|
|
1357
|
+
const before = await probeToolWith(job.tool, defaultRunner, { plan: installPlan, preferences: job.preferences, cacheMs: 0 })
|
|
1358
|
+
job.beforeProbe = before
|
|
1054
1359
|
if (job.cancelRequested) { markCancelled(job); return }
|
|
1055
1360
|
let latest = null
|
|
1056
1361
|
let order = null
|
|
@@ -1066,7 +1371,7 @@ async function runInstallJob(manager, args, job) {
|
|
|
1066
1371
|
return
|
|
1067
1372
|
}
|
|
1068
1373
|
if (order === 0) {
|
|
1069
|
-
const readiness = await trustedExecutionReadiness(job.tool.id)
|
|
1374
|
+
const readiness = await trustedExecutionReadiness(job.tool.id, { preferences: job.preferences, plan: job.plan })
|
|
1070
1375
|
if (job.cancelRequested) { markCancelled(job); return }
|
|
1071
1376
|
// A repair re-runs the same command even when nothing looks stale: a
|
|
1072
1377
|
// broken install reports the right version, so "already current" is
|
|
@@ -1089,6 +1394,7 @@ async function runInstallJob(manager, args, job) {
|
|
|
1089
1394
|
installed: before.installed === true,
|
|
1090
1395
|
order,
|
|
1091
1396
|
platform: process.platform,
|
|
1397
|
+
configured: job.configured,
|
|
1092
1398
|
})
|
|
1093
1399
|
let triedClaudeUpdate = false
|
|
1094
1400
|
if (plan === 'claude-update-then-registry') {
|
|
@@ -1101,12 +1407,14 @@ async function runInstallJob(manager, args, job) {
|
|
|
1101
1407
|
})
|
|
1102
1408
|
return
|
|
1103
1409
|
}
|
|
1410
|
+
if (installPlan.binDir) await mkdir(installPlan.binDir, { recursive: true })
|
|
1104
1411
|
job.phase = 'installing'
|
|
1105
1412
|
const outcome = await runCapture(manager, args, {
|
|
1106
1413
|
timeoutMs: INSTALL_TIMEOUT_MS,
|
|
1107
1414
|
useShell: IS_WINDOWS,
|
|
1108
1415
|
signal: job.controller.signal,
|
|
1109
1416
|
onOutput: captureLines(job),
|
|
1417
|
+
env: installEnvironment(installPlan),
|
|
1110
1418
|
})
|
|
1111
1419
|
if (job.status !== 'running') return
|
|
1112
1420
|
if (outcome.cancelled || job.cancelRequested) { markCancelled(job); return }
|
|
@@ -1119,7 +1427,7 @@ async function runInstallJob(manager, args, job) {
|
|
|
1119
1427
|
return
|
|
1120
1428
|
}
|
|
1121
1429
|
if (!outcome.ok) {
|
|
1122
|
-
if (IS_WINDOWS && job.tool.id === 'minimax-code') {
|
|
1430
|
+
if (IS_WINDOWS && job.tool.id === 'minimax-code' && !job.configured) {
|
|
1123
1431
|
await runOfficialMiniMaxInstaller(job)
|
|
1124
1432
|
return
|
|
1125
1433
|
}
|
|
@@ -1131,19 +1439,19 @@ async function runInstallJob(manager, args, job) {
|
|
|
1131
1439
|
job.phase = 'verifying'
|
|
1132
1440
|
await ensureNpmPrefixOnPath()
|
|
1133
1441
|
probeCache.delete(job.tool.id)
|
|
1134
|
-
const probe = await probeToolWith(job.tool, defaultRunner)
|
|
1442
|
+
const probe = await probeToolWith(job.tool, defaultRunner, { plan: installPlan, preferences: job.preferences, cacheMs: 0 })
|
|
1135
1443
|
if (job.cancelRequested) { markCancelled(job); return }
|
|
1136
1444
|
job.postInstallProbe = probe
|
|
1137
1445
|
const latestAfter = (await latestVersions.lookup(job.tool).catch(() => null)) ?? latest
|
|
1138
1446
|
const stillOlder = Boolean(latestAfter?.version && versionStillOlder(probe.version, latestAfter.version))
|
|
1139
|
-
if (stillOlder && job.tool.id === 'claude-code' && !triedClaudeUpdate) {
|
|
1447
|
+
if (stillOlder && job.tool.id === 'claude-code' && !triedClaudeUpdate && !job.configured) {
|
|
1140
1448
|
triedClaudeUpdate = true
|
|
1141
1449
|
const finished = await tryClaudeUpdate(job, latestAfter.version)
|
|
1142
1450
|
if (finished || job.status !== 'running') return
|
|
1143
1451
|
failBecauseStillOlder(job, job.postInstallProbe ?? probe, latestAfter.version)
|
|
1144
1452
|
return
|
|
1145
1453
|
}
|
|
1146
|
-
if (stillOlder && IS_WINDOWS && job.tool.id === 'minimax-code') {
|
|
1454
|
+
if (stillOlder && IS_WINDOWS && job.tool.id === 'minimax-code' && !job.configured) {
|
|
1147
1455
|
pushLine(job, `— npm 完成后 ${job.tool.label} 仍是 ${probe.version},低于最新 ${latestAfter.version};改用官方安装脚本 —`)
|
|
1148
1456
|
await runOfficialMiniMaxInstaller(job)
|
|
1149
1457
|
return
|