dsh-desktop-statusbar 2.1.2 → 2.2.1

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.
@@ -109,6 +109,73 @@ export function unionSelectors(selectors) {
109
109
  return versions.join(' || ')
110
110
  }
111
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
+
112
179
  /** 去掉包裹标量的引号(成对才剥,与 parseExcludeBlock 同规则)。 */
113
180
  function unquoteScalar(value) {
114
181
  const text = String(value).trim()
@@ -269,6 +336,22 @@ function locateExcludeBlock(source) {
269
336
  */
270
337
  export function mergeExcludeIntoYaml(text, name, version, eol = '\n') {
271
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
+ }
272
355
  const found = locateExcludeBlock(source)
273
356
 
274
357
  if (found === null) {
@@ -281,11 +364,22 @@ export function mergeExcludeIntoYaml(text, name, version, eol = '\n') {
281
364
  const items = parseInlineValue(found.inline).concat(parseExcludeBlock(found.block))
282
365
  const byName = new Map()
283
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()
284
373
  for (const item of items) {
285
374
  if (item.kind !== 'entry') continue
286
375
  const groupName = nameOfSelector(item.selector)
287
- if (byName.has(groupName) !== true) { byName.set(groupName, []); order.push(groupName) }
376
+ if (byName.has(groupName) !== true) {
377
+ byName.set(groupName, [])
378
+ rawLines.set(groupName, [])
379
+ order.push(groupName)
380
+ }
288
381
  byName.get(groupName).push(item.selector)
382
+ rawLines.get(groupName).push(typeof item.line === 'string' ? item.line : null)
289
383
  }
290
384
 
291
385
  const mine = byName.get(name) ?? []
@@ -297,14 +391,22 @@ export function mergeExcludeIntoYaml(text, name, version, eol = '\n') {
297
391
  if (mine.length === 1 && versionOfSelector(mine[0]) === '') {
298
392
  return { ok: true, text: source, changed: false, reason: null }
299
393
  }
300
- if (versions.includes(version)) {
301
- /* 已经有这个版本:若只有一条规则,什么都不用改 */
302
- if (mine.length === 1) return { ok: true, text: source, changed: false, reason: null }
303
- /* 有多条我们的规则:合并成一条,避免 pnpm 只认第一条导致我们这条被忽略 */
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
+ }
304
404
  }
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)
405
+ /* 用裁剪后的版本集合替换该包名的条目(不是追加) */
406
+ byName.set(name, [keptSelector])
407
+ /* 我们这条要**重新生成**(没有可原样复用的原始行),所以原始行记 null */
408
+ rawLines.set(name, [null])
409
+ if (order.indexOf(name) === -1) order.push(name)
308
410
 
309
411
  /* 重排这个块:每个包名一条,位置取它首次出现的地方;注释原样保留在最前。 */
310
412
  const comments = items.filter((item) => item.kind === 'comment')
@@ -312,6 +414,23 @@ export function mergeExcludeIntoYaml(text, name, version, eol = '\n') {
312
414
  for (const item of comments) lines.push(item.line)
313
415
  for (const groupName of order) {
314
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
+ }
315
434
  const merged = unionSelectors(selectors)
316
435
  /* pnpm 把同名的多条并成一条;只有一个版本时保持 `name@version` 的朴素写法 */
317
436
  const selector = merged.length === 0 ? groupName : groupName + '@' + merged
@@ -89,54 +89,237 @@ export function resolveSelfDir(moduleUrl) {
89
89
  }
90
90
 
91
91
  /**
92
- * 定位 pnpm 可执行文件。
92
+ * 取一个「可能是字符串」的注入值:类型不对时退回默认值。
93
+ * 传 `null` / 数字 / 对象都按「显式给了一个不可用的值」处理,不回退。
93
94
  *
94
- * 顺序:
95
- * 1. 环境变量 `DSH_DESKTOP_STATUSBAR_PNPM`:完整命令覆盖。可以是 JSON 数组
96
- * (`["node","/path/pnpm.mjs"]`,用别的 node 跑打包的 pnpm 或包一层脚本时用),
97
- * 也可以是单个可执行文件路径。
98
- * 2. 宿主随包提供的 pnpm:从 `process.execPath` 推出 resources 目录。
99
- * DSH 桌面端把 pnpm 放在 `resources/runtime/pnpm/bin/pnpm.mjs`,
100
- * 用 Electron 可执行文件加 `ELECTRON_RUN_AS_NODE=1` 启动即可当 node 用
101
- * (官方插件管理器走的也是这条路)。
102
- * 3. PATH 上的 `pnpm`(web 端 / CLI 运行时常走这条)。
95
+ * @param {unknown} value 注入值
96
+ * @param {unknown} fallback 默认值
97
+ * @returns {string}
98
+ */
99
+ function asText(value, fallback) {
100
+ if (value === undefined) return typeof fallback === 'string' ? fallback : ''
101
+ return typeof value === 'string' ? value : ''
102
+ }
103
+
104
+ /**
105
+ * 判断一段文本看起来是不是绝对路径。
103
106
  *
104
- * @returns {{command: string, args: string[], electron: boolean}|null}
107
+ * 不用 `node:path` 的 `isAbsolute()`:它按**当前进程**的平台判断,而这里允许测试
108
+ * 注入 `options.platform`,两者可能对不上。两种平台的写法都认:
109
+ * `C:\x`(盘符)、`\\server\share`(UNC)、`/x`(POSIX 根)。
110
+ *
111
+ * @param {string} text
112
+ * @returns {boolean}
113
+ */
114
+ function looksAbsolutePath(text) {
115
+ return /^[A-Za-z]:[\\/]/.test(text)
116
+ || text.startsWith('\\\\')
117
+ || text.startsWith('/')
118
+ }
119
+
120
+ /**
121
+ * 去掉路径结尾的分隔符;盘符根(`C:\`)原样保留 —— 去掉会变成 `C:`。
122
+ *
123
+ * @param {string} text
124
+ * @returns {string}
125
+ */
126
+ function trimTrailingSeparators(text) {
127
+ let out = text
128
+ while (out.length > 1 && /[\\/]$/.test(out) === true) {
129
+ if (/^[A-Za-z]:[\\/]$/.test(out) === true) break
130
+ out = out.slice(0, -1)
131
+ }
132
+ return out
133
+ }
134
+
135
+ /**
136
+ * 解析安装根下的 `resources` 目录。
137
+ *
138
+ * 优先级(与宿主自己的 `runtimeResources()` 取值顺序一致):
139
+ * 1. 显式注入的 `resourcesPath`(测试用,也可以是调用方认定的权威值);
140
+ * 2. `process.resourcesPath` —— Electron 打包版给的权威值,比推断更稳;
141
+ * 3. 由 `execPath` 推断:`dirname(execPath)/resources`(非 Electron 环境兜底)。
142
+ *
143
+ * @param {string} resourcesPath 已处理过默认值(可能是空字符串)
144
+ * @param {string} execPath 已处理过默认值(可能是空字符串)
145
+ * @returns {string|null} 推断不出来时返回 null
146
+ */
147
+ function resolveResourcesDir(resourcesPath, execPath) {
148
+ if (resourcesPath.length > 0 && looksAbsolutePath(resourcesPath) === true) {
149
+ return trimTrailingSeparators(resourcesPath)
150
+ }
151
+ if (execPath.length > 0) return trimTrailingSeparators(join(dirname(execPath), 'resources'))
152
+ return null
153
+ }
154
+
155
+ /**
156
+ * 定位 pnpm,返回**结构化结果**(永不抛异常),把「试过哪些候选」一并带回。
157
+ *
158
+ * 只做定位,不执行任何外部命令。
159
+ *
160
+ * 候选顺序(命中即返回,逐个记进 `tried`):
161
+ * 1. `DSH_DESKTOP_STATUSBAR_PNPM`:插件自己的覆盖口,优先级最高。可以是 JSON 数组
162
+ * (`["node","/path/pnpm.mjs"]`,用别的 node 跑打包的 pnpm 时用),也可以是
163
+ * 单个可执行文件路径。**不检查文件是否存在** —— 用户显式指定的入口就该照办。
164
+ * 2. `DSH_DESKTOP_PNPM_ENTRY`:宿主自己的口径(宿主 `runtimeResources()` 在开发
165
+ * 模式下读它)。排在文件候选之前,宿主以后挪位置也不会失配。
166
+ * 3. 宿主随包提供的 pnpm,按 `resources` 目录拼出来,三条候选依次尝试:
167
+ * - `runtime/primary-runtime/dependencies/pnpm/bin/pnpm.mjs`:**当前宿主布局**。
168
+ * 优先用同一套运行时里的 node(`runtime/primary-runtime/dependencies/node/bin/node.exe`)
169
+ * 去跑它,不依赖 Electron 的 `ELECTRON_RUN_AS_NODE`;那套 node 不在时退回宿主可执行文件
170
+ * (此时才需要 `ELECTRON_RUN_AS_NODE=1`)。
171
+ * - `runtime/pnpm/bin/pnpm.mjs`:更旧的宿主布局,保留兼容。
172
+ * - `app.asar.unpacked/dsh/node_modules/pnpm/bin/pnpm.mjs`:再旧的打包布局,保留兼容。
173
+ *
174
+ * 找不到时**不做 PATH 搜索**:Windows 上 PATH 里多半是 `pnpm.cmd`,spawn 它需要
175
+ * `shell: true`,等于把命令解释权交给 shell,风险面大于收益。真需要时请用户用
176
+ * `DSH_DESKTOP_STATUSBAR_PNPM` 直接指定(issue 里的绕过写法就是这么用的)。
177
+ *
178
+ * @param {{
179
+ * env?: {[key: string]: string|undefined},
180
+ * execPath?: string,
181
+ * resourcesPath?: string,
182
+ * exists?: (path: string) => boolean,
183
+ * electron?: boolean,
184
+ * platform?: string,
185
+ * }} [options] 全部可选;只有测试会传
186
+ * @returns {{
187
+ * ok: boolean,
188
+ * runner: {command: string, args: string[], electron: boolean}|null,
189
+ * source: 'custom-env'|'host-env'|'primary-runtime'|'runtime-legacy'|'asar-unpacked'|null,
190
+ * tried: Array<{source: string, path: string|null, note: string}>,
191
+ * }}
105
192
  */
106
- export function resolvePnpm() {
107
- const explicit = process.env.DSH_DESKTOP_STATUSBAR_PNPM
108
- if (typeof explicit === 'string' && explicit.trim().length > 0) {
109
- const text = explicit.trim()
110
- if (text.startsWith('[')) {
193
+ export function resolvePnpmDetailed(options) {
194
+ const opts = options === null || typeof options !== 'object' ? {} : options
195
+ /** 环境变量来源;默认 process.env。 */
196
+ const env = opts.env === undefined ? process.env : (opts.env === null ? {} : opts.env)
197
+ /** 宿主可执行文件;空字符串表示「拿不到」。 */
198
+ const execPath = asText(opts.execPath, process.execPath)
199
+ /** 注入的 resources 目录;未传时才回落到 process.resourcesPath。 */
200
+ const resourcesPath = asText(opts.resourcesPath, process.resourcesPath)
201
+ /** 是否跑在 Electron 里 —— 只有退回宿主可执行文件当 node 用时才需要它。 */
202
+ const electron = opts.electron === undefined
203
+ ? (typeof process.versions === 'object'
204
+ && process.versions !== null
205
+ && typeof process.versions.electron === 'string'
206
+ && process.versions.electron.length > 0)
207
+ : opts.electron === true
208
+ /** 平台;只用来决定同套运行时里的 node 叫 node.exe 还是 node。 */
209
+ const platform = asText(opts.platform, process.platform)
210
+
211
+ /** 每次尝试的记录:命中的也记,失败时一并带回给调用方写日志。 */
212
+ const tried = []
213
+ /** 记一条命中并直接返回成功结果。 */
214
+ const hit = (source, runner, path) => {
215
+ tried.push({ source, path: typeof path === 'string' ? path : null, note: '命中' })
216
+ return { ok: true, runner, source, tried }
217
+ }
218
+ /** 记一条未命中,继续试下一个候选。 */
219
+ const miss = (source, path, note) => {
220
+ tried.push({ source, path: typeof path === 'string' ? path : null, note })
221
+ }
222
+ /** 全部落空。 */
223
+ const fail = () => ({ ok: false, runner: null, source: null, tried })
224
+ /** 文件存在性检查;注入的实现抛错也当成「不存在」,保证本函数永不抛。 */
225
+ const exists = (target) => {
226
+ if (typeof opts.exists === 'function') {
227
+ try { return opts.exists(target) === true } catch (error) { return false }
228
+ }
229
+ try { return existsSync(target) === true } catch (error) { return false }
230
+ }
231
+
232
+ /* 1) 插件自己的覆盖口:完全不检查文件是否存在(用户显式指定的入口) */
233
+ const custom = asText(env.DSH_DESKTOP_STATUSBAR_PNPM, '')
234
+ if (custom.trim().length > 0) {
235
+ const text = custom.trim()
236
+ if (text.startsWith('[') === true) {
237
+ let parsed = null
111
238
  try {
112
- const parsed = JSON.parse(text)
113
- if (Array.isArray(parsed) === true && parsed.length > 0 && parsed.every((one) => typeof one === 'string')) {
114
- return { command: parsed[0], args: parsed.slice(1), electron: false }
239
+ const value = JSON.parse(text)
240
+ if (Array.isArray(value) === true && value.length > 0 && value.every((one) => typeof one === 'string') === true) {
241
+ parsed = value
115
242
  }
116
243
  } catch (error) {
117
- /* 不是合法数组就当单路径处理 */
244
+ /* 不是合法数组,落到下面的单路径处理 */
118
245
  }
246
+ if (parsed !== null) {
247
+ return hit('custom-env', { command: parsed[0], args: parsed.slice(1), electron: false }, custom)
248
+ }
249
+ /* `'[]'` 也走这里:空数组不够当命令用,退化成单路径(保持旧行为) */
250
+ miss('custom-env', custom, '不是合法 JSON 数组,按单路径处理')
119
251
  }
120
- return { command: text, args: [], electron: false }
252
+ return hit('custom-env', { command: text, args: [], electron: false }, custom)
121
253
  }
122
254
 
123
- const execPath = process.execPath
124
- if (typeof execPath === 'string' && execPath.length > 0) {
125
- const resources = join(dirname(execPath), 'resources')
126
- const candidates = [
127
- join(resources, 'runtime', 'pnpm', 'bin', 'pnpm.mjs'),
128
- join(resources, 'app.asar.unpacked', 'dsh', 'node_modules', 'pnpm', 'bin', 'pnpm.mjs'),
129
- ]
130
- for (const candidate of candidates) {
131
- try {
132
- if (existsSync(candidate)) return { command: execPath, args: [candidate], electron: true }
133
- } catch (error) {
134
- /* 试下一个 */
135
- }
255
+ /* 2) 宿主自己的口径:只存在于开发模式,但插件照样优先认它 */
256
+ const hostEntry = asText(env.DSH_DESKTOP_PNPM_ENTRY, '')
257
+ if (hostEntry.trim().length > 0) {
258
+ const entry = hostEntry.trim()
259
+ if (execPath.length === 0) {
260
+ miss('host-env', hostEntry, '没有可用的宿主可执行文件')
261
+ } else if (exists(entry) === true) {
262
+ /* tried.path 记变量原值(契约口径),实际执行用去掉首尾空白的路径 */
263
+ return hit('host-env', { command: execPath, args: [entry], electron }, hostEntry)
264
+ } else {
265
+ miss('host-env', hostEntry, '不存在')
136
266
  }
137
267
  }
138
268
 
139
- return null
269
+ /* 3) 按 resources 目录拼出来的文件候选 */
270
+ const resources = resolveResourcesDir(resourcesPath, execPath)
271
+ if (resources === null) {
272
+ miss('primary-runtime', null, '无法推断 resources 目录')
273
+ return fail()
274
+ }
275
+
276
+ /* 3.1 当前宿主布局:优先用同一套运行时里的 node,不碰 ELECTRON_RUN_AS_NODE */
277
+ const primaryPnpm = join(resources, 'runtime', 'primary-runtime', 'dependencies', 'pnpm', 'bin', 'pnpm.mjs')
278
+ if (exists(primaryPnpm) === true) {
279
+ const primaryNode = join(resources, 'runtime', 'primary-runtime', 'dependencies', 'node', 'bin', platform === 'win32' ? 'node.exe' : 'node')
280
+ if (exists(primaryNode) === true) {
281
+ return hit('primary-runtime', { command: primaryNode, args: [primaryPnpm], electron: false }, primaryPnpm)
282
+ }
283
+ if (execPath.length > 0) {
284
+ return hit('primary-runtime', { command: execPath, args: [primaryPnpm], electron }, primaryPnpm)
285
+ }
286
+ miss('primary-runtime', primaryPnpm, '找到 pnpm,但同套运行时里的 node 与宿主可执行文件都不可用')
287
+ } else {
288
+ miss('primary-runtime', primaryPnpm, '不存在')
289
+ }
290
+
291
+ /* 3.2 / 3.3 旧布局:向后兼容,用宿主可执行文件加 ELECTRON_RUN_AS_NODE 去跑 */
292
+ const legacyCandidates = [
293
+ { source: 'runtime-legacy', file: join(resources, 'runtime', 'pnpm', 'bin', 'pnpm.mjs') },
294
+ { source: 'asar-unpacked', file: join(resources, 'app.asar.unpacked', 'dsh', 'node_modules', 'pnpm', 'bin', 'pnpm.mjs') },
295
+ ]
296
+ for (const candidate of legacyCandidates) {
297
+ if (exists(candidate.file) !== true) {
298
+ miss(candidate.source, candidate.file, '不存在')
299
+ continue
300
+ }
301
+ if (execPath.length === 0) {
302
+ miss(candidate.source, candidate.file, '找到 pnpm,但没有可用的宿主可执行文件')
303
+ continue
304
+ }
305
+ return hit(candidate.source, { command: execPath, args: [candidate.file], electron }, candidate.file)
306
+ }
307
+
308
+ return fail()
309
+ }
310
+
311
+ /**
312
+ * 定位 pnpm 可执行文件(向后兼容的薄包装)。
313
+ *
314
+ * 内部走 `resolvePnpmDetailed()`,只关心「最终能不能跑」,不关心试过哪些候选。
315
+ * 现有测试与 `verify-self-update.js --online` 用的都是这个签名。
316
+ *
317
+ * @param {object} [options] 透传给 resolvePnpmDetailed(测试注入用)
318
+ * @returns {{command: string, args: string[], electron: boolean}|null}
319
+ */
320
+ export function resolvePnpm(options) {
321
+ const detail = resolvePnpmDetailed(options)
322
+ return detail.ok === true ? detail.runner : null
140
323
  }
141
324
 
142
325
  /**
@@ -231,9 +414,9 @@ export const RELEASE_AGE_OVERRIDE = '--config.minimum-release-age=0'
231
414
  * - `--save-exact`:清单里写死精确版本,避免 `^` 范围日后漂移。
232
415
  * - 发布年龄归零:见 RELEASE_AGE_OVERRIDE。
233
416
  *
234
- * 不加 `--no-frozen-lockfile`:pnpm 11.7.0 的 `add` 不认这个选项
235
- * (实测报 `Unknown option: 'frozen-lockfile'`)。`add` 本来就会更新锁文件,
236
- * 不需要它。
417
+ * 不加 `--no-frozen-lockfile`:pnpm 11 的 `add` 不认这个选项
418
+ * (实测报 `Unknown option: 'frozen-lockfile'`;宿主当前自带 pnpm 11.28.5,同样如此)。
419
+ * `add` 本来就会更新锁文件,不需要它。
237
420
  *
238
421
  * @param {string[]} base pnpm 自身的参数(例如打包 pnpm 的 .mjs 路径)
239
422
  * @param {string} target 安装目标 `包名@版本`
@@ -257,14 +440,30 @@ export async function runSelfUpdate(options) {
257
440
  刷新页面不算数 —— 插件包元数据(含 client.js 的 revision)在启动时缓存、直到重启才更新
258
441
  (见 @deepseek-ai/dsh-hmr 的已知限制与 dsh-client-modules 的说明)。
259
442
  不用「要不要重启」去猜,直接如实告诉界面。 */
260
- const result = { ok: false, from, to: options.version, reason: null, detail: null, profileDir: null, changedExclude: false, restartRequired: true }
443
+ const result = { ok: false, from, to: options.version, reason: null, detail: null, profileDir: null, changedExclude: false, restartRequired: true, tried: [] }
261
444
 
262
- const pnpm = resolvePnpm()
263
- if (pnpm === null) {
445
+ /* 注意:必须在找 profile 之前就报 pnpm-not-found —— 找不到 pnpm 时 profile
446
+ 目录是否存在无关紧要,先告诉用户真正的卡点。 */
447
+ const pnpm = resolvePnpmDetailed()
448
+ if (pnpm.ok !== true) {
264
449
  result.reason = 'pnpm-not-found'
450
+ /* 把试过的候选写进结果和日志:issue 里那次排查全靠手工逐条 Test-Path,
451
+ 以后用户/开发者看日志就能定位,不用再猜插件找的是哪条路径。 */
452
+ result.tried = pnpm.tried
453
+ .slice(0, 12)
454
+ .map((one) => {
455
+ const where = one.path === null ? '—' : one.path
456
+ const line = one.source + ': ' + where + '(' + one.note + ')'
457
+ return line.length > 300 ? line.slice(0, 300) + '…' : line
458
+ })
459
+ log('update: pnpm-not-found,尝试过:')
460
+ for (const line of result.tried) log(' - ' + line)
265
461
  return result
266
462
  }
267
463
 
464
+ const runner = pnpm.runner
465
+ log('update: pnpm 来源 ' + pnpm.source + ' → ' + runner.command + ' ' + runner.args.join(' ') + (runner.electron === true ? '(ELECTRON_RUN_AS_NODE)' : ''))
466
+
268
467
  const profileDir = findProfileDir(selfDir)
269
468
  result.profileDir = profileDir
270
469
  if (profileDir === null) {
@@ -285,13 +484,13 @@ export async function runSelfUpdate(options) {
285
484
  /* 精确版本,不用 @latest:后者在发布年龄策略下会静默装成旧的成熟版 */
286
485
  const target = SELF_NAME + '@' + options.version
287
486
  const env = { ...process.env }
288
- if (pnpm.electron === true) env.ELECTRON_RUN_AS_NODE = '1'
487
+ if (runner.electron === true) env.ELECTRON_RUN_AS_NODE = '1'
289
488
  /* CI=true:pnpm 在非交互环境下不会弹提示卡住(与官方插件管理器同一做法) */
290
489
  env.CI = 'true'
291
- const args = updateArgs(pnpm.args, target)
490
+ const args = updateArgs(runner.args, target)
292
491
 
293
492
  log('update: 开始安装 ' + target)
294
- const run = await runCommand(pnpm.command, args, {
493
+ const run = await runCommand(runner.command, args, {
295
494
  cwd: profileDir,
296
495
  env,
297
496
  timeoutMs: options.timeoutMs,
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "dsh-desktop-statusbar",
3
- "version": "2.1.2",
3
+ "version": "2.2.1",
4
4
  "type": "module",
5
5
  "description": "把 DSH 桌面端对话区底部的统计行换成可配置状态栏:10 个字段可选可排序,按官方峰谷口径估算费用,支持自定义模型单价与账户余额。",
6
6
  "icon": "icon.svg",
7
7
  "license": "MIT",
8
8
  "engines": {
9
- "node": "^22.19.0 || >=24.0.0"
9
+ "node": "^22.19.0 || >=24.0.0",
10
+ "dsh": ">=0.2.0-rc.2"
10
11
  },
11
12
  "author": "北浮森屿 (https://github.com/raphael-y7)",
12
13
  "repository": {