dsh-plugin 2.0.6 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.en.md +11 -11
  2. package/README.md +11 -11
  3. package/client/client.js +585 -328
  4. package/client/client.js.map +1 -1
  5. package/lib/http/diagnostics.js +2 -63
  6. package/lib/http/install.js +42 -11
  7. package/lib/http/settings.js +1 -1
  8. package/lib/services/host-version.js +72 -0
  9. package/lib/services/install/decode-output.js +23 -0
  10. package/lib/services/install/dsh-compat.js +105 -0
  11. package/lib/services/install/operation-log.js +61 -11
  12. package/lib/services/install/registry-fallback.js +89 -0
  13. package/lib/services/install/task-queue.js +95 -19
  14. package/lib/services/settings.js +1 -0
  15. package/lib/types/services/host-version.d.ts +10 -0
  16. package/lib/types/services/install/decode-output.d.ts +4 -0
  17. package/lib/types/services/install/dsh-compat.d.ts +19 -0
  18. package/lib/types/services/install/install-types.d.ts +4 -0
  19. package/lib/types/services/install/operation-log.d.ts +10 -4
  20. package/lib/types/services/install/registry-fallback.d.ts +18 -0
  21. package/lib/types/services/install/task-queue.d.ts +18 -0
  22. package/lib/types/services/settings.d.ts +3 -1
  23. package/package.json +1 -1
  24. package/src/client/components/PluginHubSection.tsx +11 -2
  25. package/src/client/components/modals/NotificationsModal.tsx +62 -5
  26. package/src/client/components/modals/modals.tsx +68 -5
  27. package/src/client/components/views/SettingsView.tsx +10 -0
  28. package/src/client/hooks/useCatalog.ts +10 -8
  29. package/src/client/hooks/useSettings.ts +4 -1
  30. package/src/client/hooks/useTaskQueue.ts +7 -1
  31. package/src/client/locales.ts +42 -0
  32. package/src/client/logic/failures.ts +86 -5
  33. package/src/client/logic/release-age.ts +40 -0
  34. package/src/client/logic/urls.ts +19 -4
  35. package/src/client/types.ts +13 -0
  36. package/src/server/http/diagnostics.ts +2 -59
  37. package/src/server/http/install.ts +46 -11
  38. package/src/server/http/settings.ts +1 -1
  39. package/src/server/services/host-version.ts +69 -0
  40. package/src/server/services/install/decode-output.ts +22 -0
  41. package/src/server/services/install/dsh-compat.ts +99 -0
  42. package/src/server/services/install/install-types.ts +4 -0
  43. package/src/server/services/install/operation-log.ts +58 -11
  44. package/src/server/services/install/registry-fallback.ts +89 -0
  45. package/src/server/services/install/task-queue.ts +107 -19
  46. package/src/server/services/settings.ts +4 -1
