@miphamai/cli 0.84.0 → 0.85.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/mipham.ts CHANGED
@@ -289,10 +289,18 @@ async function runUpdate(): Promise<boolean> {
289
289
  console.log()
290
290
  console.log(`Updating ${PACKAGE} to v${latestVersion}...`)
291
291
 
292
- const ok = performUpdate(latestVersion, workingRegistry)
293
- if (!ok) {
292
+ const result = performUpdate(latestVersion, workingRegistry)
293
+ if (!result.ok) {
294
294
  console.log()
295
- console.log(`✗ Update failed.`)
295
+ console.log(`✗ Update failed: ${result.reason ?? 'unknown error'}`)
296
+ if (result.rolledBack) {
297
+ console.log(
298
+ ` Your previous install (v${currentVersion}) has been restored — mipham still works.`,
299
+ )
300
+ } else {
301
+ console.log(' ⚠ The previous install could not be restored.')
302
+ console.log(` Reinstall with: npm install -g ${PACKAGE}@${currentVersion}`)
303
+ }
296
304
  if (backupPath && existsSync(backupPath)) {
297
305
  console.log(` Your config backup is at: ${backupPath}`)
298
306
  }
@@ -313,8 +321,15 @@ async function runUpdate(): Promise<boolean> {
313
321
  }
314
322
 
315
323
  console.log()
316
- console.log(`✓ Updated to ${PACKAGE} v${latestVersion}`)
317
- console.log(` Run 'mipham --version' to verify.`)
324
+ if (result.verified) {
325
+ // 自证已经跑过(包版本 + launcher 实跑),不再把验证推给用户。
326
+ console.log(`✓ Updated to ${PACKAGE} v${latestVersion} — verified.`)
327
+ } else {
328
+ console.log(`⚠ Installed ${PACKAGE} v${latestVersion}, but could not verify it.`)
329
+ console.log(` ${result.reason ?? ''}`)
330
+ console.log(` Please check: mipham --version`)
331
+ process.exit(1)
332
+ }
318
333
  process.exit(0)
319
334
  }
320
335
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.84.0",
3
+ "version": "0.85.0",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -430,7 +430,10 @@
430
430
  "config_preserved": "✓ Config preserved: {path}",
431
431
  "config_restored": "✓ Config restored from backup.",
432
432
  "old_version_warning": "⚠ The running Mipham Code process is still the old version.\n Run `mipham` again to use the new version, or `mipham --version` to verify.",
433
- "update_failed": "✗ Update failed.\n Try manually: {command}\n Your config backup is at: {path}"
433
+ "update_failed": "✗ Update failed: {reason}{rolledBack}\n Try manually: {command}\n Your config backup is at: {path}",
434
+ "rolled_back": "\n Your previous install was restored — mipham still works.",
435
+ "no_rollback": "\n ⚠ Your previous install could not be restored — reinstall it manually (see below).",
436
+ "unverified": "⚠ Installed, but could not verify the new version: {reason}"
434
437
  },
435
438
  "no_plan": {
436
439
  "confirmed": "✓ Plan mode exited. Your plan has been discarded.\n\nContinue chatting as normal, or type /plan to start a new plan."
@@ -430,7 +430,10 @@
430
430
  "config_preserved": "✓ 配置已保留: {path}",
431
431
  "config_restored": "✓ 配置已从备份恢复。",
432
432
  "old_version_warning": "⚠ 当前运行的 Mipham Code 仍然是旧版本。\n 再次运行 `mipham` 以使用新版本,或 `mipham --version` 验证。",
433
- "update_failed": "✗ 更新失败。\n 手动尝试: {command}\n 配置备份位于: {path}"
433
+ "update_failed": "✗ 更新失败: {reason}{rolledBack}\n 手动尝试: {command}\n 配置备份位于: {path}",
434
+ "rolled_back": "\n 已恢复原安装 —— mipham 仍可使用。",
435
+ "no_rollback": "\n ⚠ 原安装未能恢复 —— 请按下方命令手动重装。",
436
+ "unverified": "⚠ 已安装,但无法验证新版本: {reason}"
434
437
  },
435
438
  "no_plan": {
436
439
  "confirmed": "✓ 计划模式已退出。你的计划已丢弃。\n\n继续正常对话,或输入 /plan 开始新计划。"
@@ -9,7 +9,7 @@
9
9
  export const PACKAGE_NAME = '@miphamai/cli' as const
10
10
 
11
11
  /** 当前发布版本 */
12
- export const PACKAGE_VERSION = '0.84.0' as const
12
+ export const PACKAGE_VERSION = '0.85.0' as const
13
13
 
14
14
  /** npm install 全局安装命令 */
15
15
  export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
@@ -6,9 +6,21 @@
6
6
  * use the same logic.
7
7
  */
8
8
 
9
- import { readFileSync, existsSync, copyFileSync, mkdirSync, chmodSync } from 'node:fs'
10
- import { join } from 'node:path'
11
- import { execSync } from 'node:child_process'
9
+ import {
10
+ readFileSync,
11
+ existsSync,
12
+ copyFileSync,
13
+ mkdirSync,
14
+ chmodSync,
15
+ cpSync,
16
+ rmSync,
17
+ readdirSync,
18
+ lstatSync,
19
+ readlinkSync,
20
+ symlinkSync,
21
+ } from 'node:fs'
22
+ import { join, resolve } from 'node:path'
23
+ import { execSync, execFileSync } from 'node:child_process'
12
24
  import { PACKAGE_VERSION } from './package-info'
13
25
  import { miphamHome } from '../core/paths.ts'
14
26
 
@@ -150,20 +162,204 @@ function isValidSemver(v: string): boolean {
150
162
  return SEMVER_RE.test(v)
151
163
  }
152
164
 
165
+ /** 全局安装的三个位置。 */
166
+ export interface InstallPaths {
167
+ /** node prefix,例如 ~/.nvm/versions/node/v24.14.0 */
168
+ prefix: string
169
+ /** <prefix>/lib/node_modules/@miphamai/cli */
170
+ pkgDir: string
171
+ /** <prefix>/bin/mipham(Windows 下是 mipham.cmd) */
172
+ launcher: string
173
+ }
174
+
175
+ /**
176
+ * 推出全局安装路径。`fromDir` 只为测试可注入,默认本模块所在目录(<pkgDir>/src/shared)。
177
+ *
178
+ * 推不出时返回 **null**,绝不猜:`../../../..` 只是算术,它不能证明这个路径真的是一个
179
+ * node prefix。对照物是 `<prefix>/bin/npm` —— 真 prefix 一定有,猜出来的路径不一定有。
180
+ */
181
+ export function resolveInstallPaths(fromDir?: string): InstallPaths | null {
182
+ const base = fromDir ?? import.meta.dirname
183
+ if (!base) return null
184
+ const pkgDir = resolve(base, '..', '..')
185
+ 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
+
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
203
+ }
204
+
205
+ const SNAPSHOT_PREFIX = 'cli-'
206
+
207
+ function readPkgVersion(pkgDir: string): string | undefined {
208
+ try {
209
+ return (JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf-8')) as { version?: string })
210
+ .version
211
+ } catch {
212
+ return undefined
213
+ }
214
+ }
215
+
216
+ /**
217
+ * 把当前安装整份存下来。返回 null 表示**存不下来** —— 那时不得回滚(没有可回的东西)。
218
+ * 会先清掉上一次运行留下的 `cli-*` 残留:那是被中断的上一次,它备份的树早已不是任何人的安装。
219
+ */
220
+ function snapshotInstall(
221
+ paths: InstallPaths,
222
+ label: string,
223
+ backupRoot: string,
224
+ ): InstallSnapshot | null {
225
+ 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 }
253
+ } catch {
254
+ return null
255
+ }
256
+ }
257
+
258
+ /** 把快照放回去。返回是否放成功 —— 失败必须如实上报,不能让调用方以为用户还有 CLI。 */
259
+ function restoreInstall(snap: InstallSnapshot, paths: InstallPaths): boolean {
260
+ 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
272
+ } catch {
273
+ return false
274
+ }
275
+ }
276
+
277
+ function discardSnapshot(snap: InstallSnapshot | null): void {
278
+ if (!snap) return
279
+ try {
280
+ rmSync(snap.dir, { recursive: true, force: true })
281
+ } catch {
282
+ // 删不掉就留着;下一次运行开头的清理会收掉它
283
+ }
284
+ }
285
+
286
+ export interface InstallVerification {
287
+ ok: boolean
288
+ /** 包自报的版本 */
289
+ actual?: string
290
+ reason?: string
291
+ }
292
+
293
+ /**
294
+ * 装完之后的**自证**。两关,缺一不可:
295
+ * 1. `<pkgDir>/package.json` 的版本就是目标版本(事故里它连同整棵树一起没了);
296
+ * 2. **launcher 真的能跑**并报出目标版本 —— 用户敲的是 `mipham --version`,不是读文件。
297
+ * 只查退出码、或只查文件在不在,都会把「装坏了」读成成功。
298
+ */
299
+ export function verifyInstalledVersion(paths: InstallPaths, expected: string): InstallVerification {
300
+ const actual = readPkgVersion(paths.pkgDir)
301
+ if (actual === undefined) return { ok: false, reason: '安装树不完整:读不到 package.json' }
302
+ if (actual !== expected)
303
+ return { ok: false, actual, reason: `包装成了 ${actual},目标是 ${expected}` }
304
+ try {
305
+ const out = execFileSync(paths.launcher, ['--version'], {
306
+ encoding: 'utf-8',
307
+ timeout: 20_000,
308
+ stdio: ['ignore', 'pipe', 'pipe'],
309
+ shell: process.platform === 'win32',
310
+ }).trim()
311
+ if (!out.includes(expected))
312
+ return { ok: false, actual, reason: `launcher 报告 "${out}",不含 ${expected}` }
313
+ } catch (err) {
314
+ const detail = err instanceof Error ? err.message.split('\n')[0] : String(err)
315
+ return { ok: false, actual, reason: `launcher 跑不起来:${detail}` }
316
+ }
317
+ return { ok: true, actual }
318
+ }
319
+
320
+ /**
321
+ * 传给 npm 调用的选项。**只此一处** —— 默认 runner 原样转发它,所以测试断的选项
322
+ * 与生产跑的是同一个对象,不是一个长得像的副本。
323
+ */
324
+ export type InstallOptions = { encoding: 'utf-8'; stdio: 'inherit' }
325
+
326
+ /** 执行 `npm install -g`。可注入 —— 测试永不联网。 */
327
+ export type InstallRunner = (command: string, options: InstallOptions) => void
328
+
329
+ export interface UpdateDeps {
330
+ install?: InstallRunner
331
+ /** 覆盖路径解析;传 `null` 表示「推不出布局」。默认自动推断。 */
332
+ paths?: InstallPaths | null
333
+ /** 快照根目录,默认 `~/.mipham/backups`。 */
334
+ backupRoot?: string
335
+ }
336
+
337
+ export interface UpdateResult {
338
+ ok: boolean
339
+ /** 只有跑过自证才算 true —— 「装完没验证」不许冒充成功 */
340
+ verified: boolean
341
+ /** 失败后旧安装是否被放了回去 */
342
+ rolledBack: boolean
343
+ version?: string
344
+ reason?: string
345
+ }
346
+
153
347
  /**
154
- * Perform the actual update via npm install -g.
155
- * Validates the version string before shell execution (prevents command injection).
348
+ * 真正执行更新:先快照 → `npm install -g` → 自证 → 失败则回滚。
349
+ *
350
+ * 校验版本号后再进 shell(防命令注入)。
156
351
  *
157
- * @param version Target semver version (must pass isValidSemver).
158
- * @param registry Optional npm registry URL — when provided the install uses it
159
- * directly (useful when the primary registry is slow, e.g. China).
160
- * Falls back to the npm default if omitted.
161
- * Returns true on success.
352
+ * @param version 目标 semver(必须通过 isValidSemver)。
353
+ * @param registry 可选 registry URL,只接受已知 URL;省略则用 npm 默认。
162
354
  */
