@ljwei-stak/dsh-model-router 0.17.1 → 0.18.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 +390 -187
- package/.dsh-plugin/index.mjs +29 -7
- package/.dsh-plugin/official-tools-remote-service.mjs +61 -11
- package/.dsh-plugin/shared/official-tool-executor.mjs +135 -47
- package/.dsh-plugin/shared/official-tool-registry.mjs +113 -150
- package/.dsh-plugin/shared/official-tools-remote.mjs +1 -0
- package/.dsh-plugin/shared/official-tools-runtime.mjs +420 -223
- package/.dsh-plugin/shared/tool-install-preferences.mjs +77 -73
- package/.dsh-plugin/shared/vendor-mimo-grok-adapter.mjs +41 -16
- package/CHANGELOG.md +20 -1
- package/README.md +19 -8
- package/README.zh.md +19 -8
- package/docs/INSTALLATION_GUIDE.zh.md +14 -14
- package/docs/WORKBENCH_USER_GUIDE.zh.md +27 -5
- package/package.json +2 -2
|
@@ -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,7 +24,7 @@ import {
|
|
|
23
24
|
OFFICIAL_TOOLS,
|
|
24
25
|
} from './official-tool-registry.mjs'
|
|
25
26
|
import {
|
|
26
|
-
|
|
27
|
+
expandInstallDir,
|
|
27
28
|
resolveInstallLocation,
|
|
28
29
|
resolveInstallPlan,
|
|
29
30
|
resolveUninstallPlan,
|
|
@@ -40,9 +41,7 @@ const OUTPUT_LINE_CAP = 120
|
|
|
40
41
|
const IS_WINDOWS = process.platform === 'win32'
|
|
41
42
|
const CREATE_NO_WINDOW = 0x0800_0000
|
|
42
43
|
const MINIMAX_INSTALLER_URL = 'https://filecdn.minimax.chat/public/install.ps1'
|
|
43
|
-
|
|
44
|
-
// rejects an HTML error page or a substituted archive.
|
|
45
|
-
const MAX_INSTALLER_BYTES = 1_048_576
|
|
44
|
+
const MAX_INSTALLER_BYTES = 200_000
|
|
46
45
|
/** Windows ships where.exe in System32; POSIX which is ubiquitous. */
|
|
47
46
|
const LOCATOR = IS_WINDOWS ? 'where' : 'which'
|
|
48
47
|
/** Cached probe results keep plan generation fast; installs invalidate them. */
|
|
@@ -57,28 +56,56 @@ export const CLAUDE_UPDATE_ARGS = Object.freeze(['update'])
|
|
|
57
56
|
|
|
58
57
|
/**
|
|
59
58
|
* Host-side install preferences (install directory, npm source, per-tool method
|
|
60
|
-
* and script source). Refreshed
|
|
59
|
+
* and script source). Refreshed on apply and live config updates, so a client
|
|
61
60
|
* can only ever pick a method id — these addresses never arrive over the wire.
|
|
62
61
|
*/
|
|
63
62
|
let preferences = { installDir: '', registry: '', methods: {}, scriptUrls: {}, allowScriptInstall: true }
|
|
64
63
|
|
|
65
64
|
export function setInstallPreferences(next) {
|
|
66
65
|
if (next && typeof next === 'object') {
|
|
67
|
-
const
|
|
68
|
-
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
|
+
}
|
|
69
71
|
// A new directory needs its own PATH entry before the next probe runs.
|
|
70
|
-
if (
|
|
72
|
+
if (previous !== JSON.stringify(preferences)) {
|
|
73
|
+
prefixPromise = null
|
|
74
|
+
probeCache.clear()
|
|
75
|
+
}
|
|
71
76
|
}
|
|
72
77
|
return preferences
|
|
73
78
|
}
|
|
74
79
|
|
|
75
80
|
export function installPreferences() {
|
|
76
|
-
return { ...preferences }
|
|
81
|
+
return { ...preferences, methods: { ...preferences.methods }, scriptUrls: { ...preferences.scriptUrls } }
|
|
77
82
|
}
|
|
78
83
|
|
|
79
84
|
/** The machine this Host runs on; the plan resolver needs both to be explicit. */
|
|
80
85
|
export function currentInstallOptions() {
|
|
81
|
-
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))
|
|
82
109
|
}
|
|
83
110
|
|
|
84
111
|
/**
|
|
@@ -89,10 +116,20 @@ export function currentInstallOptions() {
|
|
|
89
116
|
* cmd strip the wrong quotes and launch `node` against a missing script:
|
|
90
117
|
* `MODULE_NOT_FOUND` at `executeUserEntryPoint` with an empty requireStack.
|
|
91
118
|
* That failed `npm config get prefix`, `--version`, and `npm install -g` for
|
|
92
|
-
* 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.
|
|
93
121
|
*/
|
|
94
122
|
export function shellSpawnSpec(executable, args = [], { useShell = false } = {}) {
|
|
95
|
-
|
|
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
|
+
}
|
|
96
133
|
}
|
|
97
134
|
|
|
98
135
|
/** Decode a CLI banner. Native Windows exes often write UTF-16 to a pipe. */
|
|
@@ -131,26 +168,22 @@ export function staleInstallMessage(label, installedVersion, latestVersion) {
|
|
|
131
168
|
* Which updater to run. `order` is compareReleaseVersions(installed, latest):
|
|
132
169
|
* -1 older, 0 same, 1 newer, null when either version is unknown.
|
|
133
170
|
*/
|
|
134
|
-
export function officialUpdatePlan({ toolId, installed, order, platform,
|
|
171
|
+
export function officialUpdatePlan({ toolId, installed, order, platform, configured = false }) {
|
|
135
172
|
if (installed && order === 1) return 'refuse-downgrade'
|
|
136
173
|
if (installed && order === 0) return 'already-current'
|
|
174
|
+
if (configured) return 'registry-install'
|
|
137
175
|
if (installed && order === -1 && toolId === 'claude-code') return 'claude-update-then-registry'
|
|
138
176
|
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'
|
|
144
177
|
return 'minimax-official-installer'
|
|
145
178
|
}
|
|
146
179
|
return 'registry-install'
|
|
147
180
|
}
|
|
148
181
|
|
|
149
|
-
async function trustedExecutionReadiness(toolId) {
|
|
182
|
+
async function trustedExecutionReadiness(toolId, options) {
|
|
150
183
|
// Loaded only during an install to avoid coupling fast version probes to
|
|
151
184
|
// the executor. The executor itself uses this runtime for PATH discovery.
|
|
152
185
|
const { officialToolReadiness } = await import('./official-tool-executor.mjs')
|
|
153
|
-
return officialToolReadiness(toolId)
|
|
186
|
+
return officialToolReadiness(toolId, process.cwd(), options)
|
|
154
187
|
}
|
|
155
188
|
|
|
156
189
|
function runCapture(executable, args, { timeoutMs, onOutput, useShell = false, signal, env } = {}) {
|
|
@@ -245,121 +278,73 @@ function runCapture(executable, args, { timeoutMs, onOutput, useShell = false, s
|
|
|
245
278
|
})
|
|
246
279
|
}
|
|
247
280
|
|
|
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
281
|
export function installerScriptText(source) {
|
|
254
282
|
return decodeProcessOutput(Buffer.isBuffer(source) ? source : Buffer.from(source ?? '')).replace(/^\uFEFF/, '')
|
|
255
283
|
}
|
|
256
284
|
|
|
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
285
|
export function isMarkupDocument(text) {
|
|
264
286
|
const head = String(text ?? '').replace(/^\uFEFF/, '').trimStart()
|
|
265
|
-
if (!head.startsWith('<')) return false
|
|
266
|
-
if (head.startsWith('<#')) return false
|
|
287
|
+
if (!head.startsWith('<') || head.startsWith('<#')) return false
|
|
267
288
|
return /^<(?:!|\?|[A-Za-z])/.test(head)
|
|
268
289
|
}
|
|
269
290
|
|
|
270
|
-
const MINIMAX_INSTALLER_MARKERS = Object.freeze(['@minimax-ai/code', 'MCODE_INSTALL_DIR', 'MiniMax Code'])
|
|
271
|
-
|
|
272
291
|
/**
|
|
273
292
|
* MiniMax's official Windows installer script is run as published (it installs
|
|
274
|
-
* `@minimax-ai/code@latest`).
|
|
275
|
-
*
|
|
276
|
-
* the real script `application/octet-stream`.
|
|
293
|
+
* `@minimax-ai/code@latest`). Only structural sanity is checked: it must be the
|
|
294
|
+
* MiniMax Code installer, not an HTML error page or another script.
|
|
277
295
|
*/
|
|
278
296
|
export function officialMiniMaxInstaller(source) {
|
|
279
297
|
const text = installerScriptText(source)
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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('、')}),已拒绝执行。`)
|
|
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))) {
|
|
301
|
+
throw new Error('下载内容不是 MiniMax Code 官方 Windows 安装脚本,已拒绝执行。')
|
|
287
302
|
}
|
|
288
303
|
return text
|
|
289
304
|
}
|
|
290
305
|
|
|
291
|
-
|
|
292
|
-
|
|
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')
|
|
306
|
+
async function fetchOfficialMiniMaxInstaller(signal) {
|
|
307
|
+
return officialMiniMaxInstaller(await fetchVendorScript(MINIMAX_INSTALLER_URL, signal, { verify: { mustInclude: ['@minimax-ai/code', 'MiniMax Code CLI'] } }))
|
|
302
308
|
}
|
|
303
309
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000),
|
|
307
|
-
})
|
|
308
|
-
if (!response.ok || !response.body) throw new Error(`官方脚本下载失败:HTTP ${response.status}`)
|
|
309
|
-
const chunks = []
|
|
310
|
-
let length = 0
|
|
311
|
-
for await (const chunk of response.body) {
|
|
312
|
-
length += chunk.length
|
|
313
|
-
if (length > MAX_INSTALLER_BYTES) throw new Error('官方安装脚本超过大小上限。')
|
|
314
|
-
chunks.push(chunk)
|
|
315
|
-
}
|
|
316
|
-
return officialMiniMaxInstaller(Buffer.concat(chunks))
|
|
310
|
+
export function minimaxOfficialInstallDir(selectedPreferences = {}, home = homedir(), existingRoot = '') {
|
|
311
|
+
return selectedPreferences.installDir || existingRoot || join(home, '.minimax-code')
|
|
317
312
|
}
|
|
318
313
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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 ''
|
|
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 }
|
|
336
321
|
}
|
|
337
322
|
|
|
338
323
|
async function runOfficialMiniMaxInstaller(job, { reason } = {}) {
|
|
339
324
|
let staging = null
|
|
340
|
-
let
|
|
325
|
+
let prefixRoot = null
|
|
341
326
|
try {
|
|
342
327
|
job.finishedAt = null
|
|
343
328
|
job.error = null
|
|
344
329
|
job.phase = 'downloading-and-verifying'
|
|
345
330
|
pushLine(job, reason || '— npm 原生依赖未就绪;改用 MiniMax 官方 Windows 安装脚本(安装最新版)—')
|
|
346
|
-
const
|
|
347
|
-
const installDir =
|
|
348
|
-
pushLine(job, preferences.installDir
|
|
349
|
-
? `— 使用已配置的安装目录:${installDir} —`
|
|
350
|
-
: existingRoot
|
|
351
|
-
? `— 未配置统一安装目录,沿用已安装的官方目录:${installDir} —`
|
|
352
|
-
: `— 未配置统一安装目录,使用 MiniMax 官方默认位置:${installDir} —`)
|
|
331
|
+
const { plan, executionPreferences } = miniMaxFallbackPlan(job.preferences, job.beforeProbe, '')
|
|
332
|
+
const installDir = plan.installDir
|
|
353
333
|
await mkdir(installDir, { recursive: true })
|
|
354
|
-
|
|
334
|
+
prefixRoot = await realpath(installDir)
|
|
355
335
|
const script = await fetchOfficialMiniMaxInstaller(job.controller.signal)
|
|
356
|
-
staging = await mkdtemp(join(
|
|
336
|
+
staging = await mkdtemp(join(prefixRoot, '.model-router-minimax-installer-'))
|
|
357
337
|
const scriptPath = join(staging, 'install.ps1')
|
|
358
338
|
const tempPath = join(staging, 'temp')
|
|
359
339
|
await mkdir(tempPath)
|
|
360
340
|
// Windows PowerShell 5 reads a BOM-less .ps1 using the system ANSI page.
|
|
361
341
|
// The upstream installer contains Unicode art, so write a UTF-8 BOM.
|
|
362
342
|
await writeFile(scriptPath, `\uFEFF${script}`, 'utf8')
|
|
343
|
+
job.plan = plan
|
|
344
|
+
job.methodId = plan.methodId
|
|
345
|
+
job.installDir = installDir
|
|
346
|
+
job.command = planCommandLine(plan)
|
|
347
|
+
pushLine(job, `— 本次更新目录:${installDir} —`)
|
|
363
348
|
const powershell = join(process.env.SystemRoot ?? 'C:\\Windows', 'System32',
|
|
364
349
|
'WindowsPowerShell', 'v1.0', 'powershell.exe')
|
|
365
350
|
const utilityModule = join(process.env.SystemRoot ?? 'C:\\Windows', 'System32',
|
|
@@ -369,17 +354,12 @@ async function runOfficialMiniMaxInstaller(job, { reason } = {}) {
|
|
|
369
354
|
+ 'Import-Module -Name $env:MODEL_ROUTER_UTILITY_MODULE -ErrorAction Stop; '
|
|
370
355
|
+ '& $env:MODEL_ROUTER_MINIMAX_INSTALL_SCRIPT'
|
|
371
356
|
job.phase = 'installing'
|
|
372
|
-
const scriptRegistry = minimaxScriptRegistry(preferences.registry || 'https://registry.npmjs.org/')
|
|
373
|
-
if (!scriptRegistry && preferences.registry) {
|
|
374
|
-
pushLine(job, '— MiniMax 官方脚本不接受当前 npm 源,本次使用脚本自己的默认源 —')
|
|
375
|
-
}
|
|
376
357
|
const outcome = await runCapture(powershell,
|
|
377
358
|
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', command], {
|
|
378
359
|
timeoutMs: INSTALL_TIMEOUT_MS, signal: job.controller.signal,
|
|
379
|
-
env: { ...process.env, MCODE_INSTALL_DIR: installDir,
|
|
380
|
-
MCODE_NO_MODIFY_PATH: '1',
|
|
381
|
-
|
|
382
|
-
npm_config_cache: join(staging, 'npm-cache'),
|
|
360
|
+
env: { ...process.env, ...plan.env, MCODE_INSTALL_DIR: installDir,
|
|
361
|
+
MCODE_NO_MODIFY_PATH: '1', MCODE_DOWNLOAD_MIRROR: 'global',
|
|
362
|
+
npm_config_cache: join(prefixRoot, '.model-router-npm-cache'),
|
|
383
363
|
TEMP: tempPath, TMP: tempPath,
|
|
384
364
|
MODEL_ROUTER_UTILITY_MODULE: utilityModule,
|
|
385
365
|
MODEL_ROUTER_MINIMAX_INSTALL_SCRIPT: scriptPath },
|
|
@@ -393,12 +373,12 @@ async function runOfficialMiniMaxInstaller(job, { reason } = {}) {
|
|
|
393
373
|
if (!outcome.ok) throw new Error(outcome.timedOut
|
|
394
374
|
? '官方安装脚本超时并已终止。'
|
|
395
375
|
: `官方安装脚本退出码 ${outcome.code ?? '信号终止'}。${outcome.stderr.split('\n').find(Boolean)?.slice(0, 180) ?? ''}`)
|
|
396
|
-
appendToProcessPath(installDir)
|
|
376
|
+
appendToProcessPath(installDir, { prepend: true })
|
|
397
377
|
job.phase = 'verifying'
|
|
398
378
|
probeCache.delete(job.tool.id)
|
|
399
|
-
const probe = await probeToolWith(job.tool, defaultRunner)
|
|
379
|
+
const probe = await probeToolWith(job.tool, defaultRunner, { preferences: executionPreferences, plan, cacheMs: 0 })
|
|
400
380
|
job.postInstallProbe = probe
|
|
401
|
-
const readiness = await trustedExecutionReadiness(job.tool.id)
|
|
381
|
+
const readiness = await trustedExecutionReadiness(job.tool.id, { preferences: executionPreferences })
|
|
402
382
|
if (!probe.installed || !readiness.ready) {
|
|
403
383
|
throw new Error(`官方安装器完成,但执行入口未通过 npm registry 摘要核验:${readiness.reason ?? probe.detail}`)
|
|
404
384
|
}
|
|
@@ -415,9 +395,9 @@ async function runOfficialMiniMaxInstaller(job, { reason } = {}) {
|
|
|
415
395
|
job.error = `MiniMax 官方安装兜底失败:${String(error?.message ?? error).slice(0, 300)}`
|
|
416
396
|
job.finishedAt = new Date().toISOString()
|
|
417
397
|
} finally {
|
|
418
|
-
if (staging &&
|
|
419
|
-
const part = relative(
|
|
420
|
-
if (part.startsWith('.model-router-minimax-installer-') && !part.includes(sep)
|
|
398
|
+
if (staging && prefixRoot) {
|
|
399
|
+
const part = relative(prefixRoot, staging)
|
|
400
|
+
if (part.startsWith('.model-router-minimax-installer-') && !part.includes(sep)) {
|
|
421
401
|
await rm(staging, { recursive: true, force: true }).catch(() => {})
|
|
422
402
|
}
|
|
423
403
|
}
|
|
@@ -446,19 +426,78 @@ export function verifiedVendorScript(source, verify = {}) {
|
|
|
446
426
|
return text
|
|
447
427
|
}
|
|
448
428
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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(() => {})
|
|
460
500
|
}
|
|
461
|
-
return Buffer.concat(chunks)
|
|
462
501
|
}
|
|
463
502
|
|
|
464
503
|
const POWERSHELL = join(process.env.SystemRoot ?? 'C:\\Windows', 'System32',
|
|
@@ -474,7 +513,7 @@ export function vendorScriptInvocation(plan, scriptPath) {
|
|
|
474
513
|
args: ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...directoryArgs],
|
|
475
514
|
}
|
|
476
515
|
}
|
|
477
|
-
return { file: 'bash', args: [scriptPath, ...directoryArgs] }
|
|
516
|
+
return { file: plan.shell === 'sh' ? 'sh' : 'bash', args: [scriptPath, ...directoryArgs] }
|
|
478
517
|
}
|
|
479
518
|
|
|
480
519
|
/**
|
|
@@ -484,12 +523,23 @@ export function vendorScriptInvocation(plan, scriptPath) {
|
|
|
484
523
|
* than an older one that happened to be on PATH first.
|
|
485
524
|
*/
|
|
486
525
|
async function runVendorScriptInstallJob(job, plan) {
|
|
526
|
+
if (job.status !== 'running') return
|
|
487
527
|
let staging = null
|
|
488
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
|
+
}
|
|
489
537
|
job.phase = 'downloading-and-verifying'
|
|
490
538
|
for (const notice of plan.notices ?? []) pushLine(job, notice)
|
|
491
539
|
pushLine(job, `— 下载官方安装脚本:${plan.scriptUrl}`)
|
|
492
|
-
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)
|
|
493
543
|
staging = await mkdtemp(join(tmpdir(), '.model-router-tool-install-'))
|
|
494
544
|
const isPowerShell = plan.shell === 'powershell'
|
|
495
545
|
const scriptPath = join(staging, isPowerShell ? 'install.ps1' : 'install.sh')
|
|
@@ -502,7 +552,7 @@ async function runVendorScriptInstallJob(job, plan) {
|
|
|
502
552
|
const outcome = await runCapture(invocation.file, invocation.args, {
|
|
503
553
|
timeoutMs: INSTALL_TIMEOUT_MS,
|
|
504
554
|
signal: job.controller.signal,
|
|
505
|
-
env:
|
|
555
|
+
env: installEnvironment(plan),
|
|
506
556
|
onOutput: captureLines(job),
|
|
507
557
|
})
|
|
508
558
|
if (outcome.cancelled || job.cancelRequested) { markCancelled(job); return }
|
|
@@ -512,17 +562,10 @@ async function runVendorScriptInstallJob(job, plan) {
|
|
|
512
562
|
throw new Error(outcome.timedOut ? '官方安装脚本超时并已终止。'
|
|
513
563
|
: `官方安装脚本退出码 ${outcome.code ?? '信号终止'}。${String(outcome.stderr ?? '').split('\n').find(Boolean)?.slice(0, 200) ?? ''}`)
|
|
514
564
|
}
|
|
515
|
-
|
|
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
|
-
}
|
|
565
|
+
if (plan.binDir) appendToProcessPath(plan.binDir, { prepend: true })
|
|
523
566
|
job.phase = 'verifying'
|
|
524
567
|
probeCache.delete(job.tool.id)
|
|
525
|
-
const probe = await probeToolWith(job.tool, defaultRunner, { cacheMs: 0 })
|
|
568
|
+
const probe = await probeToolWith(job.tool, defaultRunner, { cacheMs: 0, plan, preferences: job.preferences })
|
|
526
569
|
job.postInstallProbe = probe
|
|
527
570
|
if (!probe.installed) {
|
|
528
571
|
throw new Error(`官方安装脚本已完成,但 PATH 上仍找不到 ${job.tool.probeExecutables?.[0] ?? job.tool.label}:${probe.detail}`)
|
|
@@ -551,10 +594,10 @@ async function runVendorScriptInstallJob(job, plan) {
|
|
|
551
594
|
* the file must sit inside that tool's install directory, and it can never be a
|
|
552
595
|
* home directory or a filesystem root.
|
|
553
596
|
*/
|
|
554
|
-
/** One relative path under an install directory. `..` never leaves that directory. */
|
|
555
597
|
export function scriptBinaryCandidate(installDir, relative) {
|
|
556
|
-
const
|
|
557
|
-
|
|
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 === '..')) {
|
|
558
601
|
throw new Error('拒绝删除安装目录之外的文件。')
|
|
559
602
|
}
|
|
560
603
|
return join(installDir, ...parts)
|
|
@@ -585,13 +628,13 @@ function isInsideDirectory(parent, child) {
|
|
|
585
628
|
* reinstall keeps the user's login.
|
|
586
629
|
*/
|
|
587
630
|
async function runVendorScriptUninstallJob(job, plan) {
|
|
631
|
+
if (job.status !== 'running') return
|
|
588
632
|
try {
|
|
589
633
|
job.phase = 'removing'
|
|
590
|
-
const location =
|
|
634
|
+
const location = { dir: plan.dir, binaries: plan.binaries }
|
|
591
635
|
const removed = []
|
|
592
|
-
const
|
|
593
|
-
|
|
594
|
-
const candidate = scriptBinaryCandidate(location.dir, relative)
|
|
636
|
+
for (const binary of location?.binaries ?? []) {
|
|
637
|
+
const candidate = scriptBinaryCandidate(location.dir, binary)
|
|
595
638
|
// checkedRemovableFile re-checks the basename, the install directory and
|
|
596
639
|
// the root/home guards even though every part came from the registry.
|
|
597
640
|
const target = checkedRemovableFile(candidate, {
|
|
@@ -601,16 +644,16 @@ async function runVendorScriptUninstallJob(job, plan) {
|
|
|
601
644
|
await rm(target, { force: true })
|
|
602
645
|
removed.push(target)
|
|
603
646
|
}
|
|
604
|
-
const stripped = await stripPathProfileBlocks(job.tool).catch(error => `(未能清理 PATH 配置:${String(error?.message ?? error).slice(0, 120)})`)
|
|
605
647
|
if (removed.length === 0) {
|
|
606
648
|
throw new Error(`在 ${location?.dir || '安装目录'} 下没有找到 ${job.tool.label} 的可执行文件;它可能是用包管理器安装的,请改用该方式卸载。`)
|
|
607
649
|
}
|
|
650
|
+
const stripped = await stripPathProfileBlocks(job.tool).catch(error => [`(未能清理 PATH 配置:${String(error?.message ?? error).slice(0, 120)})`])
|
|
608
651
|
for (const path of removed) pushLine(job, `已删除 ${path}`)
|
|
609
652
|
if (stripped.length) pushLine(job, `已清理 shell 配置中的 PATH 记录:${stripped.join('、')}`)
|
|
610
653
|
pushLine(job, `已保留 ${job.tool.label} 的配置与登录信息(${homedir()}/${String(job.tool.defaultScriptInstallDir ?? '').split('/')[0]})。`)
|
|
611
654
|
job.phase = 'verifying'
|
|
612
655
|
probeCache.delete(job.tool.id)
|
|
613
|
-
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 } })
|
|
614
657
|
job.postInstallProbe = probe
|
|
615
658
|
job.status = 'succeeded'
|
|
616
659
|
job.finishedAt = new Date().toISOString()
|
|
@@ -630,23 +673,25 @@ async function runVendorScriptUninstallJob(job, plan) {
|
|
|
630
673
|
* writes. Only a well-formed `# stepcode … # stepcode end` block is removed; a
|
|
631
674
|
* bare mention is left alone, exactly as the vendor's installer decides.
|
|
632
675
|
*/
|
|
633
|
-
export function stripPathBlock(content, marker) {
|
|
676
|
+
export function stripPathBlock(content, marker, closingMarker = `${marker} end`) {
|
|
634
677
|
const text = String(content ?? '')
|
|
635
678
|
if (!text.includes(marker)) return null
|
|
636
|
-
const lines = text.
|
|
679
|
+
const lines = text.match(/[^\r\n]*(?:\r\n|\n|$)/g)?.filter(Boolean) ?? []
|
|
637
680
|
const kept = []
|
|
638
681
|
let removed = 0
|
|
639
|
-
let
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
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
|
|
646
688
|
}
|
|
647
|
-
|
|
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
|
|
648
693
|
}
|
|
649
|
-
return removed > 0 ? kept.join('
|
|
694
|
+
return removed > 0 ? kept.join('') : null
|
|
650
695
|
}
|
|
651
696
|
|
|
652
697
|
const SHELL_PROFILES = ['.bashrc', '.bash_profile', '.zshrc', '.profile']
|
|
@@ -660,7 +705,11 @@ async function stripPathProfileBlocks(tool) {
|
|
|
660
705
|
let content
|
|
661
706
|
try { content = await readFile(path, 'utf8') } catch { continue }
|
|
662
707
|
let next = content
|
|
663
|
-
|
|
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
|
+
}
|
|
664
713
|
if (next === content) continue
|
|
665
714
|
const temporary = `${path}.model-router-${process.pid}.tmp`
|
|
666
715
|
await writeFile(temporary, next, 'utf8')
|
|
@@ -683,17 +732,37 @@ export function versionFromBanner(banner) {
|
|
|
683
732
|
|
|
684
733
|
/**
|
|
685
734
|
* Probe one registry tool. `runner` is injectable for offline tests and must
|
|
686
|
-
* 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.
|
|
687
738
|
*/
|
|
688
|
-
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])
|
|
689
746
|
const cached = cache.get(tool.id)
|
|
690
|
-
if (cached && now() - cached.at < cacheMs) return cached.value
|
|
691
|
-
const probe = await probeUncached(tool, runner)
|
|
692
|
-
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 })
|
|
693
750
|
return probe
|
|
694
751
|
}
|
|
695
752
|
|
|
696
|
-
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 }) {
|
|
697
766
|
if (tool.unsupported) {
|
|
698
767
|
return { id: tool.id, installed: false, version: null, status: 'unsupported', detail: tool.unsupportedReason }
|
|
699
768
|
}
|
|
@@ -709,23 +778,68 @@ async function probeUncached(tool, runner) {
|
|
|
709
778
|
? `检测到已安装的 ZCode ${other.version ?? '(版本未知)'}${other.root ? `(${other.root})` : ''},但 ZCode.exe 未通过智谱发布者签名或 GLM 组件检查,因此不通过插件调用;可以继续直接使用 ZCode 桌面版。`
|
|
710
779
|
: '未找到具有智谱有效签名的 ZCode 桌面版。' }
|
|
711
780
|
}
|
|
712
|
-
|
|
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)) {
|
|
713
835
|
// The official Windows installer keeps mcode under a versioned releases
|
|
714
836
|
// directory. A partial npm install can leave a broken shim earlier on PATH;
|
|
715
|
-
// prefer the checksum-verified installer release in that case.
|
|
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.
|
|
837
|
+
// prefer the checksum-verified installer release in that case.
|
|
718
838
|
const { findManagedMiniMaxEntry } = await import('./official-tool-executor.mjs')
|
|
719
|
-
const
|
|
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')])
|
|
839
|
+
const managed = await findManagedMiniMaxEntry(process.cwd())
|
|
727
840
|
if (managed) return { id: tool.id, installed: true, version: managed.version,
|
|
728
841
|
status: 'installed', detail: '已按官方 npm registry 摘要核验 MiniMax 官方 Windows 安装器中的 CLI 文件。',
|
|
842
|
+
installDir: managed.installDir, binDir: managed.installDir,
|
|
729
843
|
bannerLine: `MiniMax Code ${managed.version}` }
|
|
730
844
|
}
|
|
731
845
|
for (const executable of tool.probeExecutables) {
|
|
@@ -742,12 +856,12 @@ async function probeUncached(tool, runner) {
|
|
|
742
856
|
if (attempt.timedOut) {
|
|
743
857
|
return { id: tool.id, installed: false, version: null, status: 'probe-timeout', detail: `${executable} --version 超时(${PROBE_TIMEOUT_MS / 1000}s)` }
|
|
744
858
|
}
|
|
745
|
-
const attempts = [attempt]
|
|
859
|
+
const attempts = [{ ...attempt, executablePath: locatorPaths(located.stdout)[0] }]
|
|
746
860
|
if (!versionFromBanner(probeBanner(attempt))) {
|
|
747
861
|
for (const target of locatorPaths(located.stdout)) {
|
|
748
862
|
if (target === executable) continue
|
|
749
863
|
const direct = await runner(target, ['--version'], { timeoutMs: PROBE_TIMEOUT_MS, useShell: false })
|
|
750
|
-
attempts.push(direct)
|
|
864
|
+
attempts.push({ ...direct, executablePath: target })
|
|
751
865
|
if (direct.timedOut) break
|
|
752
866
|
if (direct.ok || versionFromBanner(probeBanner(direct))) break
|
|
753
867
|
}
|
|
@@ -762,6 +876,7 @@ async function probeUncached(tool, runner) {
|
|
|
762
876
|
version,
|
|
763
877
|
status: 'installed',
|
|
764
878
|
detail: tool.probeNote ?? '',
|
|
879
|
+
executablePath: chosen.executablePath,
|
|
765
880
|
bannerLine: banner.split('\n').find(Boolean)?.slice(0, 200) ?? '',
|
|
766
881
|
}
|
|
767
882
|
}
|
|
@@ -783,17 +898,16 @@ export const defaultRunner = (executable, args, options = {}) =>
|
|
|
783
898
|
let prefixPromise = null
|
|
784
899
|
|
|
785
900
|
/** Add one directory to this process's PATH unless it is already there. */
|
|
786
|
-
function appendToProcessPath(directory, {
|
|
901
|
+
export function appendToProcessPath(directory, { prepend = false, env = process.env, platform = process.platform } = {}) {
|
|
787
902
|
const target = String(directory ?? '').trim()
|
|
788
903
|
if (!target || !isAbsolute(target)) return
|
|
789
|
-
const separator =
|
|
790
|
-
const
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
if (includeBin && !IS_WINDOWS) appendToProcessPath(join(target, 'bin'), { includeBin: false })
|
|
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)
|
|
797
911
|
}
|
|
798
912
|
|
|
799
913
|
/**
|
|
@@ -812,9 +926,9 @@ export function ensureNpmPrefixOnPath() {
|
|
|
812
926
|
timeoutMs: 5_000,
|
|
813
927
|
useShell: IS_WINDOWS,
|
|
814
928
|
})
|
|
815
|
-
if (!outcome.ok)
|
|
929
|
+
if (!outcome.ok) throw new Error('npm prefix is unavailable')
|
|
816
930
|
let prefix = outcome.stdout.trim()
|
|
817
|
-
if (!prefix)
|
|
931
|
+
if (!prefix) throw new Error('npm prefix is empty')
|
|
818
932
|
if (prefix.startsWith('\\\\?\\')) prefix = prefix.slice(4)
|
|
819
933
|
if (IS_WINDOWS && !process.env.GROK_HOME && /^[D-Z]:[\\/]/i.test(prefix)) {
|
|
820
934
|
// Grok's npm bootstrap otherwise expands its native binary under
|
|
@@ -822,18 +936,14 @@ export function ensureNpmPrefixOnPath() {
|
|
|
822
936
|
// the same non-C drive as this user's npm global prefix.
|
|
823
937
|
process.env.GROK_HOME = join(prefix, '.model-router-grok')
|
|
824
938
|
}
|
|
825
|
-
appendToProcessPath(prefix)
|
|
939
|
+
appendToProcessPath(IS_WINDOWS ? prefix : join(prefix, 'bin'))
|
|
826
940
|
} catch { /* environment stays untouched */ }
|
|
827
941
|
// A --prefix install lands outside the npm prefix, so it needs its own
|
|
828
942
|
// PATH entry or every later probe reports the tool as missing.
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
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 */ }
|
|
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
|
+
}
|
|
837
947
|
}
|
|
838
948
|
})()
|
|
839
949
|
}
|
|
@@ -910,8 +1020,46 @@ function pushLine(job, line) {
|
|
|
910
1020
|
job.outputTail.push(line)
|
|
911
1021
|
}
|
|
912
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
|
+
|
|
913
1061
|
/** Reserve the single per-tool job slot and serialise against every other tool. */
|
|
914
|
-
function beginJob(tool, { operation, methodId, command, installDir = '', run }) {
|
|
1062
|
+
function beginJob(tool, { operation, methodId, command, installDir = '', plan, configured = false, run }) {
|
|
915
1063
|
const existing = installJobs.get(tool.id)
|
|
916
1064
|
if (existing?.status === 'running') throw new Error(`${tool.label} 已有${operation === 'uninstall' ? '卸载' : '安装'}任务正在执行。`)
|
|
917
1065
|
const job = {
|
|
@@ -920,6 +1068,9 @@ function beginJob(tool, { operation, methodId, command, installDir = '', run })
|
|
|
920
1068
|
methodId,
|
|
921
1069
|
command,
|
|
922
1070
|
installDir,
|
|
1071
|
+
plan,
|
|
1072
|
+
preferences: installPreferences(),
|
|
1073
|
+
configured: configured || plan?.kind === 'package-manager',
|
|
923
1074
|
status: 'running',
|
|
924
1075
|
phase: 'queued',
|
|
925
1076
|
cancelRequested: false,
|
|
@@ -935,8 +1086,28 @@ function beginJob(tool, { operation, methodId, command, installDir = '', run })
|
|
|
935
1086
|
}
|
|
936
1087
|
installJobs.set(tool.id, job)
|
|
937
1088
|
probeCache.delete(tool.id)
|
|
938
|
-
installChain = installChain.then(
|
|
1089
|
+
installChain = installChain.then(async () => {
|
|
939
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 }
|
|
940
1111
|
job.status = 'failed'
|
|
941
1112
|
job.finishedAt = new Date().toISOString()
|
|
942
1113
|
job.error = `${operation === 'uninstall' ? '卸载' : '安装'}流程失败:${String(error?.message ?? error).slice(0, 200)}`
|
|
@@ -947,15 +1118,19 @@ function beginJob(tool, { operation, methodId, command, installDir = '', run })
|
|
|
947
1118
|
/** One-line summary of a resolved plan, for the job log and the UI. */
|
|
948
1119
|
export function planCommandLine(plan, operation = 'install') {
|
|
949
1120
|
if (plan.kind === 'script') {
|
|
950
|
-
const
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
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}` : ''}`
|
|
1128
|
+
return operation === 'uninstall'
|
|
1129
|
+
? `删除 ${plan.dir} 下的 ${(plan.binaries ?? []).join('、') || '可执行文件'}`
|
|
1130
|
+
: plan.shell === 'powershell' && directoryArgs
|
|
1131
|
+
? `${environment}& ([scriptblock]::Create((irm ${plan.scriptUrl})))${directoryArgs}` : piped
|
|
957
1132
|
}
|
|
958
|
-
return `${plan.manager} ${plan.args.join(' ')}`
|
|
1133
|
+
return `${plan.manager} ${plan.args.map(arg => /\s/.test(arg) ? JSON.stringify(arg) : arg).join(' ')}`
|
|
959
1134
|
}
|
|
960
1135
|
|
|
961
1136
|
/**
|
|
@@ -979,9 +1154,11 @@ export function startInstall(toolId, { method } = {}) {
|
|
|
979
1154
|
methodId: plan.methodId,
|
|
980
1155
|
command: planCommandLine(plan),
|
|
981
1156
|
installDir: plan.installDir ?? '',
|
|
1157
|
+
plan,
|
|
1158
|
+
configured: usesConfiguredInstallPlan(tool.id, preferences, method),
|
|
982
1159
|
run: job => (plan.kind === 'script'
|
|
983
1160
|
? runVendorScriptInstallJob(job, plan)
|
|
984
|
-
: runInstallJob(
|
|
1161
|
+
: runInstallJob(job.plan, job)),
|
|
985
1162
|
})
|
|
986
1163
|
}
|
|
987
1164
|
|
|
@@ -1001,10 +1178,12 @@ export function startRepair(toolId, { method } = {}) {
|
|
|
1001
1178
|
methodId: plan.methodId,
|
|
1002
1179
|
command: `修复:${planCommandLine(plan)}`,
|
|
1003
1180
|
installDir: plan.installDir ?? '',
|
|
1181
|
+
plan,
|
|
1182
|
+
configured: usesConfiguredInstallPlan(tool.id, preferences, method),
|
|
1004
1183
|
run: async job => {
|
|
1005
1184
|
job.repair = true
|
|
1006
1185
|
if (plan.kind === 'script') return runVendorScriptInstallJob(job, plan)
|
|
1007
|
-
return runInstallJob(
|
|
1186
|
+
return runInstallJob(job.plan, job)
|
|
1008
1187
|
},
|
|
1009
1188
|
})
|
|
1010
1189
|
}
|
|
@@ -1022,17 +1201,20 @@ export function startUninstall(toolId, { method } = {}) {
|
|
|
1022
1201
|
methodId: plan.methodId,
|
|
1023
1202
|
command: planCommandLine(plan, 'uninstall'),
|
|
1024
1203
|
installDir: plan.dir ?? plan.installDir ?? '',
|
|
1204
|
+
plan,
|
|
1205
|
+
configured: usesConfiguredInstallPlan(tool.id, preferences, method),
|
|
1025
1206
|
run: job => (plan.kind === 'script'
|
|
1026
1207
|
? runVendorScriptUninstallJob(job, plan)
|
|
1027
|
-
: runPackageManagerUninstallJob(job, plan)),
|
|
1208
|
+
: runPackageManagerUninstallJob(job, job.plan)),
|
|
1028
1209
|
})
|
|
1029
1210
|
}
|
|
1030
1211
|
|
|
1031
1212
|
async function runPackageManagerUninstallJob(job, plan) {
|
|
1213
|
+
if (job.status !== 'running') return
|
|
1032
1214
|
try {
|
|
1033
1215
|
job.phase = 'preflight'
|
|
1034
1216
|
await ensureNpmPrefixOnPath()
|
|
1035
|
-
const before = await probeToolWith(job.tool, defaultRunner)
|
|
1217
|
+
const before = await probeToolWith(job.tool, defaultRunner, { plan, preferences: job.preferences, cacheMs: 0 })
|
|
1036
1218
|
if (job.cancelRequested) { markCancelled(job); return }
|
|
1037
1219
|
if (!before.installed) {
|
|
1038
1220
|
job.status = 'succeeded'
|
|
@@ -1047,6 +1229,7 @@ async function runPackageManagerUninstallJob(job, plan) {
|
|
|
1047
1229
|
useShell: IS_WINDOWS,
|
|
1048
1230
|
signal: job.controller.signal,
|
|
1049
1231
|
onOutput: captureLines(job),
|
|
1232
|
+
env: installEnvironment(plan),
|
|
1050
1233
|
})
|
|
1051
1234
|
if (outcome.cancelled || job.cancelRequested) { markCancelled(job); return }
|
|
1052
1235
|
job.exitCode = outcome.code
|
|
@@ -1057,7 +1240,7 @@ async function runPackageManagerUninstallJob(job, plan) {
|
|
|
1057
1240
|
}
|
|
1058
1241
|
job.phase = 'verifying'
|
|
1059
1242
|
probeCache.delete(job.tool.id)
|
|
1060
|
-
const probe = await probeToolWith(job.tool, defaultRunner, { cacheMs: 0 })
|
|
1243
|
+
const probe = await probeToolWith(job.tool, defaultRunner, { cacheMs: 0, preferences: job.preferences, plan })
|
|
1061
1244
|
job.postInstallProbe = probe
|
|
1062
1245
|
job.status = 'succeeded'
|
|
1063
1246
|
job.finishedAt = new Date().toISOString()
|
|
@@ -1082,6 +1265,13 @@ function captureLines(job) {
|
|
|
1082
1265
|
}
|
|
1083
1266
|
}
|
|
1084
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
|
+
|
|
1085
1275
|
/** Mark the job succeeded only when the fresh probe is installed, ready, and not still older. */
|
|
1086
1276
|
async function finishVerifiedInstall(job, probe) {
|
|
1087
1277
|
job.postInstallProbe = probe
|
|
@@ -1097,10 +1287,10 @@ async function finishVerifiedInstall(job, probe) {
|
|
|
1097
1287
|
failBecauseStillOlder(job, probe, latest.version)
|
|
1098
1288
|
return true
|
|
1099
1289
|
}
|
|
1100
|
-
const readiness = await trustedExecutionReadiness(job.tool.id)
|
|
1290
|
+
const readiness = await trustedExecutionReadiness(job.tool.id, { preferences: job.preferences, plan: job.plan })
|
|
1101
1291
|
if (job.cancelRequested) { markCancelled(job); return true }
|
|
1102
1292
|
if (!readiness.ready) {
|
|
1103
|
-
if (IS_WINDOWS && job.tool.id === 'minimax-code') {
|
|
1293
|
+
if (IS_WINDOWS && job.tool.id === 'minimax-code' && !job.configured) {
|
|
1104
1294
|
await runOfficialMiniMaxInstaller(job)
|
|
1105
1295
|
return true
|
|
1106
1296
|
}
|
|
@@ -1130,7 +1320,7 @@ async function tryClaudeUpdate(job, latestVersion) {
|
|
|
1130
1320
|
if (job.cancelRequested) { markCancelled(job); return true }
|
|
1131
1321
|
pushLine(job, '$ claude update')
|
|
1132
1322
|
job.phase = 'installing'
|
|
1133
|
-
const outcome = await runCapture('claude', CLAUDE_UPDATE_ARGS, {
|
|
1323
|
+
const outcome = await runCapture(job.beforeProbe?.executablePath || 'claude', CLAUDE_UPDATE_ARGS, {
|
|
1134
1324
|
timeoutMs: CLAUDE_UPDATE_TIMEOUT_MS,
|
|
1135
1325
|
useShell: IS_WINDOWS,
|
|
1136
1326
|
signal: job.controller.signal,
|
|
@@ -1144,7 +1334,10 @@ async function tryClaudeUpdate(job, latestVersion) {
|
|
|
1144
1334
|
}
|
|
1145
1335
|
await ensureNpmPrefixOnPath()
|
|
1146
1336
|
probeCache.delete(job.tool.id)
|
|
1147
|
-
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 })
|
|
1148
1341
|
if (job.cancelRequested) { markCancelled(job); return true }
|
|
1149
1342
|
if (latestVersion && versionStillOlder(probe.version, latestVersion)) {
|
|
1150
1343
|
job.postInstallProbe = probe
|
|
@@ -1154,13 +1347,15 @@ async function tryClaudeUpdate(job, latestVersion) {
|
|
|
1154
1347
|
return finishVerifiedInstall(job, probe)
|
|
1155
1348
|
}
|
|
1156
1349
|
|
|
1157
|
-
async function runInstallJob(
|
|
1350
|
+
async function runInstallJob(installPlan, job) {
|
|
1351
|
+
const { manager, args } = installPlan
|
|
1158
1352
|
if (job.status !== 'running') return
|
|
1159
1353
|
job.phase = 'preflight'
|
|
1160
1354
|
await ensureNpmPrefixOnPath()
|
|
1161
1355
|
if (job.cancelRequested) { markCancelled(job); return }
|
|
1162
1356
|
probeCache.delete(job.tool.id)
|
|
1163
|
-
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
|
|
1164
1359
|
if (job.cancelRequested) { markCancelled(job); return }
|
|
1165
1360
|
let latest = null
|
|
1166
1361
|
let order = null
|
|
@@ -1176,7 +1371,7 @@ async function runInstallJob(manager, args, job) {
|
|
|
1176
1371
|
return
|
|
1177
1372
|
}
|
|
1178
1373
|
if (order === 0) {
|
|
1179
|
-
const readiness = await trustedExecutionReadiness(job.tool.id)
|
|
1374
|
+
const readiness = await trustedExecutionReadiness(job.tool.id, { preferences: job.preferences, plan: job.plan })
|
|
1180
1375
|
if (job.cancelRequested) { markCancelled(job); return }
|
|
1181
1376
|
// A repair re-runs the same command even when nothing looks stale: a
|
|
1182
1377
|
// broken install reports the right version, so "already current" is
|
|
@@ -1199,7 +1394,7 @@ async function runInstallJob(manager, args, job) {
|
|
|
1199
1394
|
installed: before.installed === true,
|
|
1200
1395
|
order,
|
|
1201
1396
|
platform: process.platform,
|
|
1202
|
-
|
|
1397
|
+
configured: job.configured,
|
|
1203
1398
|
})
|
|
1204
1399
|
let triedClaudeUpdate = false
|
|
1205
1400
|
if (plan === 'claude-update-then-registry') {
|
|
@@ -1212,12 +1407,14 @@ async function runInstallJob(manager, args, job) {
|
|
|
1212
1407
|
})
|
|
1213
1408
|
return
|
|
1214
1409
|
}
|
|
1410
|
+
if (installPlan.binDir) await mkdir(installPlan.binDir, { recursive: true })
|
|
1215
1411
|
job.phase = 'installing'
|
|
1216
1412
|
const outcome = await runCapture(manager, args, {
|
|
1217
1413
|
timeoutMs: INSTALL_TIMEOUT_MS,
|
|
1218
1414
|
useShell: IS_WINDOWS,
|
|
1219
1415
|
signal: job.controller.signal,
|
|
1220
1416
|
onOutput: captureLines(job),
|
|
1417
|
+
env: installEnvironment(installPlan),
|
|
1221
1418
|
})
|
|
1222
1419
|
if (job.status !== 'running') return
|
|
1223
1420
|
if (outcome.cancelled || job.cancelRequested) { markCancelled(job); return }
|
|
@@ -1230,7 +1427,7 @@ async function runInstallJob(manager, args, job) {
|
|
|
1230
1427
|
return
|
|
1231
1428
|
}
|
|
1232
1429
|
if (!outcome.ok) {
|
|
1233
|
-
if (IS_WINDOWS && job.tool.id === 'minimax-code') {
|
|
1430
|
+
if (IS_WINDOWS && job.tool.id === 'minimax-code' && !job.configured) {
|
|
1234
1431
|
await runOfficialMiniMaxInstaller(job)
|
|
1235
1432
|
return
|
|
1236
1433
|
}
|
|
@@ -1242,19 +1439,19 @@ async function runInstallJob(manager, args, job) {
|
|
|
1242
1439
|
job.phase = 'verifying'
|
|
1243
1440
|
await ensureNpmPrefixOnPath()
|
|
1244
1441
|
probeCache.delete(job.tool.id)
|
|
1245
|
-
const probe = await probeToolWith(job.tool, defaultRunner)
|
|
1442
|
+
const probe = await probeToolWith(job.tool, defaultRunner, { plan: installPlan, preferences: job.preferences, cacheMs: 0 })
|
|
1246
1443
|
if (job.cancelRequested) { markCancelled(job); return }
|
|
1247
1444
|
job.postInstallProbe = probe
|
|
1248
1445
|
const latestAfter = (await latestVersions.lookup(job.tool).catch(() => null)) ?? latest
|
|
1249
1446
|
const stillOlder = Boolean(latestAfter?.version && versionStillOlder(probe.version, latestAfter.version))
|
|
1250
|
-
if (stillOlder && job.tool.id === 'claude-code' && !triedClaudeUpdate) {
|
|
1447
|
+
if (stillOlder && job.tool.id === 'claude-code' && !triedClaudeUpdate && !job.configured) {
|
|
1251
1448
|
triedClaudeUpdate = true
|
|
1252
1449
|
const finished = await tryClaudeUpdate(job, latestAfter.version)
|
|
1253
1450
|
if (finished || job.status !== 'running') return
|
|
1254
1451
|
failBecauseStillOlder(job, job.postInstallProbe ?? probe, latestAfter.version)
|
|
1255
1452
|
return
|
|
1256
1453
|
}
|
|
1257
|
-
if (stillOlder && IS_WINDOWS && job.tool.id === 'minimax-code') {
|
|
1454
|
+
if (stillOlder && IS_WINDOWS && job.tool.id === 'minimax-code' && !job.configured) {
|
|
1258
1455
|
pushLine(job, `— npm 完成后 ${job.tool.label} 仍是 ${probe.version},低于最新 ${latestAfter.version};改用官方安装脚本 —`)
|
|
1259
1456
|
await runOfficialMiniMaxInstaller(job)
|
|
1260
1457
|
return
|