@noob-stupid/dsh-plugin-console 0.5.37 → 0.5.39

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.
@@ -12,7 +12,9 @@
12
12
  // ③ 回读核实 readInstalledVersion / lockVersion;都不匹配才回落手铺文件,且**必须**带 lockNote 警告。
13
13
  import { existsSync, readFileSync, writeFileSync } from 'node:fs'
14
14
  import { join } from 'node:path'
15
- import { linkSpecFor, linkSpecIsIntact, materializePackageForLink, probeRegistryPackage, suggestedPinAction } from './dep-source.js'
15
+ import { linkSpecFor, linkSpecIsIntact, materializePackageForLink, probeRegistryPackage, sameSourceSpec, suggestedPinAction } from './dep-source.js'
16
+ import { canWriteImporterEntry, lockEntryKeyMatches, verifyLockReadOnly, writeLockImporterEntry } from './lock-importer.js'
17
+ import { readManifestDeps } from './lockfile-health.js'
16
18
  import { pnpmInstall } from './install.js'
17
19
  import { fetchJsonUrl } from '../infra/http.js'
18
20
  import { runPnpmWithFallback } from '../infra/exec.js'
@@ -72,8 +74,15 @@ function lockVersion(profileDir, name) {
72
74
  return null
73
75
  }
74
76
  for (let i = 0; i < lines.length; i += 1) {
75
- const bare = lines[i].trim().replace(/^'|':?$/gu, '').replace(/'$/u, '')
76
- if (bare === name || lines[i].trim() === `'${name}':`) {
77
+ // 键形态三种都认(0.5.38 改错):`name`、`'name':`(pnpm 对**含 `/`/`@`** 的包名加引号)、
78
+ // `name:`(pnpm 对**未加 scope** 的包名不加引号)。旧判据漏了第三种 ⇒ 未加 scope 的 `link:`
79
+ // 依赖在 importers 里明明有条目却读不出来(真机 desktop profile 的 `dshmarket` 就是:
80
+ // lock 的 35-37 行写着 `dshmarket: / specifier: link:… / version: link:../../plugin-src/dshmarket`,
81
+ // 而 `lockVersion()` 返回 null)——后果是面板永远报"没写进 pnpm-lock.yaml"、pin 永远 partial、
82
+ // 每次对账都白跑一次 `pnpm add`。真值一律从下面这一处读,别在调用方再补一套。
83
+ // 0.5.39:键判据搬进 lock-importer.js#lockEntryKeyMatches —— 定点**写**入也要认同一批键形态,
84
+ // 两处各写一份必然再次分叉(写进去的键、读的时候认不出,就是 0.5.38 那个现场)。
85
+ if (lockEntryKeyMatches(lines[i], name)) {
77
86
  for (let k = i + 1; k < Math.min(i + 8, lines.length); k += 1) {
78
87
  const m = /^\s*version:\s*(\S+)\s*$/u.exec(lines[k])
79
88
  if (m) return m[1]
@@ -202,14 +211,113 @@ const EXACT_VERSION_RE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u
202
211
  */
203
212
  const DIST_TAG_RE = /^[A-Za-z][A-Za-z0-9._-]*$/u
204
213
 
214
+ /**
215
+ * manifest spec 的写入器(0.5.39 加法):**复用 manifest.js 那一个既有写入器**
216
+ * `setProfileDependency`(只改 `dependencies[包名]` 一个键、走同一把写队列),本模块**不新造第二套**。
217
+ * 为什么用动态 import:manifest.js 顶部 import 了本模块的 `reconcileLockfile`(syncLockFor 用),
218
+ * 静态互相 import 会成环;这里只在**真的需要改清单**时才按需取一次(同进程内 import 有缓存,零成本)。
219
+ * 单测可用 `deps.setSpec` 直接替换(离线夹具不需要真写清单时)。
220
+ */
221
+ async function defaultSetSpec(profileDir, packageName, spec) {
222
+ // 注意:这里**不能**用解构(`const { setProfileDependency } = await import(…)`)—— 架构守卫的自由变量
223
+ // 扫描认不出解构声明(`const {` 形态),会把导入名误判成"没声明/没 import"的自由变量。
224
+ const manifestModule = await import('./manifest.js')
225
+ return manifestModule.setProfileDependency(profileDir, packageName, spec)
226
+ }
227
+
228
+ /** 该包在清单里属于哪个依赖分区(复用 lockfile-health.js 的既有解析器,不新写一份)。 */
229
+ function manifestSectionOf(profileDir, name) {
230
+ try {
231
+ const { deps } = readManifestDeps(readFileSync(join(String(profileDir), 'package.json'), 'utf8'))
232
+ return deps.find((d) => d.name === name)?.section ?? 'dependencies'
233
+ } catch {
234
+ return 'dependencies'
235
+ }
236
+ }
237
+
238
+ /**
239
+ * 非 registry 来源的 lock 持久化收口(0.5.39 加法,用户点名的修 1):
240
+ * · `items`(**能由 importer 条目完整表达**的来源,目前只有 `link:`)→ **只写 lock importer 条目**
241
+ * (lock-importer.js 是唯一实现;persist.js 走的 reconcile 通路就是这一条),清单形态与目标不等价时
242
+ * 先用既有 `setProfileDependency` 写规范形;写完跑**一次** pnpm **只读**校验确认 lock 仍可解析;
243
+ * · `skipped`(file:/workspace:/portal:/git+/URL…)→ lock 记录不止 importer 一行(pnpm 还要写
244
+ * packages/snapshots 的解析记录),本地无法确定 ⇒ **既不伪造条目、也不用 `pnpm add` 当持久化手段**,
245
+ * 由调用方如实报「未持久化」+ 一键钉住。
246
+ * 返回 `{ writes, verified, verifyNote, errors, manifestWrites, changed }`;`verified` 为 `true|false|null`
247
+ * (null = pnpm 执行方式不可用 ⇒ 无法确认)。
248
+ */
249
+ async function settleImporterEntries({ profileDir, items = [], deps = {}, registries = [] } = {}) {
250
+ const setSpec = typeof deps.setSpec === 'function' ? deps.setSpec : defaultSetSpec
251
+ const writeImporter = typeof deps.writeImporter === 'function' ? deps.writeImporter : writeLockImporterEntry
252
+ const verify = typeof deps.verifyLock === 'function' ? deps.verifyLock : verifyLockReadOnly
253
+ const writes = []
254
+ const errors = []
255
+ const manifestWrites = []
256
+ for (const item of items) {
257
+ // ① 清单里必须有这个来源的声明:lock 条目不能单独存在(清单没有 = pnpm 会把它当多余条目清掉)
258
+ const before = profileSpec(profileDir, item.name)
259
+ if (typeof before !== 'string' || before.trim() === '') {
260
+ writes.push({ name: item.name, changed: false, skipped: true, reason: '清单 dependencies 里没有这个依赖的声明(lock 条目不能单独存在)—— 未写 lock,请先走「声明」通路' })
261
+ continue
262
+ }
263
+ // ② 形态与目标**不等价**才算真漂移 → 写**规范形**;等价(正/反斜杠、相对↔绝对同一目标)⇒ 零写盘
264
+ if (!sameSourceSpec(profileDir, before, item.spec)) {
265
+ try {
266
+ const wrote = await setSpec(profileDir, item.name, item.spec)
267
+ manifestWrites.push({ name: item.name, changed: wrote?.changed === true, before, after: wrote?.after ?? null })
268
+ } catch (error) {
269
+ const reason = `清单写回失败:${error instanceof Error ? error.message : String(error)}`
270
+ errors.push(reason)
271
+ writes.push({ name: item.name, changed: false, skipped: true, reason })
272
+ continue
273
+ }
274
+ const actual = profileSpec(profileDir, item.name)
275
+ if (!sameSourceSpec(profileDir, actual, item.spec)) {
276
+ const reason = `清单写回未生效(现在仍是 ${actual ?? '(没有这个条目)'})—— 未写 lock`
277
+ errors.push(`${item.name}:${reason}`)
278
+ writes.push({ name: item.name, changed: false, skipped: true, reason })
279
+ continue
280
+ }
281
+ }
282
+ // ③ 定点写 lock importer 条目(读改写 + 读回核实都在 lock-importer.js 里)
283
+ let written = null
284
+ try {
285
+ written = await writeImporter(profileDir, { name: item.name, spec: item.spec, section: manifestSectionOf(profileDir, item.name), deps })
286
+ } catch (error) {
287
+ written = { changed: false, reason: `lock 写入失败:${error instanceof Error ? error.message : String(error)}` }
288
+ }
289
+ if (written?.changed !== true && written?.unchanged !== true) errors.push(`${item.name}:${written?.reason ?? 'lock 写入未成功'}`)
290
+ writes.push({ name: item.name, ...written, reason: written?.reason ?? null })
291
+ }
292
+ let verified
293
+ let verifyNote = null
294
+ if (writes.some((w) => w.changed === true)) {
295
+ // 写出后**必须**用 pnpm 的只读用法确认 lock 仍可解析;失败或无法确认 ⇒ 调用方报「未持久化」
296
+ let verdict = null
297
+ try {
298
+ verdict = await verify({ profileDir, registry: registries?.[0] ?? null })
299
+ } catch (error) {
300
+ verdict = { verified: null, reason: `只读校验执行失败:${error instanceof Error ? error.message : String(error)}` }
301
+ }
302
+ verified = verdict?.verified ?? null
303
+ verifyNote = verdict?.reason ?? null
304
+ if (verified !== true) errors.push(`pnpm 只读校验${verified === null ? '无法确认' : '未通过'}:${verifyNote ?? '原因未知'}`)
305
+ }
306
+ return { writes, manifestWrites, verified, verifyNote, errors, changed: writes.some((w) => w.changed === true) }
307
+ }
308
+
205
309
  /**
206
310
  * 通用 lock 对账(**任何**安装通道装完都该调用,支持一次对账多个包):
207
311
  * 走非 pnpm 通道(并行 curl / curl tarball / GitHub Release / git clone 装配 / 套装装配)装的包,
208
312
  * node_modules 里的版本与 `pnpm-lock.yaml` 记的版本可能不一致——之后任何 pnpm 操作(开关插件改
209
313
  * `dsh.profile.bundles`、`dsh plugin add/remove`)都可能按 lock 把它**还原**甚至当外来物处理。这里:
210
314
  * ① 全部一致 → 直接返回(不跑 pnpm,零成本);
211
- * ② 有漂移 → **一次** `pnpm add <spec1> <spec2> …` 把所有漂移包精确写进 lock;
212
- * ③ 仍对不上 → 返回 lockNote(逐包列出)与可复制命令,由调用方如实展示,绝不假装成功。
315
+ * ② 有漂移 → registry 形态(版本号/dist-tag):**一次** `pnpm add <spec1> <spec2> …` 把它们精确写进
316
+ * lock(**行为一个字未改**);非 registry 来源里的 `link:`:**只写 lock importer 条目**
317
+ * (domain/lock-importer.js,唯一实现),再用 pnpm 的**只读**用法确认 lock 可解析 ——
318
+ * `pnpm add` **不再**充当非 registry 来源的持久化手段;
319
+ * ③ 仍对不上(含只读校验没通过/无法确认)→ 返回 lockNote(逐包列出)与可复制命令,由调用方如实展示,
320
+ * 绝不假装成功。
213
321
  * `packages` 形如 [{ name, version }];只给 `packageName` 时等价于单包对账(向后兼容)。
214
322
  *
215
323
  * ★ 缺陷②修复(0.4.0-beta.16 / 0.3.63):写回 spec 前必须确认来源,**绝不能把 release 来源的包
@@ -223,7 +331,7 @@ const DIST_TAG_RE = /^[A-Za-z][A-Za-z0-9._-]*$/u
223
331
  * 查无此包(404)→ 物化到 `<DSH_HOME>/plugin-src/<包名>` 并写 `link:<绝对路径>`,同时回 depNote;
224
332
  * 连物化都做不到 → **跳过对齐且不碰 package.json**,只记 note。
225
333
  */
226
- async function reconcileLockfile({ profileDir, packageName = null, packages = null, registries = [], pnpmAdd = pnpmInstall, execOpts = {}, fetchJson = fetchJsonUrl, home = null } = {}) {
334
+ async function reconcileLockfile({ profileDir, packageName = null, packages = null, registries = [], pnpmAdd = pnpmInstall, execOpts = {}, fetchJson = fetchJsonUrl, home = null, deps = {} } = {}) {
227
335
  const targets = Array.isArray(packages)
228
336
  ? packages.filter((p) => p && typeof p.name === 'string' && p.name !== '')
229
337
  : (packageName === null ? [] : [{ name: packageName, version: null }])
@@ -257,8 +365,13 @@ async function reconcileLockfile({ profileDir, packageName = null, packages = nu
257
365
  const aligned = (r) => {
258
366
  if (r.installed === null) return false
259
367
  if (typeof r.spec === 'string' && r.spec.startsWith('link:')) {
368
+ // 0.5.39 改错(Fix 2):lock 里那条 link 条目还必须**指向同一个目标**才算对齐。
369
+ // 旧判据只要求它以 `link:` 开头 ⇒ 「清单指 A、lock 指 B」被当成对齐(真实漂移:两处目标不同),
370
+ // 而 pnpm 下一次操作会按 lock 把链接还原到 B。等价判据与清单写入共用同一把尺子
371
+ // (dep-source.js#sameSourceSpec:正/反斜杠、相对↔绝对都算同一目标)。
260
372
  return linkSpecIsIntact(profileDir, r.name, r.spec)
261
373
  && typeof r.lock === 'string' && r.lock.trim().startsWith('link:')
374
+ && sameSourceSpec(profileDir, r.lock, r.spec)
262
375
  }
263
376
  return r.installed === r.lock || sourcePinned(r.lock)
264
377
  }
@@ -288,8 +401,11 @@ async function reconcileLockfile({ profileDir, packageName = null, packages = nu
288
401
  if (/^https?:/u.test(spec.trim()) || misrecorded(row)) {
289
402
  return linkPlan(row, '该包原先以 tarball URL 记录(pnpm 10 重写 lock 会丢 integrity,实测 ERR_PNPM_MISSING_TARBALL_INTEGRITY)')
290
403
  }
291
- // link: 规格:链接可能已被 release/curl 通道的"先删再铺"打断 → 先把新副本刷进 plugin-src 再重放
404
+ // link: 规格:链接**还在**(node_modules 里就是指向清单那个目标的链接)→ 一个字节都不用物化,
405
+ // 按清单形态重放即可(0.5.39:等价即零漂移,别为了写一条 lock 去复制整棵包);
406
+ // 链接已被 release/curl 通道的"先删再铺"打断 → 先把新副本刷进 plugin-src 再重放(既有能力,不变)
292
407
  if (spec.trim().startsWith('link:')) {
408
+ if (linkSpecIsIntact(profileDir, name, spec)) return { spec: spec.trim(), reason: null, note: null }
293
409
  const dir = materializePackageForLink(profileDir, name, home === null ? {} : { home })
294
410
  return { spec: dir === null ? spec.trim() : linkSpecFor(dir), reason: null, note: null }
295
411
  }
@@ -319,14 +435,29 @@ async function reconcileLockfile({ profileDir, packageName = null, packages = nu
319
435
  const errors = []
320
436
  let method = null
321
437
  const planned = []
438
+ let importerSettled = null
322
439
  if (drifted.length > 0) {
323
440
  // 只对"能读到实际版本"的包做对齐;读不到的(目录都没有)无法对账,留给 lockNote
324
- const specs = []
441
+ const specs = [] // registry 形态(版本号 / dist-tag)→ **照旧**走既有 pnpm add 通道
442
+ const importerItems = [] // 非 registry 来源且**能由 importer 条目完整表达**(link:)→ 定点写 lock
443
+ const notWritable = [] // 其余非 registry 来源(file:/git+/URL/…)→ 本地不伪造、也不用 pnpm add
325
444
  for (const row of drifted) {
326
445
  if (row.installed === null) continue
327
446
  const plan = await planWriteback(row)
328
- planned.push({ name: row.name, installed: row.installed, spec: plan.spec, reason: plan.reason ?? null, note: plan.note ?? null })
329
- if (plan.spec !== null) specs.push(plan.spec)
447
+ const item = { name: row.name, installed: row.installed, spec: plan.spec, reason: plan.reason ?? null, note: plan.note ?? null }
448
+ if (plan.spec !== null && canWriteImporterEntry(plan.spec)) {
449
+ item.channel = 'importer'
450
+ importerItems.push(item)
451
+ } else if (plan.spec !== null && SOURCE_PINNED_RE.test(plan.spec.trim())) {
452
+ // 修 1(0.5.39):非 registry 来源**绝不**再用 `pnpm add` 当持久化手段;本地又无法确定它的
453
+ // lock 记录(上面 IMPORTER_ONLY_RE 的注释说了为什么)⇒ 一个字节都不写,交给下面的文案如实报。
454
+ item.channel = 'importer-skipped'
455
+ notWritable.push(item)
456
+ } else {
457
+ item.channel = 'pnpm'
458
+ if (plan.spec !== null) specs.push(plan.spec)
459
+ }
460
+ planned.push(item)
330
461
  }
331
462
  if (specs.length > 0) {
332
463
  try {
@@ -336,18 +467,43 @@ async function reconcileLockfile({ profileDir, packageName = null, packages = nu
336
467
  errors.push(`pnpm add 对齐失败:${error instanceof Error ? error.message : String(error)}`)
337
468
  }
338
469
  }
470
+ if (importerItems.length > 0) {
471
+ importerSettled = await settleImporterEntries({ profileDir, items: importerItems, deps, registries })
472
+ errors.push(...importerSettled.errors)
473
+ if (importerSettled.changed === true) method = method === null ? 'lock-importer' : `${method}+lock-importer`
474
+ }
339
475
  rows = snap()
340
476
  drifted = driftedOf(rows)
341
477
  }
342
478
  // ★ 文案一律按**实际写入形态**生成(读回清单再描述)——"计划写 link:"与"清单里真是 link:"
343
479
  // 是两件事:pnpm add 失败、或后续某一步又把 spec 覆盖成版本号时,绝不能报"已钉住"。
344
480
  const suggestedActions = []
481
+ const writeOf = new Map((importerSettled?.writes ?? []).map((w) => [w.name, w]))
345
482
  for (const item of planned) {
346
483
  const actual = profileSpec(profileDir, item.name)
347
484
  if (item.spec === null) {
348
485
  if (item.note !== null) depNotes.push(item.note)
349
486
  continue
350
487
  }
488
+ if (item.channel === 'importer') {
489
+ const wrote = writeOf.get(item.name) ?? null
490
+ if (wrote !== null && (wrote.changed === true || wrote.unchanged === true)) {
491
+ if (item.reason !== null) {
492
+ depNotes.push(`${item.name}:${item.reason},已按 link: 形式记录依赖(${actual})—— lock 的 importer 条目已**定点写入**(${wrote.version ?? ''}),不经 npm registry 解析、不经 tarball 完整性校验`)
493
+ }
494
+ continue
495
+ }
496
+ const why = `${item.name}:${item.reason !== null ? `${item.reason};` : ''}${wrote?.reason ?? 'lock importer 条目未能写入'}(清单现在是 ${actual ?? '(没有这个条目)'})—— lock 条目**未写入**`
497
+ depNotes.push(`${why}。请执行「钉住」动作或手动执行:${command(item.name, item.installed)}`)
498
+ suggestedActions.push(suggestedPinAction({ packageName: item.name, version: item.installed, profileDir, reason: why }))
499
+ continue
500
+ }
501
+ if (item.channel === 'importer-skipped') {
502
+ const why = `${item.name}:来源是 ${item.spec}(非 registry)—— lock 里的记录不止 importer 一行(pnpm 还要写 packages/snapshots 的解析记录),本地无法确定 ⇒ **未写入 lock**,也没用 pnpm add 顶替`
503
+ depNotes.push(`${why}。要钉住请执行「钉住」动作或手动执行:${command(item.name, item.installed)}`)
504
+ suggestedActions.push(suggestedPinAction({ packageName: item.name, version: item.installed, profileDir, reason: why }))
505
+ continue
506
+ }
351
507
  if (actual === item.spec) {
352
508
  if (item.spec.startsWith('link:') && item.reason !== null) {
353
509
  depNotes.push(`${item.name}:${item.reason},已按 link: 形式记录依赖(${actual})—— 不经 npm registry 解析、不经 tarball 完整性校验,pnpm 重建 lock 也能装上`)
@@ -358,7 +514,39 @@ async function reconcileLockfile({ profileDir, packageName = null, packages = nu
358
514
  depNotes.push(`${why}。请执行「钉住」动作或手动执行:${command(item.name, item.installed)}`)
359
515
  suggestedActions.push(suggestedPinAction({ packageName: item.name, version: item.installed, profileDir, reason: why }))
360
516
  }
361
- const lockUpdated = drifted.length === 0
517
+ // 只读校验没通过/无法确认 ⇒ **不算已持久化**(修 1 的硬要求:不许报成功),把缺口与出路如实写进 lockNote
518
+ const lockVerified = importerSettled === null ? undefined : importerSettled.verified
519
+ const verifyBlocked = lockVerified === false || lockVerified === null
520
+ const verifyNames = (importerSettled?.writes ?? []).filter((w) => w.changed === true).map((w) => w.name)
521
+ const verifyNote = verifyBlocked
522
+ ? `${verifyNames.join('、')} 的 lock importer 条目已按来源写入,但 pnpm 的**只读**校验${lockVerified === null ? '无法确认' : '未通过'}(${importerSettled?.verifyNote ?? '原因未知'})—— 按**未持久化**处理,绝不报成功`
523
+ : null
524
+ if (verifyBlocked && verifyNames.length > 0 && !suggestedActions.some((a) => a?.payload?.packageName === verifyNames[0])) {
525
+ const target = planned.find((p) => p.name === verifyNames[0]) ?? { name: verifyNames[0], installed: readInstalledVersion(profileDir, verifyNames[0]) }
526
+ suggestedActions.push(suggestedPinAction({ packageName: target.name, version: target.installed ?? null, profileDir, reason: verifyNote }))
527
+ }
528
+ // 只写 lock 成功、但 node_modules 里的链接还没就位(release/curl/URL 通道「先删再铺」把链接换成了真实目录):
529
+ // 这条**不是**"没写进 lock",文案必须分开说;同时要给「缺口明细 + 一键钉住」。
530
+ const writtenNames = new Set((importerSettled?.writes ?? []).filter((w) => w.changed === true || w.unchanged === true).map((w) => w.name))
531
+ const linkPending = drifted.filter((r) => writtenNames.has(r.name) && typeof r.lock === 'string' && r.lock.trim().startsWith('link:'))
532
+ for (const row of linkPending) {
533
+ if (suggestedActions.some((a) => a?.payload?.packageName === row.name)) continue
534
+ const reason = `${row.name}:lock 条目已按来源写入(${row.lock}),但 node_modules 里的链接尚未就位 —— 点「钉住」可立即用 pnpm 把链接建起来(否则要等下一次 pnpm 操作)`
535
+ depNotes.push(reason)
536
+ suggestedActions.push(suggestedPinAction({ packageName: row.name, version: row.installed, profileDir, reason }))
537
+ }
538
+ const lockUpdated = drifted.length === 0 && !verifyBlocked
539
+ const notWritten = drifted.filter((r) => !linkPending.includes(r))
540
+ const driftedText = drifted.length === 0
541
+ ? null
542
+ : [
543
+ linkPending.length > 0
544
+ ? `${linkPending.map((r) => `${r.name}:lock 条目已按来源写入(${r.lock}),但 node_modules 里的链接尚未就位 —— 下一次 pnpm 操作会按这条 lock 重建链接`).join(';')}`
545
+ : null,
546
+ notWritten.length > 0
547
+ ? `${notWritten.length} 个包没写进 pnpm-lock.yaml(${notWritten.map((r) => `${r.name}:装了 ${r.installed ?? '未知'}/lock 里是 ${r.lock ?? '未记录'}`).join(';')}):之后任何 pnpm 操作(开关插件、dsh plugin add/remove)都可能把它们还原。要钉住请执行:${command(notWritten[0].name, notWritten[0].installed)}`
548
+ : null,
549
+ ].filter((x) => x !== null).join(';')
362
550
  return {
363
551
  method,
364
552
  packages: rows.map((r) => ({ name: r.name, installedVersion: r.installed, lockVersion: r.lock, spec: r.spec, aligned: r.installed !== null && (r.installed === r.lock || sourcePinned(r.lock)) })),
@@ -366,9 +554,19 @@ async function reconcileLockfile({ profileDir, packageName = null, packages = nu
366
554
  installedVersion: rows.length === 1 ? rows[0].installed : null,
367
555
  lockVersion: rows.length === 1 ? rows[0].lock : null,
368
556
  lockUpdated,
557
+ // 只读校验的结论如实下发(true=确认可解析 / false=未通过 / null=无法确认;没写过条目时是 undefined)
558
+ lockVerified,
559
+ lockVerifyNote: importerSettled?.verifyNote ?? null,
560
+ lockImporterWrites: (importerSettled?.writes ?? []).map((w) => ({ name: w.name, changed: w.changed === true, version: w.version ?? null, reason: w.reason ?? null })),
369
561
  lockNote: lockUpdated
370
562
  ? null
371
- : `${drifted.length} 个包没写进 pnpm-lock.yaml(${drifted.map((r) => `${r.name}:装了 ${r.installed ?? '未知'}/lock 里是 ${r.lock ?? '未记录'}`).join(';')}):之后任何 pnpm 操作(开关插件、dsh plugin add/remove)都可能把它们还原。要钉住请执行:${command(drifted[0].name, drifted[0].installed)}`,
563
+ : [
564
+ verifyNote,
565
+ // 定点写入失败的逐条原因也要进 lockNote(缺口明细:面板只看 lockNote 时也说得清)
566
+ (importerSettled?.writes ?? []).filter((w) => w.changed !== true && w.unchanged !== true && typeof w.reason === 'string' && w.reason !== '')
567
+ .map((w) => `${w.name}:${w.reason}`).join(';') || null,
568
+ drifted.length === 0 ? null : `${driftedText}`,
569
+ ].filter((x) => x !== null).join(';'),
372
570
  // 非常规来源(link:)写回时的**用户可见**说明:绝不静默(缺陷②的隐蔽性正是"装完看不出问题")。
373
571
  // 文案由上面的"读回真实值"步骤产出,因此永远不会出现"面板说已钉住、磁盘上仍是版本号"。
374
572
  depNote: depNotes.length > 0 ? depNotes.join(';') : null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noob-stupid/dsh-plugin-console",
3
- "version": "0.5.37",
3
+ "version": "0.5.39",
4
4
  "description": "DSH 框架升级安全与插件升级门控:一键升级、失败自动回滚、升级后回滚上版、旧插件不适配自动禁用;内置多源插件市场为发现层,插件源全部可自定义,可指向公司内网私有源 / 私有索引 / 本地 Git 仓库,纯内网离线可用 | Framework upgrade safety & plugin version gating for DSH, with a customizable multi-source plugin market: point every source at internal mirrors or a local file:// repo for intranet-only, offline installs.",
5
5
  "repository": {
6
6
  "type": "git",