dsh-plugin 1.1.2 → 1.2.1

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 (110) hide show
  1. package/client/client.js +4465 -1442
  2. package/client/client.js.map +1 -1
  3. package/lib/http/routes.js +438 -22
  4. package/lib/index.js +1 -1
  5. package/lib/services/install/install.js +4 -0
  6. package/lib/services/install/npm-check.js +55 -0
  7. package/lib/services/{npm-resolve.js → install/npm-resolve.js} +7 -5
  8. package/lib/services/{preflight.js → install/preflight.js} +15 -4
  9. package/lib/services/{task-queue.js → install/task-queue.js} +155 -66
  10. package/lib/services/loader.js +56 -1
  11. package/lib/services/log.js +138 -0
  12. package/lib/services/probe.js +157 -0
  13. package/lib/services/{profile.js → profile/profile.js} +29 -1
  14. package/lib/services/settings.js +60 -0
  15. package/lib/types/http/routes.d.ts +1 -1
  16. package/lib/types/index.d.ts +1 -1
  17. package/lib/types/services/{install-types.d.ts → install/install-types.d.ts} +3 -0
  18. package/lib/types/services/{install.d.ts → install/install.d.ts} +5 -5
  19. package/lib/types/services/install/npm-check.d.ts +12 -0
  20. package/lib/types/services/{npm-resolve.d.ts → install/npm-resolve.d.ts} +2 -1
  21. package/lib/types/services/{task-queue.d.ts → install/task-queue.d.ts} +5 -1
  22. package/lib/types/services/loader.d.ts +14 -8
  23. package/lib/types/services/log.d.ts +49 -0
  24. package/lib/types/services/probe.d.ts +30 -0
  25. package/lib/types/services/{pending-restart.d.ts → profile/pending-restart.d.ts} +1 -1
  26. package/lib/types/services/{profile.d.ts → profile/profile.d.ts} +2 -0
  27. package/lib/types/services/settings.d.ts +24 -0
  28. package/package.json +1 -1
  29. package/src/client/components/PluginHubSection.tsx +341 -120
  30. package/src/client/components/{CatalogList.tsx → catalog/CatalogList.tsx} +11 -7
  31. package/src/client/components/{PluginCard.tsx → catalog/PluginCard.tsx} +5 -4
  32. package/src/client/components/layout/CatalogControls.tsx +80 -0
  33. package/src/client/components/{CatalogHeader.tsx → layout/CatalogHeader.tsx} +4 -4
  34. package/src/client/components/{CategoryTabs.tsx → layout/CategoryTabs.tsx} +3 -3
  35. package/src/client/components/layout/SectionTabs.tsx +60 -0
  36. package/src/client/components/{AboutModal.tsx → modals/AboutModal.tsx} +7 -7
  37. package/src/client/components/{HubUpdateModal.tsx → modals/HubUpdateModal.tsx} +5 -5
  38. package/src/client/components/modals/InstalledDetailModal.tsx +193 -0
  39. package/src/client/components/modals/LogsModal.tsx +301 -0
  40. package/src/client/components/modals/LogsPathModal.tsx +161 -0
  41. package/src/client/components/{NotificationsModal.tsx → modals/NotificationsModal.tsx} +47 -20
  42. package/src/client/components/{ProgressView.tsx → modals/ProgressView.tsx} +2 -2
  43. package/src/client/components/{modals.tsx → modals/modals.tsx} +117 -40
  44. package/src/client/components/{Dropdown.tsx → ui/Dropdown.tsx} +14 -6
  45. package/src/client/components/ui/Toggle.tsx +28 -0
  46. package/src/client/components/ui/icons.tsx +343 -0
  47. package/src/client/components/views/CustomInstallView.tsx +250 -0
  48. package/src/client/components/views/DiagnosticsView.tsx +172 -0
  49. package/src/client/components/views/InstalledView.tsx +328 -0
  50. package/src/client/components/views/LogsView.tsx +150 -0
  51. package/src/client/components/views/MarketView.tsx +66 -0
  52. package/src/client/components/views/SettingsView.tsx +273 -0
  53. package/src/client/data/catalog.ts +38 -0
  54. package/src/client/data/host.ts +107 -0
  55. package/src/client/data/hub.ts +41 -0
  56. package/src/client/hooks/useCatalog.ts +71 -115
  57. package/src/client/hooks/useIncrementalList.ts +46 -0
  58. package/src/client/hooks/useLanguage.ts +40 -0
  59. package/src/client/hooks/useSettings.ts +90 -0
  60. package/src/client/hooks/useTaskQueue.ts +156 -39
  61. package/src/client/locales.ts +358 -38
  62. package/src/client/logic/constants.ts +86 -0
  63. package/src/client/{lib → logic}/failures.ts +95 -74
  64. package/src/client/logic/install-command.ts +56 -0
  65. package/src/client/logic/installed.ts +207 -0
  66. package/src/client/logic/normalize.ts +52 -0
  67. package/src/client/logic/urls.ts +125 -0
  68. package/src/client/styles/CustomInstallView.module.css +191 -0
  69. package/src/client/styles/DiagnosticsView.module.css +231 -0
  70. package/src/client/styles/Dropdown.module.css +42 -41
  71. package/src/client/styles/Header.module.css +100 -132
  72. package/src/client/styles/InstalledView.module.css +447 -0
  73. package/src/client/styles/List.module.css +6 -0
  74. package/src/client/styles/LogsView.module.css +503 -0
  75. package/src/client/styles/Modal.module.css +352 -70
  76. package/src/client/styles/SectionTabs.module.css +142 -0
  77. package/src/client/styles/SettingsView.module.css +232 -0
  78. package/src/client/styles/Toggle.module.css +60 -0
  79. package/src/client/styles/tokens.module.css +1 -0
  80. package/src/client/types.ts +23 -3
  81. package/src/server/http/routes.ts +414 -22
  82. package/src/server/index.ts +1 -1
  83. package/src/server/services/{install-types.ts → install/install-types.ts} +3 -1
  84. package/src/server/services/{install.ts → install/install.ts} +5 -5
  85. package/src/server/services/install/npm-check.ts +53 -0
  86. package/src/server/services/{npm-resolve.ts → install/npm-resolve.ts} +7 -5
  87. package/src/server/services/{preflight.ts → install/preflight.ts} +16 -4
  88. package/src/server/services/{task-queue.ts → install/task-queue.ts} +157 -63
  89. package/src/server/services/loader.ts +55 -1
  90. package/src/server/services/log.ts +167 -0
  91. package/src/server/services/probe.ts +157 -0
  92. package/src/server/services/{pending-restart.ts → profile/pending-restart.ts} +1 -1
  93. package/src/server/services/{profile.ts → profile/profile.ts} +31 -1
  94. package/src/server/services/settings.ts +79 -0
  95. package/lib/services/install.js +0 -4
  96. package/src/client/components/CatalogControls.tsx +0 -108
  97. package/src/client/components/icons.tsx +0 -129
  98. package/src/client/lib/catalog.ts +0 -293
  99. package/src/client/lib/env.ts +0 -20
  100. /package/lib/services/{install-types.js → install/install-types.js} +0 -0
  101. /package/lib/services/{installed-versions.js → profile/installed-versions.js} +0 -0
  102. /package/lib/services/{pending-restart.js → profile/pending-restart.js} +0 -0
  103. /package/lib/services/{progress.js → profile/progress.js} +0 -0
  104. /package/lib/types/services/{preflight.d.ts → install/preflight.d.ts} +0 -0
  105. /package/lib/types/services/{installed-versions.d.ts → profile/installed-versions.d.ts} +0 -0
  106. /package/lib/types/services/{progress.d.ts → profile/progress.d.ts} +0 -0
  107. /package/src/client/{lib → logic}/format.ts +0 -0
  108. /package/src/client/{lib → logic}/renderMarkdown.ts +0 -0
  109. /package/src/server/services/{installed-versions.ts → profile/installed-versions.ts} +0 -0
  110. /package/src/server/services/{progress.ts → profile/progress.ts} +0 -0