@@ -49,12 +49,15 @@ function reasonTitleOf(kind: FailureKind, action: FailureAction): string {
49
49
  case 'dshMissing': return 'dsh command not found on the user machine'
50
50
  case 'pnpmMissing': return 'pnpm command not found on the user machine'
51
51
  case 'pnpmStore': return 'pnpm store version mismatch on the user machine'
52
+ case 'pnpmVirtualStore': return 'the profile modules directory was built with a different virtual-store-dir-max-length on the user machine'
52
53
  case 'pnpmWorkspace': return `pnpm workspace-root check blocked the ${word} on the user machine`
53
54
  case 'originRejected': return 'request origin rejected by the local service on the user machine'
54
55
  case 'pnpmPolicy': return `pnpm supply-chain policy blocked the ${word} on the user machine`
55
56
  case 'pnpmUnusedPatch': return 'stale pnpm patch entry on the user machine'
56
57
  case 'pnpmMissingDep': return 'dependency not found on the npm registry on the user machine'
57
- case 'registryVersionStale': return 'requested version not available on the npm registry in use on the user machine'
58
+ case 'registryVersionStale': return `requested version not available on the npm registry in use on the user machine`
59
+ case 'registryAuth': return `npm registry rejected the credentials for this ${word} on the user machine`
60
+ case 'profileExclusive': return `the desktop profile is managed only by the Electron app, so the CLI refused the ${word}`
58
61
  case 'pnpmLinkedDirMissing': return 'dangling local (link:) dependency in the user profile'
59
62
  case 'fileLocked': return 'file locked by another process on the user machine'
60
63
  case 'accessDenied': return 'file write access denied on the user machine'
@@ -68,6 +71,8 @@ function reasonTitleOf(kind: FailureKind, action: FailureAction): string {
68
71
  case 'timeout': return `${word} timed out`
69
72
  case 'profileLock': return 'DSH profile is locked by another process'
70
73
  case 'pluginMissingDep': return 'a dependency of the plugin is not published on npm'
74
+ case 'dshTooOld': return 'the plugin requires a newer DSH than the host runs'
75
+ case 'localEnv': return `no plugin-side cause found — the failure points at the local environment for this ${word}`
71
76
  case 'hub': return `${word} failed (cause unclassified)`
72
77
  default: return `plugin ${word} failed`
73
78
  }
@@ -101,6 +106,16 @@ export function pluginIssueUrl(repo: string, message: string, env?: EnvInfo | nu
101
106
  ? 'a dependency declared by the plugin is not published on the public npm registry (missing, removed, or never released); please publish or correct that dependency, or ship it with the plugin'
102
107
  : kind === 'network'
103
108
  ? 'network connectivity issue on the user side (DNS, proxy, firewall, or a blocked connection)'
109
+ : kind === 'localEnv'
110
+ ? 'no evidence of a plugin-side cause; the log shows local-environment signals (system error codes, certificates/proxy, or a broken local config), so this is reported for triage of the classifier only'
111
+ : kind === 'dshTooOld'
112
+ ? 'the plugin declares a minimum DSH version this host does not meet — upgrade DSH first (local environment issue)'
113
+ : kind === 'pnpmVirtualStore'
114
+ ? 'the profile node_modules was created with a different virtual-store-dir-max-length (or a different pnpm config), so pnpm refuses to reuse it (local environment issue)'
115
+ : kind === 'registryAuth'
116
+ ? 'the npm registry rejected the request with 401/403 — the configured registry needs credentials or blocks this network (local environment issue)'
117
+ : kind === 'profileExclusive'
118
+ ? 'the desktop profile can only be managed by the Electron app, and the install was attempted from an external CLI (local environment issue)'
104
119
  : kind === 'pluginPrepare'
105
120
  ? 'plugin prepare/build script failed during install (packaging/distribution issue)'
106
121
  : kind === 'pnpmIgnoredBuild'
@@ -163,8 +178,8 @@ export function pluginIssueUrl(repo: string, message: string, env?: EnvInfo | nu
163
178
  `- OS: ${env.platform} ${env.arch} (${env.release})`,
164
179
  `- Profile: \`${env.profile}\``,
165
180
  `- DSH Home: \`${env.dshHome}\``,
166
- // 完整安装日志所在(正文受 URL 长度限制只能带摘要,完整日志在 hub.log,可复制/附加给作者)
167
- `- Full log: \`~/.dsh/profiles/${env.profile}/hub.log\` (paste or attach for the full output)`,
181
+ // 完整安装日志在通知中心(hub.log 只有任务开始/结束,没有 pnpm 输出,别让用户白附)
182
+ '- Full log: open the Hub notifications, select the failed record and press "Copy"',
168
183
  ]
169
184
  : []),
170
185
  // 该插件在插件市场的收录位置(详情页链接)
@@ -265,7 +280,7 @@ export function hubIssueUrl(
265
280
  `- OS: ${env.platform} ${env.arch} (${env.release})`,
266
281
  `- Profile: \`${env.profile}\``,
267
282
  `- DSH Home: \`${env.dshHome}\``,
268
- `- Full log: \`~/.dsh/profiles/${env.profile}/hub.log\` (paste or attach for the full output)`,
283
+ '- Full log: open the Hub notifications, select the failed record and press "Copy"',
269
284
  ]
270
285
  : []),
271
286
  ...(repo !== 'unknown' ? [`- Catalog: [${pluginSiteUrl(repo)}](${pluginSiteUrl(repo)})`] : []),
@@ -108,6 +108,19 @@ export interface HubUpdateInfo {
108
108
  publishedAt?: string | null
109
109
  /** Markdown 变更记录;可传字符串,或 { zh, en } 双语言对象按界面语言取 */
110
110
  notes?: string | Record<string, string> | null
111
+ /**
112
+ * 最近若干发版(含最新版),按版本号降序无关、由客户端自行排序。
113
+ * 用途:连续快速发版时,最新版往往还在 pnpm 的 24h 供应链门禁内;
114
+ * 客户端据此回退到「更早、但已过门禁」的版本提示更新,而不是整体跳过。
115
+ */
116
+ recentVersions?: HubUpdateVersion[]
117
+ }
118
+
119
+ /** 单个发版记录:版本号 + 与 npm registry 同源的发布时间 + 该版本变更记录。 */
120
+ export interface HubUpdateVersion {
121
+ version: string
122
+ publishedAt?: string | null
123
+ notes?: string | Record<string, string> | null
111
124
  }
112
125
 
113
126
  /**
@@ -5,72 +5,15 @@
5
5
  *
6
6
  * 诊断路由:流式连通性自检、代理校验,以及与任务模块 /env 路由共享的宿主环境快照。
7
7
  */
8
- import { readFileSync } from 'node:fs'
9
8
  import { homedir, release } from 'node:os'
10
- import { dirname, join, resolve } from 'node:path'
9
+ import { join } from 'node:path'
11
10
  import { spawnSync } from 'node:child_process'
12
11
  import { loadSettings } from '../services/settings.ts'
12
+ import { hostDshVersion, toolVersion } from '../services/host-version.ts'
13
13
  import { gitLsRemote, probeUrl, systemProxy } from '../services/probe.ts'
14
14
  import { appendLog } from '../services/log.ts'
