dsh-plugin 0.1.1 → 1.1.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.
Files changed (67) hide show
  1. package/README.en.md +56 -28
  2. package/README.md +56 -28
  3. package/client/client.js +2282 -230
  4. package/client/client.js.map +1 -1
  5. package/lib/http/routes.js +432 -0
  6. package/lib/index.js +17 -8
  7. package/lib/services/install-types.js +11 -0
  8. package/lib/services/install.js +4 -0
  9. package/lib/services/installed-versions.js +66 -0
  10. package/lib/services/loader.js +98 -0
  11. package/lib/services/npm-resolve.js +67 -0
  12. package/lib/services/pending-restart.js +34 -0
  13. package/lib/services/preflight.js +94 -0
  14. package/lib/services/profile.js +88 -0
  15. package/lib/services/progress.js +50 -0
  16. package/lib/services/task-queue.js +457 -0
  17. package/lib/types/http/routes.d.ts +23 -0
  18. package/lib/types/index.d.ts +27 -0
  19. package/lib/types/services/install-types.d.ts +69 -0
  20. package/lib/types/services/install.d.ts +18 -0
  21. package/lib/types/services/installed-versions.d.ts +24 -0
  22. package/lib/types/services/loader.d.ts +43 -0
  23. package/lib/types/services/npm-resolve.d.ts +6 -0
  24. package/lib/types/services/pending-restart.d.ts +13 -0
  25. package/lib/types/services/preflight.d.ts +6 -0
  26. package/lib/types/services/profile.d.ts +24 -0
  27. package/lib/types/services/progress.d.ts +17 -0
  28. package/lib/types/services/task-queue.d.ts +82 -0
  29. package/package.json +27 -6
  30. package/src/client/components/CatalogControls.tsx +104 -0
  31. package/src/client/components/CatalogHeader.tsx +78 -0
  32. package/src/client/components/CatalogList.tsx +75 -0
  33. package/src/client/components/CategoryTabs.tsx +35 -0
  34. package/src/client/components/Dropdown.tsx +80 -0
  35. package/src/client/components/NotificationsModal.tsx +243 -0
  36. package/src/client/components/PluginCard.tsx +104 -0
  37. package/src/client/components/PluginHubSection.tsx +342 -0
  38. package/src/client/components/ProgressView.tsx +23 -0
  39. package/src/client/components/icons.tsx +125 -0
  40. package/src/client/components/modals.tsx +368 -0
  41. package/src/client/hooks/useCatalog.ts +202 -0
  42. package/src/client/hooks/useTaskQueue.ts +502 -0
  43. package/src/client/index.tsx +8 -422
  44. package/src/client/lib/catalog.ts +240 -0
  45. package/src/client/lib/env.ts +16 -0
  46. package/src/client/lib/failures.ts +165 -0
  47. package/src/client/lib/format.ts +22 -0
  48. package/src/client/locales.ts +188 -18
  49. package/src/client/styles/Dropdown.module.css +123 -0
  50. package/src/client/styles/Header.module.css +432 -0
  51. package/src/client/styles/List.module.css +324 -0
  52. package/src/client/styles/Modal.module.css +1006 -0
  53. package/src/client/styles/tokens.module.css +91 -0
  54. package/src/client/types.ts +80 -0
  55. package/src/server/http/routes.ts +432 -0
  56. package/src/server/index.ts +35 -0
  57. package/src/server/services/install-types.ts +69 -0
  58. package/src/server/services/install.ts +18 -0
  59. package/src/server/services/installed-versions.ts +84 -0
  60. package/src/server/services/loader.ts +110 -0
  61. package/src/server/services/npm-resolve.ts +67 -0
  62. package/src/server/services/pending-restart.ts +36 -0
  63. package/src/server/services/preflight.ts +97 -0
  64. package/src/server/services/profile.ts +89 -0
  65. package/src/server/services/progress.ts +48 -0
  66. package/src/server/services/task-queue.ts +492 -0
  67. package/src/client/Section.module.css +0 -589
