@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.
- package/README.md +39 -1
- package/README.zh.md +27 -0
- package/lib/server/domain/dep-source.js +61 -2
- package/lib/server/domain/link-self-sufficiency.js +417 -0
- package/lib/server/domain/lock-importer.js +411 -0
- package/lib/server/domain/manifest.js +84 -16
- package/lib/server/domain/persist.js +20 -3
- package/lib/server/domain/selfupdate.js +204 -14
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
266
|
-
|
|
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
|
-
|
|
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 =
|
|
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,
|