dsh-desktop-statusbar 2.1.1 → 2.2.0

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,509 @@
1
+ /**
2
+ * 让 pnpm 放行「刚发布、还没到发布年龄门槛」的新版本。
3
+ *
4
+ * 背景:pnpm 11 自带一条供应链策略 `minimumReleaseAge`(默认 1440 分钟 = 24 小时):
5
+ * 发布时间不满 24 小时的版本会被跳过或直接拒绝。这是 pnpm 自己写死的默认值,
6
+ * DSH 没有设置它,profile 里也不会出现这个键。
7
+ *
8
+ * 三种表现(已在 pnpm 11.7.0 上实测):
9
+ * - 按包名装 `pkg` / `pkg@latest`:**静默装成旧的成熟版**,退出码 0,什么都不提示。
10
+ * - 精确版本 `pkg@2.2.0`:报 `ERR_PNPM_NO_MATURE_MATCHING_VERSION`,什么都没装。
11
+ * - 精确版本 + 该版本出现在 `minimumReleaseAgeExclude` 里:**正常装上**。
12
+ *
13
+ * 所以绕过方式就是在安装前把这个精确版本写进 profile 的 `minimumReleaseAgeExclude`。
14
+ * 这是 pnpm 自己支持的豁免机制(pnpm 在没有显式设置该键时也是靠自动往这里追加来放行),
15
+ * 不是绕过校验的后门:写入的是「包名@精确版本」,只对一个版本生效。
16
+ *
17
+ * 两个必须处理的坑:
18
+ * 1. pnpm 对同一个包名**只认第一条** `minimumReleaseAgeExclude` 规则,后面的会被忽略
19
+ * (dshmarket 记录的 #732)。所以同一个名字的多条规则必须合并成一条,
20
+ * 否则我们追加的那条会因为是第二条而失效。
21
+ * 2. 这条规则要写在 profile 的 `pnpm-workspace.yaml` 里;文件不存在时,
22
+ * 安装走 pnpm 自己的自动豁免,也不会失败,所以这里按「尽力而为」处理。
23
+ *
24
+ * @module dsh-desktop-statusbar/release-age
25
+ */
26
+
27
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
28
+ import { dirname, join, resolve } from 'node:path'
29
+
30
+ /** 本插件的包名。 */
31
+ export const SELF_NAME = 'dsh-desktop-statusbar'
32
+
33
+ /** 允许写入的版本号形态:正式版或带预发布后缀。 */
34
+ const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/
35
+
36
+ /**
37
+ * 从一个 `包名@版本` 选择器里拆出包名。
38
+ * 作用域包(`@scope/name@1.0.0`)前面还有一个 `@`,所以从下标 1 开始找。
39
+ * @param {string} selector
40
+ * @returns {string}
41
+ */
42
+ export function nameOfSelector(selector) {
43
+ const text = String(selector).trim()
44
+ const at = text.indexOf('@', 1)
45
+ return at === -1 ? text : text.slice(0, at)
46
+ }
47
+
48
+ /**
49
+ * 从一个选择器里拆出版本部分(可能形如 `1.0.0 || 2.0.0`)。
50
+ * @param {string} selector
51
+ * @returns {string}
52
+ */
53
+ export function versionOfSelector(selector) {
54
+ const text = String(selector).trim()
55
+ const at = text.indexOf('@', 1)
56
+ return at === -1 ? '' : text.slice(at + 1).trim()
57
+ }
58
+
59
+ /**
60
+ * 解析 `minimumReleaseAgeExclude` 块里的每一行。
61
+ *
62
+ * 返回项保留原始行文本,便于把「不需要动的行」原样写回去 —— 用户写的注释、
63
+ * 引号写法、缩进都不该被我们顺手改掉。
64
+ *
65
+ * @param {string} block 块的正文(不含 `minimumReleaseAgeExclude:` 那一行)
66
+ * @returns {Array<{kind: 'entry', line: string, selector: string}|{kind: 'comment', line: string}|{kind: 'other', line: string}>}
67
+ */
68
+ export function parseExcludeBlock(block) {
69
+ const items = []
70
+ for (const raw of String(block).split(/\r?\n/)) {
71
+ const line = raw.replace(/\s+$/, '')
72
+ if (line.trim().length === 0) continue
73
+ const match = /^([ \t]*)-[ \t]*(.*)$/.exec(line)
74
+ if (match === null) {
75
+ items.push({ kind: /^[ \t]*#/.test(line) ? 'comment' : 'other', line })
76
+ continue
77
+ }
78
+ /* 去掉行尾注释:只在值不是引号包裹时才剥,避免把 '#1.2.3' 里的 # 当成注释 */
79
+ let value = match[2].trim()
80
+ if (value.startsWith("'") !== true && value.startsWith('"') !== true) {
81
+ const hash = value.indexOf(' #')
82
+ if (hash !== -1) value = value.slice(0, hash).trim()
83
+ }
84
+ if (value.startsWith("'") && value.endsWith("'") && value.length > 1) value = value.slice(1, -1).replace(/''/g, "'")
85
+ if (value.startsWith('"') && value.endsWith('"') && value.length > 1) value = value.slice(1, -1)
86
+ items.push({ kind: 'entry', line, selector: value })
87
+ }
88
+ return items
89
+ }
90
+
91
+ /**
92
+ * 把多个同一包名的选择器合成一条规则。
93
+ *
94
+ * 版本之间用 ` || ` 连接(pnpm 的版本选择器语法);全部对齐去重,顺序按首次出现。
95
+ * 一个包名写成一条,绕开 pnpm「只认第一条」的行为。
96
+ *
97
+ * @param {string[]} selectors
98
+ * @returns {string}
99
+ */
100
+ export function unionSelectors(selectors) {
101
+ const versions = []
102
+ for (const selector of selectors) {
103
+ const parts = versionOfSelector(selector).split('||')
104
+ for (const one of parts) {
105
+ const trimmed = one.trim()
106
+ if (trimmed.length > 0 && versions.includes(trimmed) !== true) versions.push(trimmed)
107
+ }
108
+ }
109
+ return versions.join(' || ')
110
+ }
111
+
112
+ /**
113
+ * 本插件在豁免列表里**最多保留几个版本**。
114
+ *
115
+ * 为什么要裁:早先的实现是只追加不清理,每更新一次就往同一条规则里多塞一个版本,
116
+ * 于是磁盘上长成 `2.0.0 || 2.0.1 || 2.1.0 || 2.1.1 || 2.1.2 || …`。它不会让安装失败
117
+ * (pnpm 认 `||` 并集),但有两个实际坏处:
118
+ * · 规则无限变长,用户看不懂这一行在干什么;
119
+ * · 更要紧的是当时还只保留**最早那条**,一旦某次真的写成两条同名规则,
120
+ * pnpm 只认第一条 —— 新版本会被 24 小时发布年龄挡下。
121
+ *
122
+ * 保留 2 个(本次目标 + 上一次)而不是只留 1 个:用户升级后发现新版有问题,
123
+ * 想退回上一版时,上一版仍在豁免里,回滚不会被发布年龄策略拦。
124
+ * 更老的版本没有再回滚的现实意义(跨大版本回滚本就要手工处理配置/台账)。
125
+ */
126
+ export const MAX_EXEMPT_VERSIONS = 2;
127
+
128
+ /**
129
+ * 从一堆版本号里挑出该保留的:**目标版本必留**,再按出现顺序留最近的若干个。
130
+ *
131
+ * 不假设输入是升序(`||` 里的顺序是历史写入顺序,早期实现还可能乱序),
132
+ * 所以这里按**语义版本**排序后取最大的几个 —— 对于「最近」这个语义,
133
+ * 版本号大小比写入顺序更可靠。
134
+ *
135
+ * @param {string[]} versions 已有的版本号(可能乱序、可能重复)
136
+ * @param {string} target 本次要放行的版本
137
+ * @param {number} [keep] 最多保留几个
138
+ * @returns {string[]} 要保留的版本;目标版本排在最后(书写顺序:旧 → 新)
139
+ */
140
+ export function keepRecentVersions(versions, target, keep = MAX_EXEMPT_VERSIONS) {
141
+ const all = []
142
+ const push = (one) => {
143
+ const text = String(one).trim()
144
+ if (text.length > 0 && all.includes(text) !== true) all.push(text)
145
+ }
146
+ versions.forEach(push)
147
+ push(target)
148
+ const limit = keep > 0 ? keep : 1
149
+ if (all.length <= limit) return all
150
+ /* 语义版本比较:把 x.y.z(-pre) 拆成数字段;解析不出数字的(理论上不会有,
151
+ ensureReleaseAgeExclude 已校验过版本形态)当作 0 处理,排在最后 */
152
+ const keyOf = (one) => String(one).split('-')[0].split('.').map((part) => Number(part) || 0)
153
+ const sorted = all.slice().sort((a, b) => {
154
+ const ka = keyOf(a)
155
+ const kb = keyOf(b)
156
+ for (let i = 0; i < 3; i += 1) {
157
+ if (ka[i] !== kb[i]) return ka[i] - kb[i]
158
+ }
159
+ return 0
160
+ })
161
+ /* 从最大的往下取 limit 个,再按升序写回去(读起来自然是旧 → 新) */
162
+ const picked = sorted.slice(Math.max(0, sorted.length - limit))
163
+ /* 目标版本必须在里面 —— 极端情况下(目标版本号小于已保留的那些)也要保住它 */
164
+ if (picked.includes(target) !== true) {
165
+ picked.sort((a, b) => {
166
+ const ka = keyOf(a)
167
+ const kb = keyOf(b)
168
+ for (let i = 0; i < 3; i += 1) {
169
+ if (ka[i] !== kb[i]) return ka[i] - kb[i]
170
+ }
171
+ return 0
172
+ })
173
+ picked.shift()
174
+ picked.push(target)
175
+ }
176
+ return picked.filter((one) => one.length > 0)
177
+ }
178
+
179
+ /** 去掉包裹标量的引号(成对才剥,与 parseExcludeBlock 同规则)。 */
180
+ function unquoteScalar(value) {
181
+ const text = String(value).trim()
182
+ if (text.startsWith("'") && text.endsWith("'") && text.length > 1) return text.slice(1, -1).replace(/''/g, "'")
183
+ if (text.startsWith('"') && text.endsWith('"') && text.length > 1) return text.slice(1, -1)
184
+ return text
185
+ }
186
+
187
+ /** 找 flow 数组的闭合 `]`(考虑多行与嵌套)。找不到返回 -1。 */
188
+ function findCloseBracket(text) {
189
+ let depth = 0
190
+ for (let i = 0; i < text.length; i += 1) {
191
+ if (text[i] === '[') depth += 1
192
+ else if (text[i] === ']') {
193
+ depth -= 1
194
+ if (depth === 0) return i
195
+ }
196
+ }
197
+ return -1
198
+ }
199
+
200
+ /** 一行里 `[` 与 `]` 的数量差(用于判断 flow 数组是否还没闭合)。 */
201
+ function bracketDepth(text) {
202
+ let depth = 0
203
+ for (const ch of String(text)) {
204
+ if (ch === '[') depth += 1
205
+ else if (ch === ']') depth -= 1
206
+ }
207
+ return depth
208
+ }
209
+
210
+ /**
211
+ * 解析 `minimumReleaseAgeExclude:` **写在同行**的值(可能跨多行)。
212
+ *
213
+ * 这个键不只有「独占一行、下面挂列表」一种写法,pnpm 生态里同样常见:
214
+ * · flow 风格:`minimumReleaseAgeExclude: ["a@1.0.0"]`
215
+ * · 行尾注释:`minimumReleaseAgeExclude: # 豁免表`
216
+ * · 空数组: `minimumReleaseAgeExclude: []`
217
+ * · 跨行 flow:`minimumReleaseAgeExclude: [` / ` "a@1.0.0"` / `]`
218
+ * · 带引号的标量:`minimumReleaseAgeExclude: 'a@1.0.0'`
219
+ * 任何一种没认出来,都会被当成「文件里没有这个键」而**追加出第二个同名键** ——
220
+ * YAML 直接 `duplicated mapping key`、pnpm 拒绝启动,受害的是整个 profile。
221
+ *
222
+ * @param {string} raw 冒号之后到该键结束(含跨行)的原始文本
223
+ * @returns {Array<object>} 与 parseExcludeBlock 同形状的条目
224
+ */
225
+ function parseInlineValue(raw) {
226
+ const text = String(raw)
227
+ const firstLine = text.split(/\r?\n/)[0].trim()
228
+ if (firstLine.length === 0) return []
229
+ /* 整个值是注释:保留成注释条目,重排时统一放到块首 */
230
+ if (firstLine.startsWith('#')) return [{ kind: 'comment', line: ' ' + firstLine }]
231
+
232
+ const trimmed = text.trim()
233
+ if (trimmed.startsWith('[')) {
234
+ /* 先定位闭合括号再取内部 —— 这样 `["a"] # 说明` 的注释、以及值里带 `#` 的
235
+ 字符串(如 `["a # b"]`)都不会被误当成行尾注释切掉。 */
236
+ const close = findCloseBracket(trimmed)
237
+ const inner = (close === -1 ? trimmed.slice(1) : trimmed.slice(1, close)).trim()
238
+ if (inner.length === 0) return []
239
+ const out = []
240
+ for (const one of inner.split(',')) {
241
+ const selector = unquoteScalar(one)
242
+ if (selector.length > 0) out.push({ kind: 'entry', line: ' - ' + selector, selector })
243
+ }
244
+ return out
245
+ }
246
+
247
+ /* 标量:同样要剥引号。不剥的话 `'dsh-desktop-statusbar@2.1.1'` 会被当成
248
+ 包名带前引号的另一条规则 —— 归并失效、我们这条被追加成第二条,且不幂等。 */
249
+ const selector = unquoteScalar(trimmed)
250
+ if (selector.length === 0) return []
251
+ return [{ kind: 'entry', line: ' - ' + selector, selector }]
252
+ }
253
+
254
+ /**
255
+ * 定位 `minimumReleaseAgeExclude:` 这个键,返回它的同行值、下方块正文与整段在原文里的位置。
256
+ *
257
+ * 为什么要手写扫描而不是一条正则:这个键的写法太多,正则很难同时覆盖
258
+ * · 键独占一行、下面挂列表(块风格)
259
+ * · 键与值同行:flow 数组 `["a@1.0.0"]`、空数组 `[]`、行尾注释 `# 表`
260
+ * · **跨行 flow**:`[` 之后换行、几行之后才是 `]`
261
+ * · 标量写成带引号 `'a@1.0.0'`
262
+ * 而且还有两个容易漏的输入形态(都实测写出过重复键):
263
+ * · **文件末行没有换行符** —— 原先的正则要求键行后必须跟 `\n`,匹配失败就被
264
+ * 当成「文件里没有这个键」,于是追加出第二个同名键 → YAML `duplicated mapping key`
265
+ * → pnpm 拒绝加载整个 profile。找出原因很难,因为同一个输入加个尾换行就正常。
266
+ * · **首字节是 BOM** —— `^` 在 m 模式下不跨过 BOM,键在首行时同样匹配失败。
267
+ * Windows 编辑器给 YAML 加 BOM 很常见。
268
+ * 手写扫描对这两种天然免疫,也更好读。
269
+ *
270
+ * @param {string} source 文件全文
271
+ * @returns {{inline: string, block: string, start: number, end: number}|null}
272
+ */
273
+ function locateExcludeBlock(source) {
274
+ const KEY = 'minimumReleaseAgeExclude:'
275
+ /* 逐行定位键。行首允许 BOM(只在第一行可能),也允许前导空白(YAML 里顶层键通常顶格,
276
+ 但缩进写法不该因此漏掉)。 */
277
+ let cursor = 0
278
+ let lineStart = 0
279
+ while (lineStart <= source.length) {
280
+ const nl = source.indexOf('\n', lineStart)
281
+ const lineEnd = nl === -1 ? source.length : nl
282
+ const rawLine = source.slice(lineStart, lineEnd).replace(/\r$/, '')
283
+ const line = lineStart === 0 ? rawLine.replace(/^\uFEFF/, '') : rawLine
284
+ if (line.trimStart().startsWith(KEY)) {
285
+ const afterKey = line.trimStart().slice(KEY.length)
286
+ let inline = afterKey
287
+ let scanFrom = nl === -1 ? source.length : nl + 1
288
+ /* 同行值是未闭合的 flow 数组:继续吃后续行,直到括号配平 */
289
+ if (inline.trim().startsWith('[') && bracketDepth(inline) > 0) {
290
+ while (scanFrom < source.length && bracketDepth(inline) > 0) {
291
+ const nextNl = source.indexOf('\n', scanFrom)
292
+ const nextEnd = nextNl === -1 ? source.length : nextNl
293
+ inline += '\n' + source.slice(scanFrom, nextEnd).replace(/\r$/, '')
294
+ scanFrom = nextNl === -1 ? source.length : nextNl + 1
295
+ }
296
+ }
297
+ /* 下方块:连续的「- 条目」或注释行;遇到别的键或空行结束。
298
+ 条目允许零缩进(`- a@1.0.0` 顶格在 YAML 里是合法的)。 */
299
+ let blockEnd = scanFrom
300
+ for (;;) {
301
+ if (blockEnd >= source.length) break
302
+ const bNl = source.indexOf('\n', blockEnd)
303
+ const bEnd = bNl === -1 ? source.length : bNl
304
+ const bLine = source.slice(blockEnd, bEnd).replace(/\r$/, '')
305
+ const trimmed = bLine.trim()
306
+ if (trimmed.length === 0) break
307
+ if (/^[ \t]*-/.test(bLine) || /^[ \t]*#/.test(bLine)) {
308
+ blockEnd = bNl === -1 ? source.length : bNl + 1
309
+ continue
310
+ }
311
+ break
312
+ }
313
+ return {
314
+ inline,
315
+ block: source.slice(scanFrom, blockEnd),
316
+ start: lineStart,
317
+ end: blockEnd,
318
+ }
319
+ }
320
+ if (nl === -1) break
321
+ lineStart = nl + 1
322
+ }
323
+ return null
324
+ }
325
+
326
+ /**
327
+ * 在 `pnpm-workspace.yaml` 的正文里确保本插件的目标版本被豁免。
328
+ *
329
+ * 纯函数:只做字符串进出,方便直接测。
330
+ *
331
+ * @param {string} text 文件全文
332
+ * @param {string} name 包名
333
+ * @param {string} version 要放行的精确版本
334
+ * @param {string} eol 换行符(保持文件原有风格)
335
+ * @returns {{ok: boolean, text: string, changed: boolean, reason: string|null}}
336
+ */
337
+ export function mergeExcludeIntoYaml(text, name, version, eol = '\n') {
338
+ const source = String(text)
339
+ /* ------------------------------------------------------------------ 安全阀
340
+ 这个函数改的是**共享文件** `pnpm-workspace.yaml`:写坏了不只是本插件装不上,
341
+ 整个 profile 里所有插件都起不来(YAML `duplicated mapping key` → pnpm 拒绝解析)。
342
+
343
+ 最坏的一种输入:文件里**已经有两个** `minimumReleaseAgeExclude:` 键
344
+ (历史遗留,或两个插件各自追加过)。`locateExcludeBlock` 只定位第一个块,
345
+ 替换后第二个原样留着 —— 输出仍是两个同名键,照样把 profile 写坏。
346
+
347
+ 这种状态下唯一安全的动作是**什么都不做**:返回 ok:false,调用方
348
+ (ensureReleaseAgeExclude)会把它转成结构化失败,安装流程继续走 ——
349
+ profile 没显式设这个策略时 pnpm 自己会豁免精确版本,安装本来就能成。
350
+ 宁可这次豁免没生效,也绝不能把一个本来还能用的 profile 写坏。 */
351
+ const keyCount = (source.match(/^[ \t]*minimumReleaseAgeExclude:/gm) || []).length
352
+ if (keyCount > 1) {
353
+ return { ok: false, text: source, changed: false, reason: 'duplicate-key' }
354
+ }
355
+ const found = locateExcludeBlock(source)
356
+
357
+ if (found === null) {
358
+ /* 文件里还没有这个键:追加一个块。缩进用两个空格,与 pnpm 自己写出来的样式一致。 */
359
+ const trimmed = source.replace(/\s+$/, '')
360
+ const block = 'minimumReleaseAgeExclude:' + eol + ' - ' + name + '@' + version + eol
361
+ return { ok: true, text: trimmed.length === 0 ? block : trimmed + eol + eol + block, changed: true, reason: null }
362
+ }
363
+
364
+ const items = parseInlineValue(found.inline).concat(parseExcludeBlock(found.block))
365
+ const byName = new Map()
366
+ const order = []
367
+ /* 每个包名对应的一组「原始行」:块风格解析出来的条目带着逐字节原样的 `line`,
368
+ flow 风格的条目没有(它本来就要从 flow 重写成块),记 null。
369
+ 别人的规则能用原始行时**必须原样写回** —— 早先每个条目都重新生成一遍,
370
+ 结果 `dshmarket@1.66.22 || 1.66.32` 被加上引号变成 `'dshmarket@1.66.22 || 1.66.32'`。
371
+ 语义虽然等价,但那是**别人的配置**,我们没有任何理由去改写它(用户的 diff 会脏)。 */
372
+ const rawLines = new Map()
373
+ for (const item of items) {
374
+ if (item.kind !== 'entry') continue
375
+ const groupName = nameOfSelector(item.selector)
376
+ if (byName.has(groupName) !== true) {
377
+ byName.set(groupName, [])
378
+ rawLines.set(groupName, [])
379
+ order.push(groupName)
380
+ }
381
+ byName.get(groupName).push(item.selector)
382
+ rawLines.get(groupName).push(typeof item.line === 'string' ? item.line : null)
383
+ }
384
+
385
+ const mine = byName.get(name) ?? []
386
+ const versions = mine.flatMap((selector) => versionOfSelector(selector).split('||').map((one) => one.trim())).filter((one) => one.length > 0)
387
+ /* 裸包名(`- dsh-desktop-statusbar`,无 @版本)在 pnpm 里的语义是**放行该包所有版本**。
388
+ 这种情况什么都不用做:它已经比我们要的更宽,收窄成 `名字@目标版本` 反而是倒退 ——
389
+ 会把用户原本"整包放行"的意图静默改成"只放行这一个版本"。原样返回。
390
+ (实测缺陷:早先无条件重排,用户写的裸名被改成 `名字@2.1.2`。) */
391
+ if (mine.length === 1 && versionOfSelector(mine[0]) === '') {
392
+ return { ok: true, text: source, changed: false, reason: null }
393
+ }
394
+ /* 本插件的规则要**裁剪**:只留目标版本 + 最近一个(见 MAX_EXEMPT_VERSIONS)。
395
+ 早先只追加不清理,历史版本会永久累积成一条越来越长的 `||` 列表。 */
396
+ const keptVersions = keepRecentVersions(versions, version)
397
+ const keptSelector = name + '@' + keptVersions.join(' || ')
398
+ if (mine.length === 1) {
399
+ const already = versionOfSelector(mine[0]).split('||').map((one) => one.trim()).filter((one) => one.length > 0)
400
+ /* 目标版本已在、且集合没变(不需要裁剪)→ 一个字节都不动,保持幂等 */
401
+ if (already.length === keptVersions.length && keptVersions.every((one) => already.includes(one))) {
402
+ return { ok: true, text: source, changed: false, reason: null }
403
+ }
404
+ }
405
+ /* 用裁剪后的版本集合替换该包名的条目(不是追加) */
406
+ byName.set(name, [keptSelector])
407
+ /* 我们这条要**重新生成**(没有可原样复用的原始行),所以原始行记 null */
408
+ rawLines.set(name, [null])
409
+ if (order.indexOf(name) === -1) order.push(name)
410
+
411
+ /* 重排这个块:每个包名一条,位置取它首次出现的地方;注释原样保留在最前。 */
412
+ const comments = items.filter((item) => item.kind === 'comment')
413
+ const lines = []
414
+ for (const item of comments) lines.push(item.line)
415
+ for (const groupName of order) {
416
+ const selectors = byName.get(groupName) ?? []
417
+ const raws = rawLines.get(groupName) ?? []
418
+ /* 我们自己的那条:用裁剪后的版本集合重新生成 */
419
+ if (groupName === name) {
420
+ const merged = versionOfSelector(keptSelector)
421
+ const selector = merged.length === 0 ? groupName : groupName + '@' + merged
422
+ lines.push(' - ' + (selector.startsWith('@') || selector.includes(' || ') ? "'" + selector.replace(/'/g, "''") + "'" : selector))
423
+ continue
424
+ }
425
+ /* 别人的规则:只要它还是**一条且原始行可用**,就原样写回。
426
+ 唯一例外是缩进 —— 重排后整个块统一用两空格缩进,零缩进的旧条目必须跟着对齐,
427
+ 否则同一个序列里混着两种缩进,YAML 直接解析失败(实测过)。
428
+ 所以这里只动**前导空白**,`-` 之后的正文(含引号、`||`、大小写)一个字节都不改。 */
429
+ if (raws.length === 1 && typeof raws[0] === 'string' && raws[0].length > 0) {
430
+ const body = raws[0].replace(/^[ \t]*/, '')
431
+ lines.push(' ' + body)
432
+ continue
433
+ }
434
+ const merged = unionSelectors(selectors)
435
+ /* pnpm 把同名的多条并成一条;只有一个版本时保持 `name@version` 的朴素写法 */
436
+ const selector = merged.length === 0 ? groupName : groupName + '@' + merged
437
+ /* 以 @ 开头的标量在 YAML 里非法,作用域包要加引号 —— pnpm 自己也是这么写的 */
438
+ lines.push(' - ' + (selector.startsWith('@') || selector.includes(' || ') ? "'" + selector.replace(/'/g, "''") + "'" : selector))
439
+ }
440
+
441
+ /* 用定位到的起止下标替换整段 —— 不用正则 replace,避免正则再次因为写法差异漏配。 */
442
+ const blockText = 'minimumReleaseAgeExclude:' + eol + lines.join(eol) + eol
443
+ const rewritten = source.slice(0, found.start) + blockText + source.slice(found.end)
444
+ return { ok: true, text: rewritten, changed: rewritten !== source || versions.includes(version) !== true, reason: null }
445
+ }
446
+
447
+ /**
448
+ * 从插件目录往上找到 profile 目录(含 `pnpm-workspace.yaml` 的那一层)。
449
+ *
450
+ * 不猜固定层数:pnpm 的 hoisted 布局是 `<profile>/node_modules/<包名>`,
451
+ * isolated 布局会经过 `.pnpm/<包名>@<版本>/node_modules/<包名>`,
452
+ * 两种都能靠「向上找第一个带 pnpm-workspace.yaml 的目录」覆盖。
453
+ *
454
+ * @param {string} startDir 起点(一般是本插件的包根目录)
455
+ * @param {number} [maxLevels] 最多往上找几层
456
+ * @returns {string|null}
457
+ */
458
+ export function findProfileDir(startDir, maxLevels = 6) {
459
+ let current = resolve(startDir)
460
+ for (let level = 0; level < maxLevels; level += 1) {
461
+ try {
462
+ if (existsSync(join(current, 'pnpm-workspace.yaml'))) return current
463
+ } catch (error) {
464
+ return null
465
+ }
466
+ const parent = dirname(current)
467
+ if (parent === current) return null
468
+ current = parent
469
+ }
470
+ return null
471
+ }
472
+
473
+ /**
474
+ * 把本插件的目标版本写进 profile 的 `minimumReleaseAgeExclude`。
475
+ *
476
+ * 尽力而为:文件不存在、读不出来、写不进去都返回结构化失败,不抛异常。
477
+ * 调用方即使在失败时也应当继续安装 —— profile 没显式设置该策略时,
478
+ * pnpm 自己会自动豁免精确版本,安装本就能成。
479
+ *
480
+ * @param {string} profileDir
481
+ * @param {string} version
482
+ * @param {{name?: string}} [options]
483
+ * @returns {{ok: boolean, changed: boolean, reason: string|null, path: string|null}}
484
+ */
485
+ export function ensureReleaseAgeExclude(profileDir, version, options = {}) {
486
+ const name = options.name === undefined ? SELF_NAME : options.name
487
+ if (typeof profileDir !== 'string' || profileDir.length === 0) return { ok: false, changed: false, reason: 'no-profile-dir', path: null }
488
+ if (VERSION_PATTERN.test(String(version)) !== true) return { ok: false, changed: false, reason: 'bad-version', path: null }
489
+
490
+ const file = join(profileDir, 'pnpm-workspace.yaml')
491
+ let original
492
+ try {
493
+ original = readFileSync(file, 'utf8')
494
+ } catch (error) {
495
+ return { ok: false, changed: false, reason: 'unreadable', path: file }
496
+ }
497
+
498
+ const eol = original.includes('\r\n') ? '\r\n' : '\n'
499
+ const merged = mergeExcludeIntoYaml(original, name, String(version), eol)
500
+ if (merged.ok !== true) return { ok: false, changed: false, reason: merged.reason, path: file }
501
+ if (merged.changed !== true) return { ok: true, changed: false, reason: null, path: file }
502
+
503
+ try {
504
+ writeFileSync(file, merged.text, 'utf8')
505
+ } catch (error) {
506
+ return { ok: false, changed: false, reason: 'unwritable', path: file }
507
+ }
508
+ return { ok: true, changed: true, reason: null, path: file }
509
+ }