dsh-plugin-runcat-inventory 0.3.7

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/index.js ADDED
@@ -0,0 +1,329 @@
1
+ /**
2
+ * dsh-plugin-runcat-inventory —— Host 半端(逃咪-插件总览)
3
+ *
4
+ * 职责:
5
+ * 1. 读取 loader 的实时条目(id / 模块名 / 启用状态 / Cordis fiber 状态)
6
+ * + 从各包 package.json 补充描述、版本,从 profile 清单判定来源。
7
+ * 2. 提供 /runcat-api/inventory(GET)与 /runcat-api/set-enabled(POST)。
8
+ * 3. 启用/停用 = 编辑 profile 的 cordis.patch.yml(用户覆盖层):
9
+ * 写入 {id, name, disabled: true} 补丁即可停用;移除该补丁即恢复。
10
+ * DSH 通过 HMR 监听该文件,改动热生效,无需重启 Web UI。
11
+ *
12
+ * 通信模型与 dsh-plugins-market 一致:注入 webServer,注册 prefix 路由,
13
+ * 浏览器半端用同源 fetch 调用;路由做 loopback 信任校验防 CSRF。
14
+ *
15
+ * 注意:本插件可能以 link: 方式装进 profile(真实路径在工作区),因此
16
+ * js-yaml 采用惰性解析——锚定在 profile 目录(真实安装链)上 require,
17
+ * 而不是在模块顶层静态 import。
18
+ */
19
+
20
+ import { readFileSync } from 'node:fs'
21
+ import { readFile, writeFile } from 'node:fs/promises'
22
+ import { dirname, join } from 'node:path'
23
+ import { fileURLToPath, pathToFileURL } from 'node:url'
24
+ import { createRequire } from 'node:module'
25
+
26
+ export const inject = ['webServer', 'loader']
27
+
28
+ const API_PREFIX = '/runcat-api'
29
+ const PATCH_FILENAME = 'cordis.patch.yml'
30
+
31
+ /** 本插件自身包名:其"来源"列显示仓库主页地址(用户指定)。 */
32
+ const PLUGIN_NAME = 'dsh-plugin-runcat-inventory'
33
+
34
+ /** Runtime mirror: FiberState 是跨包 const enum(与官方 inventory 一致)。 */
35
+ const FIBER_PHASE = {
36
+ 0: 'pending', // PENDING
37
+ 1: 'loading', // LOADING
38
+ 2: 'active', // ACTIVE
39
+ 3: 'failed', // FAILED
40
+ 4: null, // DISPOSED
41
+ 5: 'unloading', // UNLOADING
42
+ }
43
+
44
+ // ── js-yaml 惰性解析:优先锚定 profile 目录,回退到本模块 ─────────────
45
+ let yamlPromise = null
46
+ function getYaml(profileDir) {
47
+ if (yamlPromise !== null) return yamlPromise
48
+ yamlPromise = (async () => {
49
+ const anchors = []
50
+ if (profileDir) {
51
+ try {
52
+ anchors.push(createRequire(pathToFileURL(join(profileDir, 'package.json'))))
53
+ } catch { /* 忽略 */ }
54
+ }
55
+ try {
56
+ anchors.push(createRequire(import.meta.url))
57
+ } catch { /* 忽略 */ }
58
+ for (const req of anchors) {
59
+ try {
60
+ const resolved = req.resolve('js-yaml')
61
+ // Windows 下 resolve 返回盘符路径,import() 需要 file URL
62
+ return await import(pathToFileURL(resolved).href)
63
+ } catch { /* 换下一个锚点 */ }
64
+ }
65
+ throw new Error('js-yaml 不可用(无法解析)')
66
+ })()
67
+ return yamlPromise
68
+ }
69
+
70
+ export function apply(ctx) {
71
+ const logger = ctx.logger('runcat-inventory')
72
+
73
+ // ── 小工具(来自 market 插件的同款信任校验)─────────────────────────
74
+ function header(headers, name) {
75
+ const value = headers[name]
76
+ return Array.isArray(value) ? value[0] : value
77
+ }
78
+
79
+ function isLoopbackHostname(hostname) {
80
+ if (hostname === 'localhost' || hostname === '::1') return true
81
+ if (hostname.startsWith('127.')) return true
82
+ return false
83
+ }
84
+
85
+ function isTrustedApiRequest(req) {
86
+ const host = header(req.headers, 'host')
87
+ if (host === undefined) return false
88
+ let hostUrl
89
+ try { hostUrl = new URL('http://' + host) } catch { return false }
90
+ if (!isLoopbackHostname(hostUrl.hostname)) return false
91
+ if (header(req.headers, 'sec-fetch-site') === 'cross-site') return false
92
+ const origin = header(req.headers, 'origin')
93
+ if (origin === undefined) return true
94
+ try { return new URL(origin).host === hostUrl.host } catch { return false }
95
+ }
96
+
97
+ function readBody(req, limit) {
98
+ return new Promise((resolve, reject) => {
99
+ let size = 0
100
+ const chunks = []
101
+ req.on('data', (chunk) => {
102
+ size += chunk.length
103
+ if (size > limit) {
104
+ reject(new Error('body too large'))
105
+ req.destroy()
106
+ return
107
+ }
108
+ chunks.push(chunk)
109
+ })
110
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
111
+ req.on('error', reject)
112
+ })
113
+ }
114
+
115
+ function sendJson(res, code, obj) {
116
+ const body = JSON.stringify(obj)
117
+ res.writeHead(code, {
118
+ 'Content-Type': 'application/json; charset=utf-8',
119
+ 'Cache-Control': 'no-store',
120
+ })
121
+ res.end(body)
122
+ }
123
+
124
+ // ── 定位 profile 目录 ────────────────────────────────────────────────
125
+ // 根 Include 条目(name='cordis:include')的 config.path 就是本 profile
126
+ // 的 cordis.yml 绝对路径(file URL),其所在目录即 profile 目录。
127
+ function profileDirOf() {
128
+ for (const entry of ctx.loader.entries()) {
129
+ if (entry.options.name !== 'cordis:include') continue
130
+ const path = entry.options.config?.path
131
+ if (typeof path !== 'string') return null
132
+ try { return dirname(fileURLToPath(path)) } catch { return null }
133
+ }
134
+ return null
135
+ }
136
+
137
+ // ── 解析某个模块名对应的 package.json ──────────────────────────────
138
+ function resolvePackage(name) {
139
+ if (typeof name !== 'string' || name.length === 0) return null
140
+ const profileDir = profileDirOf()
141
+ // 1) 锚定 profile 目录的 require(与 loader 同一条解析链)
142
+ if (profileDir !== null) {
143
+ try {
144
+ const req = createRequire(pathToFileURL(join(profileDir, 'package.json')))
145
+ const pkgPath = req.resolve(name + '/package.json')
146
+ return JSON.parse(readFileSync(pkgPath, 'utf8'))
147
+ } catch { /* 继续回退 */ }
148
+ // 2) profile 级 node_modules 直读
149
+ for (const base of [join(profileDir, 'node_modules'), join(profileDir, '..', 'node_modules')]) {
150
+ try {
151
+ return JSON.parse(readFileSync(join(base, name, 'package.json'), 'utf8'))
152
+ } catch { /* 继续 */ }
153
+ }
154
+ }
155
+ return null
156
+ }
157
+
158
+ /** 清理仓库地址:去掉 git+ 前缀与 .git 后缀。 */
159
+ function cleanRepoUrl(url) {
160
+ return String(url).replace(/^git\+/, '').replace(/\.git$/, '')
161
+ }
162
+
163
+ /**
164
+ * 机器可读来源(kind + spec),显示文案由客户端翻译。
165
+ * 仅【本插件】显示仓库主页地址(用户指定);其余插件一律按安装方式
166
+ * (profile 依赖声明)分类,不读取 repository 字段。
167
+ */
168
+ function sourceOf(name, manifest, pkg) {
169
+ if (name === PLUGIN_NAME) {
170
+ const repo = pkg?.repository
171
+ const repoUrl = typeof repo === 'string' ? repo : repo?.url
172
+ if (typeof repoUrl === 'string' && repoUrl.length > 0) {
173
+ return { kind: 'repo', spec: cleanRepoUrl(repoUrl) }
174
+ }
175
+ }
176
+ const spec = manifest?.dependencies?.[name]
177
+ if (typeof spec === 'string') {
178
+ if (/^link:/.test(spec)) return { kind: 'link', spec: spec.slice(5) }
179
+ if (/^file:/.test(spec)) return { kind: 'file', spec: spec.slice(5) }
180
+ if (/^github:|^git\+/.test(spec)) return { kind: 'github', spec }
181
+ return { kind: 'npm', spec }
182
+ }
183
+ const bundles = manifest?.dsh?.profile?.bundles
184
+ if (Array.isArray(bundles) && bundles.includes(name)) return { kind: 'builtin', spec: '' }
185
+ return { kind: 'other', spec: '' }
186
+ }
187
+
188
+ // ── 采集清单 ────────────────────────────────────────────────────────
189
+ function collectInventory() {
190
+ const profileDir = profileDirOf()
191
+ let manifest = null
192
+ if (profileDir !== null) {
193
+ try {
194
+ manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8'))
195
+ } catch { /* 无清单也可用 */ }
196
+ }
197
+ const rows = []
198
+ for (const entry of ctx.loader.entries()) {
199
+ if (entry.options.group) continue
200
+ if (entry.options.name === 'cordis:include') continue
201
+ const pkg = resolvePackage(entry.options.name)
202
+ const src = sourceOf(entry.options.name, manifest, pkg)
203
+ let config = null
204
+ try {
205
+ config = entry.options.config === undefined ? null : entry.options.config
206
+ } catch { /* 保持 null */ }
207
+ rows.push({
208
+ id: entry.id, // 树内完整 id(展示用)
209
+ patchId: entry.options.id, // 原始 id(补丁定位用)
210
+ name: entry.options.name, // 模块标识符
211
+ enabled: !entry.disabled, // 是否启用(考虑祖先禁用)
212
+ fiberPhase: entry.fiber === undefined ? null : FIBER_PHASE[entry.fiber.state],
213
+ description: pkg?.description ?? '',
214
+ version: pkg?.version ?? '',
215
+ sourceKind: src.kind,
216
+ sourceSpec: src.spec,
217
+ config,
218
+ })
219
+ }
220
+ return rows
221
+ }
222
+
223
+ // ── 启用 / 停用:编辑 cordis.patch.yml ─────────────────────────────
224
+ // 停用 = 追加 {id, name, disabled: true} 补丁(用户覆盖层覆盖 bundle 层
225
+ // 的 insert);启用 = 移除我们写入的那条补丁。文件被 HMR 监听热生效。
226
+ async function setEnabled(patchId, name, enabled) {
227
+ if (typeof patchId !== 'string' || patchId.length === 0 ||
228
+ typeof name !== 'string' || name.length === 0) {
229
+ return { ok: false, code: 'MISSING_PARAMS' }
230
+ }
231
+ const profileDir = profileDirOf()
232
+ if (profileDir === null) return { ok: false, code: 'PROFILE_DIR_NOT_FOUND' }
233
+ const patchPath = join(profileDir, PATCH_FILENAME)
234
+
235
+ let raw
236
+ try {
237
+ raw = await readFile(patchPath, 'utf8')
238
+ } catch (error) {
239
+ return { ok: false, code: 'PATCH_READ_FAILED', detail: String(error?.message ?? error) }
240
+ }
241
+ let yaml
242
+ try {
243
+ yaml = await getYaml(profileDir)
244
+ } catch (error) {
245
+ return { ok: false, code: 'YAML_UNAVAILABLE', detail: String(error?.message ?? error) }
246
+ }
247
+ let patches
248
+ try {
249
+ patches = yaml.load(raw)
250
+ } catch (error) {
251
+ return { ok: false, code: 'PATCH_PARSE_FAILED', detail: String(error?.message ?? error) }
252
+ }
253
+ if (!Array.isArray(patches)) return { ok: false, code: 'PATCH_NOT_ARRAY' }
254
+
255
+ // 精确匹配我们写入的停用补丁:恰好是 {id, name, disabled: true} 三个键
256
+ const isOurs = (p) => p !== null && typeof p === 'object' && !Array.isArray(p) &&
257
+ p.id === patchId && p.name === name && p.disabled === true &&
258
+ Object.keys(p).sort().join(',') === 'disabled,id,name'
259
+ const kept = patches.filter((p) => !isOurs(p))
260
+ if (!enabled) kept.push({ id: patchId, name, disabled: true })
261
+
262
+ // 保留原文件顶部的注释块,其余按 YAML 重新序列化
263
+ const comment = (raw.match(/^(\s*#.*\n)*/) || [''])[0]
264
+ let body
265
+ try {
266
+ body = yaml.dump(kept, { indent: 2, lineWidth: -1, noRefs: true, noCompatMode: true })
267
+ } catch (error) {
268
+ return { ok: false, code: 'YAML_DUMP_FAILED', detail: String(error?.message ?? error) }
269
+ }
270
+ try {
271
+ await writeFile(patchPath, comment + body, 'utf8')
272
+ } catch (error) {
273
+ return { ok: false, code: 'PATCH_WRITE_FAILED', detail: String(error?.message ?? error) }
274
+ }
275
+ logger.info('%s %s %s(已写入 ' + PATCH_FILENAME + ',HMR 将热生效)', enabled ? '启用' : '停用', name, patchId)
276
+ return { ok: true }
277
+ }
278
+
279
+ // ── 路由 ────────────────────────────────────────────────────────────
280
+ async function handle(req, res) {
281
+ if (!isTrustedApiRequest(req)) {
282
+ sendJson(res, 403, { ok: false, code: 'FORBIDDEN' })
283
+ return
284
+ }
285
+ let url
286
+ try {
287
+ url = new URL(req.url, 'http://localhost')
288
+ } catch {
289
+ sendJson(res, 400, { ok: false, code: 'BAD_REQUEST' })
290
+ return
291
+ }
292
+ const pathname = url.pathname
293
+
294
+ if (pathname === API_PREFIX + '/inventory' && req.method === 'GET') {
295
+ try {
296
+ sendJson(res, 200, { ok: true, entries: collectInventory() })
297
+ } catch (error) {
298
+ sendJson(res, 500, { ok: false, code: 'INVENTORY_FAILED', detail: String(error?.message ?? error) })
299
+ }
300
+ return
301
+ }
302
+
303
+ if (pathname === API_PREFIX + '/set-enabled' && req.method === 'POST') {
304
+ let body
305
+ try {
306
+ body = JSON.parse((await readBody(req, 65536)) || '{}')
307
+ } catch {
308
+ sendJson(res, 400, { ok: false, code: 'BAD_JSON' })
309
+ return
310
+ }
311
+ try {
312
+ sendJson(res, 200, await setEnabled(String(body.id ?? ''), String(body.name ?? ''), Boolean(body.enabled)))
313
+ } catch (error) {
314
+ sendJson(res, 500, { ok: false, code: 'INTERNAL', detail: String(error?.message ?? error) })
315
+ }
316
+ return
317
+ }
318
+
319
+ res.writeHead(404)
320
+ res.end('not found')
321
+ }
322
+
323
+ ctx.effect(() => ctx.webServer.register({
324
+ kind: 'prefix',
325
+ path: API_PREFIX,
326
+ handler: handle,
327
+ }))
328
+ logger.info('routes registered at ' + API_PREFIX)
329
+ }
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "dsh-plugin-runcat-inventory",
3
+ "version": "0.3.7",
4
+ "description": "逃咪-插件总览(Runcat Plugin Overview):更好用的 DSH 插件列表 —— 表格视图、状态过滤、启用/停用开关(热生效)、配置查看与复制、中英双语界面。A better DSH plugin list.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/runcat-tommy/dsh-plugin-runcat-inventory"
8
+ },
9
+ "homepage": "https://github.com/runcat-tommy/dsh-plugin-runcat-inventory#readme",
10
+ "bugs": {
11
+ "url": "https://github.com/runcat-tommy/dsh-plugin-runcat-inventory/issues"
12
+ },
13
+ "author": "runcat-tommy",
14
+ "type": "module",
15
+ "main": "lib/index.js",
16
+ "exports": {
17
+ ".": {
18
+ "default": "./lib/index.js"
19
+ },
20
+ "./client": {
21
+ "default": "./lib/client.js"
22
+ },
23
+ "./package.json": "./package.json"
24
+ },
25
+ "dsh": {
26
+ "bundle": {
27
+ "patch": "./cordis.patch.yml"
28
+ },
29
+ "client": {
30
+ "platform": "web",
31
+ "inject": [
32
+ "@deepseek-ai/dsh-client-runtime"
33
+ ]
34
+ }
35
+ },
36
+ "files": [
37
+ "lib",
38
+ "cordis.patch.yml",
39
+ "README.en.md",
40
+ "CHANGELOG.md",
41
+ "CHANGELOG.en.md",
42
+ "assets"
43
+ ],
44
+ "keywords": [
45
+ "dsh",
46
+ "dsh-plugin",
47
+ "deepseek-harness",
48
+ "plugin",
49
+ "inventory",
50
+ "runcat",
51
+ "逃咪"
52
+ ],
53
+ "publishConfig": {
54
+ "registry": "https://registry.npmjs.org",
55
+ "access": "public"
56
+ },
57
+ "license": "MIT"
58
+ }