@miphamai/cli 0.85.4 → 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.
Files changed (52) hide show
  1. package/bin/mipham.ts +48 -7
  2. package/package.json +2 -2
  3. package/src/agent/agent-context.ts +8 -1
  4. package/src/agent/effectiveness-tracker.ts +16 -2
  5. package/src/agent/sub-agent.ts +25 -17
  6. package/src/agent-view/agents-standalone.tsx +42 -0
  7. package/src/core/autocomplete.ts +30 -2
  8. package/src/core/context.ts +61 -6
  9. package/src/core/dream-engine.ts +17 -2
  10. package/src/core/engine.ts +95 -34
  11. package/src/core/error-signature-db.ts +7 -2
  12. package/src/core/hooks-executor.ts +88 -11
  13. package/src/core/hooks.ts +26 -2
  14. package/src/core/instructions.ts +105 -17
  15. package/src/core/memory/memory-manager.ts +14 -6
  16. package/src/core/permission-classifier.ts +17 -5
  17. package/src/core/permission-rules.ts +1 -1
  18. package/src/core/permission.ts +50 -1
  19. package/src/core/rule-engine.ts +27 -2
  20. package/src/core/self-critique.ts +15 -3
  21. package/src/core/session-log.ts +60 -0
  22. package/src/daemon/index.ts +2 -5
  23. package/src/daemon/launch.ts +69 -2
  24. package/src/daemon/server.ts +19 -14
  25. package/src/i18n-core/locales/en-US.json +86 -143
  26. package/src/i18n-core/locales/zh-CN.json +86 -143
  27. package/src/index.tsx +17 -7
  28. package/src/mcp/client.ts +61 -0
  29. package/src/mcp/instructions.ts +49 -0
  30. package/src/mcp/types.ts +7 -0
  31. package/src/plugin/claude-plugin.ts +12 -2
  32. package/src/plugin/plugin-loader.ts +32 -14
  33. package/src/plugin/plugin-manager.ts +16 -2
  34. package/src/plugin/plugin-validator.ts +183 -1
  35. package/src/providers/anthropic.ts +159 -124
  36. package/src/providers/fetch-utils.ts +65 -34
  37. package/src/providers/openai-compat.ts +121 -106
  38. package/src/security/dangerous-rm.ts +192 -0
  39. package/src/shared/arg-validation.ts +11 -1
  40. package/src/shared/constants.ts +18 -0
  41. package/src/shared/deleted-cwd.ts +46 -1
  42. package/src/shared/package-info.ts +36 -1
  43. package/src/shared/types.ts +8 -0
  44. package/src/shared/update.ts +290 -146
  45. package/src/ui/command-picker.tsx +18 -10
  46. package/src/ui/commands.ts +20 -6
  47. package/src/ui/config-wizard.tsx +22 -19
  48. package/src/ui/graft-status.tsx +35 -6
  49. package/src/ui/input.tsx +7 -2
  50. package/src/ui/picker.tsx +38 -27
  51. package/src/ui/use-key-state.ts +55 -0
  52. package/src/daemon/message-bus.ts +0 -84
@@ -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
  /**
@@ -4,6 +4,7 @@ import TextInput from 'ink-text-input'
4
4
  import { useI18n } from '../i18n-context'
5
5
  import { getCommandList } from './commands.js'
6
6
  import { commandToken, hasInlineArgs } from './command-token.js'
7
+ import { useKeyState } from './use-key-state'
7
8
 
8
9
  interface CommandPickerProps {
9
10
  /** Text already typed (e.g. "/", "/age") — used as initial filter */
@@ -59,7 +60,9 @@ export function CommandPicker({
59
60
  const { t } = useI18n()
60
61
  const allCommands = useMemo(() => getCommandList(), [])
61
62
  const [filter, setFilter] = useState(initialFilter)
62
- const [cursorIdx, setCursorIdx] = useState(0)
63
+ // 光标走 `useKeyState`:一组按键可能在同一拍里到达(↓ 之后紧跟 Enter),确认时
64
+ // 必须读得到本次按键刚写下的那个索引,而不是上一张闭包里的。
65
+ const cursorIdx = useKeyState(0)
63
66
 
64
67
  // Filter commands based on user input (match on the command-name token, not the
65
68
  // full line — so "/loop 60s echo hello" still matches "/loop").
@@ -73,16 +76,16 @@ export function CommandPicker({
73
76
 
74
77
  // Reset cursor when filter changes
75
78
  useEffect(() => {
76
- setCursorIdx(0)
77
- }, [filter])
79
+ cursorIdx.set(0)
80
+ }, [filter, cursorIdx])
78
81
 
79
82
  // Scroll window: keep cursor in the visible range
80
83
  const scrollStart = Math.max(
81
84
  0,
82
- Math.min(cursorIdx - Math.floor(maxVisible / 2), filtered.length - maxVisible),
85
+ Math.min(cursorIdx.value - Math.floor(maxVisible / 2), filtered.length - maxVisible),
83
86
  )
84
87
  const visible = filtered.slice(scrollStart, scrollStart + maxVisible)
85
- const _adjustedCursor = cursorIdx - scrollStart
88
+ const _adjustedCursor = cursorIdx.value - scrollStart
86
89
 
87
90
  // Wrap cursor safely
88
91
  const safeCursor = (i: number) =>
@@ -97,7 +100,7 @@ export function CommandPicker({
97
100
  onSelect(trimmed)
98
101
  return
99
102
  }
100
- const selected = filtered[cursorIdx]
103
+ const selected = filtered[cursorIdx.read()]
101
104
  if (selected) onSelect(selected.name)
102
105
  }
103
106
 
@@ -113,12 +116,12 @@ export function CommandPicker({
113
116
  }
114
117
 
115
118
  if (key.upArrow) {
116
- setCursorIdx((prev) => safeCursor(prev - 1))
119
+ cursorIdx.set((prev) => safeCursor(prev - 1))
117
120
  return
118
121
  }
119
122
 
120
123
  if (key.downArrow) {
121
- setCursorIdx((prev) => safeCursor(prev + 1))
124
+ cursorIdx.set((prev) => safeCursor(prev + 1))
122
125
  return
123
126
  }
124
127
  })
@@ -143,7 +146,7 @@ export function CommandPicker({
143
146
  )}
144
147
  {visible.map((cmd, i) => {
145
148
  const globalIdx = i + scrollStart
146
- const isCursor = globalIdx === cursorIdx
149
+ const isCursor = globalIdx === cursorIdx.value
147
150
  const query = commandToken(filter)
148
151
  const segments = highlightMatches(cmd.name, query)
149
152
  const padLen = Math.max(0, 20 - cmd.name.length)
@@ -180,10 +183,15 @@ export function CommandPicker({
180
183
  {/* Filter input */}
181
184
  <Box>
182
185
  <Text color="cyan">/ </Text>
186
+ {/*
187
+ Enter 由本组件自己的 `useInput` 处理(那里才知道光标停在哪一行、要不要走
188
+ 内联参数那条路)。**不要再给 TextInput 挂 `onSubmit`** —— 它会成为第二个
189
+ 监听者,一次 Enter 变成两次提交,而 `onSelect` 的调用点接的是「提交这条
190
+ 命令」:多出来的一遍就是命令跑两次。
191
+ */}
183
192
  <TextInput
184
193
  value={filter.startsWith('/') ? filter.slice(1) : filter}
185
194
  onChange={(val) => setFilter(`/${val}`)}
186
- onSubmit={() => submitSelection()}
187
195
  placeholder={t('ui.command_picker.placeholder')}
188
196
  />
189
197
  </Box>