163
- export function performUpdate(version: string, registry?: string): boolean {
355
+ export function performUpdate(
356
+ version: string,
357
+ registry?: string,
358
+ deps: UpdateDeps = {},
359
+ ): UpdateResult {
164
360
  if (!isValidSemver(version)) {
165
361
  process.stderr.write(`⚠ Refusing to install invalid version: "${version}"\n`)
166
- return false
362
+ return { ok: false, verified: false, rolledBack: false, reason: `版本号非法:${version}` }
167
363
  }
168
364
 
169
365
  // Sanitize registry — only allow known URLs to prevent command injection
@@ -172,16 +368,56 @@ export function performUpdate(version: string, registry?: string): boolean {
172
368
 
173
369
  const registryFlag = safeRegistry ? ` --registry=${safeRegistry}` : ''
174
370
 
371
+ const paths = deps.paths !== undefined ? deps.paths : resolveInstallPaths()
372
+ const backupRoot = deps.backupRoot ?? join(miphamHome(), 'backups')
373
+ /** 生产用的 runner:把调用点给的选项原样交给 execSync(不另起一套)。 */
374
+ const defaultInstall: InstallRunner = (command, options) => {
375
+ execSync(command, options)
376
+ }
377
+ const install: InstallRunner = deps.install ?? defaultInstall
378
+
379
+ // 快照必须在安装**之前**:npm 就地重写全局包目录,安装一旦开始,旧树就没了。
380
+ let snap: InstallSnapshot | null = null
381
+ if (paths)
382
+ snap = snapshotInstall(paths, readPkgVersion(paths.pkgDir) ?? getCurrentVersion(), backupRoot)
383
+
175
384
  try {
176
- execSync(`npm install -g ${PACKAGE}@${version}${registryFlag}`, {
385
+ // 这里**故意不设 timeout**。任何一个能在正常安装途中开火的计时器,开火那一刻就是破坏
386
+ // 本身:npm 被 SIGTERM 时会留下半截树(旧包已删、新包没写完)⇒ 用户一个 CLI 都没有,
387
+ // 连 `mipham update` 本身也没了。本机实测这个包的下载要 11 分钟以上,而原来设在 10 分钟。
388
+ // 进度由 npm 自己印在用户终端上(stdio: 'inherit'),要中断交由用户决定。
389
+ install(`npm install -g ${PACKAGE}@${version}${registryFlag}`, {
177
390
  encoding: 'utf-8',
178
391
  stdio: 'inherit',
179
- timeout: 600_000, // 10 min — slow networks (e.g. China → npmjs) may need 7+ min
180
392
  })
181
- return true
182
- } catch {
183
- return false
393
+ } catch (err) {
394
+ const rolledBack = paths && snap ? restoreInstall(snap, paths) : false
395
+ discardSnapshot(snap)
396
+ const detail = err instanceof Error && err.message ? `(${err.message.split('\n')[0]})` : ''
397
+ return { ok: false, verified: false, rolledBack, reason: `安装进程被中断${detail}` }
184
398
  }
399
+
400
+ if (!paths) {
401
+ // 推不出布局 ⇒ 自证不了。如实返回「装了但没验证」,绝不印「✓ 已更新」。
402
+ discardSnapshot(snap)
403
+ return {
404
+ ok: true,
405
+ verified: false,
406
+ rolledBack: false,
407
+ version,
408
+ reason: '无法定位全局安装路径,未能验证',
409
+ }
410
+ }
411
+
412
+ const check = verifyInstalledVersion(paths, version)
413
+ if (!check.ok) {
414
+ const rolledBack = snap ? restoreInstall(snap, paths) : false
415
+ discardSnapshot(snap)
416
+ return { ok: false, verified: false, rolledBack, version, reason: check.reason }
417
+ }
418
+
419
+ discardSnapshot(snap)
420
+ return { ok: true, verified: true, rolledBack: false, version }
185
421
  }
186
422
 
187
423
  /**
@@ -4461,15 +4461,20 @@ const upgradeCmd: CommandHandler = async (ctx) => {
4461
4461
  lines.push(`Config backed up to: ${backupPath}`)
4462
4462
  }
4463
4463
 
4464
- const ok = performUpdate(update.latest)
4464
+ const result = performUpdate(update.latest)
4465
4465
 
4466
- if (ok) {
4466
+ if (result.ok) {
4467
4467
  const configPath = getConfigPath()
4468
4468
  const { existsSync } = await import('node:fs')
4469
- ctx.setUpdateStatus({ state: 'installed', latest: update.latest })
4469
+ // 只有自证通过才置「已装待重启」—— 装坏了还提示重启,是在骗用户。
4470
+ if (result.verified) ctx.setUpdateStatus({ state: 'installed', latest: update.latest })
4470
4471
  lines.push('')
4471
4472
  lines.push(t('commands.upgrade.updated', { version: update.latest }))
4472
4473
 
4474
+ if (!result.verified) {
4475
+ lines.push(t('commands.upgrade.unverified', { reason: result.reason ?? '' }))
4476
+ }
4477
+
4473
4478
  if (existsSync(configPath)) {
4474
4479
  lines.push(t('commands.upgrade.config_preserved', { path: configPath }))
4475
4480
  } else if (backupPath) {
@@ -4482,8 +4487,17 @@ const upgradeCmd: CommandHandler = async (ctx) => {
4482
4487
  lines.push(t('commands.upgrade.old_version_warning'))
4483
4488
  } else {
4484
4489
  lines.push('')
4490
+ // 失败信息必须回答两件事:为什么,以及**我手里还有没有 CLI**。
4491
+ const rollbackNote = t(
4492
+ result.rolledBack ? 'commands.upgrade.rolled_back' : 'commands.upgrade.no_rollback',
4493
+ )
4485
4494
  lines.push(
4486
- t('commands.upgrade.update_failed', { command: NPM_UPDATE_COMMAND, path: backupPath || '' }),
4495
+ t('commands.upgrade.update_failed', {
4496
+ reason: result.reason ?? '',
4497
+ command: NPM_UPDATE_COMMAND,
4498
+ path: backupPath || '',
4499
+ rolledBack: rollbackNote,
4500
+ }),
4487
4501
  )
4488
4502
  }
4489
4503