@miphamai/cli 0.84.0 → 0.85.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/mipham.ts +20 -5
- package/package.json +1 -1
- package/src/i18n-core/locales/en-US.json +4 -1
- package/src/i18n-core/locales/zh-CN.json +4 -1
- package/src/shared/package-info.ts +1 -1
- package/src/shared/update.ts +297 -17
- package/src/ui/commands.ts +18 -4
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
|
|
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
|
-
|
|
317
|
-
|
|
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
|
@@ -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
|
|
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": "✗
|
|
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.
|
|
12
|
+
export const PACKAGE_VERSION = '0.85.1' as const
|
|
13
13
|
|
|
14
14
|
/** npm install 全局安装命令 */
|
|
15
15
|
export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
|
package/src/shared/update.ts
CHANGED
|
@@ -6,9 +6,21 @@
|
|
|
6
6
|
* use the same logic.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
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,232 @@ 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
|
+
|
|
153
175
|
/**
|
|
154
|
-
*
|
|
155
|
-
* Validates the version string before shell execution (prevents command injection).
|
|
176
|
+
* 推出全局安装路径。`fromDir` 只为测试可注入,默认本模块所在目录(<pkgDir>/src/shared)。
|
|
156
177
|
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
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.
|
|
178
|
+
* 推不出时返回 **null**,绝不猜:`../../../..` 只是算术,它不能证明这个路径真的是一个
|
|
179
|
+
* node prefix。对照物是 `<prefix>/bin/npm` —— 真 prefix 一定有,猜出来的路径不一定有。
|
|
162
180
|
*/
|
|
163
|
-
export function
|
|
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
|
+
* `detached: true`:npm 自成**进程组** ⇒ 终端按 Ctrl-C 时内核发出的 SIGINT
|
|
325
|
+
* (只发给**前台**进程组)到不了它。没有这一条,Ctrl-C 会把 CLI 与 npm 一起打死在
|
|
326
|
+
* `reify` 中间 —— 旧树已删、新树没写完。它与 `blockSigintDuringInstall()` 是一对:
|
|
327
|
+
* 那条保住 CLI(好让下面的 catch 有机会回滚),这条保住 npm。
|
|
328
|
+
*/
|
|
329
|
+
export type InstallOptions = { encoding: 'utf-8'; stdio: 'inherit'; detached: true }
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* 安装期间把 CLI 自己的 SIGINT 挡掉,返回「撤销」函数。
|
|
333
|
+
*
|
|
334
|
+
* 回滚代码就在 `performUpdate` 的 catch 里 —— 而终端按 Ctrl-C 时 SIGINT 发给**整个
|
|
335
|
+
* 前台进程组**,CLI 与 npm 会一起死 ⇒ catch 永远不执行,用户手里留下半截树且**无人回滚**
|
|
336
|
+
* (2026-09-22 事故里「回滚写了却没跑」的那一格)。
|
|
337
|
+
*
|
|
338
|
+
* ⚠️ 挂了 handler 之后信号**不会立刻**变成 JS 回调:事件循环正阻塞在 `execSync` 里,
|
|
339
|
+
* 回调要等它返回才跑(实测打印顺序是「execSync 返回」在前、「handler 跑了」在后)。
|
|
340
|
+
* 所以**不能**用一个标志位判断「用户按过 Ctrl-C」—— 读完 execSync 立刻看,它一定还是
|
|
341
|
+
* `false`。本函数只保证进程活着,不做上报。
|
|
342
|
+
*
|
|
343
|
+
* ⚠️ 「安装期间 Ctrl-C 会被忽略」是**用户可见的行为变化** —— 本次改动只落在代码与测试,
|
|
344
|
+
* 发布那一笔必须写进 `CHANGELOG.md`(别忘了它是代价,不是附带好处)。
|
|
345
|
+
*/
|
|
346
|
+
function blockSigintDuringInstall(): () => void {
|
|
347
|
+
const swallow = (): void => {}
|
|
348
|
+
process.on('SIGINT', swallow)
|
|
349
|
+
return () => {
|
|
350
|
+
process.off('SIGINT', swallow)
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** 执行 `npm install -g`。可注入 —— 测试永不联网。 */
|
|
355
|
+
export type InstallRunner = (command: string, options: InstallOptions) => void
|
|
356
|
+
|
|
357
|
+
export interface UpdateDeps {
|
|
358
|
+
install?: InstallRunner
|
|
359
|
+
/** 覆盖路径解析;传 `null` 表示「推不出布局」。默认自动推断。 */
|
|
360
|
+
paths?: InstallPaths | null
|
|
361
|
+
/** 快照根目录,默认 `~/.mipham/backups`。 */
|
|
362
|
+
backupRoot?: string
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export interface UpdateResult {
|
|
366
|
+
ok: boolean
|
|
367
|
+
/** 只有跑过自证才算 true —— 「装完没验证」不许冒充成功 */
|
|
368
|
+
verified: boolean
|
|
369
|
+
/** 失败后旧安装是否被放了回去 */
|
|
370
|
+
rolledBack: boolean
|
|
371
|
+
version?: string
|
|
372
|
+
reason?: string
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* 真正执行更新:先快照 → `npm install -g` → 自证 → 失败则回滚。
|
|
377
|
+
*
|
|
378
|
+
* 校验版本号后再进 shell(防命令注入)。
|
|
379
|
+
*
|
|
380
|
+
* @param version 目标 semver(必须通过 isValidSemver)。
|
|
381
|
+
* @param registry 可选 registry URL,只接受已知 URL;省略则用 npm 默认。
|
|
382
|
+
*/
|
|
383
|
+
export function performUpdate(
|
|
384
|
+
version: string,
|
|
385
|
+
registry?: string,
|
|
386
|
+
deps: UpdateDeps = {},
|
|
387
|
+
): UpdateResult {
|
|
164
388
|
if (!isValidSemver(version)) {
|
|
165
389
|
process.stderr.write(`⚠ Refusing to install invalid version: "${version}"\n`)
|
|
166
|
-
return false
|
|
390
|
+
return { ok: false, verified: false, rolledBack: false, reason: `版本号非法:${version}` }
|
|
167
391
|
}
|
|
168
392
|
|
|
169
393
|
// Sanitize registry — only allow known URLs to prevent command injection
|
|
@@ -172,16 +396,72 @@ export function performUpdate(version: string, registry?: string): boolean {
|
|
|
172
396
|
|
|
173
397
|
const registryFlag = safeRegistry ? ` --registry=${safeRegistry}` : ''
|
|
174
398
|
|
|
399
|
+
const paths = deps.paths !== undefined ? deps.paths : resolveInstallPaths()
|
|
400
|
+
const backupRoot = deps.backupRoot ?? join(miphamHome(), 'backups')
|
|
401
|
+
/** 生产用的 runner:把调用点给的选项原样交给 execSync(不另起一套)。 */
|
|
402
|
+
const defaultInstall: InstallRunner = (command, options) => {
|
|
403
|
+
execSync(command, options)
|
|
404
|
+
}
|
|
405
|
+
const install: InstallRunner = deps.install ?? defaultInstall
|
|
406
|
+
|
|
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()
|
|
175
421
|
try {
|
|
176
|
-
|
|
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}`, {
|
|
177
431
|
encoding: 'utf-8',
|
|
178
432
|
stdio: 'inherit',
|
|
179
|
-
|
|
433
|
+
detached: true,
|
|
180
434
|
})
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
+
if (!paths) {
|
|
445
|
+
// 推不出布局 ⇒ 自证不了。如实返回「装了但没验证」,绝不印「✓ 已更新」。
|
|
446
|
+
discardSnapshot(snap)
|
|
447
|
+
return {
|
|
448
|
+
ok: true,
|
|
449
|
+
verified: false,
|
|
450
|
+
rolledBack: false,
|
|
451
|
+
version,
|
|
452
|
+
reason: '无法定位全局安装路径,未能验证',
|
|
453
|
+
}
|
|
184
454
|
}
|
|
455
|
+
|
|
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
|
+
}
|
|
462
|
+
|
|
463
|
+
discardSnapshot(snap)
|
|
464
|
+
return { ok: true, verified: true, rolledBack: false, version }
|
|
185
465
|
}
|
|
186
466
|
|
|
187
467
|
/**
|
package/src/ui/commands.ts
CHANGED
|
@@ -4461,15 +4461,20 @@ const upgradeCmd: CommandHandler = async (ctx) => {
|
|
|
4461
4461
|
lines.push(`Config backed up to: ${backupPath}`)
|
|
4462
4462
|
}
|
|
4463
4463
|
|
|
4464
|
-
const
|
|
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
|
-
|
|
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', {
|
|
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
|
|