@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.
@@ -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.5' as const
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
@@ -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` 只为测试可注入,默认本模块所在目录(<pkgDir>/src/shared)。
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(fromDir?: string): InstallPaths | null {
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
- /** 安装前的快照。npm 是就地重写,出事时没有第二份可选 —— 只有这个。 */
195
- interface InstallSnapshot {
196
- dir: string
197
- pkgCopy: string
198
- launcherExisted: boolean
199
- /** 符号链接的原样目标(相对路径也要原样存回) */
200
- launcherTarget: string | null
201
- /** 普通文件形态的 launcher 存到包副本**之外**,否则会被当成多出来的文件还原进 pkgDir */
202
- launcherFile: string | null
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
- const SNAPSHOT_PREFIX = 'cli-'
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
- * 把当前安装整份存下来。返回 null 表示**存不下来** —— 那时不得回滚(没有可回的东西)。
218
- * 会先清掉上一次运行留下的 `cli-*` 残留:那是被中断的上一次,它备份的树早已不是任何人的安装。
268
+ * 删掉一棵临时树。**失败不抛** —— 收尾清理失败不该把一个已经成功的更新判成失败;
269
+ * 删不掉的残留会在下一次运行开头的 `cleanStaleStaging()` 里被收掉。
219
270
  */
220
- function snapshotInstall(
221
- paths: InstallPaths,
222
- label: string,
223
- backupRoot: string,
224
- ): InstallSnapshot | null {
271
+ function rmIfPresent(target: string): void {
225
272
  try {
226
- mkdirSync(backupRoot, { recursive: true, mode: 0o700 })
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
- return null
275
+ // 见上:留着,下一次运行收
255
276
  }
256
277
  }
257
278
 
258
- /** 把快照放回去。返回是否放成功 —— 失败必须如实上报,不能让调用方以为用户还有 CLI。 */
259
- function restoreInstall(snap: InstallSnapshot, paths: InstallPaths): boolean {
279
+ /**
280
+ * 清掉上一次被 SIGKILL / 断电留下的临时物。
281
+ *
282
+ * 它们**只可能是**我们自己的:真安装树从不会被删(只会被 rename),所以「一个 CLI 都没有」
283
+ * 的形态在磁盘上留下的就是这样一堆 `.mipham-*`。清它们不会碰到任何人的安装。
284
+ */
285
+ function cleanStaleStaging(prefix: string): void {
286
+ let entries: string[]
260
287
  try {
261
- rmSync(paths.pkgDir, { recursive: true, force: true })
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 false
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
- function discardSnapshot(snap: InstallSnapshot | null): void {
278
- if (!snap) return
298
+ /**
299
+ * 同文件系统内的 rename —— 原子的那一步。返回是否成功,失败由调用方决定怎么报。
300
+ *
301
+ * 这是本模块唯一会改动真安装树的操作,而它**没有中间态**:目录要么在旧名、要么在新名,
302
+ * 不存在「写到一半」。这正是它能扛住 SIGKILL / 断电、而 `cpSync` 扛不住的原因。
303
+ */
304
+ function tryRename(from: string, to: string): boolean {
279
305
  try {
280
- rmSync(snap.dir, { recursive: true, force: true })
306
+ renameSync(from, to)
307
+ return true
281
308
  } catch {
282
- // 删不掉就留着;下一次运行开头的清理会收掉它
309
+ return false
283
310
  }
284
311
  }
285
312
 
286
- export interface InstallVerification {
287
- ok: boolean
288
- /** 包自报的版本 */
289
- actual?: string
290
- reason?: string
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
- export interface UpdateResult {
366
- ok: boolean
367
- /** 只有跑过自证才算 true —— 「装完没验证」不许冒充成功 */
368
- verified: boolean
369
- /** 失败后旧安装是否被放了回去 */
370
- rolledBack: boolean
371
- version?: string
372
- reason?: string
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
- * 真正执行更新:先快照 → `npm install -g` → 自证 → 失败则回滚。
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 { ok: false, verified: false, rolledBack: false, reason: `版本号非法:${version}` }
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
- discardSnapshot(snap)
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 check = verifyInstalledVersion(paths, version)
457
- if (!check.ok) {
458
- const rolledBack = snap ? restoreInstall(snap, paths) : false
459
- discardSnapshot(snap)
460
- return { ok: false, verified: false, rolledBack, version, reason: check.reason }
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
- discardSnapshot(snap)
464
- return { ok: true, verified: true, rolledBack: false, version }
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
  /**
@@ -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
- const rollbackNote = t(
4505
- result.rolledBack ? 'commands.upgrade.rolled_back' : 'commands.upgrade.no_rollback',
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 ?? '',