15
15
  import { readJsonBody, requireTrustedPost, sendJson, type WebRoute, type WebServerService } from './shared.ts'
16
16
 
17
- /** 宿主 dsh CLI 版本:优先直接跑 `dsh --version`(与 pnpm/npm/git 同款同步探测,1500ms 超时兜底,
18
- * 绝不挂起);拿不到再退回从入口 `process.argv[1]` 向上找 package.json(dsh 不在 PATH 时兜底)。 */
19
- function hostDshVersion(): string | null {
20
- const viaCli = toolVersion('dsh', ['--version'])
21
- if (viaCli) return viaCli
22
- const entry = process.argv[1]
23
- if (entry === undefined) return null
24
- let dir = dirname(resolve(entry))
25
- for (let depth = 0; depth < 4; depth++) {
26
- try {
27
- const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { name?: string; version?: string }
28
- const name = manifest.name ?? ''
29
- if (typeof manifest.version === 'string' && (name === 'dsh' || name.includes('dsh') || name.includes('harness'))) {
30
- return manifest.version
31
- }
32
- } catch {
33
- /* 不是包根,继续向上查找 */
34
- }
35
- const parent = dirname(dir)
36
- if (parent === dir) break
37
- dir = parent
38
- }
39
- return null
40
- }
41
-
42
- /** 从命令输出里抽出版本号本体:pnpm/npm 直接打印 `11.7.0`,而 `git --version` 打印
43
- * `git version 2.55.0.windows.5` —— 原样取首行会让 issue 正文(统一再加 `v` 前缀)拼成
44
- * `vgit version 2.55.0.windows.5`。只取版本号,顺带兼容 `v1.2.3` 这类自带前缀的输出。 */
45
- function versionOf(output: string): string | null {
46
- const first = output.trim().split(/\r?\n/)[0]?.trim() ?? ''
47
- if (!first) return null
48
- const match = /\d+\.\d+[0-9A-Za-z.\-+]*/.exec(first)
49
- return match === null ? null : match[0]
50
- }
51
-
52
- /** 探测命令行工具的版本号(pnpm --version / npm --version / git --version):
53
- * 未安装 / 不在 PATH / 超时一律返回 null —— issue 环境快照如实写 unknown,绝不挂起。
54
- * (同步探测仅用于启动期/请求期的低频率 /env 快照,安装通道不经过这里。)
55
- *
56
- * Windows 上 pnpm/npm 是 .cmd shim,不带 shell 的 spawnSync 起不来 —— 上报的 issue 里
57
- * 因此长期是 `pnpm: unknown` / `npm: unknown`,而 pnpm 版本恰恰是 allowBuilds 那类问题的
58
- * 关键信息(门禁行为随版本变)。故 win32 走 shell 让 cmd 解析 shim;参数是固定的
59
- * `--version`,没有注入面。 */
60
- function toolVersion(cmd: string, args: string[]): string | null {
61
- try {
62
- const r = spawnSync(cmd, args, {
63
- timeout: 1500,
64
- encoding: 'utf8',
65
- shell: process.platform === 'win32',
66
- })
67
- if (r.error || r.status !== 0) return null
68
- return versionOf(r.stdout ?? '')
69
- } catch {
70
- return null
71
- }
72
- }
73
-
74
17
  /** 本机实际生效的 npm registry:诊断与安装同口径 —— 设置里留空时读用户 npm 配置
75
18
  * (含 profile 目录的 .npmrc),而不是硬探官方源,避免「能装但诊断说源不通」。
76
19
  * 读不到(npm 缺失/超时/输出异常)时回退官方源。 */
@@ -11,11 +11,13 @@ import { recordResolvedNpmPackage } from '../services/profile/installed-versions
11
11
  import { isDshNpmPackageForRepo } from '../services/install/npm-resolve.ts'
12
12
  import { probeUrl, systemProxy } from '../services/probe.ts'
13
13
  import { loadSettings, type HubSettings } from '../services/settings.ts'
14
+ import { registryCandidates, registryHost, userNpmRegistry } from '../services/install/registry-fallback.ts'
15
+ import { profileDirectory } from '../services/profile/profile.ts'
14
16
  import { readInstalled } from './catalog.ts'
15
17
  import { COMMAND_TIMEOUT_MS, readJsonBody, requireTrustedPost, sendJson, type WebRoute, type WebServerService } from './shared.ts'
16
18
 
