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,368 @@
1
+ /**
2
+ * 自更新:定位 pnpm → 放行发布年龄 → 装精确版本 → 核对结果。
3
+ *
4
+ * 为什么不用 DSH 官方的插件安装接口(`ctx.remote.pluginManager.installBundle`):
5
+ * 1. 那个服务由**另一个插件**(插件管理器)提供。没装它的 profile 上 `ctx.remote`
6
+ * 根本取不到 —— DSH 的依赖注入对未声明的服务会同步抛错,异常从 onClick 冒出
7
+ * 没人接,界面表现就是「点了完全没反应」。
8
+ * 2. 它内部跑裸 `pnpm add pkg@latest`,在 pnpm 11 的 `minimumReleaseAge`
9
+ * (默认 1440 分钟 = 24 小时)下会**静默装成旧的成熟版**,而且退出码是 0。
10
+ *
11
+ * 本模块的修法:自己跑 pnpm,并在跑之前把目标版本写进 profile 的
12
+ * `minimumReleaseAgeExclude`(见 release-age.js),再用**精确版本**安装。
13
+ * 精确版本是关键 —— 同样的策略下 `@latest` 会静默降级,而 `pkg@x.y.z` 要么装上、
14
+ * 要么明确报 `ERR_PNPM_NO_MATURE_MATCHING_VERSION`,两种结果都能被程序分辨。
15
+ *
16
+ * 走 pnpm(而不是自己下载 tgz 替换文件)的另一个原因:`pnpm add` 会同时更新
17
+ * profile 的 `package.json` 与锁文件,插件目录和清单保持一致;只换 node_modules
18
+ * 里的文件会让下一次 `pnpm install` 把版本退回去。
19
+ *
20
+ * @module dsh-desktop-statusbar/self-update
21
+ */
22
+
23
+ import { spawn } from 'node:child_process'
24
+ import { existsSync, readFileSync } from 'node:fs'
25
+ import { dirname, join } from 'node:path'
26
+ import { ensureReleaseAgeExclude, findProfileDir } from './release-age.js'
27
+
28
+ /** 本插件包名。 */
29
+ export const SELF_NAME = 'dsh-desktop-statusbar'
30
+
31
+ /** 一次安装的整体超时(毫秒)。pnpm 冷缓存下可能较慢。 */
32
+ const INSTALL_TIMEOUT_MS = 180000
33
+
34
+ /** 传给 pnpm 的输出上限,避免日志撑爆内存。 */
35
+ const OUTPUT_LIMIT = 64000
36
+
37
+ /** 单次更新的进行态;同一时刻只允许一个。 */
38
+ let running = null
39
+
40
+ /** 最近一次更新的结果(给界面与 GET 状态用)。 */
41
+ let lastResult = null
42
+
43
+ /**
44
+ * 读取某个插件目录里 package.json 声明的版本。
45
+ * @param {string} selfDir
46
+ * @returns {string|null}
47
+ */
48
+ export function installedVersion(selfDir) {
49
+ try {
50
+ const manifest = JSON.parse(readFileSync(join(selfDir, 'package.json'), 'utf8'))
51
+ return typeof manifest.version === 'string' ? manifest.version : null
52
+ } catch (error) {
53
+ return null
54
+ }
55
+ }
56
+
57
+ /**
58
+ * 解析本插件自己的**包根目录**(含 package.json 的那一层)。
59
+ *
60
+ * 用 `import.meta.url` 反推,再向上找到 name 匹配的那一层 —— 比猜固定层数可靠:
61
+ * 插件可能装在任意 profile、任意 node_modules 布局(hoisted 直接放
62
+ * `<profile>/node_modules/<包名>`,isolated 会经过 `.pnpm/<包名>@<版本>/node_modules/`)。
63
+ *
64
+ * @param {string} [moduleUrl] 覆盖用(测试注入)。
65
+ * @returns {string}
66
+ */
67
+ export function resolveSelfDir(moduleUrl) {
68
+ const here = moduleUrl === undefined || moduleUrl === null ? import.meta.url : moduleUrl
69
+ let path = String(here)
70
+ if (path.startsWith('file://')) {
71
+ path = path.slice('file://'.length)
72
+ if (/^\/[A-Za-z]:/.test(path)) path = path.slice(1)
73
+ try { path = decodeURIComponent(path) } catch (error) { /* 保持原样 */ }
74
+ }
75
+ let current = dirname(path)
76
+ for (let level = 0; level < 4; level += 1) {
77
+ try {
78
+ const manifest = JSON.parse(readFileSync(join(current, 'package.json'), 'utf8'))
79
+ if (manifest !== null && typeof manifest === 'object' && manifest.name === SELF_NAME) return current
80
+ } catch (error) {
81
+ /* 这一层没有可读的 package.json,继续往上 */
82
+ }
83
+ const parent = dirname(current)
84
+ if (parent === current) break
85
+ current = parent
86
+ }
87
+ /* 找不到就退回模块上一级;调用方读不到 package.json 会失败,不会误改别处 */
88
+ return dirname(dirname(path))
89
+ }
90
+
91
+ /**
92
+ * 定位 pnpm 可执行文件。
93
+ *
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 运行时常走这条)。
103
+ *
104
+ * @returns {{command: string, args: string[], electron: boolean}|null}
105
+ */
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('[')) {
111
+ 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 }
115
+ }
116
+ } catch (error) {
117
+ /* 不是合法数组就当单路径处理 */
118
+ }
119
+ }
120
+ return { command: text, args: [], electron: false }
121
+ }
122
+
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
+ }
136
+ }
137
+ }
138
+
139
+ return null
140
+ }
141
+
142
+ /**
143
+ * 跑一条命令并收集输出。
144
+ * @param {string} command
145
+ * @param {string[]} args
146
+ * @param {{cwd: string, env?: object, timeoutMs?: number}} options
147
+ * @returns {Promise<{exitCode: number|null, stdout: string, stderr: string, timedOut: boolean}>}
148
+ */
149
+ export function runCommand(command, args, options) {
150
+ return new Promise((resolve) => {
151
+ let child
152
+ try {
153
+ child = spawn(command, args, {
154
+ cwd: options.cwd,
155
+ env: options.env === undefined ? process.env : options.env,
156
+ windowsHide: true,
157
+ stdio: ['ignore', 'pipe', 'pipe'],
158
+ })
159
+ } catch (error) {
160
+ resolve({ exitCode: null, stdout: '', stderr: String(error && error.message), timedOut: false })
161
+ return
162
+ }
163
+ let stdout = ''
164
+ let stderr = ''
165
+ let settled = false
166
+ let timedOut = false
167
+ let timer = null
168
+ const finish = (exitCode) => {
169
+ if (settled) return
170
+ settled = true
171
+ if (timer !== null) clearTimeout(timer)
172
+ resolve({ exitCode, stdout, stderr, timedOut })
173
+ }
174
+ timer = setTimeout(() => {
175
+ timedOut = true
176
+ try { child.kill() } catch (error) { /* 可能已退出 */ }
177
+ /* 被杀之后 close 未必触发,兜一个底 */
178
+ setTimeout(() => finish(null), 5000)
179
+ }, options.timeoutMs === undefined ? INSTALL_TIMEOUT_MS : options.timeoutMs)
180
+
181
+ child.stdout.on('data', (chunk) => {
182
+ if (stdout.length < OUTPUT_LIMIT) stdout += String(chunk)
183
+ })
184
+ child.stderr.on('data', (chunk) => {
185
+ if (stderr.length < OUTPUT_LIMIT) stderr += String(chunk)
186
+ })
187
+ child.on('error', (error) => {
188
+ stderr += String(error && error.message)
189
+ finish(null)
190
+ })
191
+ child.on('close', (code) => finish(code))
192
+ })
193
+ }
194
+
195
+ /**
196
+ * 判断 pnpm 的输出是不是「发布年龄」拦下的。
197
+ *
198
+ * pnpm 11 用 `ERR_PNPM_NO_MATURE_MATCHING_VERSION`;老版本用
199
+ * `ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION`。两个都认,这样即使豁免没生效,
200
+ * 也能给出准确的原因而不是笼统的「更新失败」。
201
+ *
202
+ * @param {string} output
203
+ * @returns {boolean}
204
+ */
205
+ export function isReleaseAgeBlocked(output) {
206
+ const text = String(output)
207
+ return text.includes('NO_MATURE_MATCHING_VERSION') || text.includes('MINIMUM_RELEASE_AGE_VIOLATION')
208
+ }
209
+
210
+ /**
211
+ * 单次命令用的「发布年龄归零」开关。
212
+ *
213
+ * 为什么需要它,而不是只靠 `minimumReleaseAgeExclude` 放行目标版本:
214
+ * 被年龄策略拦下的不只是目标包本身,还有它的**依赖**。一旦插件新版本引入了
215
+ * 一个刚发布不满 24 小时的依赖,只放行插件自己仍然装不上
216
+ * (实测:门槛拉高时 `zod@4.4.0` 会连同插件一起被拒)。
217
+ *
218
+ * 写法用「烤肉串」形式而不是驼峰:pnpm 12.3.0 起(原生 CLI)会**静默忽略**
219
+ * `--config.minimumReleaseAge=0`(不报未知选项,直接当没传),于是重试仍会失败;
220
+ * `--config.minimum-release-age=0` 在 pnpm 10 / 11 / 12 上都被认。
221
+ * 这是这个键特有的行为,不能推广到别的 `--config.*`。
222
+ *
223
+ * 作用域只有这一条命令:不写进任何配置文件,所以不会永久改变用户的策略。
224
+ */
225
+ export const RELEASE_AGE_OVERRIDE = '--config.minimum-release-age=0'
226
+
227
+ /**
228
+ * 一次 pnpm 调用的参数。
229
+ *
230
+ * 带上下面的开关:
231
+ * - `--save-exact`:清单里写死精确版本,避免 `^` 范围日后漂移。
232
+ * - 发布年龄归零:见 RELEASE_AGE_OVERRIDE。
233
+ *
234
+ * 不加 `--no-frozen-lockfile`:pnpm 11.7.0 的 `add` 不认这个选项
235
+ * (实测报 `Unknown option: 'frozen-lockfile'`)。`add` 本来就会更新锁文件,
236
+ * 不需要它。
237
+ *
238
+ * @param {string[]} base pnpm 自身的参数(例如打包 pnpm 的 .mjs 路径)
239
+ * @param {string} target 安装目标 `包名@版本`
240
+ * @returns {string[]}
241
+ */
242
+ export function updateArgs(base, target) {
243
+ return [...base, 'add', '--save-exact', RELEASE_AGE_OVERRIDE, target]
244
+ }
245
+
246
+ /**
247
+ * 执行一次自更新。全程不抛异常,失败以结构化结果返回。
248
+ *
249
+ * @param {{selfDir: string, version: string, logger?: Function, timeoutMs?: number}} options
250
+ * @returns {Promise<object>}
251
+ */
252
+ export async function runSelfUpdate(options) {
253
+ const selfDir = options.selfDir
254
+ const log = typeof options.logger === 'function' ? options.logger : () => {}
255
+ const from = installedVersion(selfDir)
256
+ /* restartRequired 一律为 true:在 DSH 里替换已安装插件的版本**必然**需要重启,
257
+ 刷新页面不算数 —— 插件包元数据(含 client.js 的 revision)在启动时缓存、直到重启才更新
258
+ (见 @deepseek-ai/dsh-hmr 的已知限制与 dsh-client-modules 的说明)。
259
+ 不用「要不要重启」去猜,直接如实告诉界面。 */
260
+ const result = { ok: false, from, to: options.version, reason: null, detail: null, profileDir: null, changedExclude: false, restartRequired: true }
261
+
262
+ const pnpm = resolvePnpm()
263
+ if (pnpm === null) {
264
+ result.reason = 'pnpm-not-found'
265
+ return result
266
+ }
267
+
268
+ const profileDir = findProfileDir(selfDir)
269
+ result.profileDir = profileDir
270
+ if (profileDir === null) {
271
+ result.reason = 'profile-not-found'
272
+ return result
273
+ }
274
+
275
+ /* 先把目标版本写进豁免表:这是 pnpm 自己的豁免机制,写进去后目标版本无论多新
276
+ 都算「已放行」。写不成也继续 —— 下面那次运行还带着一次性归零开关兜底。 */
277
+ const ensured = ensureReleaseAgeExclude(profileDir, options.version)
278
+ result.changedExclude = ensured.changed === true
279
+ if (ensured.ok !== true) {
280
+ log('release-age: 豁免写入未成功(' + String(ensured.reason) + '),继续尝试安装')
281
+ } else if (ensured.changed === true) {
282
+ log('release-age: 已放行 ' + SELF_NAME + '@' + options.version)
283
+ }
284
+
285
+ /* 精确版本,不用 @latest:后者在发布年龄策略下会静默装成旧的成熟版 */
286
+ const target = SELF_NAME + '@' + options.version
287
+ const env = { ...process.env }
288
+ if (pnpm.electron === true) env.ELECTRON_RUN_AS_NODE = '1'
289
+ /* CI=true:pnpm 在非交互环境下不会弹提示卡住(与官方插件管理器同一做法) */
290
+ env.CI = 'true'
291
+ const args = updateArgs(pnpm.args, target)
292
+
293
+ log('update: 开始安装 ' + target)
294
+ const run = await runCommand(pnpm.command, args, {
295
+ cwd: profileDir,
296
+ env,
297
+ timeoutMs: options.timeoutMs,
298
+ })
299
+ const output = (run.stdout + '\n' + run.stderr).trim()
300
+
301
+ if (run.timedOut === true) {
302
+ result.reason = 'timeout'
303
+ result.detail = output.slice(-600)
304
+ return result
305
+ }
306
+ if (run.exitCode !== 0) {
307
+ result.reason = isReleaseAgeBlocked(output) ? 'release-age' : 'install-failed'
308
+ result.detail = output.slice(-600)
309
+ return result
310
+ }
311
+
312
+ /* 不能只看退出码:发布年龄策略下 pnpm 可能「成功」地装了旧版。
313
+ 必须回读盘上的实际版本,这是唯一可信的成功判据。 */
314
+ const now = installedVersion(selfDir)
315
+ if (now === null) {
316
+ result.reason = 'verify-failed'
317
+ return result
318
+ }
319
+ if (now !== options.version) {
320
+ result.reason = 'version-mismatch'
321
+ result.detail = '期望 ' + options.version + ',实际 ' + now
322
+ return result
323
+ }
324
+
325
+ result.ok = true
326
+ log('update: 已更新到 ' + now)
327
+ return result
328
+ }
329
+
330
+ /**
331
+ * 带并发保护地执行自更新:同一时刻只跑一个。
332
+ * @param {{selfDir: string, version: string, logger?: Function, timeoutMs?: number}} options
333
+ * @returns {Promise<object>}
334
+ */
335
+ export async function selfUpdateOnce(options) {
336
+ if (running !== null) return { ok: false, reason: 'busy', busy: true }
337
+ const task = (async () => {
338
+ try {
339
+ const value = await runSelfUpdate(options)
340
+ lastResult = value
341
+ return value
342
+ } catch (error) {
343
+ const failure = {
344
+ ok: false,
345
+ from: null,
346
+ to: options.version,
347
+ reason: 'unexpected',
348
+ detail: String(error && error.message),
349
+ }
350
+ lastResult = failure
351
+ return failure
352
+ } finally {
353
+ running = null
354
+ }
355
+ })()
356
+ running = task
357
+ return task
358
+ }
359
+
360
+ /** 更新是否正在进行。 */
361
+ export function isUpdating() {
362
+ return running !== null
363
+ }
364
+
365
+ /** 最近一次更新的结果;没跑过就是 null。 */
366
+ export function lastUpdateResult() {
367
+ return lastResult
368
+ }
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "dsh-desktop-statusbar",
3
- "version": "2.1.1",
3
+ "version": "2.2.0",
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": {
@@ -38,6 +39,7 @@
38
39
  },
