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