dsh-plugin 1.6.5 → 1.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.en.md +11 -11
  2. package/README.md +11 -11
  3. package/client/client.js +420 -389
  4. package/client/client.js.map +1 -1
  5. package/lib/http/catalog.js +132 -0
  6. package/lib/http/diagnostics.js +201 -0
  7. package/lib/http/install.js +399 -0
  8. package/lib/http/logs.js +164 -0
  9. package/lib/http/routes.js +19 -1263
  10. package/lib/http/settings.js +77 -0
  11. package/lib/http/shared.js +169 -0
  12. package/lib/http/task.js +208 -0
  13. package/lib/services/install/install.js +1 -1
  14. package/lib/services/install/task-queue.js +45 -16
  15. package/lib/services/probe.js +1 -1
  16. package/lib/types/http/catalog.d.ts +10 -0
  17. package/lib/types/http/diagnostics.d.ts +4 -0
  18. package/lib/types/http/install.d.ts +11 -0
  19. package/lib/types/http/logs.d.ts +2 -0
  20. package/lib/types/http/routes.d.ts +15 -20
  21. package/lib/types/http/settings.d.ts +2 -0
  22. package/lib/types/http/shared.d.ts +44 -0
  23. package/lib/types/http/task.d.ts +3 -0
  24. package/lib/types/services/install/install.d.ts +1 -1
  25. package/package.json +1 -1
  26. package/src/client/components/PluginHubSection.tsx +3 -0
  27. package/src/client/components/modals/NotificationsModal.tsx +34 -18
  28. package/src/client/components/modals/modals.tsx +38 -28
  29. package/src/client/components/views/InstalledView.tsx +23 -11
  30. package/src/client/hooks/useCatalog.ts +1 -1
  31. package/src/client/locales.ts +24 -8
  32. package/src/client/logic/failures.ts +36 -157
  33. package/src/client/logic/urls.ts +72 -1
  34. package/src/client/types.ts +1 -1
  35. package/src/server/http/catalog.ts +143 -0
  36. package/src/server/http/diagnostics.ts +193 -0
  37. package/src/server/http/install.ts +391 -0
  38. package/src/server/http/logs.ts +149 -0
  39. package/src/server/http/routes.ts +22 -1222
  40. package/src/server/http/settings.ts +73 -0
  41. package/src/server/http/shared.ts +191 -0
  42. package/src/server/http/task.ts +194 -0
  43. package/src/server/services/install/install-types.ts +9 -1
  44. package/src/server/services/install/install.ts +11 -1
  45. package/src/server/services/install/task-queue.ts +45 -16
  46. package/src/server/services/probe.ts +1 -1
@@ -151,7 +151,7 @@ export function removeNotification(id: number): NotificationRecord[] {
151
151
  return next
152
152
  }
153
153
 
154
- export type FailureKind = 'npmTooOld' | 'dshMissing' | 'gitMissing' | 'pnpmMissing' | 'npmMissing' | 'pnpmStore' | 'pnpmWorkspace' | 'originRejected' | 'pnpmPolicy' | 'pnpmUnusedPatch' | 'pnpmMissingDep' | 'fileLocked' | 'accessDenied' | 'fsUnavailable' | 'pnpmIgnoredBuild' | 'pluginPrepare' | 'network' | 'repo'
154
+ export type FailureKind = 'npmTooOld' | 'dshMissing' | 'gitMissing' | 'pnpmMissing' | 'npmMissing' | 'pnpmStore' | 'pnpmWorkspace' | 'originRejected' | 'pnpmPolicy' | 'pnpmUnusedPatch' | 'pnpmMissingDep' | 'registryVersionStale' | 'pnpmLinkedDirMissing' | 'fileLocked' | 'accessDenied' | 'fsUnavailable' | 'pnpmIgnoredBuild' | 'pluginPrepare' | 'network' | 'hub'
155
155
 
156
156
  /** Match the pnpm error code, not a generic mention of checksum or integrity. */