@@ -0,0 +1,432 @@
1
+ /**
2
+ * Local HTTP routes exposing real installs to the in-app Plugin Hub UI.
3
+ * The client fetches the same-origin `/dsh-plugin-hub/*` endpoints; the
4
+ * install handler validates the target, then spawns the official dsh CLI
5
+ * (see services/install.ts) and reports the captured result back.
6
+ */
7
+ import { readFileSync } from 'node:fs';
8
+ import { homedir, release } from 'node:os';
9
+ import { dirname, join, resolve } from 'node:path';
10
+ import { spawn } from 'node:child_process';
11
+ import { activeTask, cancelTask, dumpLoaderEntries, getTask, githubTarget, hasQueuedTarget, listPendingRestarts, readProfileArg, startPluginMutation, validPackageName } from '../services/install.js';
12
+ import { recordInstalledVersion, recordResolvedNpmPackage, readInstalledVersions, removeInstalledVersion } from '../services/installed-versions.js';
13
+ import { resolveNpmPackage } from '../services/npm-resolve.js';
14
+ import { preflightTarget } from '../services/preflight.js';
15
+ const PROFILE_RE = /^[A-Za-z0-9_-]+$/;
16
+ const BODY_LIMIT_BYTES = 4 * 1024;
17
+ const COMMAND_TIMEOUT_MS = 5 * 60 * 1000;
18
+ function profileDirectory(profile) {
19
+ return join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'profiles', profile);
20
+ }
21
+ /** Read non-official dependencies installed into one profile. */
22
+ export function readInstalled(profile) {
23
+ try {
24
+ const manifest = JSON.parse(readFileSync(join(profileDirectory(profile), 'package.json'), 'utf8'));
25
+ return Object.fromEntries(Object.entries(manifest.dependencies ?? {}).filter(([name]) => !name.startsWith('@deepseek-ai/')));
26
+ }
27
+ catch {
28
+ return {};
29
+ }
30
+ }
31
+ /** 宿主 dsh CLI 版本:从入口(process.argv[1])向上找最近的 dsh/harness 相关 package.json;找不到返回 null。 */
32
+ function hostDshVersion() {
33
+ const entry = process.argv[1];
34
+ if (entry === undefined)
35
+ return null;
36
+ let dir = dirname(resolve(entry));
37
+ for (let depth = 0; depth < 4; depth++) {
38
+ try {
39
+ const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
40
+ const name = manifest.name ?? '';
41
+ if (typeof manifest.version === 'string' && (name === 'dsh' || name.includes('dsh') || name.includes('harness'))) {
42
+ return manifest.version;
43
+ }
44
+ }
45
+ catch {
46
+ /* not a package root — keep walking up */
47
+ }
48
+ const parent = dirname(dir);
49
+ if (parent === dir)
50
+ break;
51
+ dir = parent;
52
+ }
53
+ return null;
54
+ }
55
+ /** 宿主环境快照:提交 bug 时随 issue 附上,便于作者复现(前端 /env 端点返回)。 */
56
+ function hostEnv(profile) {
57
+ return {
58
+ dshVersion: hostDshVersion(),
59
+ nodeVersion: process.version,
60
+ platform: process.platform,
61
+ arch: process.arch,
62
+ release: release(),
63
+ profile,
64
+ dshHome: process.env.DSH_HOME ?? join(homedir(), '.dsh'),
65
+ };
66
+ }
67
+ /** Match an install target (`github:<owner>/<repo>`) against the installed table; returns the package name when already installed. */
68
+ function installedByRepo(installed, target) {
69
+ const needle = target.toLowerCase();
70
+ for (const [name, spec] of Object.entries(installed)) {
71
+ if (spec.toLowerCase().includes(needle))
72
+ return name;
73
+ }
74
+ return null;
75
+ }
76
+ function sendJson(response, status, value) {
77
+ response.writeHead(status, {
78
+ 'cache-control': 'no-store',
79
+ 'content-type': 'application/json; charset=utf-8',
80
+ });
81
+ response.end(JSON.stringify(value));
82
+ }
83
+ /** POST mutations are only accepted from the local web server origin. */
84
+ function isSameOrigin(request) {
85
+ const origin = request.headers.origin;
86
+ const host = request.headers.host;
87
+ if (origin === undefined || host === undefined)
88
+ return false;
89
+ try {
90
+ const url = new URL(origin);
91
+ const localHostnames = new Set(['localhost', '127.0.0.1', '[::1]']);
92
+ return url.host === host && localHostnames.has(url.hostname);
93
+ }
94
+ catch {
95
+ return false;
96
+ }
97
+ }
98
+ function requireMethod(request, response, method) {
99
+ if (request.method === method)
100
+ return true;
101
+ response.writeHead(405, { allow: method });
102
+ response.end();
103
+ return false;
104
+ }
105
+ function requireTrustedPost(request, response) {
106
+ if (!requireMethod(request, response, 'POST'))
107
+ return false;
108
+ if (isSameOrigin(request))
109
+ return true;
110
+ sendJson(response, 403, { error: 'untrusted origin' });
111
+ return false;
112
+ }
113
+ async function readJsonBody(request) {
114
+ const chunks = [];
115
+ let size = 0;
116
+ for await (const chunk of request) {
117
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
118
+ size += buffer.length;
119
+ if (size > BODY_LIMIT_BYTES)
120
+ throw new Error('request body too large');
121
+ chunks.push(buffer);
122
+ }
123
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
124
+ }
125
+ /**
126
+ * Register the Plugin Hub API on the host web server and return a disposer.
127
+ * @param webServer - DSH web server service.
128
+ * @param profile - profile that owns plugin mutations.
129
+ * @param loader - running loader (optional): lets uninstall remove the entry
130
+ * immediately so the page survives a refresh without a host restart.
131
+ */
132
+ export function mountPluginHubRoutes(webServer, profile, loader) {
133
+ if (!PROFILE_RE.test(profile))
134
+ throw new Error(`invalid profile name: ${profile}`);
135
+ console.log(`[hub] routes mounted (profile=${profile}, loader=${loader === undefined ? 'undefined' : 'provided'})`);
136
+ const disposers = [
137
+ webServer.register({
138
+ kind: 'exact',
139
+ path: '/dsh-plugin-hub/debug/loader-entries',
140
+ handler: (request, response) => {
141
+ if (!requireMethod(request, response, 'GET'))
142
+ return;
143
+ sendJson(response, 200, { entries: dumpLoaderEntries(loader) });
144
+ },
145
+ }),
146
+ webServer.register({
147
+ kind: 'exact',
148
+ path: '/dsh-plugin-hub/install',
149
+ handler: async (request, response) => {
150
+ if (!requireTrustedPost(request, response))
151
+ return;
152
+ // 任务自动入队:即使已有插件操作在跑也接受请求(FIFO 串行执行),不再 409 拒绝
153
+ try {
154
+ const body = await readJsonBody(request);
155
+ // mode: 'update' = 已安装目标的覆盖更新(放行 add,pnpm 对已存在依赖原位覆盖重装)
156
+ const mode = body !== null && typeof body === 'object' && body.mode === 'update'
157
+ ? 'update'
158
+ : 'install';
159
+ const rawRepo = typeof body === 'object' && body !== null && typeof body.repo === 'string'
160
+ ? body.repo.trim()
161
+ : '';
162
+ // 前端决策的展示用仓库名(owner/repo):npm 安装时 body.repo 是包名,display 用于
163
+ // 待重启行 / 任务恢复时展示仓库名,保证用户无感知(安装通道变化不改变所见目标)
164
+ const displayRepo = typeof body === 'object' && body !== null && typeof body.display === 'string'
165
+ ? body.display.trim()
166
+ : rawRepo;
167
+ // 目标语法两态:owner/repo → github 源(走装前预检);npm 包名(@scope/name 或 name)→ 信任 registry 直接安装
168
+ const repoTarget = githubTarget(rawRepo);
169
+ let target = repoTarget ?? (repoTarget === null && validPackageName(rawRepo) ? rawRepo : null);
170
+ if (target === null) {
171
+ sendJson(response, 400, { error: 'unsupported install target' });
172
+ return;
173
+ }
174
+ // npm 优先:git 目标先反查该仓库的官方 npm 包,命中则改走 npm 通道。
175
+ // git 分发常缺构建产物/子模块导致 prepare 必败,而 npm 包是作者发布的
176
+ // 完整产物,成功率高得多;未命中或查询失败保留 github 直装(不阻塞安装,
177
+ // 预检仍会兜底拦截 git 分发缺入口文件的情况)。通用机制,不针对具体插件。
178
+ // 反查本身计入「已尝试的安装方式」:组织 scope 与 GitHub 用户名不一致时仅凭
179
+ // 仓库名猜不到包名,失败提 Issue 时作者看到我们查过的命令就能直接指认正确包名。
180
+ const attempts = [];
181
+ if (repoTarget !== null) {
182
+ const npmName = await resolveNpmPackage(rawRepo);
183
+ if (npmName !== null) {
184
+ target = npmName;
185
+ attempts.push(`npm registry search: \`npm search repository:${rawRepo}\` → found \`${npmName}\``);
186
+ // 持久化 repo → npm 包名映射:目录数据未下发 npmPackage(组织 scope 与 GitHub
187
+ // 用户名不一致)时,客户端仍能通过 /installed 的 versions 把依赖 key 匹配回仓库,
188
+ // 列表立即显示「已安装」,避免安装成功后仍显示可安装
189
+ recordResolvedNpmPackage(profile, rawRepo, npmName);
190
+ }
191
+ else {
192
+ attempts.push(`npm registry search: \`npm search repository:${rawRepo}\` → no matching package found (falling back to git install)`);
193
+ }
194
+ }
195
+ // 重复安装防护:非更新请求命中已安装目标时直接拒绝,不重复跑 CLI
196
+ // (否则 CLI 会因「已存在」失败,且用户看到的是莫名其妙的报错);
197
+ // 更新请求(mode: 'update')放行,CLI 的 add 对已存在依赖是 pnpm 原位覆盖重装。
198
+ // 命中判定双通道:npm 包名按 dependencies 键直接命中,github: 目标按 spec 值匹配。
199
+ const installed = readInstalled(profile);
200
+ const already = installedByRepo(installed, target) ?? (installed[target] !== undefined ? target : null);
201
+ if (already !== null && mode !== 'update') {
202
+ sendJson(response, 409, { error: `already installed: ${already}` });
203
+ return;
204
+ }
205
+ // 队列级去重:同一目标已在排队/执行中时拒绝,避免客户端防重竞态导致重复入队
206
+ if (hasQueuedTarget(target)) {
207
+ sendJson(response, 409, { error: `already queued: ${target}` });
208
+ return;
209
+ }
210
+ // 安装前预检:仅新安装走预检(github 源分发改入口文件缺失时直接拦截)。
211
+ // 更新是已信任目标的覆盖重装,跳过预检避免重复下载 tarball。
212
+ // npm 包信任 registry 直接放行;github 源拦截时标记 [packaging] 前缀,
213
+ // 供客户端归类为「插件分发不完整」并引导去仓库提 Issue
214
+ if (already === null) {
215
+ const preflight = await preflightTarget(target);
216
+ if (!preflight.ok) {
217
+ // 预检拦截(git 分发缺入口文件):同步 400 返回,附上已尝试的安装方式,
218
+ // 客户端失败弹窗提 Issue 时一并贴给作者
219
+ sendJson(response, 400, {
220
+ error: `[packaging] ${target}: plugin distribution is incomplete — the entry file ${preflight.missing ?? '?'} declared in package.json is not present in the git distribution (build output not committed). Report to the author.`,
221
+ attempts,
222
+ });
223
+ return;
224
+ }
225
+ }
226
+ // 入队前复检(与上方检查同语义,但必须在同步块内):preflight 的 await 期间并发请求
227
+ // 可能已通过上方检查、任务却尚未入队——此处检查与 startPluginMutation 之间无 await,
228
+ // JS 单线程保证同一目标至多一个任务,杜绝双击/并发产生双任务
229
+ if (hasQueuedTarget(target)) {
230
+ sendJson(response, 409, { error: `already queued: ${target}` });
231
+ return;
232
+ }
233
+ // Kick off the CLI in the background; the client polls /status for progress
234
+ const task = startPluginMutation({
235
+ action: 'add',
236
+ profile,
237
+ target,
238
+ // npm 安装时 target 是包名:displayTarget 固定展示仓库名,待重启行/前端恢复不受通道变化影响
239
+ displayTarget: githubTarget(displayRepo) ?? undefined,
240
+ // 已尝试的安装方式(npm 反查):实际执行命令由 spawn 时追加,失败 issue 一并展示
241
+ attempts,
242
+ timeoutMs: COMMAND_TIMEOUT_MS,
243
+ env: { ...process.env, CI: 'true' },
244
+ });
245
+ sendJson(response, 200, { ok: true, task: task.id });
246
+ }
247
+ catch (error) {
248
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
249
+ }
250
+ },
251
+ }),
252
+ webServer.register({
253
+ kind: 'exact',
254
+ path: '/dsh-plugin-hub/uninstall',
255
+ handler: async (request, response) => {
256
+ if (!requireTrustedPost(request, response))
257
+ return;
258
+ // 与安装一致:卸载也进入同一队列,FIFO 串行执行
259
+ try {
260
+ const body = await readJsonBody(request);
261
+ const name = typeof body === 'object' && body !== null && typeof body.name === 'string'
262
+ ? body.name
263
+ : '';
264
+ // 展示用的 owner/repo:卸载命令只需要 npm 包名,但待重启行要与安装行一致地显示仓库名
265
+ const repo = typeof body === 'object' && body !== null && typeof body.repo === 'string'
266
+ ? body.repo
267
+ : '';
268
+ if (!validPackageName(name) || name === 'dsh-plugin') {
269
+ sendJson(response, 400, { error: 'plugin cannot be uninstalled here' });
270
+ return;
271
+ }
272
+ if (readInstalled(profile)[name] === undefined) {
273
+ sendJson(response, 400, { error: 'plugin is not installed' });
274
+ return;
275
+ }
276
+ // 队列级去重:同一包已在排队/执行中卸载时拒绝
277
+ if (hasQueuedTarget(name)) {
278
+ sendJson(response, 409, { error: `already queued: ${name}` });
279
+ return;
280
+ }
281
+ const task = startPluginMutation({
282
+ action: 'remove',
283
+ profile,
284
+ target: name,
285
+ // 卸载的 mutation 目标是 npm 包名;待重启行要显示 owner/repo(与安装行一致),非法/缺失时回退包名
286
+ displayTarget: githubTarget(repo) ?? undefined,
287
+ // 运行中 loader:卸载成功后主动移除条目,立即生效、无需重启
288
+ uninstallLoader: loader,
289
+ timeoutMs: COMMAND_TIMEOUT_MS,
290
+ env: { ...process.env, CI: 'true' },
291
+ });
292
+ sendJson(response, 200, { ok: true, task: task.id });
293
+ }
294
+ catch (error) {
295
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
296
+ }
297
+ },
298
+ }),
299
+ webServer.register({
300
+ kind: 'exact',
301
+ path: '/dsh-plugin-hub/status',
302
+ handler: (request, response) => {
303
+ if (!requireMethod(request, response, 'GET'))
304
+ return;
305
+ const url = new URL(request.url ?? '/', 'http://localhost');
306
+ const id = Number(url.searchParams.get('task'));
307
+ const task = Number.isInteger(id) ? getTask(id) : undefined;
308
+ if (!task) {
309
+ sendJson(response, 404, { error: 'task not found' });
310
+ return;
311
+ }
312
+ sendJson(response, 200, { task });
313
+ },
314
+ }),
315
+ webServer.register({
316
+ kind: 'exact',
317
+ path: '/dsh-plugin-hub/active',
318
+ handler: (request, response) => {
319
+ if (!requireMethod(request, response, 'GET'))
320
+ return;
321
+ // 整个任务队列(running 在前、pending 在后)+ 待重启列表:客户端刷新后据此恢复
322
+ // 进行中/排队任务与「装完没重启」的持久提醒
323
+ sendJson(response, 200, { tasks: activeTask(), pendingRestarts: listPendingRestarts() });
324
+ },
325
+ }),
326
+ webServer.register({
327
+ kind: 'exact',
328
+ path: '/dsh-plugin-hub/env',
329
+ handler: (request, response) => {
330
+ if (!requireMethod(request, response, 'GET'))
331
+ return;
332
+ // 宿主机器环境快照:提交 bug 的 issue 正文会带上,便于作者复现
333
+ sendJson(response, 200, hostEnv(profile));
334
+ },
335
+ }),
336
+ webServer.register({
337
+ kind: 'exact',
338
+ path: '/dsh-plugin-hub/cancel',
339
+ handler: async (request, response) => {
340
+ if (!requireTrustedPost(request, response))
341
+ return;
342
+ try {
343
+ const body = await readJsonBody(request);
344
+ const id = typeof body === 'object' && body !== null && typeof body.id === 'number'
345
+ ? body.id
346
+ : NaN;
347
+ const ok = Number.isInteger(id) && cancelTask(id);
348
+ sendJson(response, 200, { ok });
349
+ }
350
+ catch (error) {
351
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
352
+ }
353
+ },
354
+ }),
355
+ webServer.register({
356
+ kind: 'exact',
357
+ path: '/dsh-plugin-hub/restart',
358
+ handler: (request, response) => {
359
+ if (!requireTrustedPost(request, response))
360
+ return;
361
+ // 当前宿主监听端口来自请求 Host 头(localhost:7923),解析失败回退 7923
362
+ const host = request.headers.host ?? '';
363
+ const portMatch = host.match(/:(\d+)$/);
364
+ const port = portMatch ? Number(portMatch[1]) : 7923;
365
+ // detached 子 shell 完成「停旧进程 → 等端口释放 → 后台拉起 dsh web」,
366
+ // 即使当前宿主(即本插件所在进程)被 kill,重启仍会继续执行
367
+ const script = [
368
+ `pids=$(lsof -ti tcp:${port} -sTCP:LISTEN 2>/dev/null)`,
369
+ '[ -n "$pids" ] && kill -TERM $pids 2>/dev/null || true',
370
+ `i=0; while [ -n "$(lsof -ti tcp:${port} -sTCP:LISTEN 2>/dev/null)" ] && [ $i -lt 20 ]; do sleep 0.25; i=$((i + 1)); done`,
371
+ 'mkdir -p "$HOME/.dsh/logs"',
372
+ `nohup dsh web --port ${port} >>"$HOME/.dsh/logs/dsh-web-${port}.log" 2>&1 &`,
373
+ ].join('\n');
374
+ spawn('/bin/sh', ['-c', script], { detached: true, stdio: 'ignore' }).unref();
375
+ sendJson(response, 200, { ok: true, port });
376
+ },
377
+ }),
378
+ webServer.register({
379
+ kind: 'exact',
380
+ path: '/dsh-plugin-hub/installed-version',
381
+ handler: async (request, response) => {
382
+ if (!requireTrustedPost(request, response))
383
+ return;
384
+ try {
385
+ const body = await readJsonBody(request);
386
+ const repo = typeof body === 'object' && body !== null && typeof body.repo === 'string'
387
+ ? body.repo
388
+ : '';
389
+ if (githubTarget(repo) === null) {
390
+ sendJson(response, 400, { error: 'invalid install target' });
391
+ return;
392
+ }
393
+ // version 为空 = 卸载清理记录;否则记录安装时的目录信号(最新版本 + 仓库更新时间),
394
+ // 供「有更新」比对:有版本比版本,无版本比更新时间
395
+ const version = body !== null && typeof body.version === 'string'
396
+ ? body.version
397
+ : null;
398
+ if (version === null) {
399
+ removeInstalledVersion(profile, repo);
400
+ }
401
+ else {
402
+ const updatedAt = body !== null && typeof body.updatedAt === 'string'
403
+ ? body.updatedAt
404
+ : '';
405
+ recordInstalledVersion(profile, repo, version, updatedAt);
406
+ }
407
+ sendJson(response, 200, { ok: true });
408
+ }
409
+ catch (error) {
410
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
411
+ }
412
+ },
413
+ }),
414
+ webServer.register({
415
+ kind: 'exact',
416
+ path: '/dsh-plugin-hub/installed',
417
+ handler: (request, response) => {
418
+ if (!requireMethod(request, response, 'GET'))
419
+ return;
420
+ // installed:依赖表(包名 → spec);versions:安装时记录的目录版本(repo → 版本),
421
+ // 客户端合并两者判断「是否有更新」
422
+ sendJson(response, 200, { profile, installed: readInstalled(profile), versions: readInstalledVersions(profile) });
423
+ },
424
+ }),
425
+ ];
426
+ return () => {
427
+ for (const dispose of disposers)
428
+ dispose();
429
+ };
430
+ }
431
+ /** Profile resolution shared with the client route docs. */
432
+ export { readProfileArg };
package/lib/index.js CHANGED
@@ -1,10 +1,19 @@
1
1
  /**
2
- * dsh-plugin host entry.
3
- *
4
- * The browser half (src/client) renders the DSH-Plugin catalog inside
5
- * the Settings panel and fetches it live from dsh-plugin.org — no bundled
6
- * data, no local routes. This node half is intentionally a no-op.
2
+ * dsh-plugin host entry: mounts the Plugin Hub HTTP routes once the host
3
+ * web server service is available. The browser bundle (src/client) talks to
4
+ * these same-origin routes to perform real installs from inside the app.
7
5
  */
