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/CHANGELOG.en.md +223 -0
- package/CHANGELOG.md +193 -0
- package/LICENSE +21 -0
- package/README.en.md +120 -0
- package/README.md +120 -0
- package/assets/preview-en.png +0 -0
- package/assets/preview-zh.png +0 -0
- package/cordis.patch.yml +5 -0
- package/lib/client.js +602 -0
- package/lib/index.js +329 -0
- package/package.json +58 -0
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
|
+
}
|