157
157
  function missingTarballIntegrity(message: string): boolean {
@@ -174,7 +174,7 @@ export function releaseAgeRetryAfter(message: string): string | null {
174
174
  */
175
175
  export function pnpmPolicyHintOf(message: string): 'failPnpmTarballIntegrityHint' | 'failPnpmReleaseAgeHint' | 'failPnpmPolicyHint' {
176
176
  if (missingTarballIntegrity(message)) return 'failPnpmTarballIntegrityHint'
177
- if (/ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION|Minimum release age/i.test(message)
177
+ if (/ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION|ERR_PNPM_NO_MATURE_MATCHING_VERSION|Minimum release age|minimumReleaseAge/i.test(message)
178
178
  && releaseAgeRetryAfter(message) !== null) {
179
179
  return 'failPnpmReleaseAgeHint'
180
180
  }
@@ -187,180 +187,59 @@ export function pnpmPolicyHintParams(message: string): Record<string, string> {
187
187
  }
188
188
 
189
189
  /**
190
- * 失败归类:把安装输出归到具体成因,供弹窗文案、是否引导提 Issue 与 issue 预填原因使用。
191
- * 分界是「本机环境问题」(装任何插件都会同样失败 → 不引导提 Issue)与
192
- * 「插件分发/依赖问题」(只影响这个插件 → 引导去仓库提 Issue);各分支的判定顺序有依赖,
193
- * 原因见 classifyFailure 内注释。
194
- *
195
- * 本机环境问题:
196
- * - npmTooOld:本机 npm 版本过低或自身缺陷 —— npm arborist 解 peer 依赖时抛 `edgesOut`
197
- * (build-ideal-tree.js 内部报错),或服务端已核实版本低于阈值并打了 `[npm-too-low]` 标记
198
- * → 引导升级 npm
199
- * - dshMissing:安装器 spawn 的 `dsh` 命令找不到(Windows cmd「不是内部或外部命令」/ POSIX
200
- * 「command not found」/ spawn ENOENT)→ 提示检查 PATH / 重装 DSH
201
- * - gitMissing:安装器调用 `git` 时找不到可执行文件(Windows cmd「'git' is not recognized」/
202
- * POSIX「git: command not found」/ spawn ENOENT)→ 提示安装 Git / 加入 PATH
203
- * - pnpmMissing:dsh 存在但调用的 `pnpm` 找不到(dsh 报 `pnpm not found on PATH` / POSIX
204
- * 「pnpm: command not found」/ spawn ENOENT)—— dsh 用 pnpm 管理 profile 插件
205
- * → 提示安装 / 开启 pnpm
206
- * - npmMissing:全局 npm 安装通道(`npm install -g …`)spawn 的 `npm` 命令找不到,形态同上
207
- * → 提示安装 npm(Node.js 自带)/ 加入 PATH
208
- * - pnpmStore:pnpm 报 store / virtual store 位置不匹配(`ERR_PNPM_UNEXPECTED_STORE` /
209
- * `ERR_PNPM_UNEXPECTED_VIRTUAL_STORE` / `Unexpected store location`)—— profile 目录里的依赖
210
- * 是另一个大版本的 pnpm 生成的(或 profile 目录被复制/移动、virtual-store-dir 配置变化),
211
- * 当前 pnpm 出于安全不认 → 提示清理 profile 依赖目录后用当前 pnpm 重建
212
- * - pnpmWorkspace:pnpm 拒绝在 workspace 根目录下安装(`ERR_PNPM_ADDING_TO_ROOT`)—— profile
213
- * 目录含 pnpm-workspace.yaml 即被 pnpm 视为 workspace 根,宿主调 `pnpm add` 时未声明在根
214
- * 操作(缺 `-w`/`--workspace-root`),整条命令被拒 → 提示在 profile 的 .npmrc 里加
215
- * `ignore-workspace-root-check=true` 或升级宿主
216
- * - originRejected:请求的来源没通过本地 hub 服务的校验(服务端 403,正文是裸的
217
- * `untrusted origin`)—— 请求在进入安装流程前就被拒,与 pnpm 无关;常见于用非 localhost 的
218
- * 地址(如局域网 IP)打开市场页面,或宿主页面的 origin 未被识别 → 提示从本机地址打开市场后重试
219
- * - pnpmPolicy:pnpm 的供应链安全策略拒绝安装(缺少 tarball integrity、`ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION` /
220
- * `Minimum release age` —— 锁文件里包的发布时间还不满 24 小时被拒;`untrusted origin` ——
221
- * 依赖来源未被本机 pnpm 信任)→ 提示按子场景给解法(发布未满 24 小时 → `minimumReleaseAge: 0`
222
- * 豁免或等满 24 小时;untrusted origin → 删除 profile 的 node_modules + pnpm-lock.yaml 清掉
223
- * 不受信任来源后重装)
224
- * - pnpmUnusedPatch:profile 里留着指向旧版本 dsh-plugin 的 patch 声明,本次安装解析到的版本
225
- * 已经不是它(`ERR_PNPM_UNUSED_PATCH` / `The following patches were not used: dsh-plugin@1.4.2`)
226
- * —— pnpm 发现补丁没被用上即中止整次安装 → 提示删掉该条目后重试
227
- * - pnpmMissingDep:profile 里留着一条指向 registry 上不存在的包的依赖,pnpm 解析时收到 404
228
- * (`ERR_PNPM_FETCH_404` / `Not Found - 404`)—— 该 profile 的任何安装都会先卡在这条依赖上
229
- * → 提示按包名删掉该条目后重试(404 的包通常不是正在安装的插件,故不引导提 Issue)
230
- * - fileLocked:pnpm 无法替换 profile 里被其他进程占用的文件(Windows `os error 32`
231
- * 「另一个程序正在使用此文件」/ `EBUSY` / `resource busy or locked`)—— 通常是宿主进程或
232
- * 杀毒软件实时扫描持有句柄 → 提示完全退出宿主后重试
233
- * - accessDenied:pnpm 在 profile 里替换文件时被系统拒绝写入(Windows `os error 5`
234
- * 「拒绝访问」= ERROR_ACCESS_DENIED / Node 的 `EPERM` `EACCES` / `Access is denied`)——
235
- * 文件/目录被其他进程占用、只读属性、目录 ACL 受限,或杀毒软件实时防护拦截写入
236
- * → 提示退出宿主 / 关杀软后重试,仍失败则检查只读属性并以管理员身份运行
237
- * - fsUnavailable:本机文件系统层面根本写不进去 —— 磁盘/分区空间耗尽(`ENOSPC` /
238
- * `no space left on device`,Windows 为 `os error 112`)、目标分卷或挂载点为只读
239
- * (`EROFS` / `read-only file system`,Windows 写保护为 `os error 19`)、进程可用的文件句柄
240
- * 被耗尽(`EMFILE` / `ENFILE` / `too many open files`),都发生在 pnpm 落盘阶段
241
- * → 提示按报错代码对号入座(清空间 / 换可写目录 / 重启宿主释放句柄)
242
- * - network:安装前连通性预检拦截(服务端 `[network]` 标记)、底层连接失败
243
- * (ERR_PNPM_GIT_FETCH_FAILED / ETIMEDOUT / DNS 解析 / TLS 握手 / 代理拒绝),或
244
- * registry tarball 拉取失败(`fetch failed` / `GET …/-/…tgz error (n)` —— 常见于本机
245
- * npm/pnpm 的 registry 被指向内网/自定义源,该源取不到包),或界面请求压根没送达宿主服务
246
- * (浏览器 fetch 的 `Failed to fetch` —— 宿主未就绪/正在重启,或本地代理拦了回环地址)
247
- * → 提示检查网络,registry 指向自定义源时给出换源指引
248
- *
249
- * 插件分发/依赖问题(引导去仓库提 Issue):
250
- * - pnpmIgnoredBuild:插件自身或依赖的构建脚本被 pnpm 安全白名单(allowBuilds)默认拦截
251
- * (`ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED` / `ERR_PNPM_IGNORED_BUILDS`)—— 只影响带安装期
252
- * 构建的插件 → 建议改用预编译版本或关闭安装期构建
253
- * - pluginPrepare:插件的 prepare/构建脚本实际执行失败(git tarball 常因缺失子模块或构建产物
254
- * 导致),或装后校验发现 package.json 声明的入口文件不在发布物里(服务端 `[packaging]` 标记)
255
- * —— 属插件打包/分发问题
256
- * - repo:其余失败,默认按插件仓库问题处理
190
+ * 失败归类:把安装输出归到具体成因,供弹窗文案与是否引导提 Issue 使用。
191
+ * 分界是「本机环境问题」(装任何插件都会同样失败 → 不引导提 Issue)与「插件分发问题」
192
+ * (只影响这个插件 → 才引导提 Issue)。各分支判定顺序有依赖,见函数内注释。
257
193
  */
258
194
  export function classifyFailure(message: string): FailureKind {
259
- // npm 内部崩溃(edgesOut)或服务端 [npm-too-low] 标记:是本机 npm 版本过低/自身缺陷,
260
- // 不是插件问题 —— 必须最先判,否则该报错会被外层 ERR_PNPM_PREPARE_PACKAGE 吞成
261
- // 「插件打包分发问题」,误导用户去提 Issue
195
+ // npm 自身崩溃(edgesOut)或服务端标记的本机 npm 版本过低。必须最先判,否则会被外层
196
+ // ERR_PNPM_PREPARE_PACKAGE 吞成「插件打包问题」
262
197
  if (/\[npm-too-low\]|edgesOut/i.test(message)) return 'npmTooOld'
263
- // 找不到 pnpm 命令(dsh 报 `pnpm not found on PATH` —— dsh 用它管理 profile 插件 /
264
- // POSIX「pnpm: command not found」/ spawn pnpm ENOENT / Windows cmd 中英文报错):
265
- // 本机缺 pnpm。必须在 dshMissing 之前 —— dshMissing 正则含裸「command not found」,
266
- // 会把 `pnpm: command not found` 吞成「dsh 缺失」,误引导用户去装 DSH
198
+ // 本机缺 pnpm。必须在 dshMissing 之前 —— 后者的裸「command not found」会吞掉 pnpm 缺失
267
199
  if (/\[pnpm-missing\]|pnpm not found|pnpm: command not found|spawn pnpm ENOENT|'pnpm' 不是内部或外部命令|"pnpm" 不是内部或外部命令|pnpm['"]?\s*is not recognized/i.test(message)) return 'pnpmMissing'
268
- // 找不到 git 命令(服务端 [git-missing] 标记 / Windows cmd 中英文「'git' is not recognized」/
269
- // POSIX「git: command not found」/ spawn git ENOENT):本机 Git 未安装或不在 PATH。
270
- // 必须在 dshMissing 之前 —— dshMissing 正则含裸「is not recognized / command not found」,
271
- // 会把 git 缺失('git' is not recognized as an internal or external command)吞成「dsh 缺失」,
272
- // 误导用户去装 DSH
200
+ // 本机缺 git。必须在 dshMissing 之前,原因同上(裸「is not recognized」会吞掉 git 缺失)
273
201
  if (/\[git-missing\]|spawn git ENOENT|'git' 不是内部或外部命令|"git" 不是内部或外部命令|git['"]?\s*is not recognized|git: command not found/i.test(message)) return 'gitMissing'
274
- // 找不到 npm 命令(全局 npm 安装通道 `npm install -g` spawn 的 npm 缺失 / Windows cmd 中英文
275
- // 「'npm' is not recognized」/ POSIX「npm: command not found」/ spawn npm ENOENT):本机 npm 未安装
276
- // 或不在 PATH,不是插件问题。必须在 dshMissing 之前 —— dshMissing 正则含裸「is not recognized /
277
- // command not found」,会把 npm 缺失('npm' is not recognized)吞成「dsh 缺失」,误导用户去装 DSH
202
+ // 本机缺 npm。必须在 dshMissing 之前,原因同上
278
203
  if (/\[npm-missing\]|spawn npm ENOENT|'npm' 不是内部或外部命令|"npm" 不是内部或外部命令|npm['"]?\s*is not recognized|npm: command not found/i.test(message)) return 'npmMissing'
279
- // 找不到 dsh 命令(服务端 [dsh-missing] 标记 —— 乱码免疫:Windows cmd 中文版输出 GBK,
280
- // 经 UTF-8 解码成乱码无法匹配原文,故服务端在 spawn 前用 which/where 探测并打 ASCII 标记;
281
- // 其余形态:Windows cmd「不是内部或外部命令」/ POSIX「command not found」/
282
- // node spawn ENOENT):是本机 DSH 未正确安装或不在 PATH —— 必须先判,
283
- // 否则会被外层 "Command failed" 吞成「插件打包问题」,误导用户去提 Issue
204
+ // 本机缺 dsh。服务端 [dsh-missing] 标记乱码免疫(Windows GBK 解码后无法匹配原文)
284
205
  if (/\[dsh-missing\]|不是内部或外部命令|is not recognized as an internal or external command|command not found|spawn dsh ENOENT/i.test(message)) return 'dshMissing'
285
- // pnpm 大版本/虚拟 store 位置不一致(ERR_PNPM_UNEXPECTED_STORE / ERR_PNPM_UNEXPECTED_VIRTUAL_STORE /
286
- // Unexpected store location):profile 目录里的依赖是另一个大版本 pnpm 生成的(或 profile 目录
287
- // 被复制/移动、virtual-store-dir 配置变化导致 virtual store 位置不匹配),当前 pnpm 出于安全
288
- // 不认 —— 任何插件装进该 profile 都会同样失败 → 提示清理依赖目录用当前 pnpm 重建。
289
- // 必须在 pnpmMissing 之后 —— pnpm 在(能跑起来报错),不是「找不到命令」。
206
+ // store 版本不匹配:profile 依赖由另一大版本 pnpm 生成,当前 pnpm 不认。
207
+ // 必须在 pnpmMissing 之后(pnpm 能跑起来报错,不是找不到命令)
290
208
  if (/ERR_PNPM_UNEXPECTED_(VIRTUAL_)?STORE|Unexpected (virtual )?store location/i.test(message)) return 'pnpmStore'
291
- // pnpm 拒绝在 workspace 根目录下安装(ERR_PNPM_ADDING_TO_ROOT):profile 目录被视为
292
- // pnpm workspace 根,宿主调 pnpm add 时没声明在根操作(缺 -w/--workspace-root),
293
- // pnpm 直接整条命令拒绝 —— 本机 profile/宿主调用方式问题,任何插件都装不上
209
+ // pnpm 拒绝往 workspace 根装依赖:profile 被视为 workspace 根但未声明根操作
294
210
  if (/ERR_PNPM_ADDING_TO_ROOT|add the dependency to the workspace root/i.test(message)) return 'pnpmWorkspace'
295
- // 本地 hub 服务的来源校验拒绝了请求(服务端 403,正文恰好是 `untrusted origin`):
296
- // 请求在 requireTrustedPost 就被拦下,压根没进入安装流程,与 pnpm 无关。
297
- // 必须放在 pnpmPolicy 之前 —— 后者正则含同名裸文本,会把这条吞成「pnpm 供应链策略」,
298
- // 并建议用户删掉 profile 的 node_modules + pnpm-lock.yaml(无效且具破坏性)。
299
- // 用「整行以它结尾」来区隔 pnpm 的真实报错:pnpm 的形态后面一定跟着 dsh 的上下文
300
- // (如 `untrusted origin\ndsh: pnpm failed in profile directory …`),不会被本规则命中
211
+ // 本地服务来源校验拒绝(403 untrusted origin),请求没进安装流程。必须在 pnpmPolicy 之前
212
+ // —— 后者同名裸文本会把这条误判成供应链策略。用「整行以它结尾」区隔 pnpm 真实报错
301
213
  if (/(^|\n)untrusted origin\s*$/.test(message)) return 'originRejected'
302
- // pnpm 供应链安全策略拦截(缺少 tarball 校验值 / minimumReleaseAge 拒收「刚发布」的包 /
303
- // untrusted origin 来源不受信任):pnpm 在、也连得上,纯粹是本机策略不放行 ——
304
- // 装任何「新发布/非信任来源」的插件都会同样失败。
305
- // 必须在 pnpmIgnoredBuild 之前 —— 该策略优先于「构建脚本被白名单拦截」,且两者都不引导提 Issue。
306
- if (missingTarballIntegrity(message) || /ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION|Minimum release age|untrusted origin/i.test(message)) return 'pnpmPolicy'
307
- // profile 里留着指向旧版本 dsh-plugin 的 patch 声明(ERR_PNPM_UNUSED_PATCH /
308
- // `The following patches were not used: dsh-plugin@1.4.2`):本机 pnpm 配置与本次解析到的版本
309
- // 对不上,pnpm 出于安全直接中止整次安装 —— 任何插件都装不进来 → 提示删条目后重试。
310
- // 必须在兜底之前,否则被归成「插件侧失败」引导去提 Issue。
214
+ // pnpm 供应链策略拦截(缺 tarball integrity / minimumReleaseAge / untrusted origin)。
215
+ // 必须在 pnpmIgnoredBuild 之前
216
+ if (missingTarballIntegrity(message) || /ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION|ERR_PNPM_NO_MATURE_MATCHING_VERSION|Minimum release age|minimumReleaseAge|untrusted origin/i.test(message)) return 'pnpmPolicy'
217
+ // profile 残留旧版本 patch 声明,pnpm 中止安装。必须在兜底之前
311
218
  if (/ERR_PNPM_UNUSED_PATCH|patches were not used/i.test(message)) return 'pnpmUnusedPatch'
312
- // profile 里的文件被其他进程占用,pnpm 无法替换(Windows `os error 32`
313
- // 「另一个程序正在使用此文件,进程无法访问」/ EBUSY / resource busy or locked)——
314
- // 通常是宿主进程或杀毒软件实时扫描持有句柄;`os error 32` 用 ASCII 特征,中文原文乱码也能命中
315
- // → 提示完全退出宿主后重试
219
+ // profile 文件被其他进程占用(os error 32 / EBUSY)。ASCII 特征免疫乱码
316
220
  if (/os error 32|EBUSY|resource busy or locked|being used by another process/i.test(message)) return 'fileLocked'
317
- // profile 里的文件/目录被系统拒绝写入(Windows `os error 5`「拒绝访问」= ERROR_ACCESS_DENIED /
318
- // Node 的 EPERM、EACCES / 英文 `Access is denied`):占用、只读属性、目录 ACL 或杀软拦截,
319
- // 任何插件都装不上。`os error 5` 只认 ASCII 特征 —— 中文原文经 GBK→UTF-8 解码会残缺,
320
- // 不能依赖「拒绝访问」四个字
221
+ // 系统拒绝写 profile(os error 5 / EPERM / EACCES)。ASCII 特征免疫乱码
321
222
  if (/os error 5\b|ERROR_ACCESS_DENIED|\bEPERM\b|\bEACCES\b|access is denied/i.test(message)) return 'accessDenied'
322
- // 本机文件系统层面根本写不进去:空间耗尽(ENOSPC / no space left on device / Windows os error 112)、
323
- // 目标盘或挂载点只读(EROFS / read-only file system / Windows 写保护 os error 19)、
324
- // 文件句柄耗尽(EMFILE / ENFILE / too many open files)。都在 pnpm 落盘阶段发生,
325
- // 空间不足、盘只读或句柄不够时任何插件都装不进来,不是插件问题 ——
326
- // 必须在 network / prepare 之前判:磁盘满时 pnpm 会在日志尾部混着重试与超时特征,
327
- // 先判网络会把「本机磁盘写不进去」误报成「你的网络不通」
223
+ // 文件系统写不进去(ENOSPC/EROFS/EMFILE/ENFILE)。必须在 network 之前判:
224
+ // 磁盘满时日志尾部混超时特征,先判网络会误报成「网络不通」
328
225
  if (/os error 112\b|os error 19\b|ERROR_DISK_FULL|ERROR_WRITE_PROTECT|\bENOSPC\b|\bEROFS\b|\bEMFILE\b|\bENFILE\b|no space left on device|read-only file system|too many open files/i.test(message)) return 'fsUnavailable'
329
- // 构建脚本被 pnpm 白名单(allowBuilds)拦截:插件的 prepare 脚本或依赖里的原生模块构建
330
- // 被默认拒绝(ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED / ERR_PNPM_IGNORED_BUILDS)。
331
- // 这类错误出现即说明 pnpm 已成功 fetch 到 tarball(网络是通的),主因是插件构建脚本
332
- // 被拦 —— 必须在 network 判定之前:日志尾部常混着重试残留的连接失败特征
333
- // (ETIMEDOUT / Failed to connect 等),若先判网络会把「插件分发问题」误报成「你的网络不通」。
226
+ // 构建脚本被 allowBuilds 拦截。必须在 network 之前判:日志尾部混重试残留的连接失败特征
334
227
  if (/ERR_PNPM_IGNORED_BUILDS|Ignored build scripts:|ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED/i.test(message)) return 'pnpmIgnoredBuild'
335
- // 网络问题(服务端 [network] 标记,或安装日志里的连接失败特征:git fetch 失败、
336
- // 连接超时/拒绝/重置、DNS 解析失败、TLS/SSL 握手失败,或 registry tarball 拉取失败
337
- // —— `fetch failed` / `GET https://…/-/…tgz error (n)` 是 pnpm 下载包文件时的网络层失败,
338
- // 常见于本机 npm/pnpm registry 被指向内网/自定义源而取不到包)—— 是本机网络/代理/源
339
- // 配置问题,不是插件问题。必须在 prepare/Command failed 判定之前:git fetch 失败常被
340
- // 外层包成 "Command failed: git fetch ...",先按网络特征归类才不会误判成插件问题。
341
- // 404 类「目标不存在」不含这些特征,仍归 repo(那是仓库/包的问题)。
342
- // 注意:fetch failed 只在 pnpm 拉取阶段出现;prepare/构建已跑起来(tarball 到手)的
343
- // 插件问题不带此特征,不会误伤(allowBuilds 拦截在上一分支先判)。
344
- // `Failed to fetch` 是浏览器 fetch 的 TypeError(词序与 Node 的 fetch failed 相反):界面
345
- // 请求根本没送达宿主服务(宿主未就绪/正在重启、本地代理拦了回环地址)。
228
+ // 网络/代理/源配置问题。必须在 prepare 之前判:git fetch 失败常被外层包成 "Command failed"
346
229
  if (/\[network\]|ERR_PNPM_GIT_FETCH_FAILED|ETIMEDOUT|ECONNREFUSED|ECONNRESET|ENOTFOUND|EAI_AGAIN|EPIPE|EHOSTUNREACH|ENETUNREACH|getaddrinfo|Could not connect|Could not resolve host|Network unreachable|Failed to connect|socket hang up|CERT_HAS_EXPIRED|SSL certificate problem|\bTLS\b|\bSSL\b|fetch failed|failed to fetch|\bGET https?:\/\/\S+\.tgz\s+error \(\d+\)/i.test(message)) return 'network'
347
- // 装后校验拦截(服务端 verifyInstalledEntry 标记):入口文件缺失 = git 分发缺构建产物,
348
- // 与 pluginPrepare 同类(插件打包/分发问题),引导去仓库提 Issue
230
+ // 装后校验发现入口文件缺失([packaging])= git 分发缺构建产物
349
231
  if (/\[packaging\]|entry file missing/i.test(message)) return 'pluginPrepare'
350
- // 依赖在 registry 上不存在(`ERR_PNPM_FETCH_404` / `Not Found - 404`):pnpm 连得上 registry,
351
- // 是被明确告知「这个包不存在」—— 属于 profile 的本地依赖问题,不是插件问题。profile 里留着
352
- // 一条指向未发布包的依赖条目时,该 profile 的**任何**安装都会先卡在这条依赖上。
353
- // 必须在 pluginPrepare 之前判定:此时宿主尾部只有通用的 `dsh: plugin command failed`,
354
- // 落到下面会被当成构建失败。
355
- // (两个形态:pnpm 的 `[ERR_PNPM_FETCH_404] GET <url>: Not Found - 404` 与 npm 的
356
- // `npm error 404 Not Found - GET <url> - Not found`)
232
+ // registry 上不存在的依赖(404),profile 任何安装都会卡在这条。必须在 pluginPrepare 之前判
357
233
  if (/ERR_PNPM_FETCH_404|404 Not Found|Not Found - 404/i.test(message)) return 'pnpmMissingDep'
358
- // 再判 prepare 实际执行失败:只有构建脚本真的跑挂了才是插件问题。
359
- // `Command failed` 区分大小写 —— 只匹配 pnpm 包装出的报错,不匹配宿主那句小写的
360
- // `dsh: plugin command failed`(那只是「这条命令失败了」的通用说明)。
234
+ // 要装的版本在实际安装源里没有(镜像滞后或版本写错)。必须在 pluginPrepare 之前判
235
+ if (/\bERR_PNPM_NO_MATCHING_VERSION\b|\bETARGET\b|No matching version found/i.test(message)) return 'registryVersionStale'
236
+ // profile 残留指向不存在目录的本地依赖(link:/file:)。必须在 pluginPrepare 之前判
237
+ if (/\bERR_PNPM_LINKED_PKG_DIR_NOT_FOUND\b|Could not install from .+ as it does not exist/i.test(message)) return 'pnpmLinkedDirMissing'
238
+ // 构建脚本真的跑挂了才算插件问题。Command failed 区分大小写:只匹配 pnpm 的包装,
239
+ // 不匹配宿主那句小写 dsh: plugin command failed
361
240
  if (/ERR_PNPM_PREPARE_PACKAGE|ELIFECYCLE|prepare-guard/i.test(message) || /Command failed/.test(message)) return 'pluginPrepare'
362
- // 其余失败(含 git prepare 被 pnpm 白名单拦截):当前通道装不上 = 插件分发/依赖的问题,一律提 Issue
363
- return 'repo'
241
+ // 其余未知失败:引导到插件中心仓库排查
242
+ return 'hub'
364
243
  }
365
244
 
366
245
  /** 从服务端 `[npm-too-low]` 标记行提取本机 npm 版本(如 `[npm-too-low] npm@11.3.0` → "11.3.0");
@@ -8,7 +8,7 @@
8
8
  * URL stays within GitHub's 8192-byte request limit.
9
9
  */
10
10
  import type { EnvInfo, HubPlugin, LocaleId } from '../types.ts'
11
- import { GITHUB_URL, PLUGIN_VERSION, SITE_URL } from './constants.ts'
11
+ import { GITHUB_URL, HUB_REPO, PLUGIN_VERSION, SITE_URL } from './constants.ts'
12
12
  import { MAX_CORE_CHARS, classifyFailure, coreErrorCode, pnpmPolicyHintOf, summarizeError } from './failures.ts'
13
13
  import { executedCommandOf } from './install-command.ts'
14
14
  import type { FailureKind } from './failures.ts'
@@ -55,12 +55,15 @@ function reasonTitleOf(kind: FailureKind): string {
55
55
  case 'pnpmPolicy': return 'pnpm supply-chain policy blocked the install on the user machine'
56
56
  case 'pnpmUnusedPatch': return 'stale pnpm patch entry on the user machine'
57
57
  case 'pnpmMissingDep': return 'dependency not found on the npm registry on the user machine'
58
+ case 'registryVersionStale': return 'requested version not available on the npm registry in use on the user machine'
59
+ case 'pnpmLinkedDirMissing': return 'dangling local (link:) dependency in the user profile'
58
60
  case 'fileLocked': return 'file locked by another process on the user machine'
59
61
  case 'accessDenied': return 'file write access denied on the user machine'
60
62
  case 'fsUnavailable': return 'local file system unavailable on the user machine'
61
63
  case 'pnpmIgnoredBuild': return 'build scripts blocked by pnpm allowlist'
62
64
  case 'pluginPrepare': return 'plugin distribution incomplete'
63
65
  case 'network': return 'network failure on the user side'
66
+ case 'hub': return 'install failed (cause unclassified)'
64
67
  default: return 'plugin install failed'
65
68
  }
66
69
  }
@@ -180,3 +183,71 @@ export function pluginIssueUrl(repo: string, message: string, env?: EnvInfo | nu
180
183
  }
181
184
  return url
182
185
  }
186
+
187
+ /**
188
+ * 插件中心自身仓库的预填 Issue 链接:用于「非插件侧」失败(无法识别的未知原因、
189
+ * 以及 pnpm allowBuilds 拦截这类不再归到插件作者的情形)。正文带上目标插件与完整
190
+ * 错误摘要,供插件中心维护者分诊 —— 不再把环境问题/插件中心自身问题误投给插件作者。
191
+ */
192
+ export function hubIssueUrl(pluginRepo: string | null, message: string, env?: EnvInfo | null, command?: string, attempts?: string[]): string {
193
+ const kind = classifyFailure(message)
194
+ const executed = executedCommandOf(command, attempts)
195
+ const repo = pluginRepo && pluginRepo.trim() !== '' ? pluginRepo.trim() : 'unknown'
196
+ const title = `[dsh-plugin-hub] ${reasonTitleOf(kind)}: ${repo}`
197
+ const code = coreErrorCode(message)
198
+ const build = (coreChars: number): string => {
199
+ const body = [
200
+ '## Summary',
201
+ `- Auto-classified cause: ${reasonTitleOf(kind)}`,
202
+ `- Target plugin: \`${repo}\``,
203
+ ...(code ? [`- Key error: \`${code}\``] : []),
204
+ // 为什么投递到这里而非插件仓库:只有确有证据是插件自身构建失败(pluginPrepare)才投插件仓库
205
+ '- Why filed here: the installer only routes a failure to the plugin repo when there is positive evidence the plugin build itself failed (pluginPrepare). This failure does not match that, so it is routed here for triage.',
206
+ '',
207
+ `## [DSH Plugin 插件市场](${SITE_URL}) · 安装 Plugin 失败错误信息`,
208
+ `本错误信息由 [dsh-plugin-hub](${GITHUB_URL}) 插件市场的安装程序自动生成,随本次安装失败一并提交。`,
209
+ `- 实际执行的安装命令:${executed ? `\`${executed}\`` : 'unknown (no executed command recorded)'}`,
210
+ `- 执行结果:安装失败,未能安装该插件。`,
211
+ ...(attempts && attempts.length > 0
212
+ ? ['', '## Attempted install channels(已尝试的安装方式)', ...attempts.map((a) => `- ${a}`)]
213
+ : []),
214
+ '',
215
+ '## Environment',
216
+ `- Plugin: \`${repo}\``,
217
+ ...(env
218
+ ? [
219
+ `- DSH: ${env.dshVersion ? `v${env.dshVersion}` : 'unknown'}`,
220
+ `- Plugin Hub: v${PLUGIN_VERSION}`,
221
+ `- Node: ${env.nodeVersion}`,
222
+ `- pnpm: ${env.pnpmVersion ? `v${env.pnpmVersion}` : 'unknown'}`,
223
+ `- npm: ${env.npmVersion ? `v${env.npmVersion}` : 'unknown'}`,
224
+ `- git: ${env.gitVersion ? `v${env.gitVersion}` : 'unknown'}`,
225
+ `- OS: ${env.platform} ${env.arch} (${env.release})`,
226
+ `- Profile: \`${env.profile}\``,
227
+ `- DSH Home: \`${env.dshHome}\``,
228
+ `- Full log: \`~/.dsh/profiles/${env.profile}/hub.log\` (paste or attach for the full output)`,
229
+ ]
230
+ : []),
231
+ ...(repo !== 'unknown' ? [`- Catalog: [${pluginSiteUrl(repo)}](${pluginSiteUrl(repo)})`] : []),
232
+ '',
233
+ '## Error (core)',
234
+ '',
235
+ '```',
236
+ summarizeError(message, coreChars),
237
+ '```',
238
+ '',
239
+ '---',
240
+ `This error was produced by the [dsh-plugin-hub](${GITHUB_URL}) installer bundled with DeepSeek Harness.`,
241
+ ].join('\n')
242
+ return `https://github.com/${HUB_REPO}/issues/new?title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`
243
+ }
244
+ const MAX_URL_CHARS = 7600
245
+ let url = build(MAX_CORE_CHARS)
246
+ if (url.length > MAX_URL_CHARS) {
247
+ for (const budget of [4000, 2500, 1400, 800, 400]) {
248
+ url = build(budget)
249
+ if (url.length <= MAX_URL_CHARS) break
250
+ }
251
+ }
252
+ return url
253
+ }
@@ -116,7 +116,7 @@ export interface HubUpdateInfo {
116
116
  * - `pending` 远端有更新但发布未满门槛(见 useCatalog)→ 暂缓提示并说明原因
117
117
  * - `latest` 与远端最新版一致 → 确已最新
118
118
  * 「有新版但暂缓提示」必须与「确已最新」分开:两者若共用文案,弹窗会在本机版本落后时
119
- * 谎称「你正在使用 v{远端最新版},已是最新」(见 issue #145)。
119
+ * 谎称「你正在使用 v{远端最新版},已是最新」。
120
120
  */
121
121
  export type HubUpdateState = 'none' | 'available' | 'pending' | 'latest'
122
122
 
@@ -0,0 +1,143 @@
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
+ * Catalog proxy routes: server-side caching of the directory/stats payloads
7
+ * and profile-level installed-package reads shared with the install/task modules.
8
+ */
9
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
10
+ import { homedir } from 'node:os'
11
+ import { dirname, join } from 'node:path'
12
+ import { githubRepoOf } from '../services/install/install.ts'
13
+ import { loadSettings } from '../services/settings.ts'
14
+ import { fetchViaCurl, systemProxy } from '../services/probe.ts'
15
+ import { CATALOG_CACHE_TTL_MS, requireMethod, sendJson, type WebRoute, type WebServerService } from './shared.ts'
16
+
17
+ /** 磁盘缓存内容:原始响应字符串 + 写入时间戳(TTL 判定用)。 */
18
+ interface CatalogCacheEntry {
19
+ at: number
20
+ body: string
21
+ }
22
+
23
+ function catalogCacheFile(profile: string, key: string): string {
24
+ return join(profileDirectory(profile), 'cache', `catalog-${key}.json`)
25
+ }
26
+
27
+ /** 读缓存:文件存在、JSON 合法、未超过 maxAgeMs → 返回原始响应字符串;否则 null(视为 miss)。
28
+ * maxAgeMs 传 Infinity 即「任意年龄的缓存」,供实时拉取失败时兜底使用。 */
29
+ function readCatalogCache(file: string, maxAgeMs: number): string | null {
30
+ try {
31
+ const entry = JSON.parse(readFileSync(file, 'utf8')) as CatalogCacheEntry
32
+ if (entry && typeof entry.at === 'number' && typeof entry.body === 'string'
33
+ && Date.now() - entry.at < maxAgeMs) {
34
+ return entry.body
35
+ }
36
+ } catch {
37
+ // 文件缺失 / 解析失败 / 字段不完整 → miss,重新拉取覆盖写盘
38
+ }
39
+ return null
40
+ }
41
+
42
+ /** 写缓存:目录不存在则创建;写失败静默忽略(缓存只是加速,不影响功能)。 */
43
+ function writeCatalogCache(file: string, body: string): void {
44
+ try {
45
+ mkdirSync(dirname(file), { recursive: true })
46
+ writeFileSync(file, JSON.stringify({ at: Date.now(), body } satisfies CatalogCacheEntry))
47
+ } catch {
48
+ // 磁盘只读/无权限等极端情况:忽略,继续走直连
49
+ }
50
+ }
51
+
52
+ export function profileDirectory(profile: string): string {
53
+ return join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'profiles', profile)
54
+ }
55
+
56
+ /** Read non-official dependencies installed into one profile. */
57
+ export function readInstalled(profile: string): Record<string, string> {
58
+ try {
59
+ const manifest = JSON.parse(readFileSync(join(profileDirectory(profile), 'package.json'), 'utf8')) as {
60
+ dependencies?: Record<string, string>
61
+ }
62
+ return Object.fromEntries(
63
+ Object.entries(manifest.dependencies ?? {}).filter(([name]) => !name.startsWith('@deepseek-ai/')),
64
+ )
65
+ } catch {
66
+ return {}
67
+ }
68
+ }
69
+
70
+ /** 读已装依赖实际落在磁盘上的仓库身份(`owner/repo`):解析 node_modules/<name>/package.json
71
+ * 的 repository 字段。已装 spec 可能是纯版本号(丢了仓库身份),但真实包元数据仍指向来源仓库,
72
+ * 据此可区分「同名依赖其实是同一仓库的残留(应转 update)」还是「跨仓库包名冲突(应报错)」。
73
+ * 读不到 / 解析不出返回 null(交给调用方按冲突处理)。 */
74
+ export function installedRepoOf(profile: string, name: string): string | null {
75
+ try {
76
+ const pkg = JSON.parse(readFileSync(join(profileDirectory(profile), 'node_modules', name, 'package.json'), 'utf8')) as {
77
+ repository?: string | { url?: string }
78
+ }
79
+ const repo = typeof pkg.repository === 'string' ? pkg.repository : pkg.repository?.url
80
+ return repo ? githubRepoOf(repo) : null
81
+ } catch {
82
+ return null
83
+ }
84
+ }
85
+
86
+ export function registerCatalogRoutes(webServer: WebServerService, profile: string): WebRoute[] {
87
+ return [
88
+ {
89
+ kind: 'exact',
90
+ path: '/dsh-plugin-hub/catalog',
91
+ handler: async (request, response) => {
92
+ if (!requireMethod(request, response, 'GET')) return
93
+ // 目录/统计数据服务端代理:浏览器不直连 dsh-plugin.org,统一经此路由
94
+ // 转发(curl 子进程注入代理 env),与 npm / git 安装通道走同一代理口径,
95
+ // 「npm / git / 目录数据请求统一走该代理」的设置文案因此真实生效。
96
+ const url = new URL(request.url ?? '/', 'http://localhost')
97
+ const settings = loadSettings(profile)
98
+ // 代理优先级与安装/诊断一致:设置里的代理 → 系统代理 → 环境变量 → 直连
99
+ const proxy = settings.proxy !== ''
100
+ ? settings.proxy
101
+ : (systemProxy() ?? process.env.HTTPS_PROXY ?? process.env.https_proxy ?? '')
102
+ const isStats = url.searchParams.get('stats') === '1'
103
+ const lang = url.searchParams.get('lang') === 'en' ? 'en' : 'zh'
104
+ const target = isStats
105
+ ? 'https://api.dsh-plugin.org/stats.json'
106
+ : `https://api.dsh-plugin.org/plugins.${lang}.json`
107
+ // 1 小时本地缓存:命中直接返回(跨重启生效,插件市场秒开),未命中才走 curl 拉远程
108
+ const cacheFile = catalogCacheFile(profile, isStats ? 'stats' : `plugins-${lang}`)
109
+ const cached = readCatalogCache(cacheFile, CATALOG_CACHE_TTL_MS)
110
+ if (cached !== null) {
111
+ try {
112
+ sendJson(response, 200, JSON.parse(cached))
113
+ return
114
+ } catch {
115
+ // 缓存正文损坏:忽略,走重新拉取并覆盖写盘
116
+ }
117
+ }
118
+ const r = await fetchViaCurl(target, proxy, 20000)
119
+ if (!r.ok || r.body === '') {
120
+ // 实时拉取失败:退回任意年龄的本地缓存(上次成功数据),
121
+ // 避免证书吊销受阻 / 网络抖动时整个插件市场打不开 —— 数据可能过期,但好过空白。
122
+ const stale = readCatalogCache(cacheFile, Number.POSITIVE_INFINITY)
123
+ if (stale !== null) {
124
+ try {
125
+ sendJson(response, 200, JSON.parse(stale))
126
+ return
127
+ } catch {
128
+ // 兜底缓存也损坏:落到下面的 502
129
+ }
130
+ }
131
+ sendJson(response, 502, { error: 'catalog fetch failed' })
132
+ return
133
+ }
134
+ try {
135
+ sendJson(response, 200, JSON.parse(r.body))
136
+ writeCatalogCache(cacheFile, r.body)
137
+ } catch {
138
+ sendJson(response, 502, { error: 'catalog fetch returned invalid JSON' })
139
+ }
140
+ },
141
+ },
142
+ ]
143
+ }