dsh-plugin 1.6.5 → 1.6.6

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 (46) hide show
  1. package/README.en.md +11 -11
  2. package/README.md +11 -11
  3. package/client/client.js +420 -389
  4. package/client/client.js.map +1 -1
  5. package/lib/http/catalog.js +132 -0
  6. package/lib/http/diagnostics.js +201 -0
  7. package/lib/http/install.js +399 -0
  8. package/lib/http/logs.js +164 -0
  9. package/lib/http/routes.js +19 -1263
  10. package/lib/http/settings.js +77 -0
  11. package/lib/http/shared.js +169 -0
  12. package/lib/http/task.js +208 -0
  13. package/lib/services/install/install.js +1 -1
  14. package/lib/services/install/task-queue.js +45 -16
  15. package/lib/services/probe.js +1 -1
  16. package/lib/types/http/catalog.d.ts +10 -0
  17. package/lib/types/http/diagnostics.d.ts +4 -0
  18. package/lib/types/http/install.d.ts +11 -0
  19. package/lib/types/http/logs.d.ts +2 -0
  20. package/lib/types/http/routes.d.ts +15 -20
  21. package/lib/types/http/settings.d.ts +2 -0
  22. package/lib/types/http/shared.d.ts +44 -0
  23. package/lib/types/http/task.d.ts +3 -0
  24. package/lib/types/services/install/install.d.ts +1 -1
  25. package/package.json +1 -1
  26. package/src/client/components/PluginHubSection.tsx +3 -0
  27. package/src/client/components/modals/NotificationsModal.tsx +34 -18
  28. package/src/client/components/modals/modals.tsx +38 -28
  29. package/src/client/components/views/InstalledView.tsx +23 -11
  30. package/src/client/hooks/useCatalog.ts +1 -1
  31. package/src/client/locales.ts +24 -8
  32. package/src/client/logic/failures.ts +36 -157
  33. package/src/client/logic/urls.ts +72 -1
  34. package/src/client/types.ts +1 -1
  35. package/src/server/http/catalog.ts +143 -0
  36. package/src/server/http/diagnostics.ts +193 -0
  37. package/src/server/http/install.ts +391 -0
  38. package/src/server/http/logs.ts +149 -0
  39. package/src/server/http/routes.ts +22 -1222
  40. package/src/server/http/settings.ts +73 -0
  41. package/src/server/http/shared.ts +191 -0
  42. package/src/server/http/task.ts +194 -0
  43. package/src/server/services/install/install-types.ts +9 -1
  44. package/src/server/services/install/install.ts +11 -1
  45. package/src/server/services/install/task-queue.ts +45 -16
  46. package/src/server/services/probe.ts +1 -1
