@noob-stupid/dsh-plugin-console 0.5.38 → 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.
@@ -20,7 +20,7 @@
20
20
  // 不经 tarball 完整性校验,lock 删掉重建、node_modules 清空重装都稳定通过。
21
21
 
22
22
  import { existsSync, rmSync, mkdirSync, realpathSync } from 'node:fs'
23
- import { basename, dirname, join } from 'node:path'
23
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
24
24
  import { copyTree } from '../infra/fsx.js'
25
25
  import { fetchJsonUrl } from '../infra/http.js'
26
26
  import { dshHome } from '../infra/paths.js'
@@ -113,6 +113,65 @@ function linkSpecFor(dir) {
113
113
  return `link:${String(dir).replace(/\\/gu, '/')}`
114
114
  }
115
115
 
116
+ /**
117
+ * 依赖规格的**唯一规范形**(0.5.39 加法):`link:` / `file:` 这类**路径型**来源统一成 pnpm 实际写出的
118
+ * 形态 —— **正斜杠**(是绝对还是相对保持调用方给的选择:pnpm 的 lock 里由它自己决定,见
119
+ * lock-importer.js#lockVersionText)。其它 spec(版本号/范围/dist-tag/URL/git)只做 trim。
120
+ *
121
+ * 为什么必须归一(真机证据,2026-10-04):desktop profile 的 `@noob-stupid/dsh-connection-card-host`
122
+ * 清单里是 `link:D:\dsh-link\dsh-connection-card-host`(**反斜杠**),而所有写回路径都是逐字比较
123
+ * (`pinProfileDependency` / `setProfileDependency` / `declareProfileDependency` 的 `deps[name] === spec`)
124
+ * ⇒ 任何一次「钉住」都会把它改成正斜杠 —— 用户没要求的漂移,而且幂等性彻底失效(每次都"有变化")。
125
+ */
126
+ function canonicalSourceSpec(spec) {
127
+ if (typeof spec !== 'string') return null
128
+ const s = spec.trim()
129
+ const m = /^(link:|file:)([\s\S]*)$/u.exec(s)
130
+ if (m === null) return s
131
+ let target = m[2].replace(/\\/gu, '/').replace(/\/{2,}/gu, '/')
132
+ // 去掉尾部多余的斜杠(但别把盘根 `C:/` 或 POSIX 根 `/` 也削掉)
133
+ if (target.length > 1 && /\/$/u.test(target) && !/:\/$/u.test(target)) target = target.replace(/\/+$/u, '')
134
+ return `${m[1]}${target}`
135
+ }
136
+
137
+ /** 路径型 spec 的目标目录(相对写法按 profile 目录解析);非路径型返回 null。 */
138
+ function sourceSpecTarget(profileDir, spec) {
139
+ const canonical = canonicalSourceSpec(spec)
140
+ if (canonical === null) return null
141
+ const m = /^(link:|file:)([\s\S]*)$/u.exec(canonical)
142
+ if (m === null || m[2] === '') return null
143
+ const raw = m[2]
144
+ try {
145
+ return isAbsolute(raw) ? raw : resolve(String(profileDir ?? ''), raw)
146
+ } catch {
147
+ return null
148
+ }
149
+ }
150
+
151
+ /**
152
+ * 两种写法是否**指向同一个东西**(0.5.39 加法,Fix 2 的判据):等价即视为**完好、零写盘**。
153
+ * · 先比规范形(`link:C:/x` ↔ `link:C:\x` ↔ `link:C:/x/` 全部等价);
154
+ * · 路径型再比 realpath(相对↔绝对、Windows 大小写差异、符号链接指向同一目录都算等价)。
155
+ * 只用它做"要不要写"的判断;真正写盘时一律写**规范形**(canonicalSourceSpec)。
156
+ */
157
+ function sameSourceSpec(profileDir, a, b) {
158
+ const na = canonicalSourceSpec(a)
159
+ const nb = canonicalSourceSpec(b)
160
+ if (na === null || nb === null) return false
161
+ if (na === nb) return true
162
+ if (!/^(?:link:|file:)/u.test(na) || !/^(?:link:|file:)/u.test(nb)) return false
163
+ const pa = sourceSpecTarget(profileDir, na)
164
+ const pb = sourceSpecTarget(profileDir, nb)
165
+ if (pa === null || pb === null) return false
166
+ try {
167
+ const ra = realpathSync(pa)
168
+ const rb = realpathSync(pb)
169
+ return process.platform === 'win32' ? ra.toLowerCase() === rb.toLowerCase() : ra === rb
170
+ } catch {
171
+ return false
172
+ }
173
+ }
174
+
116
175
  /**
117
176
  * manifest 里的 `link:` 规格当前是否**真的**还生效(`node_modules/<包名>` 就是指向它的那个链接)。
118
177
  * 为什么必须查:release / curl 通道更新包时是「先 rmSync 再 copyTree」,会把 `node_modules/<包名>`
@@ -198,4 +257,4 @@ function suggestedReconcileAction({ profileDir = null, profile = null, reason =
198
257
  }
199
258
  }
200
259
 
201
- export { PLUGIN_SRC_DIR, NPM_NAME_RE, probeRegistryPackage, probeFailureKind, materializePackageForLink, linkSpecFor, linkSpecIsIntact, profileNameOf, suggestedAllowBuildsAction, suggestedPinAction, suggestedReconcileAction }
260
+ export { PLUGIN_SRC_DIR, NPM_NAME_RE, probeRegistryPackage, probeFailureKind, materializePackageForLink, linkSpecFor, linkSpecIsIntact, profileNameOf, suggestedAllowBuildsAction, suggestedPinAction, suggestedReconcileAction, canonicalSourceSpec, sameSourceSpec, sourceSpecTarget }
@@ -0,0 +1,411 @@
1
+ // L1 · domain —— lock-importer.js(pnpm-lock.yaml importer 条目的**定点写入** + **只读**校验;0.5.39 加法)
2
+ //
3
+ // 为什么存在(用户 2026-10-04 定案,两处真缺陷):
4
+ // ① `reconcileLockfile` 过去对 `link:` 漂移一律跑一次**完整** `pnpm add`(拉包、动 node_modules、
5
+ // 重写整份 lock)—— 而 lock 里 link 依赖的持久化**只需要 importer 里那两行**
6
+ // (pnpm 对 link 依赖不写 packages/snapshots —— 本机 pnpm 11.21.0 实测)。所以非 registry 来源的
7
+ // lock 持久化 = 本模块的定点写入(**唯一实现**;persist.js 走的 reconcile 通路就是这一条),
8
+ // `pnpm add` **不再**充当持久化手段。
9
+ // ② 写出后必须用 **只读** 的 pnpm 校验确认 lock 仍可解析:`install --lockfile-only --dry-run
10
+ // --frozen-lockfile`(实测:成功与失败两种结局下 lock 都逐字节不变);失败或无法确认 ⇒ 调用方
11
+ // 报「未持久化」+ 一键钉住,**绝不报成功**。
12
+ //
13
+ // ★ 现场事实(2026-10-04 实测,pnpm 11.21.0,本机 desktop profile 的副本):
14
+ // `pnpm install --lockfile-only`(**不加** `--dry-run`)会把**无关条目**的 peer 后缀剥掉 ——
15
+ // 实测把 `@deepseek-ai/dsh-experimental-schedule-bundle` / `dsh-schedule` 的
16
+ // `(@deepseek-ai/dsh-brand@0.1.7-rc.2)` 去掉。⇒ 它**不是**"定点补一条"的无损工具,因此:
17
+ // · 本模块的写入是**纯文本外科手术**(只动目标条目的那几行,其余字节逐字保留,见 planLockImporterEntry);
18
+ // · 校验只用 `--dry-run`(并且每次校验后核对 lock 字节,一旦发现被动过就**立即还原**并报未确认)。
19
+ //
20
+ // 分层:L1 domain —— 不认识 cordis ctx;IO 与 pnpm 执行都可注入(便于离线单测跑正控/负控)。
21
+ import { readFileSync, writeFileSync } from 'node:fs'
22
+ import { isAbsolute, join, relative, resolve } from 'node:path'
23
+ import { buildPnpmEnv, runPnpmWithFallback } from '../infra/exec.js'
24
+ import { queuedWrite } from '../infra/fsx.js'
25
+
26
+ /** importers 段里 pnpm 认的三个依赖分区(与 lockfile-health.js#DEP_SECTIONS 同一形状)。 */
27
+ const IMPORTER_SECTIONS = ['dependencies', 'devDependencies', 'optionalDependencies']
28
+
29
+ /**
30
+ * 能由 importer 条目**完整**表达的来源(唯一白名单):`link:`。
31
+ * 为什么其余的不在这里(本机 pnpm 11.21.0 实测):`file:` / `git+` / tarball URL 依赖除了 importer 那两行,
32
+ * pnpm 还要写 `packages:` / `snapshots:` 里的**解析记录**(file: 目录 = `resolution: {directory: …}`,
33
+ * file: tgz 与 URL = `integrity`),只留 importer 时 `pnpm install --lockfile-only` 会把 packages 段
34
+ * 重新补出来 ⇒ 本地无法凭空确定,**绝不伪造**(也不退回 `pnpm add`;由调用方如实报未持久化 + 一键钉住)。
35
+ */
36
+ const IMPORTER_ONLY_RE = /^link:/u
37
+
38
+ /** 该校验/写入用的 lock 文件名(唯一常量,别处不要再拼字面量)。 */
39
+ const LOCKFILE_NAME = 'pnpm-lock.yaml'
40
+
41
+ /** 只读校验的超时(`--dry-run --lockfile-only` 实测 0.5~1.5s;给足余量,失败即如实报)。 */
42
+ const VERIFY_TIMEOUT_MS = 60000
43
+
44
+ /** 该来源是否走「只写 lock importer 条目」的定点写入(唯一判据;调用方不许再各判一套)。 */
45
+ function canWriteImporterEntry(spec) {
46
+ return typeof spec === 'string' && IMPORTER_ONLY_RE.test(spec.trim())
47
+ }
48
+
49
+ /**
50
+ * lock 里 importer 键行的判据(**唯一真源**):`name:` / `'name':` / `"name":` 三种形态都认。
51
+ * 与 selfupdate.js#lockVersion 的键判据**逐字同一份**(真机证据:pnpm 对含 `/`、`@` 的包名加引号,
52
+ * 对未加 scope 的裸名不加 —— 0.5.38 修过一次"裸键读不出来",两处各写一份必然再次分叉)。
53
+ */
54
+ function lockEntryKeyMatches(line, name) {
55
+ const trimmed = String(line).trim()
56
+ const bare = trimmed.replace(/^'|':?$/gu, '').replace(/'$/u, '')
57
+ const bareKey = trimmed.replace(/'$/u, '').replace(/:$/u, '')
58
+ const unquoted = trimmed.replace(/^"|":?$/gu, '').replace(/"$/u, '')
59
+ return trimmed === `'${name}':` || trimmed === `"${name}":` || bare === name || bareKey === name || unquoted === name
60
+ }
61
+
62
+ /** YAML 明文标量安全吗(危险形态才加引号)。普通路径/URL/`link:…` 一律**明文** —— pnpm 自己就是
63
+ * 这么写的(真机 lock 证据:`specifier: link:C:/Users/花火/.dsh/plugin-src/…` 未加引号;
64
+ * `@` 与 `:` 出现在**中间**是合法明文,只有出现在开头/结尾或后跟空格才是危险形态)。 */
65
+ function yamlScalar(value) {
66
+ const text = String(value)
67
+ const needsQuote = text === ''
68
+ || /[\n\r\t]/u.test(text)
69
+ || /^\s|\s$/u.test(text)
70
+ || /:\s|:$/u.test(text)
71
+ || /#/u.test(text)
72
+ || /^[!&*?|>%@`"'[\]{},-]/u.test(text)
73
+ return needsQuote ? `'${text.replace(/'/gu, "''")}'` : text
74
+ }
75
+
76
+ /**
77
+ * `link:` 规格在 lock 里的 `version:` 形态(**pnpm 实际写出的形态**,真机 + 本机实测双重证据):
78
+ * · 同盘 → 相对 profile 目录的路径(`link:../../plugin-src/x`);
79
+ * · 跨盘(`path.relative` 只能给出绝对路径)→ 绝对路径(`link:D:/dsh-link/x`);
80
+ * · 一律**正斜杠**(清单里的反斜杠目标也归一成正斜杠 —— 真机 desktop profile 的
81
+ * `link:D:\dsh-link\…` 在 lock 里就是 `link:D:/dsh-link/…`)。
82
+ * 非 link: 规格返回 null(不在本模块的白名单里)。
83
+ */
84
+ function lockVersionText(profileDir, spec) {
85
+ if (!canWriteImporterEntry(spec)) return null
86
+ const raw = spec.trim().slice('link:'.length).replace(/\\/gu, '/')
87
+ if (raw === '') return null
88
+ const abs = isAbsolute(raw) ? raw : resolve(String(profileDir), raw)
89
+ let rel = ''
90
+ try {
91
+ rel = relative(String(profileDir), abs)
92
+ } catch {
93
+ rel = ''
94
+ }
95
+ const text = rel !== '' && !isAbsolute(rel) ? rel : abs
96
+ return `link:${String(text).replace(/\\/gu, '/')}`
97
+ }
98
+
99
+ /**
100
+ * 纯函数:把 lock 文本改成"该包在 importer 里有正确条目"的文本(**只动它自己那几行**)。
101
+ * 返回 `{ ok, changed, text, reason }`;`changed:false` 表示磁盘上的形态已经就是目标形态(零写盘)。
102
+ * 形态不认识的 lock(没有 importers 段 / 缩进不是 pnpm 的形状)→ `ok:false` + 原因,**绝不猜着改**。
103
+ */
104
+ function planLockImporterEntry(lockText, { name, specifier, version, section = IMPORTER_SECTIONS[0] } = {}) {
105
+ const text = typeof lockText === 'string' ? lockText : String(lockText ?? '')
106
+ const eol = /\r\n/u.test(text) ? '\r\n' : '\n'
107
+ const trailing = /\r?\n$/u.test(text)
108
+ const lines = text.split(/\r?\n/u)
109
+ if (trailing && lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
110
+ const rebuild = (next) => next.join(eol) + (trailing ? eol : '')
111
+ const fail = (reason) => ({ ok: false, changed: false, text, reason })
112
+
113
+ const start = lines.findIndex((l) => /^importers:\s*$/u.test(l))
114
+ if (start === -1) return fail('lock 里没有 importers 段(形态不认识),已跳过定点写入')
115
+ // 根 importer(profile 是单项目工程 → 键是 `.`;找不到就退回第一个 importer)
116
+ let importerAt = -1
117
+ let importerEnd = lines.length
118
+ for (let i = start + 1; i < lines.length; i += 1) {
119
+ if (/^[A-Za-z]/u.test(lines[i])) {
120
+ if (importerAt !== -1) importerEnd = i
121
+ break
122
+ }
123
+ const key = /^\s{2}(\S.*?):\s*$/u.exec(lines[i])
124
+ if (key !== null) {
125
+ if (importerAt === -1) importerAt = i
126
+ if (key[1] === '.') {
127
+ importerAt = i
128
+ // 该 importer 的结束位置:下一个缩进 ≤2 的非空行
129
+ for (let k = i + 1; k < lines.length; k += 1) {
130
+ if (lines[k].trim() !== '' && !/^\s{3,}/u.test(lines[k])) {
131
+ importerEnd = k
132
+ break
133
+ }
134
+ }
135
+ break
136
+ }
137
+ }
138
+ }
139
+ if (importerAt === -1) return fail('lock 里读不到 importer 键(形态不认识),已跳过定点写入')
140
+
141
+ const wanted = [yamlScalar(`specifier: ${specifier}`), yamlScalar(`version: ${version}`)]
142
+ // 扫该 importer:既找该包现有条目(就地改),也记录目标分区的插入点
143
+ let foundAt = -1
144
+ let foundSection = null
145
+ let foundBlockEnd = -1
146
+ let target = IMPORTER_SECTIONS.includes(section) ? section : IMPORTER_SECTIONS[0]
147
+ let currentSection = null
148
+ let insertAt = -1
149
+ for (let i = importerAt + 1; i < importerEnd; i += 1) {
150
+ const line = lines[i]
151
+ if (line.trim() === '') continue
152
+ const sec = /^\s{4}([A-Za-z]+):\s*$/u.exec(line)
153
+ if (sec !== null) {
154
+ currentSection = sec[1]
155
+ if (currentSection === target) {
156
+ // 目标分区的插入点:本分区最后一个条目之后(遇到缩进 ≤4 的非空行即本分区结束)
157
+ let last = i
158
+ for (let k = i + 1; k < importerEnd; k += 1) {
159
+ if (lines[k].trim() !== '' && !/^\s{5,}/u.test(lines[k])) break
160
+ last = k
161
+ }
162
+ insertAt = last + 1
163
+ }
164
+ continue
165
+ }
166
+ if (foundAt === -1 && /^\s{6}\S/u.test(line) && lockEntryKeyMatches(line, name)) {
167
+ foundAt = i
168
+ foundSection = currentSection
169
+ // 该条目的块范围(键行 + 缩进更深的字段行)
170
+ foundBlockEnd = i + 1
171
+ for (let k = i + 1; k < importerEnd; k += 1) {
172
+ if (lines[k].trim() !== '' && !/^\s{7,}/u.test(lines[k])) break
173
+ foundBlockEnd = k + 1
174
+ }
175
+ }
176
+ }
177
+
178
+ if (foundAt !== -1) {
179
+ // 就地改:只替换/补齐 specifier 与 version 两行,其它字段与所有无关行逐字保留
180
+ const keyIndent = /^\s*/u.exec(lines[foundAt])[0]
181
+ const fieldIndent = `${keyIndent} `
182
+ const block = lines.slice(foundAt + 1, foundBlockEnd)
183
+ const next = [...block]
184
+ let sawSpecifier = false
185
+ let sawVersion = false
186
+ for (let k = 0; k < next.length; k += 1) {
187
+ const field = /^\s*(specifier|version):/u.exec(next[k])
188
+ if (field === null) continue
189
+ if (field[1] === 'specifier') {
190
+ sawSpecifier = true
191
+ next[k] = `${fieldIndent}specifier: ${yamlScalar(specifier)}`
192
+ } else {
193
+ sawVersion = true
194
+ next[k] = `${fieldIndent}version: ${yamlScalar(version)}`
195
+ }
196
+ }
197
+ if (!sawSpecifier) next.unshift(`${fieldIndent}specifier: ${yamlScalar(specifier)}`)
198
+ if (!sawVersion) next.push(`${fieldIndent}version: ${yamlScalar(version)}`)
199
+ if (next.join(eol) === block.join(eol)) {
200
+ return { ok: true, changed: false, text, reason: null, section: foundSection ?? target, version, specifier }
201
+ }
202
+ return {
203
+ ok: true,
204
+ changed: true,
205
+ text: rebuild([...lines.slice(0, foundAt + 1), ...next, ...lines.slice(foundBlockEnd)]),
206
+ reason: null,
207
+ section: foundSection ?? target,
208
+ version,
209
+ specifier,
210
+ }
211
+ }
212
+
213
+ // 没有该条目 → 在目标分区末尾插入一条(分区不存在时先补分区头)
214
+ const keyText = /[/@]/u.test(name) ? `'${name}':` : `${name}:`
215
+ const block = [` ${keyText}`, ` specifier: ${yamlScalar(specifier)}`, ` version: ${yamlScalar(version)}`]
216
+ if (insertAt === -1) {
217
+ // 分区不存在:插到该 importer 现有分区之后(没有分区就紧跟 importer 键行)
218
+ let at = importerAt + 1
219
+ for (let i = importerAt + 1; i < importerEnd; i += 1) {
220
+ if (lines[i].trim() === '') continue
221
+ at = i + 1
222
+ }
223
+ return {
224
+ ok: true,
225
+ changed: true,
226
+ text: rebuild([...lines.slice(0, at), ` ${target}:`, ...block, ...lines.slice(at)]),
227
+ reason: null,
228
+ section: target,
229
+ version,
230
+ specifier,
231
+ }
232
+ }
233
+ return {
234
+ ok: true,
235
+ changed: true,
236
+ text: rebuild([...lines.slice(0, insertAt), ...block, ...lines.slice(insertAt)]),
237
+ reason: null,
238
+ section: target,
239
+ version,
240
+ specifier,
241
+ }
242
+ }
243
+
244
+ /**
245
+ * 写入(**唯一写入口**):读 → 纯函数改造 → 写 → **读回核实**(既有规矩:写完必须读回,绝不凭返回值
246
+ * 报成功)。走 fsx 的写队列(与清单/补丁写入同一把锁,避免并发读改写互相覆盖)。
247
+ * 返回 `{ changed, unchanged, specifier, version, section, reason }`;任何一步不成 ⇒ `changed:false` + 原因。
248
+ */
249
+ async function writeLockImporterEntry(profileDir, { name, spec, section = IMPORTER_SECTIONS[0], deps = {} } = {}) {
250
+ const readText = typeof deps.readText === 'function' ? deps.readText : (p) => readFileSync(p, 'utf8')
251
+ const writeText = typeof deps.writeText === 'function' ? deps.writeText : (p, text) => writeFileSync(p, text, 'utf8')
252
+ const file = join(String(profileDir), LOCKFILE_NAME)
253
+ const specifier = typeof spec === 'string' ? spec.trim() : ''
254
+ const version = lockVersionText(profileDir, specifier)
255
+ if (version === null) {
256
+ return { changed: false, unchanged: false, specifier, version: null, section, reason: '该来源的 lock 记录不止 importer 一行,本地无法确定 ⇒ 未写入(不用 pnpm add 当持久化手段)' }
257
+ }
258
+ return queuedWrite(async () => {
259
+ let text = null
260
+ try {
261
+ text = readText(file)
262
+ } catch (error) {
263
+ return { changed: false, unchanged: false, specifier, version, section, reason: `读不到 ${LOCKFILE_NAME}(${error instanceof Error ? error.message : String(error)}),未写入` }
264
+ }
265
+ const plan = planLockImporterEntry(text, { name, specifier, version, section })
266
+ if (plan.ok !== true) return { changed: false, unchanged: false, specifier, version, section, reason: plan.reason }
267
+ if (plan.changed !== true) return { changed: false, unchanged: true, specifier: plan.specifier, version: plan.version, section: plan.section, reason: null }
268
+ try {
269
+ writeText(file, plan.text)
270
+ } catch (error) {
271
+ return { changed: false, unchanged: false, specifier, version, section, reason: `写盘失败:${error instanceof Error ? error.message : String(error)}` }
272
+ }
273
+ let after = null
274
+ try {
275
+ after = readText(file)
276
+ } catch (error) {
277
+ return { changed: false, unchanged: false, specifier, version, section, reason: `写盘后读回失败:${error instanceof Error ? error.message : String(error)}` }
278
+ }
279
+ if (after !== plan.text) return { changed: false, unchanged: false, specifier, version, section, reason: '写盘后读回与预期不一致(未能核实),按未写入处理' }
280
+ return { changed: true, unchanged: false, specifier: plan.specifier, version: plan.version, section: plan.section, reason: null }
281
+ })
282
+ }
283
+
284
+ /**
285
+ * 从 lock 自己的 `settings:` 段取出 pnpm 配置,供**只读**校验按 lock 的形态复算
286
+ * (不镜像它时 pnpm 直接 `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH`:本机实测 `autoInstallPeers`)。
287
+ * 只认单行标量;对象/数组等复杂设置一律不传(不猜)。
288
+ */
289
+ function lockfileSettingsFlags(lockText) {
290
+ const lines = String(lockText ?? '').split(/\r?\n/u)
291
+ const start = lines.findIndex((l) => /^settings:\s*$/u.test(l))
292
+ if (start === -1) return []
293
+ const flags = []
294
+ for (let i = start + 1; i < lines.length; i += 1) {
295
+ if (/^[A-Za-z]/u.test(lines[i])) break
296
+ const m = /^\s{2}([A-Za-z][A-Za-z0-9]*):\s*(.+?)\s*$/u.exec(lines[i])
297
+ if (m === null) continue
298
+ const value = m[2].replace(/^'|'$/gu, '')
299
+ if (!/^[A-Za-z0-9._-]+$/u.test(value)) continue
300
+ flags.push(`--config.${m[1]}=${value}`)
301
+ }
302
+ return flags
303
+ }
304
+
305
+ /** 只读校验的 argv(**唯一产出点**,纯函数,便于单测断言"没有任何写盘开关")。 */
306
+ function verifyArgsFor({ registry = null, settings = [], relaxReleaseAge = false } = {}) {
307
+ const args = ['install', '--lockfile-only', '--dry-run', '--frozen-lockfile']
308
+ if (typeof registry === 'string' && registry.trim() !== '') args.push('--registry', registry.trim())
309
+ args.push(...settings)
310
+ // ★ 供应链年龄闸(pnpm 的 minimumReleaseAge,默认 24h)会在**解析之前**卡住整份 lock:
311
+ // 本机实测(刚发布的 @noob-stupid/dsh-plugin-console@0.5.37 在 24h 窗口内)不加它时任何
312
+ // `--lockfile-only` 调用都只报 ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION,看不出 lock 到底能不能解析。
313
+ // 它与"lock 可解析"是两件事,所以**只读重试**时才放宽;`--dry-run` + `--lockfile-only` 下不装任何东西
314
+ // (实测 lock 逐字节不变)。**写盘路径永不出现这个开关**(修复 argv 由 lockfile-health#repairArgsFor
315
+ // 唯一产出,那一处一个字没动)。
316
+ if (relaxReleaseAge) args.push('--config.minimumReleaseAge=0')
317
+ return args
318
+ }
319
+
320
+ /** 错误文本尾部(面板要能看到 pnpm 的原始输出,不允许只报"失败了")。 */
321
+ function tailOf(error) {
322
+ const message = String(error?.message ?? error ?? '')
323
+ return message.split(/\r?\n/u).filter((l) => l.trim() !== '').slice(-3).join(' ⏎ ').slice(0, 400)
324
+ }
325
+
326
+ /**
327
+ * **只读**校验:这条 lock 现在还能被 pnpm 解析吗(`install --lockfile-only --dry-run --frozen-lockfile`)。
328
+ * 返回 `{ verified, via, reason, attempts, changed }`:
329
+ * · `verified:true` —— pnpm 退出码 0,且 lock 字节**逐字节未变**;
330
+ * · `verified:false` —— pnpm 明确报错(lock 形态/配置有问题);
331
+ * · `verified:null` —— pnpm 根本跑不起来(执行方式不可用)⇒ "无法确认",调用方按未持久化处理。
332
+ * `changed:true` 表示校验过程**动过** lock(理论上不可能,`--dry-run` 的硬前提)——此时立即还原原字节
333
+ * 并把结论降为未确认:宁可报"没确认",也绝不留下被校验命令改过的 lock。
334
+ */
335
+ async function verifyLockReadOnly({ profileDir, registry = null, run = runPnpmWithFallback, deps = {}, execOpts = {} } = {}) {
336
+ const file = join(String(profileDir), LOCKFILE_NAME)
337
+ let before = null
338
+ try {
339
+ before = readFileSync(file, 'utf8')
340
+ } catch (error) {
341
+ return { verified: false, via: null, changed: false, attempts: [], reason: `读不到 ${LOCKFILE_NAME}(${error instanceof Error ? error.message : String(error)}),无法校验` }
342
+ }
343
+ const settings = lockfileSettingsFlags(before)
344
+ const attempts = []
345
+ const once = async (relax) => {
346
+ const args = verifyArgsFor({ registry, settings, relaxReleaseAge: relax })
347
+ try {
348
+ await run(args, { execOpts: { cwd: profileDir, timeout: VERIFY_TIMEOUT_MS, windowsHide: true, maxBuffer: 4 * 1024 * 1024, env: buildPnpmEnv(registry), ...execOpts } })
349
+ attempts.push({ args, ok: true, tail: null })
350
+ return { ok: true, tail: null }
351
+ } catch (error) {
352
+ const tail = tailOf(error)
353
+ attempts.push({ args, ok: false, tail })
354
+ return { ok: false, tail, error }
355
+ }
356
+ }
357
+ const guard = () => {
358
+ // 校验必须是无损的:核对字节,被动过就还原(并如实标注 changed)
359
+ let now = null
360
+ try {
361
+ now = readFileSync(file, 'utf8')
362
+ } catch {
363
+ return { changed: true, restored: false }
364
+ }
365
+ if (now === before) return { changed: false, restored: false }
366
+ try {
367
+ writeFileSync(file, before, 'utf8')
368
+ return { changed: true, restored: true }
369
+ } catch {
370
+ return { changed: true, restored: false }
371
+ }
372
+ }
373
+ let outcome = await once(false)
374
+ if (outcome.ok !== true && /MINIMUM_RELEASE_AGE|minimumReleaseAge/iu.test(String(outcome.tail))) outcome = await once(true)
375
+ const bytes = guard()
376
+ const via = attempts.filter((a) => a.ok).length > 0 ? 'pnpm install --lockfile-only --dry-run --frozen-lockfile' : null
377
+ if (bytes.changed) {
378
+ return {
379
+ verified: null,
380
+ via,
381
+ changed: true,
382
+ restored: bytes.restored,
383
+ attempts,
384
+ reason: `只读校验**改动了** ${LOCKFILE_NAME}(违反只读前提)——已${bytes.restored ? '还原' : '尝试还原失败'},按未确认处理`,
385
+ }
386
+ }
387
+ if (outcome.ok === true) return { verified: true, via, changed: false, attempts, reason: null }
388
+ const unavailable = /ENOENT|Cannot find module|已尝试:/u.test(String(outcome.tail)) && !/ERR_PNPM_/u.test(String(outcome.tail))
389
+ return {
390
+ verified: unavailable ? null : false,
391
+ via,
392
+ changed: false,
393
+ attempts,
394
+ reason: `${unavailable ? 'pnpm 执行方式不可用(无法确认)' : 'pnpm 只读校验未通过'}:${outcome.tail ?? '(无输出)'}`,
395
+ }
396
+ }
397
+
398
+ export {
399
+ IMPORTER_SECTIONS,
400
+ IMPORTER_ONLY_RE,
401
+ LOCKFILE_NAME,
402
+ VERIFY_TIMEOUT_MS,
403
+ canWriteImporterEntry,
404
+ lockEntryKeyMatches,
405
+ lockVersionText,
406
+ lockfileSettingsFlags,
407
+ planLockImporterEntry,
408
+ verifyArgsFor,
409
+ verifyLockReadOnly,
410
+ writeLockImporterEntry,
411
+ }
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { readFile, writeFile } from 'node:fs/promises'
14
14
  import { join } from 'node:path'
15
- import { linkSpecFor, materializePackageForLink, probeFailureKind, probeRegistryPackage, suggestedPinAction } from './dep-source.js'
15
+ import { linkSpecFor, materializePackageForLink, probeFailureKind, probeRegistryPackage, sameSourceSpec, suggestedPinAction } from './dep-source.js'
16
16
  import { queuedWrite } from '../infra/fsx.js'
17
17
  import { resolvePackageJson } from '../infra/paths.js'
18
18
  import { reconcileLockfile } from './selfupdate.js'
@@ -174,7 +174,9 @@ async function declareProfileDependency(profileDir, packageName, version = null,
174
174
  const result = await queuedWrite(async () => {
175
175
  const manifest = await readManifest(profileDir)
176
176
  const deps = { ...(manifest.dependencies ?? {}) }
177
- if (deps[packageName] === plan.spec) return { changed: false }
177
+ // 0.5.39 改错(Fix 2):逐字比较会把「同一目标的另一种写法」当成漂移 —— 真机 desktop profile 的
178
+ // `link:D:\dsh-link\…`(反斜杠)会被这条路径无谓改写成正斜杠(用户没要求的漂移)。等价 ⇒ 零写盘。
179
+ if (sameSourceSpec(profileDir, deps[packageName], plan.spec)) return { changed: false }
178
180
  deps[packageName] = plan.spec
179
181
  manifest.dependencies = deps
180
182
  await writeManifest(profileDir, manifest)
@@ -230,14 +232,16 @@ async function pinProfileDependency(profileDir, packageName, options = {}) {
230
232
  const changed = await queuedWrite(async () => {
231
233
  const manifest = await readManifest(profileDir)
232
234
  const deps = { ...(manifest.dependencies ?? {}) }
233
- if (deps[packageName] === spec) return false
235
+ // 0.5.39 改错(Fix 2):等价写法(正/反斜杠、相对↔绝对指向同一目录)**不再改写清单** ——
236
+ // 幂等零写盘;只有真漂移(目标不同/确实缺失)才写,且写的是 linkSpecFor 的**规范形**。
237
+ if (sameSourceSpec(profileDir, deps[packageName], spec)) return false
234
238
  deps[packageName] = spec
235
239
  manifest.dependencies = deps
236
240
  await writeManifest(profileDir, manifest)
237
241
  return true
238
242
  })
239
243
  const after = await specOf(profileDir, packageName)
240
- return { ok: after === spec, spec, before, after, changed, dir }
244
+ return { ok: sameSourceSpec(profileDir, after, spec), spec, before, after, changed, dir }
241
245
  }
242
246
 
243
247
  /**
@@ -252,7 +256,8 @@ async function setProfileDependency(profileDir, packageName, spec) {
252
256
  const changed = await queuedWrite(async () => {
253
257
  const manifest = await readManifest(profileDir)
254
258
  const deps = { ...(manifest.dependencies ?? {}) }
255
- if (deps[packageName] === spec) return false
259
+ // 0.5.39 改错(Fix 2):判等用同一把尺子(canonicalSourceSpec + realpath)—— 等价即零写盘
260
+ if (sameSourceSpec(profileDir, deps[packageName], spec)) return false
256
261
  deps[packageName] = spec
257
262
  manifest.dependencies = deps
258
263
  await writeManifest(profileDir, manifest)
@@ -31,7 +31,7 @@
31
31
  // 分层:L1 domain —— 不认识 cordis ctx;IO 与四个写入器都可注入(便于离线单测跑正控/负控)。
32
32
  import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs'
33
33
  import { join } from 'node:path'
34
- import { PLUGIN_SRC_DIR, linkSpecFor, profileNameOf, linkSpecIsIntact } from './dep-source.js'
34
+ import { PLUGIN_SRC_DIR, linkSpecFor, profileNameOf, linkSpecIsIntact, sameSourceSpec } from './dep-source.js'
35
35
  import { isUnder } from '../infra/framework-root.js'
36
36
  import { addBundleToManifest, declareProfileDependency, pinProfileDependency, setProfileDependency } from './manifest.js'
37
37
  import { appendInsert, parseInsertNames } from './patch.js'
@@ -145,11 +145,15 @@ function lockPart({ profileDir, packageName, spec }) {
145
145
  const value = String(entry).trim()
146
146
  const s = typeof spec === 'string' ? spec.trim() : ''
147
147
  if (s.startsWith('link:')) {
148
- if (value.startsWith('link:')) {
148
+ // 0.5.39 改错(Fix 2):link 条目还必须**指向清单里的同一个目标** —— 只判"以 link: 开头"会把
149
+ // 「清单指 A、lock 指 B」当成就位,而 pnpm 下一次操作会按 lock 把链接还原到 B(真实漂移)。
150
+ if (value.startsWith('link:') && sameSourceSpec(profileDir, value, s)) {
149
151
  out.ok = true
150
152
  return out
151
153
  }
152
- out.reason = `清单把该包钉在 ${s},而 lock 里是 ${value}(对账后会被 pnpm 还原)`
154
+ out.reason = value.startsWith('link:')
155
+ ? `清单把该包钉在 ${s},而 lock 里是 ${value}(**指向的目标不同**,对账后会被 pnpm 还原)`
156
+ : `清单把该包钉在 ${s},而 lock 里是 ${value}(对账后会被 pnpm 还原)`
153
157
  return out
154
158
  }
155
159
  if (EXACT_VERSION_RE.test(s)) {
@@ -361,6 +365,18 @@ async function ensurePersisted({ profileDir, patchPath, packageName, mode = 'ins
361
365
  }
362
366
  }
363
367
  const after = persistReport({ profileDir, patchPath, packageName, entries })
368
+ // 0.5.39 加法(修 1 的收尾):reconcile 写出 lock importer 条目后会用 pnpm 的**只读**用法确认 lock
369
+ // 仍可解析;「没通过 / 无法确认」时**不能**因为磁盘上有那两行就报成功 —— 按未持久化处理。
370
+ // 判据与文案都来自同一条通路(reconcile 的 lockVerified/lockNote),本模块只把结论并进来,不新判一套。
371
+ let lockVerdict = null
372
+ if (lock !== null && typeof lock === 'object' && (lock.lockVerified === false || lock.lockVerified === null) && lock.lockUpdated !== true) {
373
+ const reason = lock.lockNote ?? 'lock 已写入,但 pnpm 的只读校验未能确认它可解析'
374
+ after.parts.lock = { ...after.parts.lock, ok: false, reason }
375
+ after.missing = PERSIST_PARTS.filter((key) => after.parts[key].ok !== true)
376
+ after.persisted = after.missing.length === 0
377
+ after.note = after.persisted ? null : persistNote(after)
378
+ lockVerdict = { verified: lock.lockVerified, reason }
379
+ }
364
380
  const suggested = after.persisted ? null : suggestedPersistAction({ packageName, profileDir, missing: after.missing, reason: after.note })
365
381
  return {
366
382
  persisted: after.persisted,
@@ -370,6 +386,7 @@ async function ensurePersisted({ profileDir, patchPath, packageName, mode = 'ins
370
386
  parts: after.parts,
371
387
  declared,
372
388
  lock,
389
+ lockVerdict,
373
390
  bundled,
374
391
  rowId: after.parts.mount.rowId ?? rowId ?? null,
375
392
  notes,
@@ -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,16 +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 trimmed = lines[i].trim()
76
77
  // 键形态三种都认(0.5.38 改错):`name`、`'name':`(pnpm 对**含 `/`/`@`** 的包名加引号)、
77
78
  // `name:`(pnpm 对**未加 scope** 的包名不加引号)。旧判据漏了第三种 ⇒ 未加 scope 的 `link:`
78
79
  // 依赖在 importers 里明明有条目却读不出来(真机 desktop profile 的 `dshmarket` 就是:
79
80
  // lock 的 35-37 行写着 `dshmarket: / specifier: link:… / version: link:../../plugin-src/dshmarket`,
80
81
  // 而 `lockVersion()` 返回 null)——后果是面板永远报"没写进 pnpm-lock.yaml"、pin 永远 partial、
81
82
  // 每次对账都白跑一次 `pnpm add`。真值一律从下面这一处读,别在调用方再补一套。
82
- const bare = trimmed.replace(/^'|':?$/gu, '').replace(/'$/u, '')
83
- const bareKey = trimmed.replace(/'$/u, '').replace(/:$/u, '')
84
- if (bare === name || bareKey === name || trimmed === `'${name}':`) {
83
+ // 0.5.39:键判据搬进 lock-importer.js#lockEntryKeyMatches —— 定点**写**入也要认同一批键形态,
84
+ // 两处各写一份必然再次分叉(写进去的键、读的时候认不出,就是 0.5.38 那个现场)。
85
+ if (lockEntryKeyMatches(lines[i], name)) {
85
86
  for (let k = i + 1; k < Math.min(i + 8, lines.length); k += 1) {
86
87
  const m = /^\s*version:\s*(\S+)\s*$/u.exec(lines[k])
87
88
  if (m) return m[1]
@@ -210,14 +211,113 @@ const EXACT_VERSION_RE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u
210
211
  */
211
212
  const DIST_TAG_RE = /^[A-Za-z][A-Za-z0-9._-]*$/u
212
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
+
213
309
  /**
214
310
  * 通用 lock 对账(**任何**安装通道装完都该调用,支持一次对账多个包):
215
311
  * 走非 pnpm 通道(并行 curl / curl tarball / GitHub Release / git clone 装配 / 套装装配)装的包,
216
312
  * node_modules 里的版本与 `pnpm-lock.yaml` 记的版本可能不一致——之后任何 pnpm 操作(开关插件改
217
313
  * `dsh.profile.bundles`、`dsh plugin add/remove`)都可能按 lock 把它**还原**甚至当外来物处理。这里:
218
314
  * ① 全部一致 → 直接返回(不跑 pnpm,零成本);
219
- * ② 有漂移 → **一次** `pnpm add <spec1> <spec2> …` 把所有漂移包精确写进 lock;
220
- * ③ 仍对不上 → 返回 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
+ * 绝不假装成功。
221
321
  * `packages` 形如 [{ name, version }];只给 `packageName` 时等价于单包对账(向后兼容)。
222
322
  *
223
323
  * ★ 缺陷②修复(0.4.0-beta.16 / 0.3.63):写回 spec 前必须确认来源,**绝不能把 release 来源的包
@@ -231,7 +331,7 @@ const DIST_TAG_RE = /^[A-Za-z][A-Za-z0-9._-]*$/u
231
331
  * 查无此包(404)→ 物化到 `<DSH_HOME>/plugin-src/<包名>` 并写 `link:<绝对路径>`,同时回 depNote;
232
332
  * 连物化都做不到 → **跳过对齐且不碰 package.json**,只记 note。
233
333
  */
234
- 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 = {} } = {}) {
235
335
  const targets = Array.isArray(packages)
236
336
  ? packages.filter((p) => p && typeof p.name === 'string' && p.name !== '')
237
337
  : (packageName === null ? [] : [{ name: packageName, version: null }])
@@ -265,8 +365,13 @@ async function reconcileLockfile({ profileDir, packageName = null, packages = nu
265
365
  const aligned = (r) => {
266
366
  if (r.installed === null) return false
267
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:正/反斜杠、相对↔绝对都算同一目标)。
268
372
  return linkSpecIsIntact(profileDir, r.name, r.spec)
269
373
  && typeof r.lock === 'string' && r.lock.trim().startsWith('link:')
374
+ && sameSourceSpec(profileDir, r.lock, r.spec)
270
375
  }
271
376
  return r.installed === r.lock || sourcePinned(r.lock)
272
377
  }
@@ -296,8 +401,11 @@ async function reconcileLockfile({ profileDir, packageName = null, packages = nu
296
401
  if (/^https?:/u.test(spec.trim()) || misrecorded(row)) {
297
402
  return linkPlan(row, '该包原先以 tarball URL 记录(pnpm 10 重写 lock 会丢 integrity,实测 ERR_PNPM_MISSING_TARBALL_INTEGRITY)')
298
403
  }
299
- // link: 规格:链接可能已被 release/curl 通道的"先删再铺"打断 → 先把新副本刷进 plugin-src 再重放
404
+ // link: 规格:链接**还在**(node_modules 里就是指向清单那个目标的链接)→ 一个字节都不用物化,
405
+ // 按清单形态重放即可(0.5.39:等价即零漂移,别为了写一条 lock 去复制整棵包);
406
+ // 链接已被 release/curl 通道的"先删再铺"打断 → 先把新副本刷进 plugin-src 再重放(既有能力,不变)
300
407
  if (spec.trim().startsWith('link:')) {
408
+ if (linkSpecIsIntact(profileDir, name, spec)) return { spec: spec.trim(), reason: null, note: null }
301
409
  const dir = materializePackageForLink(profileDir, name, home === null ? {} : { home })
302
410
  return { spec: dir === null ? spec.trim() : linkSpecFor(dir), reason: null, note: null }
303
411
  }
@@ -327,14 +435,29 @@ async function reconcileLockfile({ profileDir, packageName = null, packages = nu
327
435
  const errors = []
328
436
  let method = null
329
437
  const planned = []
438
+ let importerSettled = null
330
439
  if (drifted.length > 0) {
331
440
  // 只对"能读到实际版本"的包做对齐;读不到的(目录都没有)无法对账,留给 lockNote
332
- 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
333
444
  for (const row of drifted) {
334
445
  if (row.installed === null) continue
335
446
  const plan = await planWriteback(row)
336
- planned.push({ name: row.name, installed: row.installed, spec: plan.spec, reason: plan.reason ?? null, note: plan.note ?? null })
337
- 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)
338
461
  }
339
462
  if (specs.length > 0) {
340
463
  try {
@@ -344,18 +467,43 @@ async function reconcileLockfile({ profileDir, packageName = null, packages = nu
344
467
  errors.push(`pnpm add 对齐失败:${error instanceof Error ? error.message : String(error)}`)
345
468
  }
346
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
+ }
347
475
  rows = snap()
348
476
  drifted = driftedOf(rows)
349
477
  }
350
478
  // ★ 文案一律按**实际写入形态**生成(读回清单再描述)——"计划写 link:"与"清单里真是 link:"
351
479
  // 是两件事:pnpm add 失败、或后续某一步又把 spec 覆盖成版本号时,绝不能报"已钉住"。
352
480
  const suggestedActions = []
481
+ const writeOf = new Map((importerSettled?.writes ?? []).map((w) => [w.name, w]))
353
482
  for (const item of planned) {
354
483
  const actual = profileSpec(profileDir, item.name)
355
484
  if (item.spec === null) {
356
485
  if (item.note !== null) depNotes.push(item.note)
357
486
  continue
358
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
+ }
359
507
  if (actual === item.spec) {
360
508
  if (item.spec.startsWith('link:') && item.reason !== null) {
361
509
  depNotes.push(`${item.name}:${item.reason},已按 link: 形式记录依赖(${actual})—— 不经 npm registry 解析、不经 tarball 完整性校验,pnpm 重建 lock 也能装上`)
@@ -366,7 +514,39 @@ async function reconcileLockfile({ profileDir, packageName = null, packages = nu
366
514
  depNotes.push(`${why}。请执行「钉住」动作或手动执行:${command(item.name, item.installed)}`)
367
515
  suggestedActions.push(suggestedPinAction({ packageName: item.name, version: item.installed, profileDir, reason: why }))
368
516
  }
369
- 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(';')
370
550
  return {
371
551
  method,
372
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)) })),
@@ -374,9 +554,19 @@ async function reconcileLockfile({ profileDir, packageName = null, packages = nu
374
554
  installedVersion: rows.length === 1 ? rows[0].installed : null,
375
555
  lockVersion: rows.length === 1 ? rows[0].lock : null,
376
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 })),
377
561
  lockNote: lockUpdated
378
562
  ? null
379
- : `${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(';'),
380
570
  // 非常规来源(link:)写回时的**用户可见**说明:绝不静默(缺陷②的隐蔽性正是"装完看不出问题")。
381
571
  // 文案由上面的"读回真实值"步骤产出,因此永远不会出现"面板说已钉住、磁盘上仍是版本号"。
382
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.38",
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",