@noob-stupid/dsh-plugin-console 0.5.38 → 0.5.40

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.
@@ -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,8 +12,10 @@
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
+ import { ensureLinkSelfSufficiency, removeLinkSelfSufficiency } from './link-self-sufficiency.js'
16
17
  import { queuedWrite } from '../infra/fsx.js'
18
+ import { runPnpmAdd } from '../infra/exec.js'
17
19
  import { resolvePackageJson } from '../infra/paths.js'
18
20
  import { reconcileLockfile } from './selfupdate.js'
19
21
 
@@ -108,10 +110,25 @@ async function specOf(profileDir, packageName) {
108
110
  * · 本次没探到(网络/镜像不可达) → 不敢替用户猜:仍按旧行为写版本号,但**如实记 note**
109
111
  * 并给出结构化「钉住」建议动作(面板可一键执行),绝不写成"已钉住"。
110
112
  * 返回 { form, spec, note, suggestion };`spec` 为 null 表示"不要改清单"。
113
+ *
114
+ * 2026-10-06 加法 —— **来源型默认【真装】**(用户点名:「默认真装 + link 自足」):
115
+ * 调用方**知道这个包的真实来源规格**时(tarball URL / `github:owner/repo` / 本地目录),
116
+ * 传 `sourceSpec` 进来,写回的就是**那个规格本身** —— 于是 `pnpm add <spec>` 由包管理器
117
+ * 保证依赖与 peer,与官方 `dsh plugin add` 同一条通道(`form: 'source'`)。
118
+ * `link:` 退居**显式开发式安装**:仅在"拿不到真实来源规格"(或用户明确选择开发式)时使用,
119
+ * 由 `link-self-sufficiency.js` 补齐依赖与框架 peer 垫片根。
120
+ * 优先级刻意保持"registry 可解析 → 版本号"在最前:既有 registry 流程一字不改(不倒退)。
111
121
  */
112
- async function planDependencySpec({ profileDir, packageName, version, registries = [], probe = null, home = undefined, profile = null }) {
122
+ async function planDependencySpec({ profileDir, packageName, version, registries = [], probe = null, home = undefined, profile = null, sourceSpec = null }) {
113
123
  const doProbe = typeof probe === 'function' ? probe : probeRegistryPackage
124
+ const realSpec = typeof sourceSpec === 'string' && sourceSpec.trim() !== '' ? sourceSpec.trim() : null
114
125
  const linkable = () => materializePackageForLink(profileDir, packageName, home === undefined ? {} : { home })
126
+ const asSource = () => ({
127
+ form: 'source',
128
+ spec: realSpec,
129
+ note: `${packageName}:按**真实来源规格**记录依赖(${realSpec})—— 走 pnpm add <spec> 真装,依赖与 peer 由包管理器保证(与官方 dsh plugin add 同一通道)`,
130
+ suggestion: null,
131
+ })
115
132
  const asLink = (why) => {
116
133
  const dir = linkable()
117
134
  if (dir === null) {
@@ -137,6 +154,8 @@ async function planDependencySpec({ profileDir, packageName, version, registries
137
154
  result = { resolvable: false, hasVersion: false, tried: [error instanceof Error ? error.message : String(error)] }
138
155
  }
139
156
  if (result?.resolvable === true && result.hasVersion !== false) return { form: 'version', spec: version, note: null, suggestion: null }
157
+ // 来源型:调用方给了真实来源规格 ⇒ 默认真装(不写 link:)。拿不到真实规格才退回 link:。
158
+ if (probeFailureKind(result) === 'fetch-404' && realSpec !== null) return asSource()
140
159
  if (probeFailureKind(result) === 'fetch-404') {
141
160
  return asLink(result?.resolvable === true
142
161
  ? `registry 上没有 ${version} 这个版本(该包只在 GitHub release / 本地有)`
@@ -164,29 +183,66 @@ async function planDependencySpec({ profileDir, packageName, version, registries
164
183
  * lockSynced: boolean, lockNote: string|null, depNote: string|null, suggested: object|null }}
165
184
  */
166
185
  async function declareProfileDependency(profileDir, packageName, version = null, options = {}) {
167
- const { syncLock = true, registries = [], probe = null, home = undefined, profile = null } = options
168
- const empty = { changed: false, version: null, spec: null, form: null, reason: null, lockSynced: false, lockNote: null, depNote: null, suggested: null }
186
+ const { syncLock = true, registries = [], probe = null, home = undefined, profile = null, sourceSpec = null, installSource = null } = options
187
+ const empty = { changed: false, version: null, spec: null, form: null, reason: null, lockSynced: false, lockNote: null, depNote: null, suggested: null, sourceInstall: null, selfSufficiency: null }
188
+ const realSpec = typeof sourceSpec === 'string' && sourceSpec.trim() !== '' ? sourceSpec.trim() : null
189
+ // ★ 来源型默认【真装】(2026-10-06 加法):给了真实来源规格、而磁盘上还没有这个包时,
190
+ // **先真装**(pnpm add <spec>,依赖/peer 交给包管理器);装失败也**不拒绝** ——
191
+ // 如实记 sourceInstall 后继续走既有判定(退回 link: 并由自足性补齐)。
192
+ let sourceInstall = null
193
+ if (realSpec !== null && await installedVersionOf(profileDir, packageName) === null) {
194
+ sourceInstall = await installSourceDependency({ profileDir, spec: realSpec, registries, install: installSource })
195
+ }
169
196
  const resolved = typeof version === 'string' && version !== '' ? version : await installedVersionOf(profileDir, packageName)
170
- if (resolved === null) return { ...empty, reason: '读不到已安装版本' }
197
+ if (resolved === null) return { ...empty, sourceInstall, reason: '读不到已安装版本' }
171
198
  // 探测/物化放在写锁**之外**(网络探测最长 8 秒,占着清单写锁会堵住其它写)
172
- const plan = await planDependencySpec({ profileDir, packageName, version: resolved, registries, probe, home, profile })
199
+ const plan = await planDependencySpec({ profileDir, packageName, version: resolved, registries, probe, home, profile, sourceSpec: realSpec })
173
200
  if (plan.spec === null) return { ...empty, version: resolved, form: plan.form, reason: plan.note, depNote: plan.note, suggested: plan.suggestion }
174
201
  const result = await queuedWrite(async () => {
175
202
  const manifest = await readManifest(profileDir)
176
203
  const deps = { ...(manifest.dependencies ?? {}) }
177
- if (deps[packageName] === plan.spec) return { changed: false }
204
+ // 0.5.39 改错(Fix 2):逐字比较会把「同一目标的另一种写法」当成漂移 —— 真机 desktop profile 的
205
+ // `link:D:\dsh-link\…`(反斜杠)会被这条路径无谓改写成正斜杠(用户没要求的漂移)。等价 ⇒ 零写盘。
206
+ if (sameSourceSpec(profileDir, deps[packageName], plan.spec)) return { changed: false }
178
207
  deps[packageName] = plan.spec
179
208
  manifest.dependencies = deps
180
209
  await writeManifest(profileDir, manifest)
181
210
  return { changed: true }
182
211
  })
183
- const out = { ...empty, changed: result.changed, version: resolved, spec: plan.spec, form: plan.form, depNote: plan.note, suggested: plan.suggestion }
212
+ // ★ link: 自足性(2026-10-06 加法):写成 link: 之后立刻补齐 —— 目标目录内装它的 dependencies
213
+ // + 建框架 peer 垫片根。幂等(自足 ⇒ 零写盘)、绝不覆盖它自带的 node_modules、
214
+ // 失败只如实报未就绪 + 给动作 —— 安装主路径**不新增拒绝分支**。
215
+ const selfSufficiency = plan.form === 'link'
216
+ ? await ensureLinkSelfSufficiency({ profileDir, packageName, spec: plan.spec, home })
217
+ : null
218
+ const out = { ...empty, changed: result.changed, version: resolved, spec: plan.spec, form: plan.form, depNote: plan.note, suggested: plan.suggestion, sourceInstall, selfSufficiency }
184
219
  if (syncLock !== true) return out
185
220
  // **写完清单必须把 lock 拉齐**(2026-09-24 CI 实测):只写清单不写 lock → 清单/lock 不一致 →
186
221
  // 之后任何 pnpm 操作都会硬失败(ERR_PNPM_CANNOT_REMOVE_MISSING_DEPS)。
187
222
  // 放在写队列**之外**执行(对账本身会跑 pnpm,不该占着清单写锁);失败只记 note,不让安装失败。
188
223
  const synced = await syncLockFor(profileDir, [packageName], { registries, home, profile })
189
- return { ...out, ...synced, depNote: joinNotes(plan.note, synced.depNote), suggested: synced.suggested ?? plan.suggestion }
224
+ return {
225
+ ...out,
226
+ ...synced,
227
+ depNote: joinNotes(plan.note, selfSufficiency === null ? null : selfSufficiency.note, synced.depNote),
228
+ suggested: synced.suggested ?? (selfSufficiency === null ? null : selfSufficiency.action) ?? plan.suggestion,
229
+ }
230
+ }
231
+
232
+ /**
233
+ * 来源型**真装**(2026-10-06 加法):把真实来源规格交给包管理器 —— `pnpm add <spec>` 进 profile,
234
+ * 与官方 `dsh plugin add` 同一条通道(依赖与 peer 由 pnpm 保证,我们不手铺)。
235
+ * 返回 `{ ok, spec, error }`,**绝不抛**:安装主路径不得新增拒绝分支。
236
+ */
237
+ async function installSourceDependency({ profileDir, spec, registries = [], install = null, timeout = 180000 } = {}) {
238
+ const runner = typeof install === 'function' ? install : runPnpmAdd
239
+ const registry = (Array.isArray(registries) ? registries : []).find((r) => typeof r === 'string' && r.trim() !== '') ?? 'https://registry.npmmirror.com'
240
+ try {
241
+ await runner({ profileDir, spec, registry, timeout })
242
+ return { ok: true, spec, error: null }
243
+ } catch (error) {
244
+ return { ok: false, spec, error: String(error instanceof Error ? error.message : error).slice(0, 600) }
245
+ }
190
246
  }
191
247
 
192
248
  /** 多段说明拼接(空段自动跳过):文案只做拼接,不覆盖 —— 覆盖就是"谎报"的来源。 */
@@ -230,14 +286,19 @@ async function pinProfileDependency(profileDir, packageName, options = {}) {
230
286
  const changed = await queuedWrite(async () => {
231
287
  const manifest = await readManifest(profileDir)
232
288
  const deps = { ...(manifest.dependencies ?? {}) }
233
- if (deps[packageName] === spec) return false
289
+ // 0.5.39 改错(Fix 2):等价写法(正/反斜杠、相对↔绝对指向同一目录)**不再改写清单** ——
290
+ // 幂等零写盘;只有真漂移(目标不同/确实缺失)才写,且写的是 linkSpecFor 的**规范形**。
291
+ if (sameSourceSpec(profileDir, deps[packageName], spec)) return false
234
292
  deps[packageName] = spec
235
293
  manifest.dependencies = deps
236
294
  await writeManifest(profileDir, manifest)
237
295
  return true
238
296
  })
239
297
  const after = await specOf(profileDir, packageName)
240
- return { ok: after === spec, spec, before, after, changed, dir }
298
+ // ★ link: 自足性(2026-10-06 加法):钉住是"显式开发式安装"的入口,钉完立刻补齐
299
+ // (目标目录内装 dependencies + 框架 peer 垫片根)。幂等、绝不覆盖、失败只如实报。
300
+ const selfSufficiency = await ensureLinkSelfSufficiency({ profileDir, packageName, spec, home })
301
+ return { ok: sameSourceSpec(profileDir, after, spec), spec, before, after, changed, dir, selfSufficiency }
241
302
  }
242
303
 
243
304
  /**
@@ -252,7 +313,8 @@ async function setProfileDependency(profileDir, packageName, spec) {
252
313
  const changed = await queuedWrite(async () => {
253
314
  const manifest = await readManifest(profileDir)
254
315
  const deps = { ...(manifest.dependencies ?? {}) }
255
- if (deps[packageName] === spec) return false
316
+ // 0.5.39 改错(Fix 2):判等用同一把尺子(canonicalSourceSpec + realpath)—— 等价即零写盘
317
+ if (sameSourceSpec(profileDir, deps[packageName], spec)) return false
256
318
  deps[packageName] = spec
257
319
  manifest.dependencies = deps
258
320
  await writeManifest(profileDir, manifest)
@@ -261,9 +323,11 @@ async function setProfileDependency(profileDir, packageName, spec) {
261
323
  return { before, after: await specOf(profileDir, packageName), changed }
262
324
  }
263
325
 
264
- /** 卸载即撤销声明。 */
265
- async function undeclareProfileDependency(profileDir, packageName) {
266
- return queuedWrite(async () => {
326
+ /** 卸载即撤销声明(并清掉**我们造的** link 垫片 —— 卸载即净)。
327
+ * `options.home` 必须与当初 `declareProfileDependency` 用的 home 一致(生产两边都不传 = `dshHome()`);
328
+ * 只有隔离测试才会传私有 home —— 传错不会误删(记录读不到 ⇒ 无事发生),但也清不掉。 */
329
+ async function undeclareProfileDependency(profileDir, packageName, options = {}) {
330
+ const result = await queuedWrite(async () => {
267
331
  const manifest = await readManifest(profileDir)
268
332
  const deps = { ...(manifest.dependencies ?? {}) }
269
333
  if (!(packageName in deps)) return { changed: false }
@@ -272,6 +336,10 @@ async function undeclareProfileDependency(profileDir, packageName) {
272
336
  await writeManifest(profileDir, manifest)
273
337
  return { changed: true }
274
338
  })
339
+ // 2026-10-06 加法:link 自足性造的垫片是我们加的,卸载时只清**我们造的**那些
340
+ // (记录里逐个核对"现在仍是指向当初目标的链接",被人改过的一律不碰)。清不掉只如实报。
341
+ const shims = removeLinkSelfSufficiency({ packageName, home: options.home ?? null })
342
+ return { ...result, shimsRemoved: shims.removed, shimsKept: shims.kept, selfSufficiencyNote: shims.note }
275
343
  }
276
344
 
277
345
  /**
@@ -325,6 +393,6 @@ async function applyDependencyBackfill(profileDir, options = {}) {
325
393
 
326
394
  export {
327
395
  DEFAULT_BUNDLES, addBundleToManifest, applyDependencyBackfill, declareProfileDependency,
328
- installedVersionOf, planDependencyBackfill, planDependencySpec, pinProfileDependency, removeBundleFromManifest,
396
+ installedVersionOf, installSourceDependency, planDependencyBackfill, planDependencySpec, pinProfileDependency, removeBundleFromManifest,
329
397
  setProfileDependency, specOf, syncLockFor, undeclareProfileDependency,
330
398
  }
@@ -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,