17
19
  /** 安装/卸载 CLI 的子进程环境:透传宿主环境并注入 HTTP(S) 代理,
18
- * 代理来源与诊断一致(设置代理 → 系统代理 → 宿主 env);不注入 npm registry,沿用用户本机 npm 配置。 */
20
+ * 代理来源与诊断一致(设置代理 → 系统代理 → 宿主 env);registry 由任务队列按候选源逐次注入。 */
19
21
  function mutationEnv(settings: HubSettings): NodeJS.ProcessEnv {
20
22
  const env: NodeJS.ProcessEnv = { ...process.env, CI: 'true' }
21
23
  const proxy = settings.proxy !== '' ? settings.proxy : (systemProxy() ?? '')
@@ -64,6 +66,17 @@ export function registerInstallRoutes(webServer: WebServerService, profile: stri
64
66
  const lang = typeof body === 'object' && body !== null && (body as { lang?: unknown }).lang === 'en'
65
67
  ? 'en'
66
68
  : 'zh'
69
+ // 代理口径与诊断/安装通道一致:设置里的代理 → 系统代理 → 环境变量 → 直连。
70
+ const effectiveProxy = settings.proxy !== ''
71
+ ? settings.proxy
72
+ : (systemProxy() ?? process.env.HTTPS_PROXY ?? process.env.https_proxy ?? '')
73
+ // 安装用的 npm 源:设置里显式选择优先;未配置时读本机 npm 配置(与诊断同口径)。
74
+ // 只有公共源允许失败后换源;自定义/内网源保持单一候选,避免私有包名泄露给公共源。
75
+ const registries = registryCandidates(
76
+ settings.npmRegistry,
77
+ settings.npmRegistry === '' ? userNpmRegistry(profileDirectory(profile)) : '',
78
+ settings.registryFallback,
79
+ )
67
80
  // 全局 npm 安装(`npm install -g <pkgs>`):不进任何 profile、无需宿主重启。
68
81
  // 包列表优先取 body.globalNpm,兜底从 rawRepo 解析,保证直接调 API 传完整命令也能识别。
69
82
  const bodyGlobalNpm = Array.isArray((body as { globalNpm?: unknown }).globalNpm)
@@ -95,6 +108,8 @@ export function registerInstallRoutes(webServer: WebServerService, profile: stri
95
108
  globalNpm,
96
109
  timeoutMs: COMMAND_TIMEOUT_MS,
97
110
  env: mutationEnv(settings),
111
+ // 候选源(首选在前):任务内遇到传输层失败时按顺序换源重试
112
+ registries,
98
113
  // 全局 npm 安装由 NPM 包卡片/官方命令发起:日志体现入口渠道
99
114
  installChannel: installChannel ?? 'npm',
100
115
  })
@@ -107,12 +122,17 @@ export function registerInstallRoutes(webServer: WebServerService, profile: stri
107
122
  return
108
123
  }
109
124
  const target = rawRepo
125
+ // 钉住精确版本(Hub 自我更新):最新版可能仍在 pnpm 供应链门禁内,
126
+ // 客户端提示的是「已过门禁」的版本,服务端必须按该版本安装而不是 @latest。
127
+ const pinVersion = typeof (body as { version?: unknown }).version === 'string'
128
+ ? (body as { version: string }).version.trim()
129
+ : ''
130
+ if (pinVersion !== '' && !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?$/.test(pinVersion)) {
131
+ sendJson(response, 400, { error: 'invalid version' })
132
+ return
133
+ }
110
134
  // 已尝试的安装方式(供失败提 Issue 预填):保持为空
111
135
  const attempts: string[] = []
112
- // 代理口径与诊断/安装通道一致:设置里的代理 → 系统代理 → 环境变量 → 直连。
113
- const effectiveProxy = settings.proxy !== ''
114
- ? settings.proxy
115
- : (systemProxy() ?? process.env.HTTPS_PROXY ?? process.env.https_proxy ?? '')
116
136
  // 目录插件安装:目录数据可能滞后于打包拆分,确认目录下发的 npm 目标确为该仓库的 DSH 包,
117
137
  // 命中则记录 repo → 包名映射,客户端据此把依赖 key 匹配回仓库、列表立即显示「已安装」。
118
138
  const catalogRepo = source === 'catalog' ? githubRepoOf(displayRepo) : null
@@ -136,18 +156,30 @@ export function registerInstallRoutes(webServer: WebServerService, profile: stri
136
156
  return
137
157
  }
138
158
  // 安装前网络探测:探 registry 根而非具体包(404 是「包不存在」而非网络不通);仅新安装执行,更新跳过。
139
- // 不通则 400 拦下并打 [network] 标记,客户端据此提示网络问题,而非插件问题。
159
+ // 首选源不通时按候选顺序继续探下一个,能通的那个提到首位供实际安装使用。
160
+ // 全部不通则 400 拦下并打 [network] 标记,客户端据此提示网络问题,而非插件问题。
161
+ let ordered = registries
140
162
  if (already === null) {
141
- const probeTarget = `${(settings.npmRegistry.replace(/\/+$/, '') || 'https://registry.npmjs.org')}/`
142
- const net = await probeUrl(probeTarget, effectiveProxy, 6000)
143
- if (!net.ok) {
163
+ const tried: string[] = []
164
+ let chosen: string | null = null
165
+ for (const candidate of registries) {
166
+ tried.push(candidate)
167
+ const net = await probeUrl(`${candidate}/`, effectiveProxy, 6000)
168
+ if (net.ok) {
169
+ chosen = candidate
170
+ break
171
+ }
172
+ }
173
+ if (chosen === null) {
144
174
  // 错误消息按界面语言输出,让用户看清是网络问题而非插件问题
175
+ const hosts = tried.map(registryHost).join(' → ')
145
176
  const error = lang === 'zh'
146
- ? `[network] 安装已中止:安装前无法连接到 npm 源(${probeTarget})—— 您的网络似乎不通或被拦截(断网 / DNS 解析失败 / 防火墙 / 代理配置问题)。这不是插件本身的问题,请检查网络连接或代理设置,运行「系统诊断」检测各通道连通性后重试。`
147
- : `[network] install aborted: cannot reach the npm registry (${probeTarget}) before install — your network connection appears to be down or blocked (DNS / proxy / firewall). This is not a problem with the plugin itself. Check your connection or proxy settings, run the connectivity diagnostic, then retry.`
177
+ ? `[network] 安装已中止:安装前无法连接到 npm 源(${hosts})—— 您的网络似乎不通或被拦截(断网 / DNS 解析失败 / 防火墙 / 代理配置问题)。这不是插件本身的问题,请检查网络连接或代理设置,运行「系统诊断」检测各通道连通性后重试。`
178
+ : `[network] install aborted: cannot reach the npm registry (${hosts}) before install — your network connection appears to be down or blocked (DNS / proxy / firewall). This is not a problem with the plugin itself. Check your connection or proxy settings, run the connectivity diagnostic, then retry.`
148
179
  sendJson(response, 400, { error, attempts })
149
180
  return
150
181
  }
182
+ ordered = [chosen, ...registries.filter((r) => r !== chosen)]
151
183
  }
152
184
  // 入队前复检:网络探测的 await 期间并发请求可能已通过上方检查,而此处到 startPluginMutation
153
185
  // 之间无 await,JS 单线程保证同一目标至多一个任务,杜绝双击/并发双任务。
@@ -170,6 +202,9 @@ export function registerInstallRoutes(webServer: WebServerService, profile: stri
170
202
  loader,
171
203
  timeoutMs: COMMAND_TIMEOUT_MS,
172
204
  env: mutationEnv(settings),
205
+ // 候选源(首选在前):任务内遇到传输层失败时按顺序换源重试
206
+ registries: ordered,
207
+ installSpec: pinVersion !== '' ? `${target}@${pinVersion}` : undefined,
173
208
  // 自定义安装入口渠道:日志/报错据此溯源
174
209
  installChannel,
175
210
  })
@@ -28,7 +28,7 @@ export function registerSettingsRoutes(webServer: WebServerService, profile: str
28
28
  // 白名单字段:只接受已知设置项,防客户端把任意键写进文件
29
29
  const patch: Record<string, unknown> = {}
30
30
  const source = typeof body === 'object' && body !== null ? body as Record<string, unknown> : {}
31
- for (const key of ['checkUpdatesOnStart', 'enableNpmInstall', 'npmRegistry', 'proxy', 'logPath'] as const) {
31
+ for (const key of ['checkUpdatesOnStart', 'enableNpmInstall', 'npmRegistry', 'registryFallback', 'proxy', 'logPath'] as const) {
32
32
  if (source[key] !== undefined) patch[key] = source[key]
33
33
  }
34
34
  // 日志位置:非空时预建目录验证可写,失败直接 400 拦截(默认位置永远合法,空串 = 回默认)
@@ -0,0 +1,69 @@
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
+ * 宿主与命令行工具的版本探测:dsh 版本同时用于诊断快照和「插件要求更高 DSH」判定,
7
+ * pnpm/npm/git 只用于诊断快照。同步探测 + 1500ms 超时兜底,拿不到一律 null,绝不挂起。
8
+ */
9
+ import { readFileSync } from 'node:fs'
10
+ import { dirname, join, resolve } from 'node:path'
11
+ import { spawnSync } from 'node:child_process'
12
+
13
+ /** 从命令输出里抽出版本号本体:pnpm/npm 直接打印 `11.7.0`,而 `git --version` 打印
14
+ * `git version 2.55.0.windows.5` —— 原样取首行会让 issue 正文拼成 `vgit version …`。
15
+ * 只取版本号,顺带兼容 `v1.2.3` 这类自带前缀的输出。 */
16
+ export function versionOf(output: string): string | null {
17
+ const first = output.trim().split(/\r?\n/)[0]?.trim() ?? ''
18
+ if (!first) return null
19
+ const match = /\d+\.\d+[0-9A-Za-z.\-+]*/.exec(first)
20
+ return match === null ? null : match[0]
21
+ }
22
+
23
+ /** 探测命令行工具版本:未安装 / 不在 PATH / 超时一律 null。
24
+ * Windows 上 pnpm/npm 是 .cmd shim,不走 shell 起不来;参数是固定的 `--version`,没有注入面。 */
25
+ export function toolVersion(cmd: string, args: string[]): string | null {
26
+ try {
27
+ const r = spawnSync(cmd, args, {
28
+ timeout: 1500,
29
+ encoding: 'utf8',
30
+ shell: process.platform === 'win32',
31
+ })
32
+ if (r.error || r.status !== 0) return null
33
+ return versionOf(r.stdout ?? '')
34
+ } catch {
35
+ return null
36
+ }
37
+ }
38
+
39
+ let cachedDshVersion: string | null = null
40
+
41
+ /** 宿主 dsh 版本:优先 `dsh --version`;拿不到再从入口 `process.argv[1]` 向上找 package.json
42
+ * (dsh 不在 PATH 时兜底)。探测成功即缓存,失败不缓存以便下次重试。 */
43
+ export function hostDshVersion(): string | null {
44
+ if (cachedDshVersion !== null) return cachedDshVersion
45
+ const viaCli = toolVersion('dsh', ['--version'])
46
+ if (viaCli !== null) {
47
+ cachedDshVersion = viaCli
48
+ return viaCli
49
+ }
50
+ const entry = process.argv[1]
51
+ if (entry === undefined) return null
52
+ let dir = dirname(resolve(entry))
53
+ for (let depth = 0; depth < 4; depth++) {
54
+ try {
55
+ const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { name?: string; version?: string }
56
+ const name = manifest.name ?? ''
57
+ if (typeof manifest.version === 'string' && (name === 'dsh' || name.includes('dsh') || name.includes('harness'))) {
58
+ cachedDshVersion = manifest.version
59
+ return cachedDshVersion
60
+ }
61
+ } catch {
62
+ /* 不是包根,继续向上查找 */
63
+ }
64
+ const parent = dirname(dir)
65
+ if (parent === dir) break
66
+ dir = parent
67
+ }
68
+ return null
69
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * 子进程输出解码:默认 UTF-8;出现替换字符(中文 Windows 的 GBK 输出)时改用 GBK 重解。
3
+ * 用 StringDecoder 跨 chunk 缓冲,避免 UTF-8 多字节字符被 chunk 边界切断后误判为 GBK。
4
+ */
5
+ import { StringDecoder } from 'node:string_decoder'
6
+
7
+ export class OutputDecoder {
8
+ private readonly utf8 = new StringDecoder('utf8')
9
+
10
+ push(chunk: Buffer): string {
11
+ const text = this.utf8.write(chunk)
12
+ if (!text.includes('\uFFFD')) return text
13
+ try {
14
+ const gbk = new TextDecoder('gbk').decode(chunk)
15
+ // GBK 能完整解出且含中文才采用:纯 ASCII 或仍有替换字符时保持 UTF-8 结果
16
+ if (!gbk.includes('\uFFFD') && /[\u4e00-\u9fff]/.test(gbk)) return gbk
17
+ } catch {
18
+ // 无完整 ICU(TextDecoder 不支持 gbk)时退回 UTF-8 结果
19
+ }
20
+ return text
21
+ }
22
+ }
@@ -0,0 +1,99 @@
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
+ * 「插件要求的 DSH 版本高于当前宿主」判定:安装失败后读一次 npm 元数据,取
7
+ * peerDependencies(@deepseek-ai/dsh-*)与 engines.dsh 声明的最低版本里最高的那个,
8
+ * 与宿主版本比较;宿主更低就产出 [dsh-too-old] 标记 —— 前端据此提示「先升级 DSH」,
9
+ * 不再把这类失败归成「未分类」丢给用户或插件作者。
10
+ *
11
+ * 只在失败路径调用:peer 不满足本身通常只是警告,成功安装时不该出现这个提示;
12
+ * 任何网络/解析异常都静默返回 null,不改变原有失败处理。
13
+ */
14
+ import { hostDshVersion } from '../host-version.ts'
15
+
16
+ /** 从安装目标里取 npm 包名:`@scope/pkg@1.2.3` → `@scope/pkg`。
17
+ * git / URL / 本地路径等非 npm 目标返回 null。 */
18
+ export function npmNameOf(target: string): string | null {
19
+ const raw = target.trim()
20
+ if (raw === '') return null
21
+ if (/^(?:github:|git\+|https?:|file:|link:|\.{0,2}[\\/])/i.test(raw)) return null
22
+ // owner/repo(非 scope):git 目标的另一种写法,不是包名
23
+ if (!raw.startsWith('@') && /^[^\s@]+\/[^\s]+$/.test(raw)) return null
24
+ const at = raw.lastIndexOf('@')
25
+ const name = at > 0 ? raw.slice(0, at) : raw
26
+ return /^(@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*$/i.test(name) ? name : null
27
+ }
28
+
29
+ /** `0.2.1-rc.1` / `^0.2.1` / `>=0.2.0 <0.3.0` → [0,2,1];解析不出返回 null。
30
+ * 只取版本三段:DSH 的 prerelease 序号(rc.1)不参与「太低」判断,避免比较误差。 */
31
+ export function versionTriple(raw: string): [number, number, number] | null {
32
+ const m = /(\d+)\.(\d+)\.(\d+)/.exec(raw)
33
+ return m === null ? null : [Number(m[1]), Number(m[2]), Number(m[3])]
34
+ }
35
+
36
+ function compareTriple(a: [number, number, number], b: [number, number, number]): number {
37
+ for (let i = 0; i < 3; i++) {
38
+ if (a[i] !== b[i]) return a[i]! - b[i]!
39
+ }
40
+ return 0
41
+ }
42
+
43
+ /** 插件声明的最低 DSH 版本:engines.dsh 与 peerDependencies 里 @deepseek-ai/dsh-* 的下限取最大。
44
+ * 返回原始声明文本(如 `^0.2.1-rc.1`)供提示直接展示;没有声明返回 null。 */
45
+ export function dshFloorOf(manifest: unknown): string | null {
46
+ const m = manifest as { engines?: Record<string, unknown>; peerDependencies?: Record<string, unknown> } | null
47
+ const ranges: string[] = []
48
+ const engineDsh = m?.engines?.dsh
49
+ if (typeof engineDsh === 'string') ranges.push(engineDsh)
50
+ for (const [name, range] of Object.entries(m?.peerDependencies ?? {})) {
51
+ if (/^@deepseek-ai\/dsh(?:-|$)/.test(name) && typeof range === 'string') ranges.push(range)
52
+ }
53
+ let floor: string | null = null
54
+ let floorTriple: [number, number, number] | null = null
55
+ for (const range of ranges) {
56
+ const triple = versionTriple(range)
57
+ if (triple === null) continue
58
+ if (floorTriple === null || compareTriple(triple, floorTriple) > 0) {
59
+ floor = range
60
+ floorTriple = triple
61
+ }
62
+ }
63
+ return floor
64
+ }
65
+
66
+ /**
67
+ * 产出「插件要求更高的 DSH」标记行;没有声明、读不到元数据、宿主版本未知或已满足时返回 null。
68
+ * 用版本级 manifest(`/<pkg>/latest`):packument 的精简形态不带 peerDependencies。
69
+ */
70
+ export async function dshTooOldMarker(
71
+ target: string,
72
+ opts: { currentVersion?: string | null; fetchImpl?: typeof fetch; timeoutMs?: number; registry?: string } = {},
73
+ ): Promise<string | null> {
74
+ const name = npmNameOf(target)
75
+ if (name === null) return null
76
+ const current = opts.currentVersion ?? hostDshVersion()
77
+ if (current === null) return null
78
+ const currentTriple = versionTriple(current)
79
+ if (currentTriple === null) return null
80
+ const base = (opts.registry ?? 'https://registry.npmjs.org').replace(/\/+$/, '')
81
+ // scope 包的路径要编码:`@scope/name` → `%40scope%2Fname`
82
+ const url = `${base}/${name.replace('@', '%40').replace('/', '%2F')}/latest`
83
+ let manifest: unknown
84
+ try {
85
+ const res = await (opts.fetchImpl ?? fetch)(url, {
86
+ headers: { accept: 'application/json' },
87
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 4000),
88
+ })
89
+ if (!res.ok) return null
90
+ manifest = await res.json()
91
+ } catch {
92
+ return null
93
+ }
94
+ const floor = dshFloorOf(manifest)
95
+ const floorTriple = floor === null ? null : versionTriple(floor)
96
+ if (floor === null || floorTriple === null) return null
97
+ if (compareTriple(currentTriple, floorTriple) >= 0) return null
98
+ return `[dsh-too-old] ${name} requires DSH >= ${floor} (declared in engines / peerDependencies), but this host runs ${current} — upgrade DSH before installing`
99
+ }
@@ -72,6 +72,10 @@ export interface QueueItem {
72
72
  env?: NodeJS.ProcessEnv
73
73
  globalNpm?: string[]
74
74
  installChannel?: 'npm'
75
+ /** npm 源候选(首选在前):传输层失败时按顺序换源重试 */
76
+ registries?: string[]
77
+ /** 精确安装 spec(Hub 自我更新钉版本) */
78
+ installSpec?: string
75
79
  }
76
80
  }
77
81
 
@@ -6,36 +6,71 @@
6
6
  * 失败回捞:DSH 有时只把一行摘要(`dsh: plugin command failed; diagnostics: <path>`)
7
7
  * 回显到控制台,真正的 pnpm 报错写进该路径的 operation 日志。这里从输出里解析出该路径
8
8
  * 并读取其尾部,补进失败信息 —— 否则前端拿不到任何错误特征,只能把失败归为「未知」。
9
- * 只允许读 DSH 自己的 operation 日志(路径含 .plugin-manager 且以 pnpm.log 结尾),
9
+ * 只允许读 profile 目录下的 pnpm.log(dsh 自己的 operation 日志),
10
10
  * 防止插件把任意路径打印进日志造成越权读取;任何异常都静默降级(返回 null),
11
11
  * 不改变原有失败处理。
12
12
  */
13
13
  import { readFileSync, readdirSync, statSync } from 'node:fs'
14
- import { join } from 'node:path'
14
+ import { basename, join, resolve, sep } from 'node:path'
15
15
  import { CAPTURE_LIMIT_BYTES } from './install-types.ts'
16
16
 
17
+ /** 去掉 ANSI 颜色码:宿主的摘要行可能带终端着色,直接匹配路径会失败。 */
18
+ function stripAnsi(text: string): string {
19
+ return text.replace(/\x1b\[[0-9;]*m/g, '')
20
+ }
21
+
17
22
  /**
18
23
  * 从 DSH 输出里解析 operation 日志路径(`diagnostics: <path>`)。
19
- * 只认 DSH 的 operation 日志:路径含 `.plugin-manager` 且以 `pnpm.log` 结尾;
20
- * 不是这种路径(或压根没有该行)返回 null,调用方据此跳过回捞。
24
+ * 去掉首尾引号与句末标点(不同版本/语言的摘要行尾部可能带 `。`、`.`、`)`);
25
+ * 相对路径原样返回,由读取方按 profile 目录解析。
21
26
  */
22
27
  export function operationLogPathOf(output: string): string | null {
23
28
  for (const raw of output.split(/\r?\n/)) {
24
- const m = /diagnostics:\s*"?(.+?)"?\s*$/i.exec(raw.trim())
29
+ // 兼容纯文本与 JSON 包裹两种形态:`diagnostics: <path>` / `"diagnostics": "<path>"}`
30
+ const m = /"?diagnostics"?\s*:\s*(.+)$/i.exec(stripAnsi(raw).trim())
25
31
  if (m === null) continue
26
- const path = m[1].trim()
27
- if (!path.includes('.plugin-manager') || !path.toLowerCase().endsWith('pnpm.log')) continue
28
- return path
32
+ let path = m[1]
33
+ .trim()
34
+ .replace(/[.,;:,。;、)\]}]+$/, '')
35
+ .trim()
36
+ // JSON 形态:引号包裹且反斜杠被转义过,按字符串字面量还原;普通文本里的引号直接剥掉
37
+ if (path.startsWith('"') && path.endsWith('"')) {
38
+ try {
39
+ const parsed: unknown = JSON.parse(path)
40
+ if (typeof parsed === 'string') path = parsed
41
+ } catch {
42
+ path = path.slice(1, -1)
43
+ }
44
+ }
45
+ path = path.trim()
46
+ // URL 不是本地日志:避免把网络地址当文件读
47
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(path)) continue
48
+ if (path.toLowerCase().endsWith('pnpm.log')) return path
29
49
  }
30
50
  return null
31
51
  }
