dsh-plugin 1.6.4 → 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 (51) hide show
  1. package/README.en.md +11 -11
  2. package/README.md +11 -11
  3. package/client/client.js +450 -405
  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 -1261
  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/npm-resolve.js +44 -15
  15. package/lib/services/install/task-queue.js +45 -16
  16. package/lib/services/probe.js +18 -1
  17. package/lib/types/http/catalog.d.ts +10 -0
  18. package/lib/types/http/diagnostics.d.ts +4 -0
  19. package/lib/types/http/install.d.ts +11 -0
  20. package/lib/types/http/logs.d.ts +2 -0
  21. package/lib/types/http/routes.d.ts +15 -20
  22. package/lib/types/http/settings.d.ts +2 -0
  23. package/lib/types/http/shared.d.ts +44 -0
  24. package/lib/types/http/task.d.ts +3 -0
  25. package/lib/types/services/install/install.d.ts +1 -1
  26. package/lib/types/services/install/npm-resolve.d.ts +3 -2
  27. package/lib/types/services/probe.d.ts +8 -0
  28. package/package.json +1 -1
  29. package/src/client/components/PluginHubSection.tsx +7 -2
  30. package/src/client/components/modals/HubUpdateModal.tsx +19 -10
  31. package/src/client/components/modals/NotificationsModal.tsx +34 -18
  32. package/src/client/components/modals/modals.tsx +38 -28
  33. package/src/client/components/views/InstalledView.tsx +23 -11
  34. package/src/client/hooks/useCatalog.ts +13 -9
  35. package/src/client/locales.ts +28 -8
  36. package/src/client/logic/failures.ts +36 -157
  37. package/src/client/logic/urls.ts +72 -1
  38. package/src/client/types.ts +11 -0
  39. package/src/server/http/catalog.ts +143 -0
  40. package/src/server/http/diagnostics.ts +193 -0
  41. package/src/server/http/install.ts +391 -0
  42. package/src/server/http/logs.ts +149 -0
  43. package/src/server/http/routes.ts +22 -1220
  44. package/src/server/http/settings.ts +73 -0
  45. package/src/server/http/shared.ts +191 -0
  46. package/src/server/http/task.ts +194 -0
  47. package/src/server/services/install/install-types.ts +9 -1
  48. package/src/server/services/install/install.ts +11 -1
  49. package/src/server/services/install/npm-resolve.ts +41 -13
  50. package/src/server/services/install/task-queue.ts +45 -16
  51. package/src/server/services/probe.ts +19 -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'
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import { get } from 'node:https'
15
15
  import { githubRepoOf } from '../profile/profile.ts'
16
+ import { finalUrl } from '../probe.ts'
16
17
 
17
18
  /** 反查缓存:repo 小写 → npm 包名(null 表示已确认无对应 npm 包;网络异常不缓存)。 */
18
19
  const cache = new Map<string, string | null>()
@@ -30,16 +31,21 @@ const SEARCH_RESULT_LIMIT = 250
30
31
  * 安装仍会重试反查,避免一次瞬时故障把整个会话锁死在 git 通道。
31
32
  * registry 参数:npm 镜像源地址,空串 = 官方源;与安装通道吃同一 registry,
32
33
  * 保证「配置了镜像」时反查和安装走同一个源(镜像节点同步完整时结果一致)。
34
+ * proxy 参数:GitHub 改名跳转探测走的代理(与安装/诊断同一口径)。
33
35
  * 慢网络下失败不阻塞安装。
34
36
  */