8
- export const name = 'dsh-plugin'
9
-
10
- export function apply() {}
6
+ import { readProfileArg } from './services/install.js';
7
+ import { mountPluginHubRoutes } from './http/routes.js';
8
+ export const name = 'dsh-plugin';
9
+ /**
10
+ * Mount the Plugin Hub routes after the web server service becomes available.
11
+ * @param ctx - cordis host context.
12
+ * @param config - optional profile override.
13
+ */
14
+ export function apply(ctx, config = {}) {
15
+ const profile = config.profile ?? readProfileArg('web');
16
+ ctx.inject(['webServer'], (host) => {
17
+ host.effect(() => mountPluginHubRoutes(host.webServer, profile, host.loader), 'dsh-plugin: http routes');
18
+ });
19
+ }
@@ -0,0 +1,11 @@
1
+ /** 安装/卸载运行时的公共类型与常量(无依赖,供各服务模块复用)。 */
2
+ /** Grammar of an accepted `github:<owner>/<repo>` install target. */
3
+ export const REPO_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
4
+ /** Grammar of an npm package name (used for uninstall targets). */
5
+ export const PACKAGE_RE = /^(?:@[a-z0-9._-]+\/)?[A-Za-z0-9._-]+$/;
6
+ /** 捕获输出上限:stdout/stderr 各保留尾部 N 字节。 */
7
+ export const CAPTURE_LIMIT_BYTES = 64 * 1024;
8
+ /** Max output lines kept per task (newest wins). */
9
+ export const MAX_TASK_LINES = 200;
10
+ /** Max tracked tasks; oldest finished tasks are dropped first. */
11
+ export const MAX_TASKS = 50;
@@ -0,0 +1,4 @@
1
+ export { addPendingRestart, clearPendingRestart, listPendingRestarts } from './pending-restart.js';
2
+ export { dumpLoaderEntries, removeLoadedEntry } from './loader.js';
3
+ export { addAllowBuildsKey, githubTarget, parseAllowBuildsKey, readProfileArg, validPackageName } from './profile.js';
4
+ export { activeTask, cancelTask, getTask, hasQueuedTarget, hasRunningTask, runPluginMutation, startPluginMutation } from './task-queue.js';
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Installed-plugin version tracking.
3
+ *
4
+ * The installed table (`readInstalled` in routes.ts) only knows the manifest
5
+ * spec of each dependency (`github:<owner>/<repo>`), which carries no version.
6
+ * To let the Plugin Hub tell "an update is available", we record the catalog
7
+ * signal present at install time into a small per-profile JSON file, then
8
+ * compare it against the catalog's current signal later.
9
+ *
10
+ * Two signals cover every repo:
11
+ * - version: the catalog's latest release tag (e.g. v1.2.3). Repos that
12
+ * publish releases are compared on this.
13
+ * - updatedAt: the repo's last push time (GitHub pushed_at, `repoUpdatedAt`).
14
+ * Repos that never publish a release fall back to this: a newer push time
15
+ * means new commits → an update is available.
16
+ *
17
+ * Storage: `DSH_HOME/profiles/<profile>/gro.ngilp-hsd-versions.json`
18
+ * { "<owner>/<repo>" (lowercase): { version: string, updatedAt: string, installedAt: string } }
19
+ */
20
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
21
+ import { homedir } from 'node:os';
22
+ import { dirname, join } from 'node:path';
23
+ export function versionsFilePath(profile) {
24
+ return join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'profiles', profile, 'gro.ngilp-hsd-versions.json');
25
+ }
26
+ export function readInstalledVersions(profile) {
27
+ try {
28
+ const raw = JSON.parse(readFileSync(versionsFilePath(profile), 'utf8'));
29
+ return raw !== null && typeof raw === 'object' ? raw : {};
30
+ }
31
+ catch {
32
+ return {};
33
+ }
34
+ }
35
+ /** Record the catalog signals (version + repo updated time) present at install time for one repo. */
36
+ export function recordInstalledVersion(profile, repo, version, updatedAt) {
37
+ const table = readInstalledVersions(profile);
38
+ const key = repo.toLowerCase();
39
+ // 合并写入:保留已记录的 npmPackage(npm 优先通道的 repo → 包名映射),
40
+ // 客户端同步版本信号时不能把它覆盖掉
41
+ table[key] = { ...table[key], version, updatedAt, installedAt: new Date().toISOString() };
42
+ writeVersions(profile, table);
43
+ }
44
+ /**
45
+ * Record the npm package name resolved for a repo (npm-first install channel).
46
+ * Called at `/install` time so the mapping exists even before the task finishes;
47
+ * harmless when the repo is not (yet) installed — the client only treats a repo
48
+ * as installed when its dependency key actually exists.
49
+ */
50
+ export function recordResolvedNpmPackage(profile, repo, npmPackage) {
51
+ const table = readInstalledVersions(profile);
52
+ const key = repo.toLowerCase();
53
+ table[key] = { ...table[key], npmPackage };
54
+ writeVersions(profile, table);
55
+ }
56
+ /** Forget the recorded version for one repo (called on uninstall). */
57
+ export function removeInstalledVersion(profile, repo) {
58
+ const table = readInstalledVersions(profile);
59
+ delete table[repo.toLowerCase()];
60
+ writeVersions(profile, table);
61
+ }
62
+ function writeVersions(profile, table) {
63
+ const file = versionsFilePath(profile);
64
+ mkdirSync(dirname(file), { recursive: true });
65
+ writeFileSync(file, JSON.stringify(table, null, 2) + '\n', 'utf8');
66
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * 运行中 loader 的读取与停用:卸载成功后对匹配条目做 live-disable,
3
+ * 使卸载立即生效(刷新页面不再加载已卸载的 client bundle,也无需重启)。
4
+ */
5
+ /** 运行中 loader 的全部条目(id + options.name),诊断用。 */
6
+ export function dumpLoaderEntries(loader) {
7
+ if (!loader)
8
+ return [];
9
+ const entries = [];
10
+ for (const entry of Array.from(loader.entries())) {
11
+ entries.push({ id: entry.id, name: entry.options?.name });
12
+ }
13
+ return entries;
14
+ }
15
+ /**
16
+ * 卸载目标(npm 包名,如 `@scope/widget`)与 loader 条目名的匹配。
17
+ * loader 条目名由插件作者/工具写入,形式不可控,常见变体:
18
+ * - 完整 npm 名:`@scope/widget`
19
+ * - 去 `@` 前缀:`scope/widget`
20
+ * - 去 scope 的短名:`widget`
21
+ * - scope 分隔符变 `-`:`scope-widget`
22
+ * 因此归一化后比较全串与短名两个维度。profile 内包名唯一,宽松匹配不会误伤其它包。
23
+ */
24
+ function nameMatches(entryName, target) {
25
+ if (!entryName)
26
+ return false;
27
+ // 归一化:去 @、非 [a-z0-9-] 一律换 `-`、小写
28
+ const norm = (s) => s.trim().replace(/^@+/, '').replace(/[^a-z0-9-]/gi, '-').toLowerCase();
29
+ const e = norm(entryName);
30
+ if (!e)
31
+ return false;
32
+ const t = norm(target); // @scope/pkg → scope-pkg
33
+ const tShort = norm(target.replace(/^@[^/]+\//, '')); // @scope/pkg → pkg(无 scope 时与原串一致)
34
+ // 1) 全串相等:覆盖 完整名 == 完整名 / 去@ == 去@ / scope-pkg == scope/pkg 归一化
35
+ if (e === t)
36
+ return true;
37
+ // 2) 条目是去 scope 的短名(bundle patch 手写短名最常见)
38
+ if (e === tShort)
39
+ return true;
40
+ // 3) 反向变体:目标短名可能就是条目的完整形式(条目 `scope-widget`,
41
+ // 目标短名 `widget` 是它的尾段)——用「条目名以短名结尾」收口,避免误伤。
42
+ if (tShort.length >= 2 && e.endsWith(`-${tShort}`))
43
+ return true;
44
+ return false;
45
+ }
46
+ /**
47
+ * 卸载成功后从运行中 loader 停用指定包的条目,使卸载立即生效(刷新页面不再
48
+ * 加载已卸载的 client bundle,也无需重启)。做法:按 name 扫描
49
+ * `loader.entries()`(含嵌套子树),对每个匹配条目
50
+ * `entry.update({ disabled: true })` 做 live-disable —— fiber 被 dispose,
51
+ * client-modules 对账后不再把它写进 `__DSH_BOOT__`,页面刷新即恢复。
52
+ * 返回 true = 无需重启(条目已停用,或本就没加载过);false = 未能停用,
53
+ * 需登记「待重启清理」兜底。宿主关键包(@deepseek-ai/* 与本插件自身)一律跳过。
54
+ */
55
+ export async function removeLoadedEntry(loader, name) {
56
+ if (name === 'dsh-plugin' || name.startsWith('@deepseek-ai/'))
57
+ return false;
58
+ // 5s 超时兜底:fiber dispose 卡住时不能让卸载任务(乃至整个队列)被拖死
59
+ const removal = (async () => {
60
+ const entries = dumpLoaderEntries(loader);
61
+ const match = entries.find((e) => e.name === name);
62
+ console.log(`[hub-uninstall] loader=${entries.length} entries; target=${name}; match=${match ? match.id : 'none'}; names=${entries.map((e) => e.name ?? '').join(',')}`);
63
+ let found = false;
64
+ for (const entry of Array.from(loader.entries())) {
65
+ if (!nameMatches(entry.options?.name, name))
66
+ continue;
67
+ found = true;
68
+ // force=true:disable 可能落在 init 进行中,options 翻转但 fiber 仍会起来,
69
+ // 重试直到实际状态与目标一致
70
+ for (let attempt = 0; attempt < 3; attempt++) {
71
+ try {
72
+ await entry.update({ disabled: true }, false, true);
73
+ break;
74
+ }
75
+ catch (error) {
76
+ console.log(`[hub-uninstall] disable ${name} attempt ${attempt + 1} failed: ${error instanceof Error ? error.message : String(error)}`);
77
+ if (attempt === 2)
78
+ return false;
79
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, 200));
80
+ }
81
+ }
82
+ }
83
+ if (!found) {
84
+ // 运行中 loader 无该条目:宿主从未加载过它(装完未重启),磁盘已干净,无需重启
85
+ console.log(`[hub-uninstall] no live entry for ${name}; nothing to disable`);
86
+ }
87
+ return true;
88
+ })();
89
+ const result = await Promise.race([
90
+ removal.catch((error) => {
91
+ console.log(`[hub-uninstall] disable failed: ${error instanceof Error ? error.message : String(error)}`);
92
+ return false;
93
+ }),
94
+ new Promise((resolve) => setTimeout(() => resolve(false), 5_000)),
95
+ ]);
96
+ console.log(`[hub-uninstall] disable ${name} -> ${result ? 'ok/clean' : 'timed out or failed'}`);
97
+ return result;
98
+ }