32
52
 
53
+ /**
54
+ * 只允许读 profile 目录下的 pnpm.log(dsh 自己的操作日志):
55
+ * 绝对路径必须落在该目录内,相对路径按该目录解析。
56
+ * 未提供 profileDir 时退回旧口径(路径须含 .plugin-manager),防止插件打印任意路径造成越权读取。
57
+ */
58
+ function allowedLogPath(raw: string, profileDir?: string): string | null {
59
+ const path = resolve(profileDir ?? '', raw)
60
+ if (basename(path).toLowerCase() !== 'pnpm.log') return null
61
+ if (profileDir === undefined) return path.includes('.plugin-manager') ? path : null
62
+ const root = resolve(profileDir)
63
+ return path.startsWith(`${root}${sep}`) ? path : null
64
+ }
65
+
33
66
  /**
34
67
  * 回捞 operation 日志尾部,作为失败详情返回;拿不到(无路径 / 文件不存在 / 读失败 /
35
- * 内容为空)返回 null。尾部截到与子进程输出相同的上限,避免超大日志撑爆内存与消息。
68
+ * 内容为空 / 路径越界)返回 null。尾部截到与子进程输出相同的上限。
36
69
  */
37
- export function backfillOperationLog(output: string): string | null {
38
- const path = operationLogPathOf(output)
70
+ export function backfillOperationLog(output: string, profileDir?: string): string | null {
71
+ const raw = operationLogPathOf(output)
72
+ if (raw === null) return null
73
+ const path = allowedLogPath(raw, profileDir)
39
74
  if (path === null) return null
40
75
  try {
41
76
  const text = readFileSync(path, 'utf8').trim()
@@ -46,6 +81,18 @@ export function backfillOperationLog(output: string): string | null {
46
81
  }
47
82
  }
48
83
 
84
+ /**
85
+ * 输出里是否有 dsh 自己的内容 —— 只有 `[exit N]`、`[install-channel]`、`[timed out]`
86
+ * 这类本队列自己打的标记时返回 false。用于把「宿主什么都没回显」的失败显式留痕,
87
+ * 否则上报正文只剩退出码,无从排查。
88
+ */
89
+ export function hasSubstantiveOutput(output: string): boolean {
90
+ return output.split(/\r?\n/).some((raw) => {
91
+ const line = raw.trim()
92
+ return line !== '' && !/^\[(?:exit\b|timed out\]|install-channel\]|stale pipe\]|error\]|pnpm log\])/.test(line)
93
+ })
94
+ }
95
+
49
96
  /**
50
97
  * 运行期抓取:宿主的 operation 日志只在命令运行期间存在,结束后可能被清理,
51
98
  * 退出后再按路径读往往已经拿不到。这里在安装过程中轮询 profile 下最新的