@@ -0,0 +1,77 @@
1
+ /**
2
+ * DSH Plugin Hub — the community plugin marketplace for DeepSeek Harness.
3
+ * Website: https://dsh-plugin.org
4
+ * GitHub: https://github.com/dshplugin/dsh-plugin-hub
5
+ *
6
+ * Settings routes: read/write the hub settings and reset them to defaults.
7
+ */
8
+ import { mkdirSync } from 'node:fs';
9
+ import { dirname } from 'node:path';
10
+ import { loadSettings, saveSettings, resetSettings } from '../services/settings.js';
11
+ import { appendLog, customLogFile } from '../services/log.js';
12
+ import { readJsonBody, requireTrustedPost, sendJson } from './shared.js';
13
+ export function registerSettingsRoutes(webServer, profile) {
14
+ return [
15
+ {
16
+ kind: 'exact',
17
+ path: '/dsh-plugin-hub/settings',
18
+ // GET 读 / POST 写:webServer.register 按 (kind, path) 唯一,多方法必须合并到同一个 handler 按 method 分派
19
+ handler: async (request, response) => {
20
+ if (request.method === 'GET') {
21
+ sendJson(response, 200, loadSettings(profile));
22
+ return;
23
+ }
24
+ if (!requireTrustedPost(request, response))
25
+ return;
26
+ try {
27
+ const body = await readJsonBody(request);
28
+ // 白名单字段:只接受已知设置项,防客户端把任意键写进文件
29
+ const patch = {};
30
+ const source = typeof body === 'object' && body !== null ? body : {};
31
+ for (const key of ['checkUpdatesOnStart', 'enableNpmInstall', 'enableGitInstall', 'enableDshInstall', 'npmRegistry', 'proxy', 'logPath']) {
32
+ if (source[key] !== undefined)
33
+ patch[key] = source[key];
34
+ }
35
+ // 日志位置:非空时预建目录验证可写,失败直接 400 拦截(默认位置永远合法,空串 = 回默认)
36
+ if (typeof patch.logPath === 'string' && patch.logPath.trim() !== '') {
37
+ try {
38
+ mkdirSync(dirname(customLogFile(patch.logPath)), { recursive: true });
39
+ }
40
+ catch {
41
+ sendJson(response, 400, { error: 'log path is not writable' });
42
+ return;
43
+ }
44
+ }
45
+ sendJson(response, 200, saveSettings(profile, patch));
46
+ // 系统日志:设置项变更
47
+ appendLog(profile, {
48
+ at: Date.now(),
49
+ level: 'info',
50
+ category: 'settings',
51
+ event: 'settings.update',
52
+ message: `更新设置:${Object.keys(patch).join(', ')}`,
53
+ });
54
+ }
55
+ catch (error) {
56
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
57
+ }
58
+ },
59
+ },
60
+ {
61
+ kind: 'exact',
62
+ path: '/dsh-plugin-hub/settings/reset',
63
+ handler: async (request, response) => {
64
+ if (!requireTrustedPost(request, response))
65
+ return;
66
+ sendJson(response, 200, resetSettings(profile));
67
+ appendLog(profile, {
68
+ at: Date.now(),
69
+ level: 'warn',
70
+ category: 'settings',
71
+ event: 'settings.reset',
72
+ message: '恢复全部默认设置',
73
+ });
74
+ },
75
+ },
76
+ ];
77
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * 跨平台宿主重启脚本(以 `node -e` 运行,独立于宿主进程)。
3
+ * 不用 `/bin/sh -c`:Windows 无 /bin/sh,且 lsof/nohup 仅 POSIX 存在。
4
+ * 由 `spawn(process.execPath, ['-e', SCRIPT, port], { detached, stdio:'ignore' })` 孵化,
5
+ * 宿主进程被 kill 后仍能完成「停旧 → 等端口释放 → 拉起新 dsh web」。
6
+ * 端口探测不到监听进程时不再假装重启(记一行日志后退出,见脚本内注释)。
7
+ */
8
+ export const HOST_RESTART_SCRIPT = String.raw `const C = require('node:child_process')
9
+ const F = require('node:fs')
10
+ const P = require('node:path')
11
+ const O = require('node:os')
12
+ const win = process.platform === 'win32'
13
+ const port = Number(process.argv[1])
14
+
15
+ function findPids() {
16
+ if (win) {
17
+ const out = C.spawnSync('netstat', ['-ano', '-p', 'tcp'], { encoding: 'utf8' })
18
+ const found = []
19
+ for (const line of String(out.stdout || '').split(/\r?\n/)) {
20
+ if (!/LISTENING/i.test(line)) continue
21
+ const parts = line.trim().split(/\s+/)
22
+ const addr = parts[1] || ''
23
+ if (!addr.endsWith(':' + port)) continue
24
+ const pid = parts[parts.length - 1]
25
+ if (pid && /\d+/.test(pid)) found.push(pid)
26
+ }
27
+ return found
28
+ }
29
+ const out = C.spawnSync('lsof', ['-ti', 'tcp:' + port, '-sTCP:LISTEN'], { encoding: 'utf8' })
30
+ return String(out.stdout || '').split(/\s+/).filter(Boolean)
31
+ }
32
+
33
+ const targets = findPids()
34
+ // 一个监听进程都没找到 = 端口探测本身就失败了(netstat 输出格式 / 本地化差异),
35
+ // 旧宿主仍占着端口。此时再拉一个 dsh web 只会在日志里以 EADDRINUSE 静默退出,
36
+ // 用户却看到「重启完成」——不制造这种假象,留一行可诊断的记录后退出。
37
+ if (targets.length === 0) {
38
+ logLine('restart aborted: no listener found on port ' + port)
39
+ process.exit(0)
40
+ }
41
+
42
+ for (const pid of targets) {
43
+ try {
44
+ if (win) C.spawnSync('taskkill', ['/pid', pid, '/t', '/f'])
45
+ else process.kill(Number(pid), 'SIGTERM')
46
+ } catch {}
47
+ }
48
+
49
+ let tries = 0
50
+ ;(function waitForStop() {
51
+ if (findPids().length === 0 || ++tries > 20) return startHost()
52
+ setTimeout(waitForStop, 250)
53
+ })()
54
+
55
+ function logLine(msg) {
56
+ try {
57
+ const dir = P.join(O.homedir(), '.dsh', 'logs')
58
+ F.mkdirSync(dir, { recursive: true })
59
+ F.appendFileSync(P.join(dir, 'dsh-web-' + port + '.log'), '[' + new Date().toISOString() + '] ' + msg + '\n')
60
+ } catch {}
61
+ }
62
+
63
+ function startHost() {
64
+ const dir = P.join(O.homedir(), '.dsh', 'logs')
65
+ F.mkdirSync(dir, { recursive: true })
66
+ const fd = F.openSync(P.join(dir, 'dsh-web-' + port + '.log'), 'a')
67
+ const child = C.spawn('dsh', ['web', '--port', String(port)], {
68
+ detached: true,
69
+ shell: win,
70
+ stdio: ['ignore', fd, fd],
71
+ })
72
+ child.unref()
73
+ }`;
74
+ /**
75
+ * 宿主是否由桌面应用壳托管(`ELECTRON_RUN_AS_NODE=1`:壳用 Electron 二进制以 Node 模式跑宿主)。
76
+ * 桌面端的宿主归应用壳管,插件重启不了它,也不该去 kill —— 详见 /restart 路由。
77
+ */
78
+ export function isDesktopHost() {
79
+ return process.env.ELECTRON_RUN_AS_NODE === '1';
80
+ }
81
+ export const PROFILE_RE = /^[A-Za-z0-9_-]+$/;
82
+ const BODY_LIMIT_BYTES = 4 * 1024;
83
+ export const COMMAND_TIMEOUT_MS = 5 * 60 * 1000;
84
+ /** 目录/统计数据代理的本地缓存时长:1 小时内重复打开插件市场直接读盘,
85
+ * 不重复走 curl 拉远程 —— 重启宿主后首次打开同样秒开(缓存跨重启存活)。 */
86
+ export const CATALOG_CACHE_TTL_MS = 60 * 60 * 1000;
87
+ export function sendJson(response, status, value) {
88
+ response.writeHead(status, {
89
+ 'cache-control': 'no-store',
90
+ 'content-type': 'application/json; charset=utf-8',
91
+ });
92
+ response.end(JSON.stringify(value));
93
+ }
94
+ /** 本机回环主机名:`localhost` / `127.0.0.1` / `[::1]` 指向同一台机器的同一个端口,
95
+ * 字符串不同但同源,必须按同一来源比较。 */
96
+ const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']);
97
+ /** 把 `host[:port]` 形式的 authority 归一化成 `loopback:端口` —— 三个回环别名
98
+ * 映射成同一个身份,端口缺省按 http 的 80 补齐;非回环主机名、以及解析不出来的值
99
+ * 都返回 null。 */
100
+ function loopbackAuthority(authority) {
101
+ try {
102
+ const parsed = new URL(`http://${authority}`);
103
+ if (!LOOPBACK_HOSTNAMES.has(parsed.hostname.toLowerCase()))
104
+ return null;
105
+ return `loopback:${parsed.port === '' ? '80' : parsed.port}`;
106
+ }
107
+ catch {
108
+ return null;
109
+ }
110
+ }
111
+ /** POST mutations are only accepted from the local web server origin or the desktop host page.
112
+ * Exported for tests (tests/routes.test.ts) — the origin check is the whole CSRF defence. */
113
+ export function isSameOrigin(request) {
114
+ // 服务只绑定本机回环地址:Host 不是回环(局域网 IP、被改写的域名)一律拒绝,
115
+ // 顺带挡掉 DNS rebinding(Host 是攻击者域名,即便解析到 127.0.0.1)。
116
+ const host = request.headers.host;
117
+ const hostKey = host === undefined ? null : loopbackAuthority(host);
118
+ if (hostKey === null)
119
+ return false;
120
+ const origin = request.headers.origin;
121
+ // 桌面端宿主把 `/dsh-plugin-hub/*` 转发给本地 http server 时可能不带 Origin 头,
122
+ // 此时所有 POST 都会被判成 untrusted origin。浏览器对 POST 请求
123
+ // 必定携带 Origin(含同源 POST 与跨站表单),能省略该头的只有本机原生客户端,
124
+ // 而它们本来就能伪造任意 Origin,因此放行不扩大 CSRF 面。
125
+ if (origin === undefined || origin === '')
126
+ return true;
127
+ try {
128
+ const url = new URL(origin);
129
+ // 桌面端(Electron 宿主)的页面跑在宿主自定义协议下,origin 形如 `dsh-app://app`,
130
+ // 与 Host(如 localhost:3081)必然不等,因此必须显式放行该协议,否则桌面端全部 POST
131
+ // 都会被判成 untrusted origin。该协议只有宿主自身能产生,外部网页无法把 Origin
132
+ // 伪造成 `dsh-app://…`,放行不扩大 CSRF 面。
133
+ if (url.protocol === 'dsh-app:')
134
+ return true;
135
+ // 按归一化后的「主机名:端口」比较:`localhost` / `127.0.0.1` / `[::1]` 不再互相拒绝,
136
+ // 端口仍然必须一致(另一个本机进程不能被当成同一来源)。
137
+ return loopbackAuthority(url.host) === hostKey;
138
+ }
139
+ catch {
140
+ return false;
141
+ }
142
+ }
143
+ export function requireMethod(request, response, method) {
144
+ if (request.method === method)
145
+ return true;
146
+ response.writeHead(405, { allow: method });
147
+ response.end();
148
+ return false;
149
+ }
150
+ export function requireTrustedPost(request, response) {
151
+ if (!requireMethod(request, response, 'POST'))
152
+ return false;
153
+ if (isSameOrigin(request))
154
+ return true;
155
+ sendJson(response, 403, { error: 'untrusted origin' });
156
+ return false;
157
+ }
158
+ export async function readJsonBody(request) {
159
+ const chunks = [];
160
+ let size = 0;
161
+ for await (const chunk of request) {
162
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
163
+ size += buffer.length;
164
+ if (size > BODY_LIMIT_BYTES)
165
+ throw new Error('request body too large');
166
+ chunks.push(buffer);
167
+ }
168
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
169
+ }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * DSH Plugin Hub — the community plugin marketplace for DeepSeek Harness.
3
+ * Website: https://dsh-plugin.org
4
+ * GitHub: https://github.com/dshplugin/dsh-plugin-hub
5
+ *
6
+ * Task/misc routes: task polling, host restart, loader-entry debugging, and
7
+ * the installed-plugin inventory the client uses to reconcile its list.
8
+ */
9
+ import { spawn } from 'node:child_process';
10
+ import { join } from 'node:path';
11
+ import { activeTask, cancelTask, dumpLoaderEntries, getTask, githubTarget, listPendingRestarts, validPackageName } from '../services/install/install.js';
12
+ import { readInstalledVersions, recordInstalledVersion, removeInstalledVersion } from '../services/profile/installed-versions.js';
13
+ import { isDshPlugin, isEntryLoaded } from '../services/loader.js';
14
+ import { profileDirectory, readInstalled } from './catalog.js';
15
+ import { hostEnv } from './diagnostics.js';
16
+ import { HOST_RESTART_SCRIPT, isDesktopHost, readJsonBody, requireMethod, requireTrustedPost, sendJson } from './shared.js';
17
+ export function registerTaskRoutes(webServer, profile, loader) {
18
+ return [
19
+ {
20
+ kind: 'exact',
21
+ path: '/dsh-plugin-hub/debug/loader-entries',
22
+ handler: (request, response) => {
23
+ if (!requireMethod(request, response, 'GET'))
24
+ return;
25
+ sendJson(response, 200, { entries: dumpLoaderEntries(loader) });
26
+ },
27
+ },
28
+ {
29
+ kind: 'exact',
30
+ path: '/dsh-plugin-hub/status',
31
+ handler: (request, response) => {
32
+ if (!requireMethod(request, response, 'GET'))
33
+ return;
34
+ const url = new URL(request.url ?? '/', 'http://localhost');
35
+ const id = Number(url.searchParams.get('task'));
36
+ const task = Number.isInteger(id) ? getTask(id) : undefined;
37
+ if (!task) {
38
+ sendJson(response, 404, { error: 'task not found' });
39
+ return;
40
+ }
41
+ sendJson(response, 200, { task });
42
+ },
43
+ },
44
+ {
45
+ kind: 'exact',
46
+ path: '/dsh-plugin-hub/active',
47
+ handler: (request, response) => {
48
+ if (!requireMethod(request, response, 'GET'))
49
+ return;
50
+ // 整个任务队列(running 在前、pending 在后)+ 待重启列表:客户端刷新后据此恢复
51
+ // 进行中/排队任务与「装完没重启」的持久提醒
52
+ sendJson(response, 200, { tasks: activeTask(), pendingRestarts: listPendingRestarts() });
53
+ },
54
+ },
55
+ {
56
+ kind: 'exact',
57
+ path: '/dsh-plugin-hub/env',
58
+ handler: (request, response) => {
59
+ if (!requireMethod(request, response, 'GET'))
60
+ return;
61
+ // 宿主机器环境快照:提交 bug 的 issue 正文会带上,便于作者复现
62
+ sendJson(response, 200, hostEnv(profile));
63
+ },
64
+ },
65
+ {
66
+ kind: 'exact',
67
+ path: '/dsh-plugin-hub/cancel',
68
+ handler: async (request, response) => {
69
+ if (!requireTrustedPost(request, response))
70
+ return;
71
+ try {
72
+ const body = await readJsonBody(request);
73
+ const id = typeof body === 'object' && body !== null && typeof body.id === 'number'
74
+ ? body.id
75
+ : NaN;
76
+ const ok = Number.isInteger(id) && cancelTask(id);
77
+ sendJson(response, 200, { ok });
78
+ }
79
+ catch (error) {
80
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
81
+ }
82
+ },
83
+ },
84
+ {
85
+ kind: 'exact',
86
+ path: '/dsh-plugin-hub/restart',
87
+ handler: (request, response) => {
88
+ if (!requireTrustedPost(request, response))
89
+ return;
90
+ // 桌面端:宿主由应用壳托管,插件重启不了它。Windows 上 `taskkill /T` 会把发起
91
+ // 重启的助手一起结束(助手是宿主的子进程),壳再把这次外部 kill 判成宿主崩溃
92
+ // 弹「应用无法启动或已意外停止」;退一步说,脱离壳拉起的 `dsh web` 也服务不了
93
+ // 桌面 UI(缺壳注入的 desktop:boot)。故不 kill、不拉新宿主,由客户端改提示
94
+ // 用户从菜单/托盘退出后重新打开。
95
+ if (isDesktopHost()) {
96
+ sendJson(response, 200, { ok: false, desktop: true });
97
+ return;
98
+ }
99
+ // 当前宿主监听端口来自请求 Host 头(localhost:7923),解析失败回退 7923
100
+ const host = request.headers.host ?? '';
101
+ const portMatch = host.match(/:(\d+)$/);
102
+ const port = portMatch ? Number(portMatch[1]) : 7923;
103
+ // detached 子进程完成「停旧进程 → 等端口释放 → 后台拉起 dsh web」,
104
+ // 即使当前宿主(即本插件所在进程)被 kill,重启仍会继续执行。
105
+ // 不用 /bin/sh:Windows 无此路径,且 lsof/nohup 等仅 POSIX 存在,
106
+ // 故孵化 node(process.execPath) 跑跨平台脚本 —— Windows 用
107
+ // netstat + taskkill + shell:true 解析 dsh.cmd,POSIX 用 lsof + SIGTERM。
108
+ spawn(process.execPath, ['-e', HOST_RESTART_SCRIPT, String(port)], { detached: true, stdio: 'ignore' }).unref();
109
+ sendJson(response, 200, { ok: true, port });
110
+ },
111
+ },
112
+ {
113
+ kind: 'exact',
114
+ path: '/dsh-plugin-hub/installed-version',
115
+ handler: async (request, response) => {
116
+ if (!requireTrustedPost(request, response))
117
+ return;
118
+ try {
119
+ const body = await readJsonBody(request);
120
+ const repo = typeof body === 'object' && body !== null && typeof body.repo === 'string'
121
+ ? body.repo
122
+ : '';
123
+ if (githubTarget(repo) === null) {
124
+ sendJson(response, 400, { error: 'invalid install target' });
125
+ return;
126
+ }
127
+ // version 为空 = 卸载清理记录;否则记录安装时的目录信号(最新版本 + 仓库更新时间),
128
+ // 供「有更新」比对:有版本比版本,无版本比更新时间
129
+ const version = body !== null && typeof body.version === 'string'
130
+ ? body.version
131
+ : null;
132
+ if (version === null) {
133
+ removeInstalledVersion(profile, repo);
134
+ }
135
+ else {
136
+ const updatedAt = body !== null && typeof body.updatedAt === 'string'
137
+ ? body.updatedAt
138
+ : '';
139
+ recordInstalledVersion(profile, repo, version, updatedAt);
140
+ }
141
+ sendJson(response, 200, { ok: true });
142
+ }
143
+ catch (error) {
144
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
145
+ }
146
+ },
147
+ },
148
+ {
149
+ kind: 'exact',
150
+ path: '/dsh-plugin-hub/open-path',
151
+ handler: async (request, response) => {
152
+ if (!requireTrustedPost(request, response))
153
+ return;
154
+ // 在系统文件管理器里定位并打开已安装插件目录(详情视图「在文件夹中显示」)。
155
+ // 只接受已安装依赖的包名、服务端自行拼接路径——杜绝任意路径注入
156
+ try {
157
+ const body = await readJsonBody(request);
158
+ const name = typeof body === 'object' && body !== null && typeof body.name === 'string'
159
+ ? body.name
160
+ : '';
161
+ if (!validPackageName(name) || readInstalled(profile)[name] === undefined) {
162
+ sendJson(response, 400, { error: 'plugin is not installed' });
163
+ return;
164
+ }
165
+ const dir = join(profileDirectory(profile), 'node_modules', name);
166
+ // macOS Finder 用 -R 定位选中;Windows explorer 用 /select,;Linux 打开目录(xdg-open)
167
+ const bin = process.platform === 'darwin' ? 'open'
168
+ : process.platform === 'win32' ? 'explorer'
169
+ : 'xdg-open';
170
+ const args = process.platform === 'darwin' ? ['-R', dir]
171
+ : process.platform === 'win32' ? ['/select,', dir]
172
+ : [dir];
173
+ spawn(bin, args, { detached: true, stdio: 'ignore' }).unref();
174
+ sendJson(response, 200, { ok: true });
175
+ }
176
+ catch (error) {
177
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
178
+ }
179
+ },
180
+ },
181
+ {
182
+ kind: 'exact',
183
+ path: '/dsh-plugin-hub/installed',
184
+ handler: (request, response) => {
185
+ if (!requireMethod(request, response, 'GET'))
186
+ return;
187
+ // installed:依赖表(包名 → spec);versions:安装时记录的目录版本(repo → 版本);
188
+ // paths:每个依赖在系统上的安装目录(profile/node_modules/<包名>),详情视图展示用;
189
+ // loaded:已加载进运行中 loader 的包名(官方 ctx.loader.entries(),未重启装的新插件不在其中);
190
+ // dshCapable:真正的 dsh 插件(包内声明 dsh 配置 / 在 profile bundles 清单)——
191
+ // 非 dsh 插件(如 GitHub 官方示例仓库)装上也不会被宿主加载,客户端据此不再提示「待重启」。
192
+ // 客户端合并这些判断「是否有更新」「运行状态」并展示安装路径/时间等运行时信息。
193
+ const installed = readInstalled(profile);
194
+ const paths = {};
195
+ const loaded = [];
196
+ const dshCapable = [];
197
+ for (const name of Object.keys(installed)) {
198
+ paths[name] = join(profileDirectory(profile), 'node_modules', name);
199
+ if (isEntryLoaded(loader, name))
200
+ loaded.push(name);
201
+ if (isDshPlugin(profile, name))
202
+ dshCapable.push(name);
203
+ }
204
+ sendJson(response, 200, { profile, installed, versions: readInstalledVersions(profile), paths, loaded, dshCapable });
205
+ },
206
+ },
207
+ ];
208
+ }
@@ -1,4 +1,4 @@
1
1
  export { addPendingRestart, clearPendingRestart, listPendingRestarts } from '../profile/pending-restart.js';
2
2
  export { dumpLoaderEntries, removeLoadedEntry } from '../loader.js';
3
- export { addAllowBuildsKey, githubRefOf, githubRepoOf, githubTarget, globalNpmPackagesOf, installTargetOf, parseAllowBuildsKey, readProfileArg, validPackageName } from '../profile/profile.js';
3
+ export { addAllowBuildsKey, githubRefOf, githubRepoOf, githubTarget, globalNpmPackagesOf, installTargetOf, parseAllowBuildsKey, readProfileArg, validPackageName, } from '../profile/profile.js';
4
4
  export { activeTask, cancelTask, getTask, hasQueuedTarget, hasRunningTask, runPluginMutation, startPluginMutation } from './task-queue.js';
@@ -41,12 +41,30 @@ export function activeTask() {
41
41
  for (const item of queue) {
42
42
  const task = item.task;
43
43
  if (task.status === 'pending') {
44
- found.push({ id: task.id, target: show(task), action: task.action, status: 'pending', progress: task.progress, lines: task.lines.slice(0, MAX_TASK_LINES), attempts: task.attempts, installChannel: task.installChannel });
44
+ found.push({
45
+ id: task.id,
46
+ target: show(task),
47
+ action: task.action,
48
+ status: 'pending',
49
+ progress: task.progress,
50
+ lines: task.lines.slice(0, MAX_TASK_LINES),
51
+ attempts: task.attempts,
52
+ installChannel: task.installChannel,
53
+ });
45
54
  }
46
55
  }
47
56
  tasks.forEach((task) => {
48
57
  if (task.status === 'running') {
49
- found.unshift({ id: task.id, target: show(task), action: task.action, status: 'running', progress: task.progress, lines: task.lines.slice(0, MAX_TASK_LINES), attempts: task.attempts, installChannel: task.installChannel });
58
+ found.unshift({
59
+ id: task.id,
60
+ target: show(task),
61
+ action: task.action,
62
+ status: 'running',
63
+ progress: task.progress,
64
+ lines: task.lines.slice(0, MAX_TASK_LINES),
65
+ attempts: task.attempts,
66
+ installChannel: task.installChannel,
67
+ });
50
68
  }
51
69
  });
52
70
  return found;
@@ -117,7 +135,7 @@ function cliInvocation(profile) {
117
135
  if (entry !== undefined) {
118
136
  // 打包桌面端(Electron):宿主入口在 app.asar 内,机器上根本没有 `dsh` 可执行文件
119
137
  //(Windows 那条 dsh.cmd shim 只存在于宿主进程自己的 PATH 里),直接回退 PATH 必然
120
- // 报 [dsh-missing](见 issue #71)。这里改用应用自己的 CLI 引导脚本调起同一份打包
138
+ // 报 [dsh-missing]。这里改用应用自己的 CLI 引导脚本调起同一份打包
121
139
  // CLI:Electron 可执行文件 + --expose-internals + desktop-cli.js,与内置终端、
122
140
  // 官方插件页安装完全同一条路径 —— desktop profile 由应用独占管理的授权也在其中。
123
141
  const bootstrap = desktopCliBootstrap(entry);
@@ -172,9 +190,8 @@ export function desktopCliBootstrap(entry) {
172
190
  }
173
191
  /**
174
192
  * dsh 命令存在性探测:which/where 找 PATH 里的 dsh。
175
- * Windows shell 模式命令缺失时 cmd 只输出 GBK 乱码「不是内部或外部命令」(UTF-8 解码后
176
- * 无法匹配),POSIX 非 shell 模式才会抛 spawn dsh ENOENT —— 两种形态都不可靠,统一在
177
- * spawn 前用 which/where 探测,命令不存在则直接失败并打 ASCII 标记 [dsh-missing](乱码免疫)。
193
+ * 命令缺失时各平台报错形态不同(Windows shell 只回 GBK 乱码、POSIX 才抛 spawn ENOENT),
194
+ * 统一在 spawn 前用 which/where 探测,命令不存在则直接失败并打 ASCII 标记 [dsh-missing](乱码免疫)。
178
195
  * 探测自身异常(which/where 不可用等)不拦截安装,交给装后校验兜底。
179
196
  */
180
197
  function dshOnPath(env) {
@@ -233,7 +250,22 @@ export function startPluginMutation(options) {
233
250
  // 卸载入队时先快照「该包是否带客户端 UI」:卸载完成后包已从磁盘删除,读不到 package.json,
234
251
  // 只能在此刻取;卸载成功且曾存活时据此决定是否要刷新页面摘除插件面板
235
252
  const hadClientUi = options.action === 'remove' ? hasClientUi(options.profile, options.target) : undefined;
236
- const task = { id: nextTaskId, target: options.target, displayTarget: options.displayTarget, globalNpm: options.globalNpm, action: options.action, status: 'pending', timedOut: false, exitCode: null, progress: 0, lines: [], attempts: options.attempts ?? [], needsRestart: false, hadClientUi, installChannel: options.installChannel };
253
+ const task = {
254
+ id: nextTaskId,
255
+ target: options.target,
256
+ displayTarget: options.displayTarget,
257
+ globalNpm: options.globalNpm,
258
+ action: options.action,
259
+ status: 'pending',
260
+ timedOut: false,
261
+ exitCode: null,
262
+ progress: 0,
263
+ lines: [],
264
+ attempts: options.attempts ?? [],
265
+ needsRestart: false,
266
+ hadClientUi,
267
+ installChannel: options.installChannel,
268
+ };
237
269
  nextTaskId = nextTaskId >= Number.MAX_SAFE_INTEGER ? 1 : nextTaskId + 1;
238
270
  tasks.set(task.id, task);
239
271
  if (tasks.size > MAX_TASKS) {
@@ -367,8 +399,8 @@ function spawnMutation(options) {
367
399
  let child;
368
400
  try {
369
401
  // 独立 CLI 场景(file: 'dsh',宿主加载器走 node bin 不经过这里):先探测 dsh 是否在 PATH。
370
- // 命令不存在时 Windows shell 模式只回传 GBK 乱码、POSIX 才抛 spawn dsh ENOENT —— 统一前置
371
- // 拦截并打 ASCII 标记 [dsh-missing],前端据此精准提示「dsh 未加入 PATH」而不是笼统的插件失败。
402
+ // 命令缺失时各平台报错形态不同(GBK 乱码 / spawn ENOENT),统一前置拦截并打 ASCII 标记
403
+ // [dsh-missing],前端据此精准提示「dsh 未加入 PATH」而不是笼统的插件失败。
372
404
  if (invocation.file === 'dsh' && !dshOnPath(env)) {
373
405
  const detail = '[dsh-missing] the dsh CLI was not found on this machine (DeepSeek Harness not installed, or dsh not on the PATH)';
374
406
  if (task) {
@@ -494,12 +526,9 @@ function spawnMutation(options) {
494
526
  });
495
527
  return (async () => {
496
528
  let result = await spawnOnce(args);
497
- // npm 11 的 arborist peer-set bug(`Cannot read properties of null (reading 'children')`):
498
- // 某些包的 peerDependencies 用「双段 prerelease 范围」(如 dsh-tui@0.9.0 的 `^0.1.0-rc.6 || ^0.1.1-rc.1`,
499
- // 26 个必需 peer 全是这种形状)会让 `npm install -g` 在解析阶段直接崩溃 —— 退出码 1、什么都没装,
500
- // 与是不是从我们界面发起无关(终端里同样复现)。首次失败后自动带 `--legacy-peer-deps` 重试一次:
501
- // 跳过 peer 自动解析即可绕过该 bug(peer 通常由同装的 @deepseek-ai/dsh 依赖树天然提供)。
502
- // 二次尝试仍失败则保留其错误信息,不掩盖真正的原因。
529
+ // npm 11 的 arborist peer-set bug(`Cannot read properties of null (reading 'children')`)
530
+ // 会让 `npm install -g` 在解析阶段直接崩溃。首次失败后自动带 `--legacy-peer-deps` 重试一次:
531
+ // 跳过 peer 自动解析即可绕过该 bug;二次尝试仍失败则保留其错误信息,不掩盖真正的原因。
503
532
  if (isGlobalNpm && result.exitCode !== 0 && !result.timedOut && task?.status !== 'cancelled') {
504
533
  const retryArgs = ['install', '-g', '--legacy-peer-deps', ...(globalNpm ?? [])];
505
534
  const retryExecuted = `npm install -g --legacy-peer-deps ${(globalNpm ?? []).join(' ')}`;
@@ -611,7 +640,7 @@ function ignoredBuildKeys(output) {
611
640
  }
612
641
  /** npm arborist edgesOut 崩溃 + 本机 npm 版本过低 → 把 [npm-too-low] 标记追加到任务输出,
613
642
  * 前端据此展示「本机 npm 版本过低」的准确原因 —— 避免该报错被外层 ERR_PNPM_PREPARE_PACKAGE
614
- * 误判成插件打包/分发问题、误导用户提 Issue。检测逻辑在独立的 npm-check.ts(本机 npm 环境职责)。 */
643
+ * 误判成插件打包/分发问题。检测逻辑在独立的 npm-check.ts(本机 npm 环境职责)。 */
615
644
  function annotateNpmTooLow(result, options) {
616
645
  if (options.action === 'remove' || result.exitCode === 0 || result.timedOut)
617
646
  return result;
@@ -140,7 +140,7 @@ function runCurl(args, env, timeoutMs) {
140
140
  * 若本机 curl 不认这个参数(curl < 7.70)则去掉参数重跑一次,保证老版本不因此直接失败;
141
141
  * 降级后仍被吊销检查拦死(CRYPT_E_NO_REVOCATION_CHECK)时,再用 --ssl-no-revoke
142
142
  * 彻底关掉吊销检查跑一次 —— 该参数 curl ≥ 7.19 即存在,覆盖面比降级参数更广,
143
- * 否则「能上网但取不到 CRL」的 Windows 机器会把所有通道误报成不可达(见 issue #71)。
143
+ * 否则「能上网但取不到 CRL」的 Windows 机器会把所有通道误报成不可达。
144
144
  * 吊销检查只影响本次探测自身的信任判断,不改变系统 TLS 配置。 */
145
145
  async function curlWithTlsFallback(args, env, timeoutMs) {
146
146
  const tlsArgs = curlTlsArgs();
@@ -0,0 +1,10 @@
1
+ import { type WebRoute, type WebServerService } from './shared.ts';
2
+ export declare function profileDirectory(profile: string): string;
3
+ /** Read non-official dependencies installed into one profile. */
4
+ export declare function readInstalled(profile: string): Record<string, string>;
5
+ /** 读已装依赖实际落在磁盘上的仓库身份(`owner/repo`):解析 node_modules/<name>/package.json
6
+ * 的 repository 字段。已装 spec 可能是纯版本号(丢了仓库身份),但真实包元数据仍指向来源仓库,
7
+ * 据此可区分「同名依赖其实是同一仓库的残留(应转 update)」还是「跨仓库包名冲突(应报错)」。
8
+ * 读不到 / 解析不出返回 null(交给调用方按冲突处理)。 */
9
+ export declare function installedRepoOf(profile: string, name: string): string | null;
10
+ export declare function registerCatalogRoutes(webServer: WebServerService, profile: string): WebRoute[];
@@ -0,0 +1,4 @@
1
+ import { type WebRoute, type WebServerService } from './shared.ts';
2
+ /** 宿主环境快照:提交 bug 时随 issue 附上,便于作者复现(前端 /env 端点返回)。 */
3
+ export declare function hostEnv(profile: string): Record<string, string | null>;
4
+ export declare function registerDiagnosticsRoutes(webServer: WebServerService, profile: string): WebRoute[];
@@ -0,0 +1,11 @@
1
+ /**
2
+ * DSH Plugin Hub — the community plugin marketplace for DeepSeek Harness.
3
+ * Website: https://dsh-plugin.org
4
+ * GitHub: https://github.com/dshplugin/dsh-plugin-hub
5
+ *
6
+ * Install/uninstall routes: validate the target and spawn the official dsh CLI
7
+ * (see services/install/install.ts), reporting the captured result back.
8
+ */
9
+ import { type LoaderHandle } from '../services/install/install.ts';
10
+ import { type WebRoute, type WebServerService } from './shared.ts';
11
+ export declare function registerInstallRoutes(webServer: WebServerService, profile: string, loader: LoaderHandle | undefined): WebRoute[];
@@ -0,0 +1,2 @@
1
+ import { type WebRoute, type WebServerService } from './shared.ts';
2
+ export declare function registerLogsRoutes(webServer: WebServerService, profile: string): WebRoute[];