39
40
  "files": [
40
41
  "lib",
42
+ "tools",
41
43
  "docs",
42
44
  "locale",
43
45
  "cordis.patch.yml",
@@ -62,5 +64,9 @@
62
64
  ],
63
65
  "platform": "web"
64
66
  }
67
+ },
68
+ "scripts": {
69
+ "test": "node tools/test.cjs",
70
+ "sync": "node tools/sync-to-plugin.cjs"
65
71
  }
66
72
  }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * 定位「DSH 实际加载的那份插件目录」。
3
+ *
4
+ * 为什么需要它:`tools/` 下几个脚本要把项目源码同步到插件目录(或反向比对),
5
+ * 而插件目录原先写死成 `~/.dsh/local-plugins/dsh-desktop-statusbar` —— 换机器、
6
+ * 换用户名、或改过安装位置就失效。这里改成三级回退,任何一级命中都行。
7
+ *
8
+ * 三级顺序(越靠前越权威):
9
+ * 1. 环境变量 `DSH_STATUS_BAR_PLUGIN_DIR` —— 显式指定,脚本/CI 用得上;
10
+ * 2. 在 DSH 主目录下探测:`<dsh-home>/local-plugins/<包名>`(本机常规布局);
11
+ * 3. 由本文件位置推导:`tools/` 的上一级 —— 脚本就住在插件目录里时天然成立。
12
+ *
13
+ * DSH 主目录的取法与插件宿主代码一致:`DSH_HOME` 优先,否则 `~/.dsh`。
14
+ * 这样测试里设了 `DSH_HOME` 也能带上,不会误指真实目录。
15
+ *
16
+ * 注意:**只做定位,不创建目录、不写任何东西**;找不到就返回 null,由调用方决定怎么报错。
17
+ */
18
+ const fs = require('fs');
19
+ const os = require('os');
20
+ const path = require('path');
21
+
22
+ /** 包名(与 package.json 的 name 一致):决定 local-plugins 下的目录名。 */
23
+ const PACKAGE_NAME = 'dsh-desktop-statusbar';
24
+
25
+ /** DSH 主目录:`DSH_HOME` 优先,否则 `~/.dsh`。 */
26
+ function dshHome() {
27
+ const fromEnv = process.env.DSH_HOME;
28
+ if (typeof fromEnv === 'string' && fromEnv.trim().length > 0) return fromEnv.trim();
29
+ return path.join(os.homedir(), '.dsh');
30
+ }
31
+
32
+ /** 一个目录像不像插件目录:有 package.json 且 name 对得上。 */
33
+ function looksLikePlugin(dir) {
34
+ try {
35
+ const raw = fs.readFileSync(path.join(dir, 'package.json'), 'utf8');
36
+ const parsed = JSON.parse(raw);
37
+ return parsed !== null && typeof parsed === 'object' && parsed.name === PACKAGE_NAME;
38
+ } catch (error) {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * 在 DSH 主目录下扫描几个已知的安装布局,返回第一个真像插件的目录。
45
+ *
46
+ * 为什么要扫而不只拼一条路径:插件的安装位置不止一种,实测至少这三种都真实存在:
47
+ * ① `<dsh-home>/local-plugins/<包名>` —— 本地开发用的 junction/symlink
48
+ * ② `<dsh-home>/profiles/<profile>/node_modules/<包名>` —— 官方桌面端装出来的位置
49
+ * (2026-10 实测:DSH 桌面端就是这种,profile 名通常是 `desktop`)
50
+ * ③ `<dsh-home>/profiles/node_modules/<包名>` —— 共享回退目录
51
+ * 只查 ① 会在桌面端上定位失败(本插件最初就栽在这),所以把 ②③ 也覆盖上。
52
+ *
53
+ * 只读目录名、只读那几个候选的 package.json,不做递归全盘扫描。
54
+ *
55
+ * @returns {string|null} 找到的第一个插件目录
56
+ */
57
+ function scanKnownLayouts() {
58
+ const home = dshHome();
59
+
60
+ /* ① 本地开发位置 */
61
+ const local = path.join(home, 'local-plugins', PACKAGE_NAME);
62
+ if (looksLikePlugin(local)) return local;
63
+
64
+ /* ③ 共享回退目录 */
65
+ const shared = path.join(home, 'profiles', 'node_modules', PACKAGE_NAME);
66
+ if (looksLikePlugin(shared)) return shared;
67
+
68
+ /* ② 各 profile 自己的 node_modules。profile 名不写死:列出来一个个试。 */
69
+ const profilesRoot = path.join(home, 'profiles');
70
+ let names = [];
71
+ try {
72
+ names = fs.readdirSync(profilesRoot);
73
+ } catch (error) {
74
+ return null;
75
+ }
76
+ const found = [];
77
+ for (const name of names) {
78
+ const candidate = path.join(profilesRoot, name, 'node_modules', PACKAGE_NAME);
79
+ if (looksLikePlugin(candidate)) found.push(candidate);
80
+ }
81
+ /* 多个 profile 都装了:按名字排序取第一个,保证同一台机器上每次结果一致
82
+ (不排序时 readdir 顺序依文件系统而异,会让"同步检查"时而通过时而失败)。 */
83
+ return found.length === 0 ? null : found.sort()[0];
84
+ }
85
+
86
+ /** 依次尝试各类来源,返回第一个成立的绝对路径;都不成立返回 null。 */
87
+ function pluginDir() {
88
+ /* 1) 环境变量显式指定:即使 package.json 认不出也接受(调用方可能有意指向别处) */
89
+ const explicit = process.env.DSH_STATUS_BAR_PLUGIN_DIR;
90
+ if (typeof explicit === 'string' && explicit.trim().length > 0) {
91
+ const dir = path.resolve(explicit.trim());
92
+ if (fs.existsSync(dir)) return dir;
93
+ }
94
+
95
+ /* 2) DSH 主目录下的已知安装布局 */
96
+ const scanned = scanKnownLayouts();
97
+ if (scanned !== null) return scanned;
98
+
99
+ /* 3) 由本文件位置推导:tools/ 的上一级(脚本就住在插件目录里时天然成立) */
100
+ const derived = path.join(__dirname, '..');
101
+ if (looksLikePlugin(derived)) return derived;
102
+
103
+ return null;
104
+ }
105
+
106
+ module.exports = {
107
+ pluginDir: pluginDir,
108
+ dshHome: dshHome,
109
+ scanKnownLayouts: scanKnownLayouts,
110
+ PACKAGE_NAME: PACKAGE_NAME,
111
+ looksLikePlugin: looksLikePlugin,
112
+ };