35
- export function resolveNpmPackage(repo: string, registry = ''): Promise<string | null> {
37
+ export async function resolveNpmPackage(repo: string, registry = '', proxy = ''): Promise<string | null> {
36
38
  const key = repo.toLowerCase()
37
- if (cache.has(key)) return Promise.resolve(cache.get(key) ?? null)
38
- const name = searchRepo(repo, registry)
39
- void name.then((found) => {
40
- if (found !== undefined) cache.set(key, found)
41
- })
42
- return name.then((found) => found ?? null)
39
+ if (cache.has(key)) return cache.get(key) ?? null
40
+ let found = await searchRepo(repo, registry)
41
+ // 仓库改名后 npm 包的 repository 已指向新名,按旧名反查不到:跟随一次 GitHub 的
42
+ // 改名跳转拿到规范名再查一遍(旧地址本身仍可用,只是仓库名变了)。
43
+ if (found === null) {
44
+ const canonical = await canonicalRepo(repo, proxy)
45
+ if (canonical !== '' && canonical.toLowerCase() !== key) found = await searchRepo(canonical, registry)
46
+ }
47
+ if (found !== undefined) cache.set(key, found)
48
+ return found ?? null
43
49
  }
44
50
 
45
51
  type NpmSearchPackage = {
@@ -77,7 +83,7 @@ export function isDshPackageMetadataForRepo(pkg: NpmSearchPackage, repo: string)
77
83
  return keywordMarked || manifestMarked
78
84
  }
79
85
 
80
- export function isDshNpmPackageForRepo(packageName: string, repo: string, registry = ''): Promise<boolean> {
86
+ export function isDshNpmPackageForRepo(packageName: string, repo: string, registry = '', proxy = ''): Promise<boolean> {
81
87
  if (packageName === '' || repo === '') return Promise.resolve(false)
82
88
  const base = registry === '' ? 'https://registry.npmjs.org' : registry.replace(/\/+$/, '')
83
89
  const url = `${base}/${encodeURIComponent(packageName)}/latest`
@@ -91,11 +97,20 @@ export function isDshNpmPackageForRepo(packageName: string, repo: string, regist
91
97
  let body = ''
92
98
  res.on('data', (chunk: Buffer) => { body += chunk.toString() })
93
99
  res.on('end', () => {
94
- try {
95
- resolve(isDshPackageMetadataForRepo(JSON.parse(body) as NpmSearchPackage, repo))
96
- } catch {
97
- resolve(false)
98
- }
100
+ void (async () => {
101
+ try {
102
+ const pkg = JSON.parse(body) as NpmSearchPackage
103
+ if (isDshPackageMetadataForRepo(pkg, repo)) {
104
+ resolve(true)
105
+ return
106
+ }
107
+ // 仓库改名后包元数据指向新名:按原名不匹配时,用 GitHub 跳转后的规范名复核一次
108
+ const canonical = await canonicalRepo(repo, proxy)
109
+ resolve(canonical !== '' && isDshPackageMetadataForRepo(pkg, canonical))
110
+ } catch {
111
+ resolve(false)
112
+ }
113
+ })()
99
114
  })
100
115
  })
101
116
  req.on('timeout', () => req.destroy())
@@ -103,6 +118,19 @@ export function isDshNpmPackageForRepo(packageName: string, repo: string, regist
103
118
  })
104
119
  }
105
120
 
121
+ /**
122
+ * 解析仓库改名后的规范 `owner/repo`:GitHub 对已改名仓库返回 301,跟随跳转取最终地址。
123
+ * 与其它 GitHub 访问同口径走 curl + 代理 env(Node 内置 https 读不到系统代理)。
124
+ * 未改名、仓库不存在、网络失败或 curl 不可用都返回 '',调用方按原名继续。
125
+ */
126
+ async function canonicalRepo(repo: string, proxy: string): Promise<string> {
127
+ if (githubRepoOf(repo) === null) return ''
128
+ const key = repo.toLowerCase()
129
+ const final = await finalUrl(`https://github.com/${repo}`, proxy, REQUEST_TIMEOUT_MS)
130
+ const canonical = final === null ? null : githubRepoOf(final)
131
+ return canonical !== null && canonical.toLowerCase() !== key ? canonical : ''
132
+ }
133
+
106
134
  function matchingPackagesForRepo(objects: NpmSearchObject[], repo: string): Array<{ name: string; isDshPlugin: boolean }> {
107
135
  const matches: Array<{ name: string; isDshPlugin: boolean }> = []
108
136