@@ -6,17 +6,21 @@
6
6
  * Local HTTP routes exposing real installs to the in-app Plugin Hub UI.
7
7
  * The client fetches the same-origin `/dsh-plugin-hub/*` endpoints; the
8
8
  * install handler validates the target, then spawns the official dsh CLI
9
- * (see services/install.ts) and reports the captured result back.
9
+ * (see services/install/install.ts) and reports the captured result back.
10
10
  */
11
- import { readFileSync } from 'node:fs'
11
+ import { mkdirSync, readFileSync } from 'node:fs'
12
12
  import type { IncomingMessage, ServerResponse } from 'node:http'
13
13
  import { homedir, release } from 'node:os'
14
14
  import { dirname, join, resolve } from 'node:path'
15
15
  import { spawn } from 'node:child_process'
16
- import { activeTask, cancelTask, dumpLoaderEntries, getTask, githubRepoOf, githubTarget, hasQueuedTarget, listPendingRestarts, readProfileArg, startPluginMutation, validPackageName, type LoaderHandle } from '../services/install.ts'
17
- import { recordInstalledVersion, recordResolvedNpmPackage, readInstalledVersions, removeInstalledVersion } from '../services/installed-versions.ts'
18
- import { resolveNpmPackage } from '../services/npm-resolve.ts'
19
- import { preflightTarget } from '../services/preflight.ts'
16
+ import { fetchViaCurl, probeUrl, systemProxy } from '../services/probe.ts'
17
+ import { activeTask, cancelTask, dumpLoaderEntries, getTask, githubRepoOf, githubTarget, globalNpmPackagesOf, hasQueuedTarget, installTargetOf, listPendingRestarts, readProfileArg, startPluginMutation, validPackageName, type LoaderHandle } from '../services/install/install.ts'
18
+ import { recordInstalledVersion, recordResolvedNpmPackage, readInstalledVersions, removeInstalledVersion } from '../services/profile/installed-versions.ts'
19
+ import { resolveNpmPackage } from '../services/install/npm-resolve.ts'
20
+ import { preflightTarget } from '../services/install/preflight.ts'
21
+ import { isDshPlugin, isEntryLoaded } from '../services/loader.ts'
22
+ import { loadSettings, saveSettings, resetSettings, type HubSettings } from '../services/settings.ts'
23
+ import { appendLog, readLog, logFilePath, defaultLogFilePath, customLogFile } from '../services/log.ts'
20
24
 
21
25
  export interface WebRoute {
22
26
  kind: 'exact'
@@ -86,6 +90,22 @@ function hostEnv(profile: string): Record<string, string | null> {
86
90
  }
87
91
  }
88
92
 
93
+ /** 安装/卸载 CLI 的子进程环境:透传宿主环境,并按设置注入 npm 镜像源与 HTTP(S) 代理。
94
+ * 代理优先级:设置里的代理 → 系统代理(macOS scutil / Windows 注册表)→ 宿主 env 原有值。
95
+ * 使安装通道与诊断使用相同的代理来源。 */
96
+ function mutationEnv(settings: HubSettings): NodeJS.ProcessEnv {
97
+ const env: NodeJS.ProcessEnv = { ...process.env, CI: 'true' }
98
+ if (settings.npmRegistry !== '') env.npm_config_registry = settings.npmRegistry
99
+ const proxy = settings.proxy !== '' ? settings.proxy : (systemProxy() ?? '')
100
+ if (proxy !== '') {
101
+ env.HTTP_PROXY = proxy
102
+ env.HTTPS_PROXY = proxy
103
+ env.http_proxy = proxy
104
+ env.https_proxy = proxy
105
+ }
106
+ return env
107
+ }
108
+
89
109
  /** Match a GitHub install target against the installed table; returns the package name when already installed. */
