@noob-stupid/dsh-plugin-console 0.5.101 → 0.5.103

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.
@@ -281,6 +281,168 @@ async function writeLockImporterEntry(profileDir, { name, spec, section = IMPORT
281
281
  })
282
282
  }
283
283
 
284
+ /**
285
+ * **registry 版本**的 lock 定点写入(2026-11-04 加法,自更新手铺兜底专用):
286
+ * 一次动作把该包的三处写到目标版本 —— ① importer 条目(`specifier` + `version`,走**同一个**规划器
287
+ * `planLockImporterEntry`);② `packages:` 键名里的版本 + `resolution.integrity`;③ `snapshots:` 键名里的版本。
288
+ * 为什么走本模块(而不是另写一套 lock 解析):lock 的**唯一**写入口就在这里(读 → 纯函数规划 → 写 → 读回核实
289
+ * → 走 fsx 的写队列),自更新的"补 lock"必须复用同一把尺子,否则读得出来的键写得进去、写的键读不出来。
290
+ *
291
+ * 与 `writeLockImporterEntry` 的差别只有一处:那个只认 `link:` 来源(`lockVersionText` 对版本号返回 null,
292
+ * 因为 registry 依赖的 lock 记录**不止** importer 一行);这里由调用方**显式**给出目标版本与完整性,
293
+ * 再交给 `planLockVersionKeys` 处理那两段。给出完整性才写完整性(见那个函数的保守规则③)。
294
+ * 返回 `{ changed, unchanged, specifier, version, importerChanged, renamed, integrityWritten, reason }`。
295
+ */
296
+ async function writeLockRegistryVersion(profileDir, { name, spec, version, integrity = null, section = IMPORTER_SECTIONS[0], deps = {} } = {}) {
297
+ const readText = typeof deps.readText === 'function' ? deps.readText : (p) => readFileSync(p, 'utf8')
298
+ const writeText = typeof deps.writeText === 'function' ? deps.writeText : (p, text) => writeFileSync(p, text, 'utf8')
299
+ const file = join(String(profileDir), LOCKFILE_NAME)
300
+ const want = typeof version === 'string' ? version.trim() : ''
301
+ const specifier = typeof spec === 'string' && spec.trim() !== '' ? spec.trim() : want
302
+ const fail = (reason) => ({ changed: false, unchanged: false, specifier, version: want, importerChanged: false, renamed: { packages: false, snapshots: false }, integrityWritten: false, reason })
303
+ if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u.test(want)) return fail(`目标版本 ${want === '' ? '(空)' : want} 不是裸版本号 —— 不往 lock 里写非版本形态`)
304
+ return queuedWrite(async () => {
305
+ let text = null
306
+ try {
307
+ text = await readText(file)
308
+ } catch (error) {
309
+ return fail(`读不到 ${LOCKFILE_NAME}(${error instanceof Error ? error.message : String(error)}),未写入`)
310
+ }
311
+ const importerPlan = planLockImporterEntry(text, { name, specifier, version: want, section })
312
+ if (importerPlan.ok !== true) return fail(importerPlan.reason ?? 'lock importer 段规划失败(形态不认识),未写入')
313
+ const keysPlan = planLockVersionKeys(importerPlan.text, { name, toVersion: want, integrity })
314
+ if (keysPlan.ok !== true) return fail(keysPlan.reason ?? 'packages/snapshots 段规划失败,未写入')
315
+ const next = keysPlan.text
316
+ const importerChanged = importerPlan.changed === true
317
+ if (next === text) {
318
+ return { changed: false, unchanged: true, specifier: importerPlan.specifier ?? specifier, version: want, importerChanged, renamed: keysPlan.renamed, integrityWritten: keysPlan.integrityWritten, reason: keysPlan.reason ?? null }
319
+ }
320
+ try {
321
+ await writeText(file, next)
322
+ } catch (error) {
323
+ return fail(`写盘失败:${error instanceof Error ? error.message : String(error)}`)
324
+ }
325
+ let after = null
326
+ try {
327
+ after = await readText(file)
328
+ } catch (error) {
329
+ return fail(`写盘后读回失败:${error instanceof Error ? error.message : String(error)}`)
330
+ }
331
+ if (after !== next) return fail('写盘后读回与预期不一致(未能核实),按未写入处理')
332
+ return { changed: true, unchanged: false, specifier: importerPlan.specifier ?? specifier, version: want, importerChanged, renamed: keysPlan.renamed, integrityWritten: keysPlan.integrityWritten, reason: keysPlan.reason ?? null }
333
+ })
334
+ }
335
+
336
+ /**
337
+ * `packages:` / `snapshots:` 段里 `'<name>@<版本>'` 键行的定位(同一个包的所有**解析记录**行)。
338
+ * 只认这两段的键行(段头顶格 + 2 空格缩进的键),避免把 importers 里的同名条目、或别的包读进来。
339
+ * 返回 `[{ index, section, indent, version, quoted }]`;版本里可能带 peer 后缀 `(peer@1.0.0)`,原样保留。
340
+ */
341
+ function resolutionKeyRows(lines, name) {
342
+ const escaped = String(name).replace(/[/\\^$*+?.()|[\]{}]/gu, '\\$&')
343
+ // 键行形态三种都认:`'name@1.0.0':`、`name@1.0.0:`、`'name@1.0.0': {}`(真机 lock 的 snapshots 就是最后一种)
344
+ const keyRe = new RegExp(`^(\\s*)'?${escaped}@([^\\s']+?)'?\\s*:\\s*(\\{.*\\})?\\s*$`, 'u')
345
+ const rows = []
346
+ let section = null
347
+ for (let i = 0; i < lines.length; i += 1) {
348
+ const top = /^([A-Za-z][A-Za-z0-9_]*):\s*$/u.exec(lines[i])
349
+ if (top !== null) {
350
+ section = top[1]
351
+ continue
352
+ }
353
+ if (section !== 'packages' && section !== 'snapshots') continue
354
+ const m = keyRe.exec(lines[i])
355
+ if (m === null) continue
356
+ rows.push({ index: i, section, indent: m[1], version: m[2], rest: m[3] ?? null, quoted: lines[i].trim().startsWith("'") })
357
+ }
358
+ return rows
359
+ }
360
+
361
+ /**
362
+ * 纯函数:把 lock 里该包的**解析记录**(`packages:` / `snapshots:` 的键与 integrity)改到目标版本。
363
+ * 为什么需要它(2026-11-04 加法):自更新的手铺兜底只把文件铺进去,`pnpm-lock.yaml` 里仍钉着旧版本 ——
364
+ * 那正是"更新成功、下一次 pnpm 操作又被还原"的老毛病。importer 条目由 planLockImporterEntry 负责
365
+ * (同一份规划器,不另写一套),这里只补它表达不了的那两段:键名里的版本 + `resolution.integrity`。
366
+ *
367
+ * 四条保守规则(**绝不猜**,做不到就一个字节都不写并把原因带回去):
368
+ * ① 没有**该版本**的完整性摘要(registry 元数据 `dist.integrity`)⇒ 整段都不动 —— 把旧版本的 integrity
369
+ * 挂到新版本号下面比"不写"更糟(pnpm 会 ERR_PNPM_TARBALL_INTEGRITY);
370
+ * ② 只有**唯一**一个陈旧键、且它的块里有**单行** `resolution: {…}` 才改名(版本与完整性必须一起落地);
371
+ * ③ 目标版本键已存在 ⇒ 不改名,只把 integrity 补成该版本的;
372
+ * ④ snapshots 段只在 packages 段真的改成功之后才跟着改(两段保持一致)。
373
+ * 返回 `{ ok, changed, text, renamed:{packages,snapshots}, integrityWritten, reason }`。
374
+ */
375
+ function planLockVersionKeys(text, { name, toVersion, integrity = null } = {}) {
376
+ const src = typeof text === 'string' ? text : String(text ?? '')
377
+ const want = typeof toVersion === 'string' ? toVersion.trim() : ''
378
+ const digest = typeof integrity === 'string' ? integrity.trim() : ''
379
+ const unchanged = (reason) => ({ ok: true, changed: false, text: src, renamed: { packages: false, snapshots: false }, integrityWritten: false, reason })
380
+ if (want === '') return { ...unchanged('没有目标版本,未改动'), ok: false }
381
+ if (digest === '') return unchanged('没有该版本的完整性摘要(registry 元数据缺失)⇒ 未改 packages/snapshots 段(绝不把旧 integrity 挂到新版本号上)')
382
+ const eol = /\r\n/u.test(src) ? '\r\n' : '\n'
383
+ const trailing = /\r?\n$/u.test(src)
384
+ const lines = src.split(/\r?\n/u)
385
+ if (trailing && lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
386
+ const rows = resolutionKeyRows(lines, name)
387
+ if (rows.length === 0) return unchanged('lock 里没有该包的 packages/snapshots 解析记录(下一次 pnpm 操作会自己补出来),未改动')
388
+ const out = [...lines]
389
+ const renamed = { packages: false, snapshots: false }
390
+ let integrityWritten = false
391
+ const reasons = []
392
+ /** 在该行所属块的 `resolution:` 行上换 integrity;块里没有单行 resolution 就什么都不写(不伪造结构)。 */
393
+ const writeIntegrity = (row) => {
394
+ for (let k = row.index + 1; k < out.length; k += 1) {
395
+ if (out[k].trim() !== '' && !/^\s{3,}/u.test(out[k])) break
396
+ const m = /^(\s*resolution:\s*)\{[^}]*\}(\s*)$/u.exec(out[k])
397
+ if (m === null) continue
398
+ if (!out[k].includes(digest)) {
399
+ out[k] = `${m[1]}{integrity: ${digest}}${m[2]}`
400
+ integrityWritten = true
401
+ }
402
+ return true
403
+ }
404
+ return false
405
+ }
406
+ for (const section of ['packages', 'snapshots']) {
407
+ const mine = rows.filter((r) => r.section === section)
408
+ if (mine.length === 0) continue
409
+ const fresh = mine.filter((r) => r.version === want || r.version.startsWith(`${want}(`))
410
+ if (fresh.length > 0) {
411
+ if (section === 'packages' && writeIntegrity(fresh[0]) !== true) reasons.push('packages 里的条目没有单行 resolution 记录 ⇒ integrity 未写(不伪造结构)')
412
+ continue
413
+ }
414
+ if (mine.length > 1) {
415
+ reasons.push(`${section} 里有 ${mine.length} 个陈旧键(${mine.map((r) => r.version).join('、')})—— 未改名(不猜该留哪一个)`)
416
+ break
417
+ }
418
+ const row = mine[0]
419
+ const suffix = /\(.*\)$/u.exec(row.version)?.[0] ?? ''
420
+ const keyBody = row.quoted ? `'${name}@${want}${suffix}':` : `${name}@${want}${suffix}:`
421
+ const nextKey = `${row.indent}${keyBody}${row.rest === null ? '' : ` ${row.rest}`}`
422
+ // 规则②:版本与完整性必须**一起**落地 —— 先在草稿上换 integrity,换不成就整体放弃这一段
423
+ const draft = [...out]
424
+ const saved = out[row.index]
425
+ out[row.index] = nextKey
426
+ if (section === 'packages' && writeIntegrity(row) !== true) {
427
+ out[row.index] = saved
428
+ reasons.push('packages 里的条目没有单行 resolution 记录 ⇒ 未改名(版本与完整性必须一起落地,不伪造结构)')
429
+ for (let k = 0; k < draft.length; k += 1) out[k] = draft[k]
430
+ integrityWritten = false
431
+ break
432
+ }
433
+ renamed[section] = true
434
+ }
435
+ const changed = renamed.packages || renamed.snapshots || integrityWritten
436
+ return {
437
+ ok: true,
438
+ changed,
439
+ text: changed ? out.join(eol) + (trailing ? eol : '') : src,
440
+ renamed,
441
+ integrityWritten,
442
+ reason: changed ? null : (reasons.join(';') || null),
443
+ }
444
+ }
445
+
284
446
  /**
285
447
  * 从 lock 自己的 `settings:` 段取出 pnpm 配置,供**只读**校验按 lock 的形态复算
286
448
  * (不镜像它时 pnpm 直接 `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH`:本机实测 `autoInstallPeers`)。
@@ -405,7 +567,10 @@ export {
405
567
  lockVersionText,
406
568
  lockfileSettingsFlags,
407
569
  planLockImporterEntry,
570
+ planLockVersionKeys,
571
+ resolutionKeyRows,
408
572
  verifyArgsFor,
409
573
  verifyLockReadOnly,
410
574
  writeLockImporterEntry,
575
+ writeLockRegistryVersion,
411
576
  }
@@ -39,6 +39,10 @@ import { join } from 'node:path'
39
39
  import { applyAllowBuilds, pendingApprovedBuilds, readWorkspaceApprovals } from './allow-builds.js'
40
40
  import { NPM_NAME_RE, sameSourceSpec, suggestedAllowBuildsAction } from './dep-source.js'
41
41
  import { classifyInstallFailure, ignoredBuildsFrom } from './install-diagnose.js'
42
+ // 同一个 profile + 包名 的安装互斥(2026-11-04 加法,见 domain/install-lock.js 顶部现场):
43
+ // 本模块的两个动作(pin-dependency / real-install)都会跑 `pnpm add`,动的就是 `node_modules/<包名>`
44
+ // 那一份目录 —— 与安装作业(domain/install-channels.js)必须串行,否则就是真机事故的重演。
45
+ import { withInstallLockOr } from './install-lock.js'
42
46
  import { runLockfileRepair } from './lockfile-health.js'
43
47
  import { pinProfileDependency, specOf } from './manifest.js'
44
48
  import { ensurePersisted } from './persist.js'
@@ -129,6 +133,19 @@ function isReleaseAgeBlock(message) {
129
133
  return /ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION|minimumReleaseAge/iu.test(String(message ?? ''))
130
134
  }
131
135
 
136
+ /**
137
+ * 过**包互斥闸**跑一次 pnpm(2026-11-04 加法):动作会把 `node_modules/<包名>` 重写一遍,与安装作业
138
+ * 动的是同一份目录 ⇒ 必须排队。拿不到锁 ⇒ 返回一份**如实失败**的结果(`locked: true` + reason),
139
+ * 绝不降级成"不拿锁照样装",也绝不报成功。
140
+ */
141
+ async function runAddUnderLock({ profileDir, packageName, timeoutMs, run }) {
142
+ return await withInstallLockOr(
143
+ { profileDir, packageName },
144
+ async () => ({ locked: false, result: await run() }),
145
+ (error) => ({ locked: true, reason: error.message, result: { ok: false, exitCode: null, stdout: '', stderr: '', relaxedReleaseAge: false, error, argv: [] } }),
146
+ )
147
+ }
148
+
132
149
  /** 跑一次 pnpm(原样),必要时**只对这一条命令**放宽供应链闸并如实标记。
133
150
  * 返回 { ok, exitCode, stdout, stderr, relaxedReleaseAge, error }。
134
151
  *
@@ -186,6 +203,7 @@ async function runSuggestedAction({ body, profileDir, registries = [], deps = {}
186
203
  registry, exitCode: null, command: null, stdout: '', stderr: '', relaxedReleaseAge: false,
187
204
  manifest: null, lock: null, notes: [], reason: null, hint: null, detail: null,
188
205
  kind: parsed.action, ignoredBuilds: [], nextAction: null,
206
+ locked: false, // 2026-11-04 加法:true = 这个包正被别的作业装着,本次**没有**启动 pnpm(如实失败)
189
207
  }
190
208
  if (parsed.action === 'allow-builds') {
191
209
  const applyAllow = typeof deps.applyAllow === 'function' ? deps.applyAllow : applyAllowBuilds
@@ -326,7 +344,19 @@ async function runSuggestedAction({ body, profileDir, registries = [], deps = {}
326
344
  }
327
345
  }
328
346
  const before = await specOf(profileDir, parsed.packageName)
329
- const run = await runAddWithReleaseAgeRetry({ profileDir, spec: record.spec, registry, runAdd, timeoutMs: ACTION_ADD_TIMEOUT_MS })
347
+ // 过包互斥闸(见上方 runAddUnderLock):拿不到锁 ⇒ 如实失败,连 pnpm 都不启动
348
+ const runGuard = await runAddUnderLock({
349
+ profileDir, packageName: parsed.packageName, timeoutMs: ACTION_ADD_TIMEOUT_MS,
350
+ run: async () => await runAddWithReleaseAgeRetry({ profileDir, spec: record.spec, registry, runAdd, timeoutMs: ACTION_ADD_TIMEOUT_MS }),
351
+ })
352
+ const run = runGuard.result
353
+ if (runGuard.locked === true) {
354
+ return {
355
+ ...base, ok: false, status: 200, locked: true, error: runGuard.reason, reason: runGuard.reason,
356
+ command: `pnpm ${pnpmAddArgs(record.spec, registry).join(' ')}`,
357
+ notes: ['这个包正在被另一个作业安装/更新:本次没有启动 pnpm,也没有动任何文件(排到队尾再试即可)。'],
358
+ }
359
+ }
330
360
  const after = await specOf(profileDir, parsed.packageName)
331
361
  const entry = lockVersion(profileDir, parsed.packageName)
332
362
  const failure = run.ok === true ? null : classifyInstallFailure(`${run.stderr}\n${String(run.error?.message ?? '')}`)
@@ -391,7 +421,12 @@ async function runSuggestedAction({ body, profileDir, registries = [], deps = {}
391
421
  const timeoutMs = Number.isFinite(deps.timeoutMs) && deps.timeoutMs > 0
392
422
  ? deps.timeoutMs
393
423
  : (pendingBuilds.pending === true ? ACTION_BUILD_TIMEOUT_MS : ACTION_ADD_TIMEOUT_MS)
394
- const run = await runAddWithReleaseAgeRetry({ profileDir, spec: pinned.spec, registry, runAdd, timeoutMs })
424
+ // 过包互斥闸(见上方 runAddUnderLock):pin 的这一步会重铺 node_modules 里那一份 —— 必须与安装作业串行
425
+ const runGuard = await runAddUnderLock({
426
+ profileDir, packageName: parsed.packageName, timeoutMs,
427
+ run: async () => await runAddWithReleaseAgeRetry({ profileDir, spec: pinned.spec, registry, runAdd, timeoutMs }),
428
+ })
429
+ const run = runGuard.result
395
430
  const after = await specOf(profileDir, parsed.packageName)
396
431
  const entry = lockVersion(profileDir, parsed.packageName)
397
432
  const lockSynced = typeof entry === 'string' && entry.startsWith('link:')
@@ -405,6 +440,10 @@ async function runSuggestedAction({ body, profileDir, registries = [], deps = {}
405
440
  const ignoredBuilds = ignored === true ? ignoredBuildsFrom(run.stderr) : []
406
441
  const nextAction = ignored === true ? suggestedAllowBuildsAction({ packages: ignoredBuilds, profile: parsed.profile, reason: 'pnpm 因构建脚本未获批准而报错' }) : null
407
442
  const notes = []
443
+ if (runGuard.locked === true) {
444
+ // 拿不到包互斥锁:pnpm 一步都没启动。清单可能已经写成 link:(pin 的第一步),要把这半件事如实说清
445
+ notes.push('这个包正在被另一个作业安装/更新:本次**没有启动 pnpm**,也没有动 node_modules 里那一份(排到队尾再试即可)。')
446
+ }
408
447
  if (run.relaxedReleaseAge) notes.push('本机 pnpm 的 24h 供应链闸(minimumReleaseAge)拦下了这次 lock 校验,已**仅对这一条命令**追加 --config.minimumReleaseAge=0 重试一次(有安全代价,未写进任何配置文件)。')
409
448
  if (pendingBuilds.pending === true) {
410
449
  notes.push(`本 profile 已有放行项但还没构建过:本次 pnpm 那一步会真的执行构建脚本(本机实测约 19 分钟),`
@@ -434,6 +473,7 @@ async function runSuggestedAction({ body, profileDir, registries = [], deps = {}
434
473
  // 只有"清单真的是 link: **且** lock 里也有这条 link"才算成功;只成一半时 partial=true 并如实说明
435
474
  ok: manifests.pinned && lockSynced,
436
475
  partial: manifests.pinned && !lockSynced,
476
+ locked: runGuard.locked === true,
437
477
  exitCode: run.exitCode,
438
478
  command: `pnpm ${argv.join(' ')}`,
439
479
  stdout: run.stdout,
@@ -444,9 +484,11 @@ async function runSuggestedAction({ body, profileDir, registries = [], deps = {}
444
484
  lock: { entry: entry ?? null, synced: lockSynced },
445
485
  notes,
446
486
  failureKind: failure?.kind ?? null,
447
- reason: manifests.pinned
448
- ? (lockSynced ? `${parsed.packageName} 已钉住:清单与 pnpm-lock.yaml 都指向 link:${pinned.dir}` : `${parsed.packageName} 的清单已钉住,lock 未对齐`)
449
- : `${parsed.packageName} 的清单写回未生效(实际是 ${after ?? '(无该条目)'})`,
487
+ reason: runGuard.locked === true
488
+ ? runGuard.reason
489
+ : (manifests.pinned
490
+ ? (lockSynced ? `${parsed.packageName} 已钉住:清单与 pnpm-lock.yaml 都指向 link:${pinned.dir}` : `${parsed.packageName} 的清单已钉住,lock 未对齐`)
491
+ : `${parsed.packageName} 的清单写回未生效(实际是 ${after ?? '(无该条目)'})`),
450
492
  }
451
493
  }
452
494
 
@@ -474,7 +474,7 @@ function settleSelfUpdateGuard(session, { targetVersion = undefined, deps = {} }
474
474
  * · 校验没过时把那段失败文案同时推进 `errors` 与 `note`(面板/接口都看得见);
475
475
  * · `installedVersion` 在"已自愈"时改报**盘上真实那一版**(否则会留一个 null 让人以为目录还是空的)。
476
476
  */
