@noob-stupid/dsh-plugin-console 0.5.37 → 0.5.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +30 -0
- package/lib/server/domain/bundle-refs.js +9 -2
- package/lib/server/domain/dep-source.js +61 -2
- package/lib/server/domain/install-cleanup.js +23 -2
- package/lib/server/domain/install-job.js +35 -47
- package/lib/server/domain/install.js +3 -0
- package/lib/server/domain/lock-importer.js +411 -0
- package/lib/server/domain/manifest.js +31 -5
- package/lib/server/domain/patch.js +14 -3
- package/lib/server/domain/persist.js +401 -0
- package/lib/server/domain/plugin-actions.js +42 -3
- package/lib/server/domain/selfupdate.js +210 -12
- 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,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
|
-
|
|
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,38 @@ 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
|
-
|
|
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
|
|
244
|
+
return { ok: sameSourceSpec(profileDir, after, spec), spec, before, after, changed, dir }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* 把清单里的该包 spec **原样写成给定值**(0.5.38 加法,供 domain/persist.js 的持久化收口用)。
|
|
249
|
+
* 场景:`node_modules/<包名>` 是指向**用户自己位置**的链接(开发目录 / 别的盘),而清单里没有条目。
|
|
250
|
+
* 此时"按磁盘真实值写 spec"就是写这条链接本身 —— 走 `pinProfileDependency` 会把它**物化复制**到
|
|
251
|
+
* plugin-src 并把 spec 换成一个新拷贝,等于悄悄改掉用户的来源(那是另一件事,不该由收口代做)。
|
|
252
|
+
* 只碰 `dependencies[packageName]` 这一个键,走同一把写队列;返回 `{ before, after, changed }`。
|
|
253
|
+
*/
|
|
254
|
+
async function setProfileDependency(profileDir, packageName, spec) {
|
|
255
|
+
const before = await specOf(profileDir, packageName)
|
|
256
|
+
const changed = await queuedWrite(async () => {
|
|
257
|
+
const manifest = await readManifest(profileDir)
|
|
258
|
+
const deps = { ...(manifest.dependencies ?? {}) }
|
|
259
|
+
// 0.5.39 改错(Fix 2):判等用同一把尺子(canonicalSourceSpec + realpath)—— 等价即零写盘
|
|
260
|
+
if (sameSourceSpec(profileDir, deps[packageName], spec)) return false
|
|
261
|
+
deps[packageName] = spec
|
|
262
|
+
manifest.dependencies = deps
|
|
263
|
+
await writeManifest(profileDir, manifest)
|
|
264
|
+
return true
|
|
265
|
+
})
|
|
266
|
+
return { before, after: await specOf(profileDir, packageName), changed }
|
|
241
267
|
}
|
|
242
268
|
|
|
243
269
|
/** 卸载即撤销声明。 */
|
|
@@ -305,5 +331,5 @@ async function applyDependencyBackfill(profileDir, options = {}) {
|
|
|
305
331
|
export {
|
|
306
332
|
DEFAULT_BUNDLES, addBundleToManifest, applyDependencyBackfill, declareProfileDependency,
|
|
307
333
|
installedVersionOf, planDependencyBackfill, planDependencySpec, pinProfileDependency, removeBundleFromManifest,
|
|
308
|
-
specOf, syncLockFor, undeclareProfileDependency,
|
|
334
|
+
setProfileDependency, specOf, syncLockFor, undeclareProfileDependency,
|
|
309
335
|
}
|
|
@@ -153,7 +153,13 @@ function sanitizePatchText(text) {
|
|
|
153
153
|
.replace(/\s+$/u, '') + '\n'
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
-
/** 解析用户补丁中 insert 块的 id → moduleName(name 字段)。
|
|
156
|
+
/** 解析用户补丁中 insert 块的 id → moduleName(name 字段)。
|
|
157
|
+
* ★ 2026-10-04 改错(真机发现,0.5.38):`name:` 的值**加了引号才算**是错的 —— YAML 里
|
|
158
|
+
* `name: dsh-whale-widget` 与 `name: 'dsh-whale-widget'` 完全等价,而本机真装的
|
|
159
|
+
* `dsh-whale-widget`(插件市场里的真实第三方插件)写的正是**不带引号**的那种。
|
|
160
|
+
* 旧判据只认带引号的值 ⇒ 那一行在解析结果里**根本不存在**:补丁自愈看不见它、
|
|
161
|
+
* 装后校验看不见它、撤销安装也看不见它("行明明在文件里,程序却说没有")。
|
|
162
|
+
* 现在两种形态都认(末尾的行内注释也剥掉),解析结果只多不少。 */
|
|
157
163
|
function parseInsertNames(text) {
|
|
158
164
|
const map = new Map()
|
|
159
165
|
const lines = text.split(/\r?\n/u)
|
|
@@ -166,8 +172,13 @@ function parseInsertNames(text) {
|
|
|
166
172
|
const idMatch = line.match(/^\s+- id: ([A-Za-z0-9_.-]+)/u)
|
|
167
173
|
if (idMatch) { curId = idMatch[1]; continue }
|
|
168
174
|
if (curId !== null) {
|
|
169
|
-
const nameMatch = line.match(/^\s+name
|
|
170
|
-
if (nameMatch) {
|
|
175
|
+
const nameMatch = line.match(/^\s+name:\s*(.+?)\s*$/u)
|
|
176
|
+
if (nameMatch) {
|
|
177
|
+
const raw = nameMatch[1].replace(/\s+#.*$/u, '').trim()
|
|
178
|
+
const value = /^(['"]).*\1$/u.test(raw) ? raw.slice(1, -1) : raw
|
|
179
|
+
if (value !== '') map.set(curId, value)
|
|
180
|
+
curId = null
|
|
181
|
+
}
|
|
171
182
|
}
|
|
172
183
|
}
|
|
173
184
|
}
|