@miphamai/cli 0.85.5 → 0.85.6
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/bin/mipham.ts +48 -7
- package/package.json +2 -2
- package/src/agent/agent-context.ts +8 -1
- package/src/agent/effectiveness-tracker.ts +16 -2
- package/src/agent/sub-agent.ts +25 -17
- package/src/core/autocomplete.ts +30 -2
- package/src/core/context.ts +5 -1
- package/src/core/dream-engine.ts +17 -2
- package/src/core/error-signature-db.ts +7 -2
- package/src/core/memory/memory-manager.ts +14 -6
- package/src/core/permission-classifier.ts +17 -5
- package/src/core/rule-engine.ts +27 -2
- package/src/core/self-critique.ts +15 -3
- package/src/daemon/launch.ts +69 -2
- package/src/i18n-core/locales/en-US.json +81 -141
- package/src/i18n-core/locales/zh-CN.json +81 -141
- package/src/providers/anthropic.ts +147 -133
- package/src/providers/fetch-utils.ts +12 -29
- package/src/providers/openai-compat.ts +121 -106
- package/src/shared/arg-validation.ts +11 -1
- package/src/shared/package-info.ts +36 -1
- package/src/shared/update.ts +290 -146
- package/src/ui/commands.ts +19 -5
- package/src/ui/graft-status.tsx +35 -6
- package/src/ui/input.tsx +7 -2
|
@@ -47,6 +47,8 @@ const KNOWN_FLAGS = [
|
|
|
47
47
|
'--safe-mode',
|
|
48
48
|
'--resume',
|
|
49
49
|
'--permission',
|
|
50
|
+
'--provider',
|
|
51
|
+
'--model',
|
|
50
52
|
]
|
|
51
53
|
|
|
52
54
|
/**
|
|
@@ -60,8 +62,16 @@ const KNOWN_FLAGS = [
|
|
|
60
62
|
* `--permission` is here for the same reason and for one more: `mipham attach <id>
|
|
61
63
|
* --permission plan` reads the session id through `firstPositional`, and without this
|
|
62
64
|
* entry it would have taken `plan` for a session id.
|
|
65
|
+
*
|
|
66
|
+
* `--provider`/`--model` are the same defect a third time, and here it was *measured*
|
|
67
|
+
* rather than reasoned about: both shipped IDE integrations build
|
|
68
|
+
* `mipham --provider <id> --model <id>` from their settings (`infrastructure/vscode/
|
|
69
|
+
* extension.js`, `MiphamAction.kt`), and that command was answered with
|
|
70
|
+
* `Unknown command: mipham deepseek` — the provider *value* blamed, the flag that was
|
|
71
|
+
* actually wrong never mentioned, exit 1, no CLI. Their values are open (a provider may
|
|
72
|
+
* be user-defined), so — unlike `--permission` — this module owns only their spelling.
|
|
63
73
|
*/
|
|
64
|
-
const VALUE_FLAGS = ['--resume', '--permission']
|
|
74
|
+
const VALUE_FLAGS = ['--resume', '--permission', '--provider', '--model']
|
|
65
75
|
|
|
66
76
|
/**
|
|
67
77
|
* The first token that would be read as a command, skipping flags and the values
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
export const PACKAGE_NAME = '@miphamai/cli' as const
|
|
10
10
|
|
|
11
11
|
/** 当前发布版本 */
|
|
12
|
-
export const PACKAGE_VERSION = '0.85.
|
|
12
|
+
export const PACKAGE_VERSION = '0.85.6' as const
|
|
13
13
|
|
|
14
14
|
/** npm install 全局安装命令 */
|
|
15
15
|
export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
|
|
@@ -56,3 +56,38 @@ export const COMPANY_NAME_ZH = '北京华安麦逄科技有限公司' as const
|
|
|
56
56
|
|
|
57
57
|
/** 公司简称 */
|
|
58
58
|
export const COMPANY_SHORT = '华安麦逄科技' as const
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 计数类常量 —— 公开面(两个官网的产品页)与文档消费的那几个数。
|
|
62
|
+
*
|
|
63
|
+
* **不要手改。** 这四个数由 `apps/cli/scripts/sync-counts.ts` 从真源产出后回写:
|
|
64
|
+
* 命令 / 提供商 / 工具在**进程内算出**(`getCommandNames()` / `DEFAULT_PROVIDERS` /
|
|
65
|
+
* `createToolRegistry()`),测试数由**一次真套件跑**的自报总数产出。
|
|
66
|
+
*
|
|
67
|
+
* 为什么要有这几个槽位(2026-09-25):两个官网的产品页把「137 命令 · 3473 测试」
|
|
68
|
+
* 当**字面量**写死,没有真源 ⇒ 只能靠人记得去改,同一处**至少手改过 7 次**
|
|
69
|
+
* (2262 → … → 3473),而每次手改本身还会再漂。站点侧的传播链其实一直存在 ——
|
|
70
|
+
* 两站的 deploy 脚本都 `cp` 本文件覆盖自己那份 `src/config/package-info.json`
|
|
71
|
+
* —— 缺的只是**槽位**。名字/版本有槽位所以不漂,计数连槽位都没有。
|
|
72
|
+
*
|
|
73
|
+
* 守卫:`apps/cli/test/integrity/published-counts.test.ts`(三个进程内计数与落盘值
|
|
74
|
+
* 逐一对齐);测试总数另由 CI 的 Test job 与套件自报的总数比对(硬门禁)。
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
/** Slash 命令总数(真源:`getCommandNames().length`,`apps/cli/src/ui/commands.ts`) */
|
|
78
|
+
export const SLASH_COMMAND_COUNT = 137 as const
|
|
79
|
+
|
|
80
|
+
/** 内置提供商总数(真源:`DEFAULT_PROVIDERS.length`,`apps/cli/src/shared/constants.ts`) */
|
|
81
|
+
export const PROVIDER_COUNT = 12 as const
|
|
82
|
+
|
|
83
|
+
/** 已注册工具总数(真源:`createToolRegistry().size`,`apps/cli/src/tools/index.ts`) */
|
|
84
|
+
export const TOOL_COUNT = 31 as const
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 测试总数(真源:**一次真套件跑**的自报总数)。
|
|
88
|
+
*
|
|
89
|
+
* 取**总数**而不是 `passed`:本机(macOS)与 CI(Linux)的 passed/skipped 切分**不同**
|
|
90
|
+
* —— `test/e2e/full-pipeline.test.ts` 在 Linux 上整文件 skip、在 macOS 上跑 ——
|
|
91
|
+
* 但**总数相同**(两边都把被 skip 的算进去)。
|
|
92
|
+
*/
|
|
93
|
+
export const TEST_COUNT = 3506 as const
|
package/src/shared/update.ts
CHANGED
|
@@ -12,12 +12,9 @@ import {
|
|
|
12
12
|
copyFileSync,
|
|
13
13
|
mkdirSync,
|
|
14
14
|
chmodSync,
|
|
15
|
-
cpSync,
|
|
16
15
|
rmSync,
|
|
16
|
+
renameSync,
|
|
17
17
|
readdirSync,
|
|
18
|
-
lstatSync,
|
|
19
|
-
readlinkSync,
|
|
20
|
-
symlinkSync,
|
|
21
18
|
} from 'node:fs'
|
|
22
19
|
import { join, resolve } from 'node:path'
|
|
23
20
|
import { execSync, execFileSync } from 'node:child_process'
|
|
@@ -166,43 +163,97 @@ function isValidSemver(v: string): boolean {
|
|
|
166
163
|
export interface InstallPaths {
|
|
167
164
|
/** node prefix,例如 ~/.nvm/versions/node/v24.14.0 */
|
|
168
165
|
prefix: string
|
|
169
|
-
/** <prefix>/lib/node_modules/@miphamai/cli */
|
|
166
|
+
/** <prefix>/lib/node_modules/@miphamai/cli(Windows 下**没有** `lib` 这一层) */
|
|
170
167
|
pkgDir: string
|
|
171
|
-
/** <prefix>/bin/mipham(Windows 下是 mipham.cmd) */
|
|
168
|
+
/** <prefix>/bin/mipham(Windows 下是 <prefix>/mipham.cmd) */
|
|
172
169
|
launcher: string
|
|
170
|
+
/**
|
|
171
|
+
* 这套布局是按哪个平台算出来的 —— **数据,不是环境**。
|
|
172
|
+
*
|
|
173
|
+
* 换手时要给 staging 也推一套布局,而那套**必须与本套同一套算术**:各自去读
|
|
174
|
+
* `process.platform` 的话,注入 Windows 形状的调用方会拿到 Unix 形状的 staging 目录,
|
|
175
|
+
* 而 D14 那个活缺陷正是「同一套平台知识存在两份、只修了一份」。把它带在结构里,
|
|
176
|
+
* 两者就不可能不一致。
|
|
177
|
+
*/
|
|
178
|
+
platform: NodeJS.Platform
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 由 node prefix 推出三个位置。**纯算术,不做任何校验** —— 校验在 `resolveInstallPaths`,
|
|
183
|
+
* 因为它的对照物(`<prefix>/bin/npm`)只有**真** node prefix 里才有;换手用的 staging prefix
|
|
184
|
+
* 是我们自己造的,里面当然没有 npm,拿同一把尺子去量它只会把它判成「不是 prefix」。
|
|
185
|
+
*
|
|
186
|
+
* **两个平台差一层**(2026-09-25 修)。npm 把全局包装进 `<prefix>/lib/node_modules`
|
|
187
|
+
* (Unix)或 `<prefix>/node_modules`(**Windows 无 `lib`**)—— 见 npm 自带源码 `lib/npm.js`
|
|
188
|
+
* 的 `globalDir`(`process.platform !== 'win32' ? <prefix>/lib/node_modules : <prefix>/node_modules`);
|
|
189
|
+
* bin 的落点也是同形状的一个分支(`bin-links/lib/bin-target.js`:全局装时
|
|
190
|
+
* `dirname(prefix)/bin` 对 **`prefix` 本身**)。
|
|
191
|
+
*
|
|
192
|
+
* **这两个分支只写在这里一处。** 别处再抄一遍就是 D14 的重演:那次是 Unix 的四层 `..` 被
|
|
193
|
+
* 抄到了 Windows 分支上,于是 `launcher` 指向 `<prefix 的父目录>/mipham.cmd`(永远不存在)
|
|
194
|
+
* ⇒ 自证必红 ⇒ **每次 `mipham update` 都把刚装好的新版回滚掉**,而因为 Windows 那半在开发机
|
|
195
|
+
* 与 CI 上都跑不到,它活了很久。
|
|
196
|
+
*/
|
|
197
|
+
export function layoutFor(prefix: string, platform: NodeJS.Platform): InstallPaths {
|
|
198
|
+
if (platform === 'win32') {
|
|
199
|
+
// <prefix>/node_modules/@miphamai/cli;shim 直接落在 <prefix>(没有 bin/)
|
|
200
|
+
return {
|
|
201
|
+
prefix,
|
|
202
|
+
platform,
|
|
203
|
+
pkgDir: join(prefix, 'node_modules', ...PACKAGE.split('/')),
|
|
204
|
+
launcher: join(prefix, 'mipham.cmd'),
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// <prefix>/lib/node_modules/@miphamai/cli;launcher 在 <prefix>/bin
|
|
208
|
+
return {
|
|
209
|
+
prefix,
|
|
210
|
+
platform,
|
|
211
|
+
pkgDir: join(prefix, 'lib', 'node_modules', ...PACKAGE.split('/')),
|
|
212
|
+
launcher: join(prefix, 'bin', 'mipham'),
|
|
213
|
+
}
|
|
173
214
|
}
|
|
174
215
|
|
|
175
216
|
/**
|
|
176
|
-
* 推出全局安装路径。`fromDir`
|
|
217
|
+
* 推出全局安装路径。`fromDir` 与 `platform` 只为测试可注入,后者默认 `process.platform`。
|
|
177
218
|
*
|
|
178
219
|
* 推不出时返回 **null**,绝不猜:`../../../..` 只是算术,它不能证明这个路径真的是一个
|
|
179
220
|
* node prefix。对照物是 `<prefix>/bin/npm` —— 真 prefix 一定有,猜出来的路径不一定有。
|
|
221
|
+
* (**这一步不能省**,而 Windows 分支过去恰好跳过了它:`platform` 是**参数**而不是直接读
|
|
222
|
+
* `process.platform`,正是为了让 Windows 那半也能在任何机器上被真跑,见
|
|
223
|
+
* `test/shared/update-safety.test.ts` 的 Windows 形状夹具。)
|
|
180
224
|
*/
|
|
181
|
-
export function resolveInstallPaths(
|
|
225
|
+
export function resolveInstallPaths(
|
|
226
|
+
fromDir?: string,
|
|
227
|
+
platform: NodeJS.Platform = process.platform,
|
|
228
|
+
): InstallPaths | null {
|
|
182
229
|
const base = fromDir ?? import.meta.dirname
|
|
183
230
|
if (!base) return null
|
|
184
231
|
const pkgDir = resolve(base, '..', '..')
|
|
185
232
|
if (!existsSync(join(pkgDir, 'package.json'))) return null
|
|
186
|
-
const prefix = resolve(pkgDir, '..', '..', '..', '..')
|
|
187
|
-
if (process.platform === 'win32') {
|
|
188
|
-
return { prefix, pkgDir, launcher: join(prefix, 'mipham.cmd') }
|
|
189
|
-
}
|
|
190
|
-
if (!existsSync(join(prefix, 'bin', 'npm'))) return null
|
|
191
|
-
return { prefix, pkgDir, launcher: join(prefix, 'bin', 'mipham') }
|
|
192
|
-
}
|
|
193
233
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
234
|
+
// prefix 由 pkgDir 数 `..` 推出来:Unix 四层(多一层 `lib`)、Windows 三层。
|
|
235
|
+
const prefix =
|
|
236
|
+
platform === 'win32'
|
|
237
|
+
? resolve(pkgDir, '..', '..', '..')
|
|
238
|
+
: resolve(pkgDir, '..', '..', '..', '..')
|
|
239
|
+
|
|
240
|
+
// 对照物:真 node prefix 的 bin/ 里一定有 npm(Windows 是 npm.cmd)。少了这一格,
|
|
241
|
+
// 拿到的就是「算出来的路径」,没有任何东西证明它真的是一个 node prefix。
|
|
242
|
+
const marker = platform === 'win32' ? join(prefix, 'npm.cmd') : join(prefix, 'bin', 'npm')
|
|
243
|
+
if (!existsSync(marker)) return null
|
|
244
|
+
|
|
245
|
+
return layoutFor(prefix, platform)
|
|
203
246
|
}
|
|
204
247
|
|
|
205
|
-
|
|
248
|
+
/**
|
|
249
|
+
* 换手用的两个临时名,都落在 `<prefix>` 下。
|
|
250
|
+
*
|
|
251
|
+
* **必须在 `<prefix>` 里**:rename 只在**同一文件系统**内原子,而 staging prefix 若建在
|
|
252
|
+
* `os.tmpdir()` 之类的别处,跨设备 rename 直接抛 `EXDEV`(Linux 上 `/tmp` 常是 tmpfs)——
|
|
253
|
+
* 那时就只能退回「复制」,而复制本身又成了「可被打断的中间态」,等于把刚拆掉的问题请回来。
|
|
254
|
+
*/
|
|
255
|
+
const STAGING_PREFIX = '.mipham-staging-'
|
|
256
|
+
const PARKED_PREFIX = '.mipham-old-'
|
|
206
257
|
|
|
207
258
|
function readPkgVersion(pkgDir: string): string | undefined {
|
|
208
259
|
try {
|
|
@@ -214,81 +265,58 @@ function readPkgVersion(pkgDir: string): string | undefined {
|
|
|
214
265
|
}
|
|
215
266
|
|
|
216
267
|
/**
|
|
217
|
-
*
|
|
218
|
-
*
|
|
268
|
+
* 删掉一棵临时树。**失败不抛** —— 收尾清理失败不该把一个已经成功的更新判成失败;
|
|
269
|
+
* 删不掉的残留会在下一次运行开头的 `cleanStaleStaging()` 里被收掉。
|
|
219
270
|
*/
|
|
220
|
-
function
|
|
221
|
-
paths: InstallPaths,
|
|
222
|
-
label: string,
|
|
223
|
-
backupRoot: string,
|
|
224
|
-
): InstallSnapshot | null {
|
|
271
|
+
function rmIfPresent(target: string): void {
|
|
225
272
|
try {
|
|
226
|
-
|
|
227
|
-
for (const entry of readdirSync(backupRoot)) {
|
|
228
|
-
if (entry.startsWith(SNAPSHOT_PREFIX))
|
|
229
|
-
rmSync(join(backupRoot, entry), { recursive: true, force: true })
|
|
230
|
-
}
|
|
231
|
-
const dir = join(
|
|
232
|
-
backupRoot,
|
|
233
|
-
`${SNAPSHOT_PREFIX}${label}-${new Date().toISOString().replace(/[:.]/g, '-')}`,
|
|
234
|
-
)
|
|
235
|
-
const pkgCopy = join(dir, 'pkg')
|
|
236
|
-
cpSync(paths.pkgDir, pkgCopy, { recursive: true })
|
|
237
|
-
|
|
238
|
-
let launcherExisted = false
|
|
239
|
-
let launcherTarget: string | null = null
|
|
240
|
-
let launcherFile: string | null = null
|
|
241
|
-
try {
|
|
242
|
-
launcherExisted = true
|
|
243
|
-
if (lstatSync(paths.launcher).isSymbolicLink()) {
|
|
244
|
-
launcherTarget = readlinkSync(paths.launcher)
|
|
245
|
-
} else {
|
|
246
|
-
launcherFile = join(dir, 'launcher')
|
|
247
|
-
copyFileSync(paths.launcher, launcherFile)
|
|
248
|
-
}
|
|
249
|
-
} catch {
|
|
250
|
-
launcherExisted = false // launcher 本来就不在,快照还原不了从未存在的东西
|
|
251
|
-
}
|
|
252
|
-
return { dir, pkgCopy, launcherExisted, launcherTarget, launcherFile }
|
|
273
|
+
rmSync(target, { recursive: true, force: true })
|
|
253
274
|
} catch {
|
|
254
|
-
|
|
275
|
+
// 见上:留着,下一次运行收
|
|
255
276
|
}
|
|
256
277
|
}
|
|
257
278
|
|
|
258
|
-
/**
|
|
259
|
-
|
|
279
|
+
/**
|
|
280
|
+
* 清掉上一次被 SIGKILL / 断电留下的临时物。
|
|
281
|
+
*
|
|
282
|
+
* 它们**只可能是**我们自己的:真安装树从不会被删(只会被 rename),所以「一个 CLI 都没有」
|
|
283
|
+
* 的形态在磁盘上留下的就是这样一堆 `.mipham-*`。清它们不会碰到任何人的安装。
|
|
284
|
+
*/
|
|
285
|
+
function cleanStaleStaging(prefix: string): void {
|
|
286
|
+
let entries: string[]
|
|
260
287
|
try {
|
|
261
|
-
|
|
262
|
-
cpSync(snap.pkgCopy, paths.pkgDir, { recursive: true })
|
|
263
|
-
if (snap.launcherExisted && !existsSync(paths.launcher)) {
|
|
264
|
-
if (snap.launcherTarget !== null) {
|
|
265
|
-
symlinkSync(snap.launcherTarget, paths.launcher)
|
|
266
|
-
} else if (snap.launcherFile !== null) {
|
|
267
|
-
copyFileSync(snap.launcherFile, paths.launcher)
|
|
268
|
-
chmodSync(paths.launcher, 0o755)
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
return true
|
|
288
|
+
entries = readdirSync(prefix)
|
|
272
289
|
} catch {
|
|
273
|
-
return
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
for (const entry of entries) {
|
|
293
|
+
if (entry.startsWith(STAGING_PREFIX) || entry.startsWith(PARKED_PREFIX))
|
|
294
|
+
rmIfPresent(join(prefix, entry))
|
|
274
295
|
}
|
|
275
296
|
}
|
|
276
297
|
|
|
277
|
-
|
|
278
|
-
|
|
298
|
+
/**
|
|
299
|
+
* 同文件系统内的 rename —— 原子的那一步。返回是否成功,失败由调用方决定怎么报。
|
|
300
|
+
*
|
|
301
|
+
* 这是本模块唯一会改动真安装树的操作,而它**没有中间态**:目录要么在旧名、要么在新名,
|
|
302
|
+
* 不存在「写到一半」。这正是它能扛住 SIGKILL / 断电、而 `cpSync` 扛不住的原因。
|
|
303
|
+
*/
|
|
304
|
+
function tryRename(from: string, to: string): boolean {
|
|
279
305
|
try {
|
|
280
|
-
|
|
306
|
+
renameSync(from, to)
|
|
307
|
+
return true
|
|
281
308
|
} catch {
|
|
282
|
-
|
|
309
|
+
return false
|
|
283
310
|
}
|
|
284
311
|
}
|
|
285
312
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
313
|
+
/**
|
|
314
|
+
* 自证结果。**失败必带 `reason`** —— 写成 `ok: boolean` + `reason?: string` 的话,调用方
|
|
315
|
+
* 拿到失败却读不到原因(`undefined` 一路飘到用户面前变成空句),而这四关每一关都能说清
|
|
316
|
+
* 自己是怎么判的。
|
|
317
|
+
*/
|
|
318
|
+
export type InstallVerification =
|
|
319
|
+
{ ok: true; actual?: string } | { ok: false; actual?: string; reason: string }
|
|
292
320
|
|
|
293
321
|
/**
|
|
294
322
|
* 装完之后的**自证**。两关,缺一不可:
|
|
@@ -354,26 +382,63 @@ function blockSigintDuringInstall(): () => void {
|
|
|
354
382
|
/** 执行 `npm install -g`。可注入 —— 测试永不联网。 */
|
|
355
383
|
export type InstallRunner = (command: string, options: InstallOptions) => void
|
|
356
384
|
|
|
385
|
+
/**
|
|
386
|
+
* 安装调用的选项。**只此一处** —— 默认 runner 原样转发它,所以测试断的选项与生产跑的
|
|
387
|
+
* 是同一个对象,不是一个长得像的副本(2.94.0 那次负控回来是绿的,正是这个形状的教训)。
|
|
388
|
+
*/
|
|
389
|
+
const INSTALL_OPTIONS: InstallOptions = { encoding: 'utf-8', stdio: 'inherit', detached: true }
|
|
390
|
+
|
|
391
|
+
/** 失败原因的第一行 —— 完整栈打在终端上只会淹掉「为什么」。 */
|
|
392
|
+
function failureDetail(err: unknown): string {
|
|
393
|
+
return err instanceof Error && err.message ? `(${err.message.split('\n')[0]})` : ''
|
|
394
|
+
}
|
|
395
|
+
|
|
357
396
|
export interface UpdateDeps {
|
|
358
397
|
install?: InstallRunner
|
|
359
398
|
/** 覆盖路径解析;传 `null` 表示「推不出布局」。默认自动推断。 */
|
|
360
399
|
paths?: InstallPaths | null
|
|
361
|
-
/** 快照根目录,默认 `~/.mipham/backups`。 */
|
|
362
|
-
backupRoot?: string
|
|
363
400
|
}
|
|
364
401
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
402
|
+
/**
|
|
403
|
+
* 失败时**用户手上那棵树**的状态。它决定调用方印哪句话,所以必须如实 ——
|
|
404
|
+
* 「没动过」是好消息,把它印成「未能恢复」就是对用户谎报他机器的状态。
|
|
405
|
+
*
|
|
406
|
+
* 注意这**不是**「我们做了什么」的记账,而是「你现在有什么」的回答:
|
|
407
|
+
* 更新失败时用户只关心一件事 —— 我还能不能敲 `mipham`。
|
|
408
|
+
*/
|
|
409
|
+
export type InstallState =
|
|
410
|
+
/** 旧安装未被本次更新改动过(staging 阶段就失败了,换手从未开始)—— 最常见的一种 */
|
|
411
|
+
| 'untouched'
|
|
412
|
+
/** 换手走到一半失败,已用反向 rename 把旧安装放回原位 */
|
|
413
|
+
| 'restored'
|
|
414
|
+
/** 已确认手上没有可用的 CLI(换手失败且放不回去;或本来就没有旧安装) */
|
|
415
|
+
| 'broken'
|
|
416
|
+
/** 推不出布局 ⇒ 装去了哪里、旧树怎样,都判断不了。保守按最坏情况报 */
|
|
417
|
+
| 'unknown'
|
|
418
|
+
|
|
419
|
+
export type UpdateResult =
|
|
420
|
+
| {
|
|
421
|
+
ok: true
|
|
422
|
+
/** 只有跑过自证才算 true —— 「装完没验证」不许冒充成功 */
|
|
423
|
+
verified: boolean
|
|
424
|
+
version: string
|
|
425
|
+
reason?: string
|
|
426
|
+
}
|
|
427
|
+
| {
|
|
428
|
+
ok: false
|
|
429
|
+
verified: false
|
|
430
|
+
installState: InstallState
|
|
431
|
+
reason: string
|
|
432
|
+
version?: string
|
|
433
|
+
}
|
|
374
434
|
|
|
375
435
|
/**
|
|
376
|
-
*
|
|
436
|
+
* 真正执行更新:**装在旁边 → 验过 → 两次 rename 换手**。
|
|
437
|
+
*
|
|
438
|
+
* 与「就地重写 + 失败回滚」的分别不是速度而是**可中断性**:旧写法里 npm 直接重写真包目录,
|
|
439
|
+
* 于是在 `reify` 中途被任何不可捕获的终止(SIGKILL / 断电 / 容器被杀)打断,用户手上就是
|
|
440
|
+
* 一棵半截树 —— 回滚代码在 CLI 进程里,而 CLI 已经死了,没人回滚。现在真包目录在**验过之前
|
|
441
|
+
* 一个字节都不动**:除了换手那两次 rename 之间的微秒级窗口,任何时刻磁盘上都有一棵完整的树。
|
|
377
442
|
*
|
|
378
443
|
* 校验版本号后再进 shell(防命令注入)。
|
|
379
444
|
*
|
|
@@ -387,7 +452,12 @@ export function performUpdate(
|
|
|
387
452
|
): UpdateResult {
|
|
388
453
|
if (!isValidSemver(version)) {
|
|
389
454
|
process.stderr.write(`⚠ Refusing to install invalid version: "${version}"\n`)
|
|
390
|
-
return {
|
|
455
|
+
return {
|
|
456
|
+
ok: false,
|
|
457
|
+
verified: false,
|
|
458
|
+
installState: 'untouched',
|
|
459
|
+
reason: `版本号非法:${version}`,
|
|
460
|
+
}
|
|
391
461
|
}
|
|
392
462
|
|
|
393
463
|
// Sanitize registry — only allow known URLs to prevent command injection
|
|
@@ -395,73 +465,147 @@ export function performUpdate(
|
|
|
395
465
|
const safeRegistry = registry && allowedRegistries.includes(registry) ? registry : undefined
|
|
396
466
|
|
|
397
467
|
const registryFlag = safeRegistry ? ` --registry=${safeRegistry}` : ''
|
|
468
|
+
const spec = `${PACKAGE}@${version}${registryFlag}`
|
|
398
469
|
|
|
399
470
|
const paths = deps.paths !== undefined ? deps.paths : resolveInstallPaths()
|
|
400
|
-
const backupRoot = deps.backupRoot ?? join(miphamHome(), 'backups')
|
|
401
471
|
/** 生产用的 runner:把调用点给的选项原样交给 execSync(不另起一套)。 */
|
|
402
472
|
const defaultInstall: InstallRunner = (command, options) => {
|
|
403
473
|
execSync(command, options)
|
|
404
474
|
}
|
|
405
475
|
const install: InstallRunner = deps.install ?? defaultInstall
|
|
406
476
|
|
|
407
|
-
// 快照必须在安装**之前**:npm 就地重写全局包目录,安装一旦开始,旧树就没了。
|
|
408
|
-
let snap: InstallSnapshot | null = null
|
|
409
|
-
if (paths)
|
|
410
|
-
snap = snapshotInstall(paths, readPkgVersion(paths.pkgDir) ?? getCurrentVersion(), backupRoot)
|
|
411
|
-
|
|
412
|
-
// 安装期间两道守卫(缺一,被保下来的都只有一半):
|
|
413
|
-
// · 这里 —— CLI 自己不被 SIGINT 打死,好让下面的 catch/回滚有机会跑;
|
|
414
|
-
// · 下面的 `detached: true` —— npm 自成进程组,终端的 SIGINT 到不了它。
|
|
415
|
-
//
|
|
416
|
-
// 守位只盖住「npm 在跑」这一段 —— 也就是**唯一会破坏磁盘**的那一段。两端各留一个
|
|
417
|
-
// 未覆盖的窄口,各自无害:① 它前面的快照(复制 6601 个文件)期间按 Ctrl-C ⇒ CLI 退出、
|
|
418
|
-
// npm 从未启动,旧树完好;② 它后面的自证(只读:读 package.json + 跑一次 launcher)
|
|
419
|
-
// 期间按 Ctrl-C ⇒ 不再自动回滚,但树是**完整的**,不是「一个 CLI 都没有」。
|
|
420
|
-
const unblockSigint = blockSigintDuringInstall()
|
|
421
|
-
try {
|
|
422
|
-
// 这里**故意不设 timeout**。任何一个能在正常安装途中开火的计时器,开火那一刻就是破坏
|
|
423
|
-
// 本身:npm 被 SIGTERM 时会留下半截树(旧包已删、新包没写完)⇒ 用户一个 CLI 都没有,
|
|
424
|
-
// 连 `mipham update` 本身也没了。本机实测这个包的下载要 11 分钟以上,而原来设在 10 分钟。
|
|
425
|
-
// 进度由 npm 自己印在用户终端上(stdio: 'inherit'),要中断交由用户决定。
|
|
426
|
-
//
|
|
427
|
-
// detached 只换进程组、不换等待语义 —— 实测 execSync 照样阻塞到 npm 退出(1.01s vs
|
|
428
|
-
// 未 detached 的 1.02s),所以自证仍在装完之后;stdio:'inherit' 下它的输出也照样
|
|
429
|
-
// 打在用户终端上(两条通道都实测可见)。
|
|
430
|
-
install(`npm install -g ${PACKAGE}@${version}${registryFlag}`, {
|
|
431
|
-
encoding: 'utf-8',
|
|
432
|
-
stdio: 'inherit',
|
|
433
|
-
detached: true,
|
|
434
|
-
})
|
|
435
|
-
} catch (err) {
|
|
436
|
-
const rolledBack = paths && snap ? restoreInstall(snap, paths) : false
|
|
437
|
-
discardSnapshot(snap)
|
|
438
|
-
const detail = err instanceof Error && err.message ? `(${err.message.split('\n')[0]})` : ''
|
|
439
|
-
return { ok: false, verified: false, rolledBack, reason: `安装进程被中断${detail}` }
|
|
440
|
-
} finally {
|
|
441
|
-
unblockSigint()
|
|
442
|
-
}
|
|
443
|
-
|
|
444
477
|
if (!paths) {
|
|
445
|
-
// 推不出布局 ⇒
|
|
446
|
-
|
|
478
|
+
// 推不出布局 ⇒ 连换手点在哪都不知道,做不了 staging。退回**旧行为**:就地装、不自证,
|
|
479
|
+
// 并如实说明。这里没有任何安全网,所以旧树的状态是「不知道」而不是「没动过」。
|
|
480
|
+
try {
|
|
481
|
+
install(`npm install -g ${spec}`, INSTALL_OPTIONS)
|
|
482
|
+
} catch (err) {
|
|
483
|
+
return {
|
|
484
|
+
ok: false,
|
|
485
|
+
verified: false,
|
|
486
|
+
installState: 'unknown',
|
|
487
|
+
reason: `安装进程被中断${failureDetail(err)}`,
|
|
488
|
+
}
|
|
489
|
+
}
|
|
447
490
|
return {
|
|
448
491
|
ok: true,
|
|
449
492
|
verified: false,
|
|
450
|
-
rolledBack: false,
|
|
451
493
|
version,
|
|
452
494
|
reason: '无法定位全局安装路径,未能验证',
|
|
453
495
|
}
|
|
454
496
|
}
|
|
455
497
|
|
|
456
|
-
const
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
498
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
499
|
+
const stagingPrefix = join(paths.prefix, `${STAGING_PREFIX}${stamp}`)
|
|
500
|
+
const parkedDir = join(paths.prefix, `${PARKED_PREFIX}${stamp}`)
|
|
501
|
+
// staging 的布局用 **paths 记下的那个平台**,不另读 process.platform —— 见 InstallPaths.platform。
|
|
502
|
+
const staging = layoutFor(stagingPrefix, paths.platform)
|
|
503
|
+
|
|
504
|
+
/** 失败时该怎么形容用户手上那棵树:换手没开始 ⇒ 没动过;本来就没有 ⇒ 没得用。 */
|
|
505
|
+
const stateIfNotSwapped = (): InstallState => (existsSync(paths.pkgDir) ? 'untouched' : 'broken')
|
|
462
506
|
|
|
463
|
-
|
|
464
|
-
|
|
507
|
+
cleanStaleStaging(paths.prefix)
|
|
508
|
+
|
|
509
|
+
// 守位盖住**整段会改磁盘的窗口**:安装 → 自证 → 换手。换成 staging 之后 npm 那一段已经
|
|
510
|
+
// 打不坏东西了(真树在旁边看着),但换手那两次 rename 是,而它们也是「CLI 被杀就会留下
|
|
511
|
+
// 半截状态」的唯一去处。两端各留一个未覆盖的窄口,都只读:① 前面 `cleanStaleStaging()`
|
|
512
|
+
// 只删我们自己的临时物;② 后面收尾删旧树 —— 那时新树已经就位并验过,删不掉只是占地方。
|
|
513
|
+
const unblockSigint = blockSigintDuringInstall()
|
|
514
|
+
try {
|
|
515
|
+
// ① 装在旁边。真树此刻一个字节都没动 —— 这一步无论怎么被打断,用户手里都还是旧版。
|
|
516
|
+
//
|
|
517
|
+
// 这里**故意不设 timeout**。任何一个能在正常安装途中开火的计时器,开火那一刻就是破坏
|
|
518
|
+
// 本身。本机实测这个包的下载要 11 分钟以上,而原来设在 10 分钟。进度由 npm 自己印在
|
|
519
|
+
// 用户终端上(stdio: 'inherit'),要中断交由用户决定。
|
|
520
|
+
//
|
|
521
|
+
// detached 只换进程组、不换等待语义 —— 实测 execSync 照样阻塞到 npm 退出。它与
|
|
522
|
+
// `blockSigintDuringInstall()` 是一对:那条保住 CLI,这条保住 npm。
|
|
523
|
+
//
|
|
524
|
+
// 路径进 shell 必须带引号:prefix 里可能有空格(`/Users/John Doe/.nvm/…`),实测
|
|
525
|
+
// 双引号形式在 sh 与 cmd.exe 两侧都成立(真 npm + 带空格 prefix 已单独探过)。
|
|
526
|
+
try {
|
|
527
|
+
install(`npm install -g --prefix "${stagingPrefix}" ${spec}`, INSTALL_OPTIONS)
|
|
528
|
+
} catch (err) {
|
|
529
|
+
rmIfPresent(stagingPrefix)
|
|
530
|
+
return {
|
|
531
|
+
ok: false,
|
|
532
|
+
verified: false,
|
|
533
|
+
installState: stateIfNotSwapped(),
|
|
534
|
+
version,
|
|
535
|
+
reason: `安装进程被中断${failureDetail(err)}`,
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// ② 先在 staging 上自证。不过 ⇒ 删掉暂存,真树连碰都没碰过 ⇒ **根本不需要回滚**。
|
|
540
|
+
const staged = verifyInstalledVersion(staging, version)
|
|
541
|
+
if (!staged.ok) {
|
|
542
|
+
rmIfPresent(stagingPrefix)
|
|
543
|
+
return {
|
|
544
|
+
ok: false,
|
|
545
|
+
verified: false,
|
|
546
|
+
installState: stateIfNotSwapped(),
|
|
547
|
+
version,
|
|
548
|
+
reason: staged.reason,
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// ③ 换手:两次 rename(同文件系统 ⇒ 各有原子性)。**launcher 全程不碰** —— 它是指向
|
|
553
|
+
// 包目录的相对符号链接(Unix)或按 `%~dp0` 解析的 shim(Windows),包路径不变,
|
|
554
|
+
// 它就永远有效。这也正是「只要搬包目录」是完整动作、不需要任何改写的原因。
|
|
555
|
+
//
|
|
556
|
+
// 旧树不是「备份」而是**从原地挪开的那一份**:拿它回滚是再一次 rename,不是复制 ——
|
|
557
|
+
// 所以回滚这一步本身也不会被中途打断(复制会)。
|
|
558
|
+
const hadOldInstall = existsSync(paths.pkgDir)
|
|
559
|
+
if (hadOldInstall && !tryRename(paths.pkgDir, parkedDir)) {
|
|
560
|
+
rmIfPresent(stagingPrefix)
|
|
561
|
+
return {
|
|
562
|
+
ok: false,
|
|
563
|
+
verified: false,
|
|
564
|
+
installState: 'untouched',
|
|
565
|
+
version,
|
|
566
|
+
reason: '无法把旧安装暂时移到一边(rename 失败,权限?)',
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
if (!tryRename(staging.pkgDir, paths.pkgDir)) {
|
|
570
|
+
// 旧树确实被挪开过 ⇒ 这一格只能是 `restored`(放回去了)或 `broken`(放不回去),
|
|
571
|
+
// **不能**是 `untouched` —— 那个词的含义是「换手从未开始」。本来就没有旧树时也无所谓
|
|
572
|
+
// 还原,直接按最坏情况报。
|
|
573
|
+
const restored = hadOldInstall && tryRename(parkedDir, paths.pkgDir)
|
|
574
|
+
rmIfPresent(stagingPrefix)
|
|
575
|
+
return {
|
|
576
|
+
ok: false,
|
|
577
|
+
verified: false,
|
|
578
|
+
installState: restored ? 'restored' : 'broken',
|
|
579
|
+
version,
|
|
580
|
+
reason: '换手失败:新树没能就位',
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// ④ 换手后再自证一次。廉价保险:在 staging 里跑得过不等于搬过来也跑得过 —— 树里若有
|
|
585
|
+
// 安装期写死的**绝对**路径,它就是搬完才指错的。失败 ⇒ 反向换手把旧树拿回来。
|
|
586
|
+
const swapped = verifyInstalledVersion(paths, version)
|
|
587
|
+
if (!swapped.ok) {
|
|
588
|
+
const badDir = `${parkedDir}-bad`
|
|
589
|
+
const restored =
|
|
590
|
+
tryRename(paths.pkgDir, badDir) && hadOldInstall && tryRename(parkedDir, paths.pkgDir)
|
|
591
|
+
rmIfPresent(badDir)
|
|
592
|
+
rmIfPresent(stagingPrefix)
|
|
593
|
+
return {
|
|
594
|
+
ok: false,
|
|
595
|
+
verified: false,
|
|
596
|
+
installState: restored ? 'restored' : 'broken',
|
|
597
|
+
version,
|
|
598
|
+
reason: swapped.reason,
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// ⑤ 收尾:旧树与暂存外壳都不再需要。删不掉也不改变结论(新树已就位并验过)。
|
|
603
|
+
rmIfPresent(parkedDir)
|
|
604
|
+
rmIfPresent(stagingPrefix)
|
|
605
|
+
return { ok: true, verified: true, version }
|
|
606
|
+
} finally {
|
|
607
|
+
unblockSigint()
|
|
608
|
+
}
|
|
465
609
|
}
|
|
466
610
|
|
|
467
611
|
/**
|
package/src/ui/commands.ts
CHANGED
|
@@ -11,7 +11,7 @@ import type { SkillsLoader } from '../skills/loader'
|
|
|
11
11
|
import { loadSkillUsage } from '../skills/usage'
|
|
12
12
|
import type { PluginManager } from '../plugin/plugin-manager'
|
|
13
13
|
import type { Message } from '../shared/types.js'
|
|
14
|
-
import type { UpdateStatus } from '../shared/update'
|
|
14
|
+
import type { InstallState, UpdateStatus } from '../shared/update'
|
|
15
15
|
import { McpClient } from '../mcp/client'
|
|
16
16
|
import { unregisterMcpServerTools } from '../mcp/registry'
|
|
17
17
|
import { buildCapabilityReport } from '../core/capability-inventory'
|
|
@@ -4438,6 +4438,21 @@ const memoryCmd: CommandHandler = async (ctx, args) => {
|
|
|
4438
4438
|
// Upgrade
|
|
4439
4439
|
// ═══════════════════════════════════════════════════════════════
|
|
4440
4440
|
|
|
4441
|
+
/**
|
|
4442
|
+
* 失败后「我手里还有没有 CLI」这句话的四种说法。
|
|
4443
|
+
*
|
|
4444
|
+
* `Record<InstallState, …>` 而非内联三元:漏一个状态**编译期就红**。写成
|
|
4445
|
+
* `result.rolledBack ? A : B` 时漏掉的那一格会静默落进 else,而 else 说的正是**最重的
|
|
4446
|
+
* 那句**(「原安装未能恢复」)—— 拿「最坏情况」当兜底,就等于把每条没想清楚的路径都
|
|
4447
|
+
* 报成灾难。
|
|
4448
|
+
*/
|
|
4449
|
+
const UPGRADE_FAILURE_NOTE: Record<InstallState, string> = {
|
|
4450
|
+
untouched: 'commands.upgrade.install_untouched',
|
|
4451
|
+
restored: 'commands.upgrade.rolled_back',
|
|
4452
|
+
broken: 'commands.upgrade.no_rollback',
|
|
4453
|
+
unknown: 'commands.upgrade.install_unknown',
|
|
4454
|
+
}
|
|
4455
|
+
|
|
4441
4456
|
const upgradeCmd: CommandHandler = async (ctx) => {
|
|
4442
4457
|
const t = resolveT(ctx)
|
|
4443
4458
|
const { checkForUpdates, backupConfig, performUpdate, restoreConfig, getConfigPath } =
|
|
@@ -4500,10 +4515,9 @@ const upgradeCmd: CommandHandler = async (ctx) => {
|
|
|
4500
4515
|
lines.push(t('commands.upgrade.old_version_warning'))
|
|
4501
4516
|
} else {
|
|
4502
4517
|
lines.push('')
|
|
4503
|
-
// 失败信息必须回答两件事:为什么,以及**我手里还有没有 CLI
|
|
4504
|
-
|
|
4505
|
-
|
|
4506
|
-
)
|
|
4518
|
+
// 失败信息必须回答两件事:为什么,以及**我手里还有没有 CLI**。四态各有各的说法 ——
|
|
4519
|
+
// 尤其「旧树没被碰过」与「旧树没能恢复」是两件不同的事,合并成一句后者必定说谎。
|
|
4520
|
+
const rollbackNote = t(UPGRADE_FAILURE_NOTE[result.installState])
|
|
4507
4521
|
lines.push(
|
|
4508
4522
|
t('commands.upgrade.update_failed', {
|
|
4509
4523
|
reason: result.reason ?? '',
|