477
- function finishSelfUpdate({ profileDir, packageName, latest, spec, installed, method, command, errors, lockVersion, lockUpdated, lockNote, registryRange, selfUpdate = null }) {
477
+ function finishSelfUpdate({ profileDir, packageName, latest, spec, installed, method, command, errors, lockVersion, lockUpdated, lockNote, registryRange, selfUpdate = null, copy = null, tarball = null, lockPatch = null }) {
478
478
  const CONSOLE_PACKAGE = packageName
479
479
  const isRegistryRange = registryRange
480
480
  const label = (e) => (e instanceof Error ? e.message : String(e))
@@ -505,6 +505,15 @@ function finishSelfUpdate({ profileDir, packageName, latest, spec, installed, me
505
505
  const noteParts = [lockUpdated ? '已写入 pnpm-lock.yaml,重启服务后生效' : '已铺入文件但未写入 pnpm-lock.yaml:重启后可用,但会被 pnpm 还原']
506
506
  if (sourceSwitch !== null) noteParts.push(sourceSwitch)
507
507
  if (specUpdated !== null) noteParts.push(`清单 spec 已同步为 ${specUpdated}(防 pnpm 降级)`)
508
+ // ★ 2026-11-04 加法:手铺那一步到底怎么铺的、lock 补没补上,都要让用户看得见(绝不静默)
509
+ if (copy !== null && copy !== undefined && copy?.channel === 'overlay') {
510
+ noteParts.push(`用预下载的 tarball 只覆盖了 ${copy.files ?? '?'} 个文件、**没有清空目录**(原地保留 ${copy.preserved ?? 0} 个不在 tarball 里的文件)`)
511
+ }
512
+ if (lockPatch !== null && lockPatch !== undefined && lockPatch?.attempted === true) {
513
+ if (lockPatch.changed === true && lockPatch.verified === true) noteParts.push(`pnpm-lock.yaml 已补齐 ${latest} 的版本/完整性并通过 pnpm 只读校验`)
514
+ else if (lockPatch.changed === true && lockPatch.verified === null) noteParts.push(`pnpm-lock.yaml 已补齐 ${latest} 的版本/完整性,但 pnpm 的只读校验**无法确认**(${lockPatch.verifyNote ?? '原因未知'})`)
515
+ else if (lockPatch.reason !== null && lockPatch.reason !== undefined) noteParts.push(`pnpm-lock.yaml 未补齐:${lockPatch.reason}`)
516
+ }
508
517
 
509
518
  // ★ 加法:自更新校验的结论(只有"本插件自己 + 更新前就有完整副本"时 selfUpdate 才非空)
510
519
  const verified = selfUpdate === null || selfUpdate === undefined ? true : selfUpdate.verified === true
@@ -512,7 +521,7 @@ function finishSelfUpdate({ profileDir, packageName, latest, spec, installed, me
512
521
  if (reason !== null) errors.push(reason)
513
522
  if (reason !== null) noteParts.push(reason)
514
523
  const installedVersion = verified !== true && typeof selfUpdate.restoredVersion === 'string' ? selfUpdate.restoredVersion : installed
515
- return { method, spec: specUpdated ?? spec, installedVersion, lockVersion, lockUpdated, lockNote, command, note: noteParts.join(';'), errors, ok: verified, reason, selfUpdate: selfUpdate ?? null }
524
+ return { method, spec: specUpdated ?? spec, installedVersion, lockVersion, lockUpdated, lockNote, command, note: noteParts.join(';'), errors, ok: verified, reason, selfUpdate: selfUpdate ?? null, copy: copy ?? null, tarball: tarball ?? null, lockPatch: lockPatch ?? null }
516
525
  }
517
526
 
518
527
  /**
@@ -543,6 +552,10 @@ function selfUpdateView({ result, current = null, latest = null }) {
543
552
  errors: result.errors,
544
553
  selfUpdate: result.selfUpdate ?? null,
545
554
  reason: result.reason ?? null,
555
+ // ★ 2026-11-04 加法(手铺兜底):这三项只增不减 —— 既有字段名与取值语义一个都没动
556
+ copy: result.copy ?? null,
557
+ tarball: result.tarball ?? null,
558
+ lockPatch: result.lockPatch ?? null,
546
559
  }
547
560
  }
548
561