90
110
  function installedByRepo(installed: Record<string, string>, target: string): string | null {
91
111
  const repo = githubRepoOf(target)
@@ -155,6 +175,14 @@ async function readJsonBody(request: IncomingMessage): Promise<unknown> {
155
175
  export function mountPluginHubRoutes(webServer: WebServerService, profile: string, loader?: LoaderHandle): () => void {
156
176
  if (!PROFILE_RE.test(profile)) throw new Error(`invalid profile name: ${profile}`)
157
177
  console.log(`[hub] routes mounted (profile=${profile}, loader=${loader === undefined ? 'undefined' : 'provided'})`)
178
+ // 系统日志:插件随宿主启动即记录一条 —— 保证「打开就有运行日志」,日志页永远有内容
179
+ appendLog(profile, {
180
+ at: Date.now(),
181
+ level: 'info',
182
+ category: 'system',
183
+ event: 'system.start',
184
+ message: `Plugin Hub 已启动(profile=${profile})`,
185
+ })
158
186
  const disposers = [
159
187
  webServer.register({
160
188
  kind: 'exact',
@@ -164,6 +192,263 @@ export function mountPluginHubRoutes(webServer: WebServerService, profile: strin
164
192
  sendJson(response, 200, { entries: dumpLoaderEntries(loader) })
165
193
  },
166
194
  }),
195
+ webServer.register({
196
+ kind: 'exact',
197
+ path: '/dsh-plugin-hub/settings',
198
+ // GET 读 / POST 写:webServer.register 按 (kind, path) 唯一,多方法必须合并到同一个 handler 按 method 分派
199
+ handler: async (request, response) => {
200
+ if (request.method === 'GET') {
201
+ sendJson(response, 200, loadSettings(profile))
202
+ return
203
+ }
204
+ if (!requireTrustedPost(request, response)) return
205
+ try {
206
+ const body = await readJsonBody(request)
207
+ // 白名单字段:只接受已知设置项,防客户端把任意键写进文件
208
+ const patch: Record<string, unknown> = {}
209
+ const source = typeof body === 'object' && body !== null ? body as Record<string, unknown> : {}
210
+ for (const key of ['checkUpdatesOnStart', 'enableNpmInstall', 'enableGitInstall', 'enableDshInstall', 'npmRegistry', 'proxy', 'logPath'] as const) {
211
+ if (source[key] !== undefined) patch[key] = source[key]
212
+ }
213
+ // 日志位置:非空时预建目录验证可写,失败直接 400 拦截(默认位置永远合法,空串 = 回默认)
214
+ if (typeof patch.logPath === 'string' && patch.logPath.trim() !== '') {
215
+ try {
216
+ mkdirSync(dirname(customLogFile(patch.logPath)), { recursive: true })
217
+ } catch {
218
+ sendJson(response, 400, { error: 'log path is not writable' })
219
+ return
220
+ }
221
+ }
222
+ sendJson(response, 200, saveSettings(profile, patch))
223
+ // 系统日志:设置项变更
224
+ appendLog(profile, {
225
+ at: Date.now(),
226
+ level: 'info',
227
+ category: 'settings',
228
+ event: 'settings.update',
229
+ message: `更新设置:${Object.keys(patch).join(', ')}`,
230
+ })
231
+ } catch (error) {
232
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
233
+ }
234
+ },
235
+ }),
236
+ webServer.register({
237
+ kind: 'exact',
238
+ path: '/dsh-plugin-hub/settings/reset',
239
+ handler: async (request, response) => {
240
+ if (!requireTrustedPost(request, response)) return
241
+ sendJson(response, 200, resetSettings(profile))
242
+ appendLog(profile, {
243
+ at: Date.now(),
244
+ level: 'warn',
245
+ category: 'settings',
246
+ event: 'settings.reset',
247
+ message: '恢复全部默认设置',
248
+ })
249
+ },
250
+ }),
251
+ webServer.register({
252
+ kind: 'exact',
253
+ path: '/dsh-plugin-hub/logs',
254
+ handler: (request, response) => {
255
+ if (!requireMethod(request, response, 'GET')) return
256
+ // 日志查看器分页接口:?offset=&limit=&category=&level=&query=,返回过滤后的倒序分页
257
+ try {
258
+ const url = new URL(request.url ?? '/', 'http://localhost')
259
+ const num = (name: string): number | undefined => {
260
+ const v = url.searchParams.get(name)
261
+ if (v === null || v === '') return undefined
262
+ const n = Number(v)
263
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : undefined
264
+ }
265
+ const limit = Math.min(num('limit') ?? 200, 500)
266
+ const category = url.searchParams.get('category') ?? 'all'
267
+ const level = url.searchParams.get('level') ?? 'all'
268
+ const query = url.searchParams.get('query') ?? ''
269
+ const result = readLog(profile, {
270
+ offset: num('offset') ?? 0,
271
+ limit,
272
+ category: category as 'all' | 'install' | 'uninstall' | 'update' | 'diagnostics' | 'settings' | 'system',
273
+ level: level as 'all' | 'debug' | 'info' | 'success' | 'warn' | 'error',
274
+ query,
275
+ })
276
+ sendJson(response, 200, {
277
+ entries: result.entries,
278
+ total: result.total,
279
+ offset: num('offset') ?? 0,
280
+ limit,
281
+ hasMore: result.total > (num('offset') ?? 0) + result.entries.length,
282
+ path: logFilePath(profile),
283
+ defaultPath: defaultLogFilePath(profile),
284
+ })
285
+ } catch {
286
+ sendJson(response, 400, { error: 'invalid log query' })
287
+ }
288
+ },
289
+ }),
290
+ webServer.register({
291
+ kind: 'exact',
292
+ path: '/dsh-plugin-hub/open-log',
293
+ handler: (request, response) => {
294
+ if (!requireTrustedPost(request, response)) return
295
+ // 在系统文件管理器里定位日志文件(macOS Finder -R / Windows explorer /select, / Linux xdg-open)
296
+ try {
297
+ const file = logFilePath(profile)
298
+ const bin = process.platform === 'darwin' ? 'open'
299
+ : process.platform === 'win32' ? 'explorer'
300
+ : 'xdg-open'
301
+ const args = process.platform === 'darwin' ? ['-R', file]
302
+ : process.platform === 'win32' ? ['/select,', file]
303
+ : [file]
304
+ const child = spawn(bin, args, { stdio: 'ignore', detached: true })
305
+ child.once('error', () => sendJson(response, 500, { error: 'failed to open log file' }))
306
+ child.once('spawn', () => sendJson(response, 200, { ok: true }))
307
+ } catch {
308
+ sendJson(response, 500, { error: 'failed to open log file' })
309
+ }
310
+ },
311
+ }),
312
+ // 系统目录选择器:弹原生文件夹对话框让用户挑日志存放目录(macOS osascript /
313
+ // Windows FolderBrowserDialog / Linux zenity),选中返回绝对路径,取消返回 cancelled。
314
+ // 与 open-log 不同,这里必须等对话框关闭才能拿结果,所以阻塞收集 stdout 再应答。
315
+ // 对话框默认定位到当前日志文件所在目录 —— 每台机器/平台的默认位置都不同,
316
+ // 这里动态取 logFilePath() 的目录,用户打开就在「它原来的位置」附近。
317
+ webServer.register({
318
+ kind: 'exact',
319
+ path: '/dsh-plugin-hub/choose-log-dir',
320
+ handler: (request, response) => {
321
+ if (!requireTrustedPost(request, response)) return
322
+ try {
323
+ const title = 'DSH Plugin Hub — choose log folder'
324
+ const startDir = dirname(logFilePath(profile))
325
+ let bin: string
326
+ let args: string[]
327
+ if (process.platform === 'darwin') {
328
+ bin = 'osascript'
329
+ args = ['-e', `POSIX path of (choose folder with prompt "${title}" default location (POSIX file "${startDir}"))`]
330
+ } else if (process.platform === 'win32') {
331
+ bin = 'powershell.exe'
332
+ args = ['-NoProfile', '-STA', '-Command', [
333
+ 'Add-Type -AssemblyName System.Windows.Forms',
334
+ '$f = New-Object System.Windows.Forms.FolderBrowserDialog',
335
+ `$f.Description = '${title}'`,
336
+ // 初始定位到当前日志目录(单引号字符串内反斜杠不转义,Windows 路径原样传入)
337
+ `$f.SelectedPath = '${startDir}'`,
338
+ 'if ($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { $f.SelectedPath } else { "" }',
339
+ ].join('; ')]
340
+ } else {
341
+ bin = 'zenity'
342
+ args = ['--file-selection', '--directory', `--title=${title}`, `--filename=${startDir}`]
343
+ }
344
+ const child = spawn(bin, args)
345
+ let out = ''
346
+ child.stdout.on('data', (d) => { out += String(d) })
347
+ child.on('error', () => sendJson(response, 500, { error: 'directory picker unavailable' }))
348
+ child.on('close', (code) => {
349
+ // macOS osascript 输出带引号(如 "/Users/x"),统一剥掉首尾空白与引号
350
+ const picked = out.trim().replace(/^"|"$/g, '')
351
+ if (code === 0 && picked !== '') sendJson(response, 200, { path: picked })
352
+ else sendJson(response, 200, { cancelled: true })
353
+ })
354
+ } catch {
355
+ sendJson(response, 500, { error: 'directory picker unavailable' })
356
+ }
357
+ },
358
+ }),
359
+ webServer.register({
360
+ kind: 'exact',
361
+ path: '/dsh-plugin-hub/diagnostics',
362
+ handler: async (request, response) => {
363
+ if (!requireTrustedPost(request, response)) return
364
+ // 连通性自检:npm registry(镜像/官方)/ GitHub API / 目录站点,逐路探测并测速。
365
+ // 与安装通道一一对应 —— npm 反查与 npm 安装走 registry、git 源走 github、目录数据走 dsh-plugin.org。
366
+ // 流式(NDJSON)逐条上报:每个探测先发命令行,完成后发结果行,客户端以黑窗口终端实时展示。
367
+ // 可选 body { key }:只重测指定通道(页面列表「重测」单项时用),缺省全量串行探测。
368
+ let onlyKey = ''
369
+ try {
370
+ const body = await readJsonBody(request)
371
+ if (body !== null && typeof body === 'object' && typeof (body as { key?: unknown }).key === 'string') {
372
+ onlyKey = (body as { key: string }).key
373
+ }
374
+ } catch {
375
+ // 空 body / 非 JSON:视为全量探测
376
+ }
377
+ const settings = loadSettings(profile)
378
+ const registry = settings.npmRegistry.replace(/\/+$/, '') || 'https://registry.npmjs.org'
379
+ // 连通性自检直接打安装通道真实访问的轻量资源:npm 源拉包元数据、git 通道探
380
+ // 仓库主页、目录站探 badge 小文件,避免拉取目录全量 JSON。
381
+ const allChecks: Array<{ key: string; url: string; display: string; cmd: string }> = [
382
+ { key: 'npm', url: `${registry}/dsh-plugin`, display: settings.npmRegistry !== '' ? settings.npmRegistry : 'registry.npmjs.org', cmd: `npm view dsh-plugin version --registry ${registry}` },
383
+ { key: 'github', url: 'https://github.com/dshplugin/dsh-plugin-hub', display: 'github.com', cmd: 'git ls-remote https://github.com/dshplugin/dsh-plugin-hub' },
384
+ { key: 'catalog', url: 'https://api.dsh-plugin.org/stats.json', display: 'api.dsh-plugin.org', cmd: 'curl -s https://api.dsh-plugin.org/stats.json' },
385
+ ]
386
+ const checks = onlyKey !== '' ? allChecks.filter((c) => c.key === onlyKey) : allChecks
387
+ response.writeHead(200, {
388
+ 'content-type': 'application/x-ndjson; charset=utf-8',
389
+ 'cache-control': 'no-store',
390
+ })
391
+ const send = (line: unknown) => { response.write(`${JSON.stringify(line)}\n`) }
392
+ // 探测走代理的优先级:设置里的代理 → 系统代理(macOS scutil / Windows 注册表)→ 环境变量 HTTPS_PROXY → 直连。
393
+ // 与安装通道一致(npm/git 靠 HTTP_PROXY 环境变量走代理),且自动跟随系统代理 ——
394
+ // 浏览器挂着代理能开、诊断就能测通,不用用户手动把代理填进设置。
395
+ const effectiveProxy = settings.proxy !== ''
396
+ ? settings.proxy
397
+ : (systemProxy() ?? process.env.HTTPS_PROXY ?? process.env.https_proxy ?? '')
398
+ const probe = (url: string) => probeUrl(url, effectiveProxy, 6000)
399
+ // 逐路串行:一条命令一条结果地推送给终端,保持「程序在跑」的真实感
400
+ const results: Array<{ key: string; ok: boolean }> = []
401
+ for (const c of checks) {
402
+ send({ type: 'probe', key: c.key, cmd: c.cmd, display: c.display })
403
+ const r = await probe(c.url)
404
+ results.push({ key: c.key, ok: r.ok })
405
+ send({ type: r.ok ? 'ok' : 'fail', key: c.key, display: c.display, ms: r.ms, status: r.status })
406
+ }
407
+ send({ type: 'end', at: Date.now() })
408
+ response.end()
409
+ // 系统日志:诊断汇总(全部通过 / 存在不可达)
410
+ const failed = results.filter((r) => !r.ok)
411
+ appendLog(profile, {
412
+ at: Date.now(),
413
+ level: failed.length === 0 ? 'success' : 'error',
414
+ category: 'diagnostics',
415
+ event: 'diagnostics.done',
416
+ message: failed.length === 0
417
+ ? `连通性检测通过(${results.length} 个通道)`
418
+ : `连通性检测:${failed.length}/${results.length} 个通道不可达`,
419
+ })
420
+ },
421
+ }),
422
+ webServer.register({
423
+ kind: 'exact',
424
+ path: '/dsh-plugin-hub/catalog',
425
+ handler: async (request, response) => {
426
+ if (!requireMethod(request, response, 'GET')) return
427
+ // 目录/统计数据服务端代理:浏览器不再直连 dsh-plugin.org,改经此路由
428
+ // 转发(curl 子进程注入代理 env),与 npm / git 安装通道走同一代理口径,
429
+ // 「npm / git / 目录数据请求统一走该代理」的设置文案因此真实生效。
430
+ const url = new URL(request.url ?? '/', 'http://localhost')
431
+ const settings = loadSettings(profile)
432
+ // 代理优先级与安装/诊断一致:设置里的代理 → 系统代理 → 环境变量 → 直连
433
+ const proxy = settings.proxy !== ''
434
+ ? settings.proxy
435
+ : (systemProxy() ?? process.env.HTTPS_PROXY ?? process.env.https_proxy ?? '')
436
+ const isStats = url.searchParams.get('stats') === '1'
437
+ const target = isStats
438
+ ? 'https://api.dsh-plugin.org/stats.json'
439
+ : `https://api.dsh-plugin.org/plugins.${url.searchParams.get('lang') === 'en' ? 'en' : 'zh'}.json`
440
+ const r = await fetchViaCurl(target, proxy, 20000)
441
+ if (!r.ok || r.body === '') {
442
+ sendJson(response, 502, { error: 'catalog fetch failed' })
443
+ return
444
+ }
445
+ try {
446
+ sendJson(response, 200, JSON.parse(r.body))
447
+ } catch {
448
+ sendJson(response, 502, { error: 'catalog fetch returned invalid JSON' })
449
+ }
450
+ },
451
+ }),
167
452
  webServer.register({
168
453
  kind: 'exact',
169
454
  path: '/dsh-plugin-hub/install',
@@ -176,39 +461,102 @@ export function mountPluginHubRoutes(webServer: WebServerService, profile: strin
176
461
  const mode = body !== null && typeof body === 'object' && (body as { mode?: unknown }).mode === 'update'
177
462
  ? 'update'
178
463
  : 'install'
464
+ // installTargetOf 先把完整 DSH 命令(`dsh plugin [--profile <p>] add <target>`)剥成裸目标,
465
+ // 兼容用户直接粘贴命令行(防输错),真正语法判定交给下面 githubRepoOf / validPackageName。
179
466
  const rawRepo = typeof body === 'object' && body !== null && typeof (body as { repo?: unknown }).repo === 'string'
180
- ? (body as { repo: string }).repo.trim()
467
+ ? installTargetOf((body as { repo: string }).repo)
181
468
  : ''
182
469
  // 前端决策的展示用仓库名(owner/repo):npm 安装时 body.repo 是包名,display 用于
183
470
  // 待重启行 / 任务恢复时展示仓库名,保证用户无感知(安装通道变化不改变所见目标)
184
471
  const displayRepo = typeof body === 'object' && body !== null && typeof (body as { display?: unknown }).display === 'string'
185
472
  ? (body as { display: string }).display.trim()
186
473
  : rawRepo
187
- // 目标语法两态:owner/repo → 显式 HTTPS Git 源(走装前预检);npm 包名(@scope/name 或 name)→ 信任 registry 直接安装
188
- const repoTarget = githubTarget(rawRepo)
189
- let target: string | null = repoTarget ?? (repoTarget === null && validPackageName(rawRepo) ? rawRepo : null)
474
+ // 请求来源:目录插件安装(catalog)走目录白名单;命令行安装(custom)按通道受安全开关控制
475
+ const source = typeof body === 'object' && body !== null && typeof (body as { source?: unknown }).source === 'string'
476
+ ? (body as { source: string }).source
477
+ : ''
478
+ const settings = loadSettings(profile)
479
+ // 全局 npm 安装(官方 README 的 `npm install -g <pkgs>`,如 dsh-tui 的安装命令):
480
+ // 直接从命令框输入格式执行全局安装,不进任何 profile、无需宿主重启。
481
+ // 包列表优先取 body.globalNpm(客户端命令框解析后显式携带);兜底再从 rawRepo 解析,
482
+ // 保证直接调 API 传完整命令也能识别。
483
+ const bodyGlobalNpm = Array.isArray((body as { globalNpm?: unknown }).globalNpm)
484
+ ? (body as { globalNpm: unknown[] }).globalNpm.filter((x): x is string => typeof x === 'string' && x !== '')
485
+ : []
486
+ let globalNpm: string[] = bodyGlobalNpm.length > 0 ? bodyGlobalNpm : (globalNpmPackagesOf(rawRepo) ?? [])
487
+ if (globalNpm.length > 0) {
488
+ // 包名逐个按 npm 包名语法校验 + 拒绝以 - 开头的 token(npm 会把 --xxx 当参数而非包名),数量封顶防滥用
489
+ if (globalNpm.length > 20 || globalNpm.some((p) => !validPackageName(p) || p.startsWith('-'))) {
490
+ sendJson(response, 400, { error: 'unsupported install target' })
491
+ return
492
+ }
493
+ // 通道门禁与安装通道同构:全局安装本质是 npm 操作,吃「启用 NPM 安装」开关
494
+ if (source !== 'catalog' && !settings.enableNpmInstall) {
495
+ sendJson(response, 403, { error: 'npm installs are disabled by the security settings' })
496
+ return
497
+ }
498
+ // 队列级去重:同一包列表已在排队/执行中时拒绝
499
+ const identity = globalNpm.join(' ')
500
+ if (hasQueuedTarget(identity)) {
501
+ sendJson(response, 409, { error: `already queued: ${identity}` })
502
+ return
503
+ }
504
+ const task = startPluginMutation({
505
+ action: 'add',
506
+ profile,
507
+ target: identity,
508
+ displayTarget: identity,
509
+ globalNpm,
510
+ timeoutMs: COMMAND_TIMEOUT_MS,
511
+ env: mutationEnv(settings),
512
+ })
513
+ sendJson(response, 200, { ok: true, task: task.id })
514
+ return
515
+ }
516
+ // 目标语法两态:GitHub 地址(owner/repo、github:、https/ssh 链接等任意写法)→ 显式
517
+ // HTTPS Git 源(走装前预检);npm 包名(@scope/name 或 name)→ 信任 registry 直接安装。
518
+ // githubRepoOf 先把各种 GitHub 地址归一成 owner/repo,让「输入地址即装」兼容粘贴完整链接。
519
+ const gitRepo = githubRepoOf(rawRepo)
520
+ const repoTarget = gitRepo !== null ? githubTarget(gitRepo) : null
521
+ let target: string | null = repoTarget ?? (validPackageName(rawRepo) ? rawRepo : null)
190
522
  if (target === null) {
191
523
  sendJson(response, 400, { error: 'unsupported install target' })
192
524
  return
193
525
  }
194
526
  // npm 优先:git 目标先反查该仓库的官方 npm 包,命中则改走 npm 通道。
195
- // git 分发常缺构建产物/子模块导致 prepare 必败,而 npm 包是作者发布的
196
- // 完整产物,成功率高得多;未命中或查询失败保留 github 直装(不阻塞安装,
197
- // 预检仍会兜底拦截 git 分发缺入口文件的情况)。通用机制,不针对具体插件。
527
+ // git 分发常缺构建产物/子模块,npm 包是作者发布的完整产物;未命中或
528
+ // 查询失败保留 github 直装(不阻塞安装,预检仍会拦截缺入口文件的情况)。
529
+ // 通用机制,不针对具体插件。
198
530
  // 反查本身计入「已尝试的安装方式」:组织 scope 与 GitHub 用户名不一致时仅凭
199
531
  // 仓库名猜不到包名,失败提 Issue 时作者看到我们查过的命令就能直接指认正确包名。
200
532
  const attempts: string[] = []
201
533
  if (repoTarget !== null) {
202
- const npmName = await resolveNpmPackage(rawRepo)
534
+ // 反查/记录统一用归一化后的 owner/repo(gitRepo),保证「输入完整链接」也走同一套 npm 反查
535
+ const repoIdentity = gitRepo ?? rawRepo
536
+ const npmName = await resolveNpmPackage(repoIdentity, settings.npmRegistry)
203
537
  if (npmName !== null) {
204
538
  target = npmName
205
- attempts.push(`npm registry search: \`npm search repository:${rawRepo}\` → found \`${npmName}\``)
539
+ attempts.push(`npm registry search: \`npm search repository:${repoIdentity}\` → found \`${npmName}\``)
206
540
  // 持久化 repo → npm 包名映射:目录数据未下发 npmPackage(组织 scope 与 GitHub
207
541
  // 用户名不一致)时,客户端仍能通过 /installed 的 versions 把依赖 key 匹配回仓库,
208
542
  // 列表立即显示「已安装」,避免安装成功后仍显示可安装
209
- recordResolvedNpmPackage(profile, rawRepo, npmName)
543
+ recordResolvedNpmPackage(profile, repoIdentity, npmName)
210
544
  } else {
211
- attempts.push(`npm registry search: \`npm search repository:${rawRepo}\` → no matching package found (falling back to git install)`)
545
+ attempts.push(`npm registry search: \`npm search repository:${repoIdentity}\` → no matching package found (falling back to git install)`)
546
+ }
547
+ }
548
+ // 命令行安装(目录外)按通道门禁:GitHub 源码通道受「启用 GitHub 源码安装」控制,
549
+ // npm 通道受「启用 NPM 安装」控制 —— 目录插件安装走目录白名单,不受开关影响。
550
+ // 通道判定看反查后的最终 target:仍是 git 目标 = 走 GitHub 源码,否则走 npm。
551
+ if (source !== 'catalog') {
552
+ const isGitChannel = repoTarget !== null && target === repoTarget
553
+ if (isGitChannel && !settings.enableGitInstall) {
554
+ sendJson(response, 403, { error: 'git installs are disabled by the security settings' })
555
+ return
556
+ }
557
+ if (!isGitChannel && !settings.enableNpmInstall) {
558
+ sendJson(response, 403, { error: 'npm installs are disabled by the security settings' })
559
+ return
212
560
  }
213
561
  }
214
562
  // 重复安装防护:非更新请求命中已安装目标时直接拒绝,不重复跑 CLI
@@ -259,7 +607,7 @@ export function mountPluginHubRoutes(webServer: WebServerService, profile: strin
259
607
  // 已尝试的安装方式(npm 反查):实际执行命令由 spawn 时追加,失败 issue 一并展示
260
608
  attempts,
261
609
  timeoutMs: COMMAND_TIMEOUT_MS,
262
- env: { ...process.env, CI: 'true' },
610
+ env: mutationEnv(settings),
263
611
  })
264
612
  sendJson(response, 200, { ok: true, task: task.id })
265
613
  } catch (error) {
@@ -304,7 +652,7 @@ export function mountPluginHubRoutes(webServer: WebServerService, profile: strin
304
652
  // 运行中 loader:卸载成功后主动移除条目,立即生效、无需重启
305
653
  uninstallLoader: loader,
306
654
  timeoutMs: COMMAND_TIMEOUT_MS,
307
- env: { ...process.env, CI: 'true' },
655
+ env: mutationEnv(loadSettings(profile)),
308
656
  })
309
657
  sendJson(response, 200, { ok: true, task: task.id })
310
658
  } catch (error) {
@@ -418,14 +766,58 @@ export function mountPluginHubRoutes(webServer: WebServerService, profile: strin
418
766
  }
419
767
  },
420
768
  }),
769
+ webServer.register({
770
+ kind: 'exact',
771
+ path: '/dsh-plugin-hub/open-path',
772
+ handler: async (request, response) => {
773
+ if (!requireTrustedPost(request, response)) return
774
+ // 在系统文件管理器里定位并打开已安装插件目录(详情视图「在文件夹中显示」)。
775
+ // 只接受已安装依赖的包名、服务端自行拼接路径——杜绝任意路径注入
776
+ try {
777
+ const body = await readJsonBody(request)
778
+ const name = typeof body === 'object' && body !== null && typeof (body as { name?: unknown }).name === 'string'
779
+ ? (body as { name: string }).name
780
+ : ''
781
+ if (!validPackageName(name) || readInstalled(profile)[name] === undefined) {
782
+ sendJson(response, 400, { error: 'plugin is not installed' })
783
+ return
784
+ }
785
+ const dir = join(profileDirectory(profile), 'node_modules', name)
786
+ // macOS Finder 用 -R 定位选中;Windows explorer 用 /select,;Linux 打开目录(xdg-open)
787
+ const bin = process.platform === 'darwin' ? 'open'
788
+ : process.platform === 'win32' ? 'explorer'
789
+ : 'xdg-open'
790
+ const args = process.platform === 'darwin' ? ['-R', dir]
791
+ : process.platform === 'win32' ? ['/select,', dir]
792
+ : [dir]
793
+ spawn(bin, args, { detached: true, stdio: 'ignore' }).unref()
794
+ sendJson(response, 200, { ok: true })
795
+ } catch (error) {
796
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
797
+ }
798
+ },
799
+ }),
421
800
  webServer.register({
422
801
  kind: 'exact',
423
802
  path: '/dsh-plugin-hub/installed',
424
803
  handler: (request, response) => {
425
804
  if (!requireMethod(request, response, 'GET')) return
426
- // installed:依赖表(包名 → spec);versions:安装时记录的目录版本(repo → 版本),
427
- // 客户端合并两者判断「是否有更新」
428
- sendJson(response, 200, { profile, installed: readInstalled(profile), versions: readInstalledVersions(profile) })
805
+ // installed:依赖表(包名 → spec);versions:安装时记录的目录版本(repo → 版本);
806
+ // paths:每个依赖在系统上的安装目录(profile/node_modules/<包名>),详情视图展示用;
807
+ // loaded:已加载进运行中 loader 的包名(官方 ctx.loader.entries(),未重启装的新插件不在其中);
808
+ // dshCapable:真正的 dsh 插件(包内声明 dsh 配置 / 在 profile bundles 清单)——
809
+ // 非 dsh 插件(如 GitHub 官方示例仓库)装上也不会被宿主加载,客户端据此不再提示「待重启」。
810
+ // 客户端合并这些判断「是否有更新」「运行状态」并展示安装路径/时间等运行时信息。
811
+ const installed = readInstalled(profile)
812
+ const paths: Record<string, string> = {}
813
+ const loaded: string[] = []
814
+ const dshCapable: string[] = []
815
+ for (const name of Object.keys(installed)) {
816
+ paths[name] = join(profileDirectory(profile), 'node_modules', name)
817
+ if (isEntryLoaded(loader, name)) loaded.push(name)
818
+ if (isDshPlugin(profile, name)) dshCapable.push(name)
819
+ }
820
+ sendJson(response, 200, { profile, installed, versions: readInstalledVersions(profile), paths, loaded, dshCapable })
429
821
  },
430
822
  }),
431
823
  ]
@@ -7,7 +7,7 @@
7
7
  * web server service is available. The browser bundle (src/client) talks to
8
8
  * these same-origin routes to perform real installs from inside the app.
9
9
  */
10
- import { readProfileArg, type LoaderHandle } from './services/install.ts'
10
+ import { readProfileArg, type LoaderHandle } from './services/install/install.ts'
11
11
  import { mountPluginHubRoutes, type WebServerService } from './http/routes.ts'
12
12
 
13
13
  export const name = 'dsh-plugin'
@@ -25,6 +25,8 @@ export interface InstallTask {
25
25
  * 失败提 Issue 时如实贴给作者,作者据此反推正确的 npm 包名(组织 scope 与 GitHub 用户名不一致时
26
26
  * 仅凭仓库名猜不到,作者看到我们查过/试过的命令就能直接指认)。 */
27
27
  attempts: string[]
28
+ /** 全局 npm 安装(官方 `npm install -g <pkgs>`):非空时任务执行全局 npm 安装,不进任何 profile */
29
+ globalNpm?: string[]
28
30
  action: 'add' | 'remove'
29
31
  /** 队列语义:pending 排队中 / running 执行中 / done / failed / cancelled(用户取消) */
30
32
  status: 'pending' | 'running' | 'done' | 'failed' | 'cancelled'
@@ -49,7 +51,7 @@ export interface Invocation {
49
51
  /** 排队中的变更任务及其启动参数。 */
50
52
  export interface QueueItem {
51
53
  task: InstallTask
52
- options: { action: 'add' | 'remove'; profile: string; target: string; timeoutMs?: number; env?: NodeJS.ProcessEnv }
54
+ options: { action: 'add' | 'remove'; profile: string; target: string; timeoutMs?: number; env?: NodeJS.ProcessEnv; globalNpm?: string[] }
53
55
  }
54
56
 
55
57
  /**
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * Install runner 的统一出口:执行真实插件变更(spawn 官方 dsh CLI 子进程)。
7
7
  * 实现按职责拆分在同目录子模块,本文件只做公共 API 的 re-export,保持外部
8
- * import 面稳定(routes.ts / index.ts 仍从 `services/install.ts` 引入)。
8
+ * import 面稳定(routes.ts / index.ts 仍从 `services/install/install.ts` 引入)。
9
9
  *
10
10
  * 模块划分:
11
11
  * - install-types.ts 公共类型与常量
@@ -15,8 +15,8 @@
15
15
  * - task-queue.ts 任务注册表 + FIFO 队列 worker + 子进程生命周期
16
16
  */
17
17
  export type { InstallResult, InstallTask, Invocation, PendingRestart, QueueItem } from './install-types.ts'
18
- export type { LoaderHandle } from './loader.ts'
19
- export { addPendingRestart, clearPendingRestart, listPendingRestarts } from './pending-restart.ts'
20
- export { dumpLoaderEntries, removeLoadedEntry } from './loader.ts'
21
- export { addAllowBuildsKey, githubRepoOf, githubTarget, parseAllowBuildsKey, readProfileArg, validPackageName } from './profile.ts'
18
+ export type { LoaderHandle } from '../loader.ts'
19
+ export { addPendingRestart, clearPendingRestart, listPendingRestarts } from '../profile/pending-restart.ts'
20
+ export { dumpLoaderEntries, removeLoadedEntry } from '../loader.ts'
21
+ export { addAllowBuildsKey, githubRepoOf, githubTarget, globalNpmPackagesOf, installTargetOf, parseAllowBuildsKey, readProfileArg, validPackageName } from '../profile/profile.ts'
22
22
  export { activeTask, cancelTask, getTask, hasQueuedTarget, hasRunningTask, runPluginMutation, startPluginMutation } from './task-queue.ts'
@@ -0,0 +1,53 @@
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
+ * 本机 npm 环境检测:判断一次安装失败是否源于「本机 npm 版本过低」。
7
+ *
8
+ * 背景:pnpm 在 git 源插件的 prepare 阶段会调 npm 装依赖,npm arborist 在解 peer 依赖时
9
+ * 有已知内部崩溃 —— `Cannot read properties of null (reading 'edgesOut')`(build-ideal-tree.js
10
+ * 的 #loadPeerSet),官方标记 Cannot Reproduce(npm/cli#8261、#9787),npm 11.6.0 起不再复现。
11
+ * 该报错会被 pnpm 包进 ERR_PNPM_PREPARE_PACKAGE 输出,若按插件打包问题引导提 Issue 就误导了用户。
12
+ *
13
+ * 这里的 `npmTooLowMarker` 是纯函数(输出文本 + 环境 → 标记行或 null),不触碰任务/队列,
14
+ * 由调用方(task-queue.ts)负责把标记追加到任务输出;spawn 读 npm 版本是唯一副作用,独立可测。
15
+ */
16
+ import { spawnSync } from 'node:child_process'
17
+
18
+ /** npm arborist 内部崩溃特征:`Cannot read properties of null (reading 'edgesOut')`。 */
19
+ export const NPM_CRASH_EDGES_OUT_RE = /Cannot read properties of null \(reading 'edgesOut'\)|edgesOut/i
20
+ /** 该缺陷在 npm 11.6.0 起不再复现;低于此版本遇到 edgesOut 崩溃即判「本机 npm 版本过低」。 */
21
+ export const NPM_MIN_VERSION = [11, 6, 0]
22
+
23
+ /** 读本机 npm 版本(major.minor.patch);npm 不在 PATH 或执行失败/超时时返回 null(不妄下结论)。 */
24
+ export function npmVersionOf(env: NodeJS.ProcessEnv | undefined): [number, number, number] | null {
25
+ try {
26
+ const res = spawnSync('npm', ['-v'], { env, encoding: 'utf8', timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'] })
27
+ if (res.error !== undefined || res.status !== 0) return null
28
+ const m = /(\d+)\.(\d+)\.(\d+)/.exec(res.stdout ?? '')
29
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null
30
+ } catch {
31
+ return null
32
+ }
33
+ }
34
+
35
+ function belowMin(v: [number, number, number]): boolean {
36
+ for (let i = 0; i < NPM_MIN_VERSION.length; i++) {
37
+ if (v[i] < NPM_MIN_VERSION[i]) return true
38
+ if (v[i] > NPM_MIN_VERSION[i]) return false
39
+ }
40
+ return false
41
+ }
42
+
43
+ /**
44
+ * 安装失败输出含 npm arborist edgesOut 崩溃特征,且本机 npm 版本低于阈值时,返回
45
+ * `[npm-too-low] npm@X.Y.Z …` 标记行;否则返回 null。前端据此展示「本机 npm 版本过低」
46
+ * 的准确原因,避免该报错被误判成插件打包/分发问题。
47
+ */
48
+ export function npmTooLowMarker(output: string, env?: NodeJS.ProcessEnv): string | null {
49
+ if (!NPM_CRASH_EDGES_OUT_RE.test(output)) return null
50
+ const v = npmVersionOf(env)
51
+ if (v === null || !belowMin(v)) return null
52
+ return `[npm-too-low] npm@${v.join('.')} — npm arborist crashed while resolving peer deps ('edgesOut'); local npm is below ${NPM_MIN_VERSION.join('.')}; upgrade npm (npm install -g npm@latest) and retry`
53
+ }