dsh-desktop-statusbar 2.1.0 → 2.1.2

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,390 @@
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
+ /** 去掉包裹标量的引号(成对才剥,与 parseExcludeBlock 同规则)。 */
113
+ function unquoteScalar(value) {
114
+ const text = String(value).trim()
115
+ if (text.startsWith("'") && text.endsWith("'") && text.length > 1) return text.slice(1, -1).replace(/''/g, "'")
116
+ if (text.startsWith('"') && text.endsWith('"') && text.length > 1) return text.slice(1, -1)
117
+ return text
118
+ }
119
+
120
+ /** 找 flow 数组的闭合 `]`(考虑多行与嵌套)。找不到返回 -1。 */
121
+ function findCloseBracket(text) {
122
+ let depth = 0
123
+ for (let i = 0; i < text.length; i += 1) {
124
+ if (text[i] === '[') depth += 1
125
+ else if (text[i] === ']') {
126
+ depth -= 1
127
+ if (depth === 0) return i
128
+ }
129
+ }
130
+ return -1
131
+ }
132
+
133
+ /** 一行里 `[` 与 `]` 的数量差(用于判断 flow 数组是否还没闭合)。 */
134
+ function bracketDepth(text) {
135
+ let depth = 0
136
+ for (const ch of String(text)) {
137
+ if (ch === '[') depth += 1
138
+ else if (ch === ']') depth -= 1
139
+ }
140
+ return depth
141
+ }
142
+
143
+ /**
144
+ * 解析 `minimumReleaseAgeExclude:` **写在同行**的值(可能跨多行)。
145
+ *
146
+ * 这个键不只有「独占一行、下面挂列表」一种写法,pnpm 生态里同样常见:
147
+ * · flow 风格:`minimumReleaseAgeExclude: ["a@1.0.0"]`
148
+ * · 行尾注释:`minimumReleaseAgeExclude: # 豁免表`
149
+ * · 空数组: `minimumReleaseAgeExclude: []`
150
+ * · 跨行 flow:`minimumReleaseAgeExclude: [` / ` "a@1.0.0"` / `]`
151
+ * · 带引号的标量:`minimumReleaseAgeExclude: 'a@1.0.0'`
152
+ * 任何一种没认出来,都会被当成「文件里没有这个键」而**追加出第二个同名键** ——
153
+ * YAML 直接 `duplicated mapping key`、pnpm 拒绝启动,受害的是整个 profile。
154
+ *
155
+ * @param {string} raw 冒号之后到该键结束(含跨行)的原始文本
156
+ * @returns {Array<object>} 与 parseExcludeBlock 同形状的条目
157
+ */
158
+ function parseInlineValue(raw) {
159
+ const text = String(raw)
160
+ const firstLine = text.split(/\r?\n/)[0].trim()
161
+ if (firstLine.length === 0) return []
162
+ /* 整个值是注释:保留成注释条目,重排时统一放到块首 */
163
+ if (firstLine.startsWith('#')) return [{ kind: 'comment', line: ' ' + firstLine }]
164
+
165
+ const trimmed = text.trim()
166
+ if (trimmed.startsWith('[')) {
167
+ /* 先定位闭合括号再取内部 —— 这样 `["a"] # 说明` 的注释、以及值里带 `#` 的
168
+ 字符串(如 `["a # b"]`)都不会被误当成行尾注释切掉。 */
169
+ const close = findCloseBracket(trimmed)
170
+ const inner = (close === -1 ? trimmed.slice(1) : trimmed.slice(1, close)).trim()
171
+ if (inner.length === 0) return []
172
+ const out = []
173
+ for (const one of inner.split(',')) {
174
+ const selector = unquoteScalar(one)
175
+ if (selector.length > 0) out.push({ kind: 'entry', line: ' - ' + selector, selector })
176
+ }
177
+ return out
178
+ }
179
+
180
+ /* 标量:同样要剥引号。不剥的话 `'dsh-desktop-statusbar@2.1.1'` 会被当成
181
+ 包名带前引号的另一条规则 —— 归并失效、我们这条被追加成第二条,且不幂等。 */
182
+ const selector = unquoteScalar(trimmed)
183
+ if (selector.length === 0) return []
184
+ return [{ kind: 'entry', line: ' - ' + selector, selector }]
185
+ }
186
+
187
+ /**
188
+ * 定位 `minimumReleaseAgeExclude:` 这个键,返回它的同行值、下方块正文与整段在原文里的位置。
189
+ *
190
+ * 为什么要手写扫描而不是一条正则:这个键的写法太多,正则很难同时覆盖
191
+ * · 键独占一行、下面挂列表(块风格)
192
+ * · 键与值同行:flow 数组 `["a@1.0.0"]`、空数组 `[]`、行尾注释 `# 表`
193
+ * · **跨行 flow**:`[` 之后换行、几行之后才是 `]`
194
+ * · 标量写成带引号 `'a@1.0.0'`
195
+ * 而且还有两个容易漏的输入形态(都实测写出过重复键):
196
+ * · **文件末行没有换行符** —— 原先的正则要求键行后必须跟 `\n`,匹配失败就被
197
+ * 当成「文件里没有这个键」,于是追加出第二个同名键 → YAML `duplicated mapping key`
198
+ * → pnpm 拒绝加载整个 profile。找出原因很难,因为同一个输入加个尾换行就正常。
199
+ * · **首字节是 BOM** —— `^` 在 m 模式下不跨过 BOM,键在首行时同样匹配失败。
200
+ * Windows 编辑器给 YAML 加 BOM 很常见。
201
+ * 手写扫描对这两种天然免疫,也更好读。
202
+ *
203
+ * @param {string} source 文件全文
204
+ * @returns {{inline: string, block: string, start: number, end: number}|null}
205
+ */
206
+ function locateExcludeBlock(source) {
207
+ const KEY = 'minimumReleaseAgeExclude:'
208
+ /* 逐行定位键。行首允许 BOM(只在第一行可能),也允许前导空白(YAML 里顶层键通常顶格,
209
+ 但缩进写法不该因此漏掉)。 */
210
+ let cursor = 0
211
+ let lineStart = 0
212
+ while (lineStart <= source.length) {
213
+ const nl = source.indexOf('\n', lineStart)
214
+ const lineEnd = nl === -1 ? source.length : nl
215
+ const rawLine = source.slice(lineStart, lineEnd).replace(/\r$/, '')
216
+ const line = lineStart === 0 ? rawLine.replace(/^\uFEFF/, '') : rawLine
217
+ if (line.trimStart().startsWith(KEY)) {
218
+ const afterKey = line.trimStart().slice(KEY.length)
219
+ let inline = afterKey
220
+ let scanFrom = nl === -1 ? source.length : nl + 1
221
+ /* 同行值是未闭合的 flow 数组:继续吃后续行,直到括号配平 */
222
+ if (inline.trim().startsWith('[') && bracketDepth(inline) > 0) {
223
+ while (scanFrom < source.length && bracketDepth(inline) > 0) {
224
+ const nextNl = source.indexOf('\n', scanFrom)
225
+ const nextEnd = nextNl === -1 ? source.length : nextNl
226
+ inline += '\n' + source.slice(scanFrom, nextEnd).replace(/\r$/, '')
227
+ scanFrom = nextNl === -1 ? source.length : nextNl + 1
228
+ }
229
+ }
230
+ /* 下方块:连续的「- 条目」或注释行;遇到别的键或空行结束。
231
+ 条目允许零缩进(`- a@1.0.0` 顶格在 YAML 里是合法的)。 */
232
+ let blockEnd = scanFrom
233
+ for (;;) {
234
+ if (blockEnd >= source.length) break
235
+ const bNl = source.indexOf('\n', blockEnd)
236
+ const bEnd = bNl === -1 ? source.length : bNl
237
+ const bLine = source.slice(blockEnd, bEnd).replace(/\r$/, '')
238
+ const trimmed = bLine.trim()
239
+ if (trimmed.length === 0) break
240
+ if (/^[ \t]*-/.test(bLine) || /^[ \t]*#/.test(bLine)) {
241
+ blockEnd = bNl === -1 ? source.length : bNl + 1
242
+ continue
243
+ }
244
+ break
245
+ }
246
+ return {
247
+ inline,
248
+ block: source.slice(scanFrom, blockEnd),
249
+ start: lineStart,
250
+ end: blockEnd,
251
+ }
252
+ }
253
+ if (nl === -1) break
254
+ lineStart = nl + 1
255
+ }
256
+ return null
257
+ }
258
+
259
+ /**
260
+ * 在 `pnpm-workspace.yaml` 的正文里确保本插件的目标版本被豁免。
261
+ *
262
+ * 纯函数:只做字符串进出,方便直接测。
263
+ *
264
+ * @param {string} text 文件全文
265
+ * @param {string} name 包名
266
+ * @param {string} version 要放行的精确版本
267
+ * @param {string} eol 换行符(保持文件原有风格)
268
+ * @returns {{ok: boolean, text: string, changed: boolean, reason: string|null}}
269
+ */
270
+ export function mergeExcludeIntoYaml(text, name, version, eol = '\n') {
271
+ const source = String(text)
272
+ const found = locateExcludeBlock(source)
273
+
274
+ if (found === null) {
275
+ /* 文件里还没有这个键:追加一个块。缩进用两个空格,与 pnpm 自己写出来的样式一致。 */
276
+ const trimmed = source.replace(/\s+$/, '')
277
+ const block = 'minimumReleaseAgeExclude:' + eol + ' - ' + name + '@' + version + eol
278
+ return { ok: true, text: trimmed.length === 0 ? block : trimmed + eol + eol + block, changed: true, reason: null }
279
+ }
280
+
281
+ const items = parseInlineValue(found.inline).concat(parseExcludeBlock(found.block))
282
+ const byName = new Map()
283
+ const order = []
284
+ for (const item of items) {
285
+ if (item.kind !== 'entry') continue
286
+ const groupName = nameOfSelector(item.selector)
287
+ if (byName.has(groupName) !== true) { byName.set(groupName, []); order.push(groupName) }
288
+ byName.get(groupName).push(item.selector)
289
+ }
290
+
291
+ const mine = byName.get(name) ?? []
292
+ const versions = mine.flatMap((selector) => versionOfSelector(selector).split('||').map((one) => one.trim())).filter((one) => one.length > 0)
293
+ /* 裸包名(`- dsh-desktop-statusbar`,无 @版本)在 pnpm 里的语义是**放行该包所有版本**。
294
+ 这种情况什么都不用做:它已经比我们要的更宽,收窄成 `名字@目标版本` 反而是倒退 ——
295
+ 会把用户原本"整包放行"的意图静默改成"只放行这一个版本"。原样返回。
296
+ (实测缺陷:早先无条件重排,用户写的裸名被改成 `名字@2.1.2`。) */
297
+ if (mine.length === 1 && versionOfSelector(mine[0]) === '') {
298
+ return { ok: true, text: source, changed: false, reason: null }
299
+ }
300
+ if (versions.includes(version)) {
301
+ /* 已经有这个版本:若只有一条规则,什么都不用改 */
302
+ if (mine.length === 1) return { ok: true, text: source, changed: false, reason: null }
303
+ /* 有多条我们的规则:合并成一条,避免 pnpm 只认第一条导致我们这条被忽略 */
304
+ }
305
+ if (byName.has(name) !== true) order.push(name)
306
+ if (byName.has(name) !== true) byName.set(name, [])
307
+ byName.get(name).push(name + '@' + version)
308
+
309
+ /* 重排这个块:每个包名一条,位置取它首次出现的地方;注释原样保留在最前。 */
310
+ const comments = items.filter((item) => item.kind === 'comment')
311
+ const lines = []
312
+ for (const item of comments) lines.push(item.line)
313
+ for (const groupName of order) {
314
+ const selectors = byName.get(groupName) ?? []
315
+ const merged = unionSelectors(selectors)
316
+ /* pnpm 把同名的多条并成一条;只有一个版本时保持 `name@version` 的朴素写法 */
317
+ const selector = merged.length === 0 ? groupName : groupName + '@' + merged
318
+ /* 以 @ 开头的标量在 YAML 里非法,作用域包要加引号 —— pnpm 自己也是这么写的 */
319
+ lines.push(' - ' + (selector.startsWith('@') || selector.includes(' || ') ? "'" + selector.replace(/'/g, "''") + "'" : selector))
320
+ }
321
+
322
+ /* 用定位到的起止下标替换整段 —— 不用正则 replace,避免正则再次因为写法差异漏配。 */
323
+ const blockText = 'minimumReleaseAgeExclude:' + eol + lines.join(eol) + eol
324
+ const rewritten = source.slice(0, found.start) + blockText + source.slice(found.end)
325
+ return { ok: true, text: rewritten, changed: rewritten !== source || versions.includes(version) !== true, reason: null }
326
+ }
327
+
328
+ /**
329
+ * 从插件目录往上找到 profile 目录(含 `pnpm-workspace.yaml` 的那一层)。
330
+ *
331
+ * 不猜固定层数:pnpm 的 hoisted 布局是 `<profile>/node_modules/<包名>`,
332
+ * isolated 布局会经过 `.pnpm/<包名>@<版本>/node_modules/<包名>`,
333
+ * 两种都能靠「向上找第一个带 pnpm-workspace.yaml 的目录」覆盖。
334
+ *
335
+ * @param {string} startDir 起点(一般是本插件的包根目录)
336
+ * @param {number} [maxLevels] 最多往上找几层
337
+ * @returns {string|null}
338
+ */
339
+ export function findProfileDir(startDir, maxLevels = 6) {
340
+ let current = resolve(startDir)
341
+ for (let level = 0; level < maxLevels; level += 1) {
342
+ try {
343
+ if (existsSync(join(current, 'pnpm-workspace.yaml'))) return current
344
+ } catch (error) {
345
+ return null
346
+ }
347
+ const parent = dirname(current)
348
+ if (parent === current) return null
349
+ current = parent
350
+ }
351
+ return null
352
+ }
353
+
354
+ /**
355
+ * 把本插件的目标版本写进 profile 的 `minimumReleaseAgeExclude`。
356
+ *
357
+ * 尽力而为:文件不存在、读不出来、写不进去都返回结构化失败,不抛异常。
358
+ * 调用方即使在失败时也应当继续安装 —— profile 没显式设置该策略时,
359
+ * pnpm 自己会自动豁免精确版本,安装本就能成。
360
+ *
361
+ * @param {string} profileDir
362
+ * @param {string} version
363
+ * @param {{name?: string}} [options]
364
+ * @returns {{ok: boolean, changed: boolean, reason: string|null, path: string|null}}
365
+ */
366
+ export function ensureReleaseAgeExclude(profileDir, version, options = {}) {
367
+ const name = options.name === undefined ? SELF_NAME : options.name
368
+ if (typeof profileDir !== 'string' || profileDir.length === 0) return { ok: false, changed: false, reason: 'no-profile-dir', path: null }
369
+ if (VERSION_PATTERN.test(String(version)) !== true) return { ok: false, changed: false, reason: 'bad-version', path: null }
370
+
371
+ const file = join(profileDir, 'pnpm-workspace.yaml')
372
+ let original
373
+ try {
374
+ original = readFileSync(file, 'utf8')
375
+ } catch (error) {
376
+ return { ok: false, changed: false, reason: 'unreadable', path: file }
377
+ }
378
+
379
+ const eol = original.includes('\r\n') ? '\r\n' : '\n'
380
+ const merged = mergeExcludeIntoYaml(original, name, String(version), eol)
381
+ if (merged.ok !== true) return { ok: false, changed: false, reason: merged.reason, path: file }
382
+ if (merged.changed !== true) return { ok: true, changed: false, reason: null, path: file }
383
+
384
+ try {
385
+ writeFileSync(file, merged.text, 'utf8')
386
+ } catch (error) {
387
+ return { ok: false, changed: false, reason: 'unwritable', path: file }
388
+ }
389
+ return { ok: true, changed: true, reason: null, path: file }
390
+ }