dsh-plugin 2.2.7 → 2.3.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.
@@ -134,7 +134,9 @@ export function registerCatalogRoutes(webServer, profile) {
134
134
  // 兜底缓存也损坏:落到下面的 502
135
135
  }
136
136
  }
137
- sendJson(response, 502, { error: 'catalog fetch failed' });
137
+ // 带上 curl 退出码与 stderr 末行:只回 error 字段时,用户贴出来的响应里
138
+ // 没有任何可诊断信息
139
+ sendJson(response, 502, { error: 'catalog fetch failed', detail: r.detail });
138
140
  return;
139
141
  }
140
142
  try {
@@ -265,11 +265,27 @@ export async function finalUrl(url, proxy, timeoutMs) {
265
265
  */
266
266
  export function fetchViaCurl(url, proxy, timeoutMs) {
267
267
  // 同样用 -sS:正文之外还要 stderr 里的 TLS 失败线索(被 stderr 收集,不外泄给用户)。
268
- // --compressed:目录 JSON 约 3.6MB,源站支持 gzip,压缩后弱网下也能在超时内拉完。
269
- const args = ['-sS', '--compressed', '--max-time', String(Math.max(1, Math.round(timeoutMs / 1000))), url];
270
- return curlWithTlsFallback(args, proxyEnv(proxy), timeoutMs).then((r) => ({
271
- ok: r.code === 0,
272
- body: r.stdout,
273
- reason: r.code !== 0 && tlsRevocationBlocked(r.stderr) ? 'tlsRevocation' : null,
274
- }));
268
+ // 编码不用 --compressed 的自动协商,而是明确只接受 gzip:协商会让服务端按本机 curl 声明的
269
+ // 能力回 br / zstd,解不开时 curl 非零退出且正文为空,目录拉取直接失败;而 gzip 的解码器
270
+ // 每个 curl 都有,对 JSON 的压缩率也够。
271
+ const gzip = ['-H', 'Accept-Encoding: gzip', '--compressed'];
272
+ const attempt = (extra) => [
273
+ '-sS', ...gzip, ...extra, '--max-time', String(Math.max(1, Math.round(timeoutMs / 1000))), url,
274
+ ];
275
+ return curlWithTlsFallback(attempt([]), proxyEnv(proxy), timeoutMs).then(async (first) => {
276
+ // 首轮失败(连接被重置 / 编码解不开 / HTTP/2 流异常)时退到 HTTP/1.1 再试一次:
277
+ // Windows 自带 curl 与大响应 + HTTP/2 的组合偶发流级失败,换协议版本代价最小;
278
+ // 只在真失败时才多一次请求。
279
+ const r = first.code === 0
280
+ ? first
281
+ : await curlWithTlsFallback(attempt(['--http1.1']), proxyEnv(proxy), timeoutMs);
282
+ const failed = r.code !== 0 || r.stdout === '';
283
+ return {
284
+ ok: !failed,
285
+ body: r.stdout,
286
+ reason: failed && tlsRevocationBlocked(r.stderr) ? 'tlsRevocation' : null,
287
+ // 只留退出码与 stderr 末行:正文不进错误详情,且要够短才能在界面上完整显示
288
+ detail: failed ? `curl exit ${r.code ?? 'null'}: ${r.stderr.trim().split('\n').slice(-1)[0] ?? ''}`.slice(0, 300) : null,
289
+ };
290
+ });
275
291
  }
@@ -79,4 +79,5 @@ export declare function fetchViaCurl(url: string, proxy: string, timeoutMs: numb
79
79
  ok: boolean;
80
80
  body: string;
81
81
  reason: ProbeFailReason | null;
82
+ detail: string | null;
82
83
  }>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin",
3
- "version": "2.2.7",
3
+ "version": "2.3.0",
4
4
  "description": "A community plugin marketplace for DeepSeek Harness, built to the official plugin spec — browse, search and install 10000+ human-curated community plugins without leaving the app. · DeepSeek Harness 社区插件市场(遵循官方开发规范):10000+ 人工精选社区插件,每日更新。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -37,6 +37,13 @@ export function HubUpdateModal({ info, state, currentVersion, lang, t, onProceed
37
37
  const pending = state === 'pending'
38
38
  // 展示的版本号:提示更新/暂缓提示时是远端新版本;否则是本机当前版本
39
39
  const shownVersion = hasUpdate || pending ? info.version : currentVersion || info.version
40
+ // 状态胶囊:颜色 + 文案在同一处说清「已是最新 / 可更新 / 暂缓提示」,
41
+ // 用户不必在多个版本号之间来回对照才判断出自己在哪种状态
42
+ const chip = hasUpdate
43
+ ? { className: styles.hubStateAvailable, label: t('hubVersionStateAvailable') }
44
+ : pending
45
+ ? { className: styles.hubStatePending, label: t('hubVersionStatePending') }
46
+ : { className: styles.hubStateLatest, label: t('hubVersionStateLatest') }
40
47
  // 变更记录:字符串直接使用;{zh,en} 对象按界面语言取,缺哪种补哪种,全缺则不出记录区
41
48
  const notesRaw = typeof info.notes === 'string'
42
49
  ? info.notes
@@ -45,7 +52,13 @@ export function HubUpdateModal({ info, state, currentVersion, lang, t, onProceed
45
52
  ? (info.notes.en ?? info.notes.zh ?? '')
46
53
  : (info.notes.zh ?? info.notes.en ?? '')
47
54
  : ''
48
- const notesHtml = notesRaw.trim() ? renderMarkdown(notesRaw) : null
55
+ // 记录开头的 H1 只是重复「DSH Plugin Hub vX」,版本已在顶部展示过;去掉后正文直接进入内容。
56
+ // 去掉后若没有正文(记录只有标题),保底仍显示原文,避免空盒子
57
+ const notesStripped = notesRaw.replace(/^\s*#\s*DSH Plugin Hub[^\n]*\n+/, '')
58
+ const notesBody = notesStripped.trim() === '' ? notesRaw : notesStripped
59
+ const notesHtml = notesBody.trim() ? renderMarkdown(notesBody) : null
60
+ // 说明只在需要动作或需要解释时出现:已是最新时胶囊已说明状态,不再重复一句话
61
+ const desc = hasUpdate ? t('hubUpdateDesc') : pending ? t('hubPendingDesc') : null
49
62
  // 发布时间:ISO 字符串转本地可读格式;非法值静默隐藏
50
63
  let published: string | null = null
51
64
  if (info.publishedAt) {
@@ -66,18 +79,18 @@ export function HubUpdateModal({ info, state, currentVersion, lang, t, onProceed
66
79
  h('div', { className: styles.modalTitle }, t(hasUpdate || pending ? 'hubUpdateTitle' : 'hubCurrentTitle')),
67
80
  h('button', {
68
81
  className: styles.modalClose,
69
- 'aria-label': t('confirmCancel'),
82
+ 'aria-label': t('close'),
70
83
  onClick: () => onClose(),
71
84
  }, h(CloseIcon)),
72
85
  ),
73
- h('div', { className: styles.modalDesc },
74
- t(hasUpdate ? 'hubUpdateDesc' : pending ? 'hubPendingDesc' : 'hubCurrentDesc', { version: shownVersion })),
75
- h('div', { className: styles.hubUpdateMeta },
76
- h('span', { className: styles.hubUpdateMetaItem }, `${t('version')} ${shownVersion}`),
86
+ h('div', { className: styles.hubVersionBar },
87
+ h('span', { className: styles.hubVersionNumber }, `v${shownVersion}`),
88
+ h('span', { className: `${styles.hubStateChip} ${chip.className}` }, chip.label),
77
89
  published
78
- ? h('span', { className: styles.hubUpdateMetaItem }, `${t('hubUpdatePublished')} ${published}`)
90
+ ? h('span', { className: styles.hubVersionDate }, `${t('hubUpdatePublished')} ${published}`)
79
91
  : null,
80
92
  ),
93
+ desc ? h('div', { className: styles.modalDesc }, desc) : null,
81
94
  notesHtml
82
95
  ? h('div', {
83
96
  className: styles.hubUpdateNotes,
@@ -90,7 +103,7 @@ export function HubUpdateModal({ info, state, currentVersion, lang, t, onProceed
90
103
  h('button', { className: styles.restartLater, onClick: onClose }, t('hubUpdateLater')),
91
104
  h('button', { className: styles.modalInstall, onClick: onProceed }, t('updateNow')),
92
105
  ]
93
- : h('button', { className: styles.modalInstall, onClick: onClose }, t(pending ? 'hubUpdateLater' : 'hubUpToDate')),
106
+ : h('button', { className: styles.modalInstall, onClick: onClose }, t('close')),
94
107
  ),
95
108
  ),
96
109
  )
@@ -333,6 +333,11 @@ export function NotificationsModal({ records, tasks, pendingRestarts, t, env, on
333
333
  // 给修复/重建指引,不引导提 Issue
334
334
  return h('div', { className: styles.failPrepareHint }, t('failProfileYamlHint'))
335
335
  }
336
+ if (kind === 'profileLocalBuild') {
337
+ // 被拦的构建脚本属于 profile 里已有的 file:/link: 本地插件,不是本次装的插件:
338
+ // pnpm 每次 add 都重校验整棵树,该 profile 内的安装都会失败 —— 给定位/移除指引,不引导提 Issue
339
+ return h('div', { className: styles.failPrepareHint }, t('failProfileLocalBuildHint'))
340
+ }
336
341
  if (kind === 'profileState') {
337
342
  // profile 的 pnpm 元数据与本机不一致(lockfile 过期 / packageManager 版本不符 /
338
343
  // 补丁未生效):pnpm 在真正装插件之前就中止,该 profile 内的安装都会失败 ——
@@ -466,6 +466,10 @@ export function ErrorModal({ message, repo, action, command, attempts, t, env, o
466
466
  // profile 里的 YAML 配置解析不了(pnpm-workspace.yaml / pnpm-lock.yaml 有重复键、缩进坏了):
467
467
  // pnpm 在依赖解析之前就退出,该 profile 内的安装都会失败 —— 给修复/重建指引,不引导提 Issue
468
468
  ? h('div', { className: styles.failPrepareHint }, t('failProfileYamlHint'))
469
+ : failureKind === 'profileLocalBuild'
470
+ // 被拦的构建脚本属于 profile 里已有的 file:/link: 本地插件,不是本次装的插件:
471
+ // pnpm 每次 add 都重校验整棵树,该 profile 内的安装都会失败 —— 给定位/移除指引,不引导提 Issue
472
+ ? h('div', { className: styles.failPrepareHint }, t('failProfileLocalBuildHint'))
469
473
  : failureKind === 'profileState'
470
474
  // profile 的 pnpm 元数据与本机不一致(lockfile 过期 / packageManager 版本不符 /
471
475
  // 补丁未生效):pnpm 在真正装插件之前就中止,该 profile 内的安装都会失败 ——
@@ -354,6 +354,7 @@ export const zh = {
354
354
  failPnpmUnusedPatchHint: '安装器调用 pnpm 时报「有些补丁没有被使用」(ERR_PNPM_UNUSED_PATCH):你的 profile 里留着一条指向旧版本 dsh-plugin 的补丁声明(例如 dsh-plugin@1.4.2),而本次安装解析到的版本已经不是它,pnpm 出于安全直接中止整次安装,因此任何插件都装不进来。这不是插件本身的问题——请在 profile 目录(~/.dsh/profiles/<profile>)的 pnpm-workspace.yaml(pnpm 11 及以上)或 package.json 的 pnpm.patchedDependencies(pnpm 10)里删除该 dsh-plugin 补丁条目后重试。',
355
355
  failProfileStateHint: '安装器调用 pnpm 时报 profile 的元数据与当前环境对不上(如 ERR_PNPM_OUTDATED_LOCKFILE / ERR_PNPM_NO_IMPORTER_MANIFEST_FOUND / ERR_PNPM_BAD_PM_VERSION / ERR_PNPM_PATCH_NOT_APPLIED):profile 里的 pnpm-lock.yaml、package.json 或补丁声明与当前 pnpm 版本不一致,pnpm 在真正装插件之前就中止了,所以这个 profile 里装任何插件都会失败。这不是插件本身的问题——请在 profile 目录(~/.dsh/profiles/<profile>)里删除 pnpm-lock.yaml 后重试(pnpm 会按当前依赖重新生成);若报的是 packageManager 版本不符,把 package.json 里的 packageManager 字段改成当前 pnpm 的大版本或直接删掉该字段;仍不行请重建该 profile 的依赖。',
356
356
  failProfileYamlHint: '安装器调用 pnpm 时报「YAML 配置损坏」(duplicated mapping key):profile 目录里的 pnpm-workspace.yaml 或 pnpm-lock.yaml 出现了重复的键(多见于手工编辑、合并冲突残留,或第三方工具重复写入同一条目)。pnpm 在解析阶段就直接退出,依赖根本没开始装,所以这个 profile 里装任何插件都会失败。这不是插件本身的问题——请打开 ~/.dsh/profiles/<profile>/ 检查这两个文件,删掉重复的那一行或那一段;不确定该改哪一处时,可以先删掉 pnpm-lock.yaml(必要时连 node_modules 一起)让 pnpm 重新生成,然后重试。',
357
+ failProfileLocalBuildHint: '安装器调用 pnpm 时,pnpm 拒绝了 profile 里某个本地包的构建脚本:这台机器的 profile 已经装了一个用 file: / link: 指向本地目录的插件,它的 postinstall 等脚本跑不通。pnpm 每次 add 都会重校验整个 profile 的依赖树,所以这一个坏包会让这个 profile 里装任何插件都失败——与本次要装的插件无关,也不该去找它的作者。请打开 profile 目录(~/.dsh/profiles/<profile>)的 package.json,看 dependencies 里值是 file: / link: 的那几项,把它们临时移除(或修好对应目录里缺失的脚本文件)后重试;pnpm 报错里「Ignored build scripts」后面列出的包名就是出错的那个。',
357
358
  failNodeEngineHint: '这个插件(或它的某个依赖)声明了更高的 Node / pnpm 版本要求(ERR_PNPM_UNSUPPORTED_ENGINE / EBADENGINE),当前版本不满足,pnpm 在安装阶段直接拒绝。这不是插件本身的问题——请把 Node 升级到要求的版本(nvm / fnm 切换,或安装新版 Node.js),确认 node -v 与 pnpm -v 都满足要求后重试。',
358
359
  // 404 依赖:点名 registry 上不存在的那个包(取不到包名时跳过这行),再给删除条目的指引
359
360
  failPnpmMissingDepTarget: '{pkg} 在 npm registry 上不存在(404)',
@@ -392,6 +393,8 @@ export const zh = {
392
393
  failReason_pnpmUnusedPatch: 'profile 里留着指向旧版本 dsh-plugin 的补丁,pnpm 出于安全中止了安装。',
393
394
  failKind_profileYamlCorrupted: 'profile 的 YAML 配置损坏',
394
395
  failReason_profileYamlCorrupted: 'profile 目录里的 pnpm-workspace.yaml 或 pnpm-lock.yaml 有重复的键,pnpm 在解析阶段就退出了。',
396
+ failKind_profileLocalBuild: 'profile 里已有的本地插件构建失败',
397
+ failReason_profileLocalBuild: 'pnpm 每次安装都会重校验整个 profile,这次失败来自 profile 里已有的 file:/link: 本地插件的构建脚本,和本次要装的插件无关。',
395
398
  failKind_profileState: 'profile 的 pnpm 元数据与本机不一致',
396
399
  failReason_profileState: 'profile 的 lockfile / package.json / packageManager 版本与当前 pnpm 对不上,安装没开始就被中止。',
397
400
  failKind_nodeEngine: '本机 Node / pnpm 版本不满足要求',
@@ -414,8 +417,8 @@ export const zh = {
414
417
  failReason_accessDenied: '系统拒绝 pnpm 写入 profile 里的文件。',
415
418
  failKind_fsUnavailable: '磁盘不可写',
416
419
  failReason_fsUnavailable: '磁盘已满、处于只读,或文件句柄耗尽。',
417
- failKind_pnpmIgnoredBuild: '原生模块构建被拦截',
418
- failReason_pnpmIgnoredBuild: '插件依赖需要在你的机器上现场编译,pnpm 默认拦住构建脚本。',
420
+ failKind_pnpmIgnoredBuild: '依赖的构建脚本被 pnpm 拦截',
421
+ failReason_pnpmIgnoredBuild: '这次要装的插件(或其依赖)声明了构建脚本,pnpm 默认拦下未放行的构建(不一定是原生模块)。',
419
422
  failKind_pluginPrepare: '插件分发不完整',
420
423
  failReason_pluginPrepare: '作者发布的包里缺文件,或构建脚本执行失败。',
421
424
  failKind_hostIncompatible: '插件不支持当前的 DSH 版本({version})',
@@ -491,16 +494,19 @@ export const zh = {
491
494
  updatePendingHint: '新版本发布未满 24 小时,pnpm 供应链门禁会拦下;到时间后再更新即可',
492
495
  hubUpdateHint: '检测到 DSH Plugin Hub 新版本,点击查看更新内容并更新',
493
496
  hubUpdateTitle: 'DSH Plugin Hub 有新版本',
494
- hubUpdateDesc: 'v{version} 已发布,点击下方按钮即可更新,完成后需重启宿主生效。',
497
+ hubUpdateDesc: '点击下方按钮即可更新,完成后需重启宿主生效。',
495
498
  hubUpdatePublished: '发布于',
496
499
  hubUpdateNotes: '更新记录',
497
500
  hubUpdateLater: '稍后再说',
498
501
  versionHint: '查看版本信息与更新记录',
499
502
  hubCurrentTitle: '当前版本',
500
- hubCurrentDesc: '你正在使用 v{version},已是最新版本。',
503
+ // 状态胶囊:版本号旁边一处说清当前处于哪种状态,不再靠多处版本号对照
504
+ hubVersionStateLatest: '已是最新',
505
+ hubVersionStateAvailable: '可更新',
506
+ hubVersionStatePending: '暂缓提示',
507
+ close: '关闭',
501
508
  // 暂缓提示:远端确有新版本,但发布未满 24 小时(pnpm 供应链安全门槛),与「已是最新」严格区分
502
- hubPendingDesc: '最新版 v{version} 已发布,但发布尚未满 24 小时:此时更新会被 pnpm 静默回退到旧版本,因此暂不提示。稍后这里会自动出现更新入口。',
503
- hubUpToDate: '已是最新版本',
509
+ hubPendingDesc: '最新版已发布,但发布尚未满 24 小时:此时更新会被 pnpm 静默回退到旧版本,因此暂不提示。稍后这里会自动出现更新入口。',
504
510
  // 头部「关注我们」弹窗:平台介绍 + 用户反馈群二维码(内容由 Worker /about Markdown 推送,非写死)
505
511
  followUs: '关注我们',
506
512
  aboutTitle: '关注我们',
@@ -856,6 +862,7 @@ export const en = {
856
862
  failPnpmUnusedPatchHint: 'pnpm reported that some patches were not used (ERR_PNPM_UNUSED_PATCH): your profile still holds a patch entry for an older dsh-plugin version (for example dsh-plugin@1.4.2), while this install resolved a different version, so pnpm aborts the whole install for safety — no plugin can be installed into that profile. This is not a problem with the plugin itself. Remove that dsh-plugin patch entry from pnpm-workspace.yaml (pnpm 11+) or from pnpm.patchedDependencies in package.json (pnpm 10) inside the profile directory (~/.dsh/profiles/<profile>), then retry.',
857
863
  failProfileStateHint: 'pnpm reported that the profile metadata does not match the current environment (e.g. ERR_PNPM_OUTDATED_LOCKFILE / ERR_PNPM_NO_IMPORTER_MANIFEST_FOUND / ERR_PNPM_BAD_PM_VERSION / ERR_PNPM_PATCH_NOT_APPLIED): pnpm-lock.yaml, package.json or the patch declarations inside the profile disagree with the pnpm version in use, so pnpm aborts before installing anything — every plugin install into this profile will fail. This is not a problem with the plugin itself. Delete pnpm-lock.yaml inside the profile directory (~/.dsh/profiles/<profile>) and retry (pnpm regenerates it from the declared dependencies); if the error is about packageManager, set that field in package.json to the current pnpm major or remove it; if it still fails, rebuild the profile dependencies.',
858
864
  failProfileYamlHint: 'pnpm reported a broken YAML config (duplicated mapping key): a YAML file inside the profile directory — pnpm-workspace.yaml or pnpm-lock.yaml — contains a duplicate key, usually after a manual edit, a leftover merge conflict, or a tool writing the same entry twice. pnpm stops at parse time and nothing is installed, so every plugin install into this profile fails the same way. This is not a problem with the plugin itself. Open ~/.dsh/profiles/<profile>/ and remove the duplicated line or block; if you are not sure which file to fix, delete pnpm-lock.yaml (and node_modules if needed) so pnpm regenerates them, then retry.',
865
+ failProfileLocalBuildHint: 'pnpm rejected a build script of a local package already in the profile: this machine\'s profile contains a plugin wired to a local directory with file: / link:, and its postinstall (or another build script) cannot run. pnpm re-validates the whole profile dependency tree on every add, so that one broken package makes every install into this profile fail — it is unrelated to the plugin you were installing, and reporting it to that plugin\'s author will not help. Open package.json in the profile directory (~/.dsh/profiles/<profile>), find the dependencies whose version starts with file: or link:, remove them temporarily (or fix the missing script in that directory), then retry. The package names after "Ignored build scripts" in the pnpm error tell you which one is failing.',
859
866
  failNodeEngineHint: 'This plugin (or one of its dependencies) declares a higher Node / pnpm version (ERR_PNPM_UNSUPPORTED_ENGINE / EBADENGINE), which the current one does not satisfy, so pnpm refuses at install time. This is not a problem with the plugin itself. Upgrade Node to the required version (switch with nvm / fnm, or install a newer Node.js), confirm node -v and pnpm -v both satisfy it, then retry.',
860
867
  // 缺失依赖:点名 404 的包(解析不出时跳过该行),再给删除指引
861
868
  failPnpmMissingDepTarget: '{pkg} was not found on the npm registry (404)',
@@ -894,6 +901,8 @@ export const en = {
894
901
  failReason_pnpmUnusedPatch: 'The profile keeps a patch for an older dsh-plugin version, so pnpm aborted for safety.',
895
902
  failKind_profileYamlCorrupted: 'profile YAML config is corrupted',
896
903
  failReason_profileYamlCorrupted: 'A YAML file in the profile directory has a duplicate key, so pnpm aborts at parse time.',
904
+ failKind_profileLocalBuild: 'a local plugin already in the profile fails its build',
905
+ failReason_profileLocalBuild: 'pnpm re-validates the whole profile on every install; this failure comes from the build script of a local (file:/link:) package already in the profile, not from the plugin being installed.',
897
906
  failKind_profileState: 'profile pnpm metadata does not match this machine',
898
907
  failReason_profileState: 'The profile lockfile / package.json / packageManager version disagrees with the current pnpm, so the install aborts before starting.',
899
908
  failKind_nodeEngine: 'Node / pnpm on this machine is below the required version',
@@ -916,8 +925,8 @@ export const en = {
916
925
  failReason_accessDenied: 'The system denied pnpm write access to files inside the profile.',
917
926
  failKind_fsUnavailable: 'the disk is not writable',
918
927
  failReason_fsUnavailable: 'The disk is full or read-only, or file handles are exhausted.',
919
- failKind_pnpmIgnoredBuild: 'native build scripts blocked',
920
- failReason_pnpmIgnoredBuild: 'A plugin dependency has to be compiled on your machine, and pnpm blocks build scripts by default.',
928
+ failKind_pnpmIgnoredBuild: 'build scripts blocked by pnpm',
929
+ failReason_pnpmIgnoredBuild: 'The plugin being installed (or one of its dependencies) declares a build script that pnpm blocks by default; the blocked script is not necessarily a native module.',
921
930
  failKind_pluginPrepare: 'plugin distribution is incomplete',
922
931
  failReason_pluginPrepare: 'The published package is missing files, or its build script failed.',
923
932
  failKind_hostIncompatible: 'plugin does not support the DSH version in use ({version})',
@@ -991,16 +1000,19 @@ export const en = {
991
1000
  updatePendingHint: 'The new release is less than 24 hours old, so the pnpm supply-chain hold rejects it until then',
992
1001
  hubUpdateHint: 'A new DSH Plugin Hub version is available — click to view the changelog and update',
993
1002
  hubUpdateTitle: 'A new DSH Plugin Hub version is available',
994
- hubUpdateDesc: 'v{version} has been released — click below to update, then restart the host to apply it.',
1003
+ hubUpdateDesc: 'Click below to update, then restart the host to apply it.',
995
1004
  hubUpdatePublished: 'Published',
996
1005
  hubUpdateNotes: "What's new",
997
1006
  hubUpdateLater: 'Later',
998
1007
  versionHint: 'View version info and changelog',
999
1008
  hubCurrentTitle: 'Current version',
1000
- hubCurrentDesc: 'You are on v{version} — this is the latest version.',
1009
+ // 状态胶囊:版本号旁边一处说清当前处于哪种状态,不再靠多处版本号对照
1010
+ hubVersionStateLatest: 'Up to date',
1011
+ hubVersionStateAvailable: 'Update available',
1012
+ hubVersionStatePending: 'Held for 24h',
1013
+ close: 'Close',
1001
1014
  // 暂缓提示:确有新版本,但仍在 pnpm 24h 供应链门禁窗口内
1002
- hubPendingDesc: 'v{version} has been released, but it is still within the 24-hour window where pnpm silently falls back to the older version, so the update is not offered yet. The update entry will appear here shortly.',
1003
- hubUpToDate: 'Up to date',
1015
+ hubPendingDesc: 'The latest version has been released, but it is still within the 24-hour window where pnpm silently falls back to the older version, so the update is not offered yet. The update entry will appear here shortly.',
1004
1016
  // 头部「关注我们」弹窗:平台介绍 + 用户反馈群二维码(内容由 Worker /about 以 Markdown 推送)
1005
1017
  followUs: 'Follow us',
1006
1018
  aboutTitle: 'Follow us',
@@ -148,7 +148,7 @@ export function removeNotification(id: number): NotificationRecord[] {
148
148
  return next
149
149
  }
150
150
 
151
- export type FailureKind = 'npmTooOld' | 'nodeEngine' | 'dshMissing' | 'gitMissing' | 'pnpmMissing' | 'npmMissing' | 'pnpmStore' | 'pnpmModulesMismatch' | 'profileYamlCorrupted' | 'profileState' | 'pnpmWorkspace' | 'originRejected' | 'pnpmPolicy' | 'pnpmUnusedPatch' | 'pnpmMissingDep' | 'pluginMissingDep' | 'registryVersionStale' | 'registryAuth' | 'profileExclusive' | 'pnpmLinkedDirMissing' | 'fileLocked' | 'accessDenied' | 'fsUnavailable' | 'buildToolchainMissing' | 'pnpmIgnoredBuild' | 'pluginPrepare' | 'hostIncompatible' | 'tlsTrust' | 'network' | 'hostCliSpawn' | 'hubState' | 'profileBundleMissing' | 'timeout' | 'profileLock' | 'dshTooOld' | 'localEnv' | 'hub'
151
+ export type FailureKind = 'npmTooOld' | 'nodeEngine' | 'dshMissing' | 'gitMissing' | 'pnpmMissing' | 'npmMissing' | 'pnpmStore' | 'pnpmModulesMismatch' | 'profileYamlCorrupted' | 'profileLocalBuild' | 'profileState' | 'pnpmWorkspace' | 'originRejected' | 'pnpmPolicy' | 'pnpmUnusedPatch' | 'pnpmMissingDep' | 'pluginMissingDep' | 'registryVersionStale' | 'registryAuth' | 'profileExclusive' | 'pnpmLinkedDirMissing' | 'fileLocked' | 'accessDenied' | 'fsUnavailable' | 'buildToolchainMissing' | 'pnpmIgnoredBuild' | 'pluginPrepare' | 'hostIncompatible' | 'tlsTrust' | 'network' | 'hostCliSpawn' | 'hubState' | 'profileBundleMissing' | 'timeout' | 'profileLock' | 'dshTooOld' | 'localEnv' | 'hub'
152
152
 
153
153
  /** 失败动作口径:标题、上报正文与提示文案按安装/更新/卸载取词,不写死「安装」。 */
154
154
  export type FailureAction = 'install' | 'update' | 'uninstall'
@@ -169,6 +169,20 @@ export function actionOfRecord(rec: { kind: NotificationRecord['kind']; action?:
169
169
  return rec.kind === 'uninstall' ? 'uninstall' : rec.kind === 'update' ? 'update' : 'install'
170
170
  }
171
171
 
172
+ /**
173
+ * `Ignored build scripts:` 名单是否**全部**是 profile 本地的 file:/link: 依赖。
174
+ * 已发布插件无法依赖用户机器上的本地路径,这类条目只可能来自 profile 里已有的别的插件;
175
+ * 而 pnpm 每次 add 都会重校验整棵 profile 依赖树,本地包构建失败会让本次安装同样失败,
176
+ * 与本次要装的插件无关。
177
+ */
178
+ function localBuildScriptsOnly(message: string): boolean {
179
+ const line = /Ignored build scripts:\s*([^\n]*)/i.exec(message)?.[1] ?? ''
180
+ const specs = line.split(',').map((spec) => spec.trim()).filter((spec) => spec !== '')
181
+ if (specs.length === 0) return false
182
+ // pnpm 写成 `dsh-purge@file:../../vendor/dsh-purge`;容错无版本号的 `file:...` 裸写法
183
+ return specs.every((spec) => /(?:^|@)(?:file|link):/i.test(spec))
184
+ }
185
+
172
186
  /** 只匹配 pnpm 的错误码,不匹配泛泛出现的 checksum / integrity 字样。 */
173
187
  function missingTarballIntegrity(message: string): boolean {
174
188
  return /\bERR_PNPM_MISSING_TARBALL_INTEGRITY\b/.test(message)
@@ -269,6 +283,9 @@ export function classifyFailure(message: string): FailureKind {
269
283
  // 文件系统写不进去(ENOSPC/EROFS/EMFILE/ENFILE)。必须在 network 之前判:
270
284
  // 磁盘满时日志尾部混超时特征,先判网络会误报成「网络不通」
271
285
  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'
286
+ // 名单全是本地 file:/link: 依赖时,失败只能来自 profile 自身的包重校验:归本机状态,
287
+ // 不能记到本次插件头上。必须在 pnpmIgnoredBuild 之前,否则会被它先按插件侧接走
288
+ if (localBuildScriptsOnly(message)) return 'profileLocalBuild'
272
289
  // 构建脚本被 allowBuilds 拦截。必须在 network 之前判:日志尾部混重试残留的连接失败特征
273
290
  if (/ERR_PNPM_IGNORED_BUILDS|Ignored build scripts:|ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED/i.test(message)) return 'pnpmIgnoredBuild'
274
291
  // npm 源拒绝了访问(401/403):私有源要登录凭据,或当前网络/IP 不被源允许。
@@ -49,6 +49,7 @@ function reasonTitleOf(kind: FailureKind, action: FailureAction): string {
49
49
  case 'nodeEngine': return `Node / pnpm on the user machine is below the version this ${word} requires`
50
50
  case 'profileState': return `the user profile's pnpm metadata does not match the local pnpm for this ${word}`
51
51
  case 'profileYamlCorrupted': return 'the user profile has a corrupted YAML config (duplicated mapping key)'
52
+ case 'profileLocalBuild': return 'a local (file:/link:) plugin already in the user profile has a failing build script'
52
53
  case 'dshMissing': return 'dsh command not found on the user machine'
53
54
  case 'pnpmMissing': return 'pnpm command not found on the user machine'
54
55
  case 'pnpmStore': return 'pnpm store version mismatch on the user machine'
@@ -113,6 +114,8 @@ export function pluginIssueUrl(repo: string, message: string, env?: EnvInfo | nu
113
114
  ? 'network connectivity issue on the user side (DNS, proxy, firewall, or a blocked connection)'
114
115
  : kind === 'tlsTrust'
115
116
  ? 'the local certificate chain is not trusted by Node/pnpm (corporate SSL inspection, antivirus web shield, or a local MITM proxy) — local environment issue'
117
+ : kind === 'profileLocalBuild'
118
+ ? 'no plugin-side cause found — pnpm rejects a build script of a local (file:/link:) package that already exists in the user profile, so every install into that profile fails the same way (local environment issue)'
116
119
  : kind === 'localEnv'
117
120
  ? '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'
118
121
  : kind === 'dshTooOld'
@@ -128,7 +131,7 @@ export function pluginIssueUrl(repo: string, message: string, env?: EnvInfo | nu
128
131
  : kind === 'pluginPrepare'
129
132
  ? 'plugin prepare/build script failed during install (packaging/distribution issue)'
130
133
  : kind === 'pnpmIgnoredBuild'
131
- ? 'plugin depends on a native module whose build script pnpm blocks by default (use a prebuilt variant)'
134
+ ? 'a package in this plugin\'s install tree (the plugin itself or one of its dependencies) declares a build script that pnpm blocks by default; pnpm fails the whole install until it is approved, and the blocked script is not necessarily a native module'
132
135
  : kind === 'originRejected'
133
136
  ? 'the request never reached the install flow — the local service rejected its origin (local environment issue)'
134
137
  : kind === 'pnpmPolicy'
@@ -1487,17 +1487,54 @@ a.failRepo:hover .failRepoUrl {
1487
1487
  max-width: calc(100vw - 48px);
1488
1488
  }
1489
1489
 
1490
- /* 版本 / 发布时间元信息行:小号标签并排,弱化但可读 */
1491
- .hubUpdateMeta {
1490
+ /* 版本条:版本号是这一屏的主信息,状态胶囊紧跟其后,发布时间靠右作为附属说明。
1491
+ 三者在同一行、字号与颜色分层,用户一眼就能分清「版本号」与「状态」 */
1492
+ .hubVersionBar {
1492
1493
  display: flex;
1493
1494
  align-items: center;
1494
1495
  flex-wrap: wrap;
1495
- gap: 4px 14px;
1496
- font-size: 12px;
1496
+ gap: 4px 10px;
1497
+ }
1498
+
1499
+ .hubVersionNumber {
1500
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
1501
+ font-size: 18px;
1502
+ font-weight: 600;
1503
+ line-height: 26px;
1504
+ color: var(--hub-text-primary);
1505
+ }
1506
+
1507
+ /* 状态胶囊:颜色本身就是区分,读不到文案也能分辨最新 / 可更新 / 暂缓 */
1508
+ .hubStateChip {
1509
+ padding: 0 8px;
1510
+ border: 1px solid transparent;
1511
+ border-radius: 999px;
1512
+ font-size: 11px;
1497
1513
  line-height: 18px;
1514
+ white-space: nowrap;
1515
+ }
1516
+
1517
+ .hubStateLatest {
1518
+ color: var(--hub-success);
1519
+ border-color: var(--hub-success-border);
1520
+ background: var(--hub-success-tint);
1521
+ }
1522
+
1523
+ .hubStateAvailable {
1524
+ color: var(--hub-warn);
1525
+ border-color: var(--hub-warn-border);
1526
+ background: var(--hub-warn-tint);
1498
1527
  }
1499
1528
 
1500
- .hubUpdateMetaItem {
1529
+ .hubStatePending {
1530
+ color: var(--hub-text-tertiary);
1531
+ border-color: var(--hub-border-2);
1532
+ }
1533
+
1534
+ .hubVersionDate {
1535
+ margin-left: auto;
1536
+ font-size: 12px;
1537
+ line-height: 18px;
1501
1538
  color: var(--hub-text-tertiary);
1502
1539
  }
1503
1540
 
@@ -148,7 +148,9 @@ export function registerCatalogRoutes(webServer: WebServerService, profile: stri
148
148
  // 兜底缓存也损坏:落到下面的 502
149
149
  }
150
150
  }
151
- sendJson(response, 502, { error: 'catalog fetch failed' })
151
+ // 带上 curl 退出码与 stderr 末行:只回 error 字段时,用户贴出来的响应里
152
+ // 没有任何可诊断信息
153
+ sendJson(response, 502, { error: 'catalog fetch failed', detail: r.detail })
152
154
  return
153
155
  }
154
156
  try {
@@ -287,13 +287,29 @@ export async function finalUrl(url: string, proxy: string, timeoutMs: number): P
287
287
  */
288
288
  export function fetchViaCurl(
289
289
  url: string, proxy: string, timeoutMs: number,
290
- ): Promise<{ ok: boolean; body: string; reason: ProbeFailReason | null }> {
290
+ ): Promise<{ ok: boolean; body: string; reason: ProbeFailReason | null; detail: string | null }> {
291
291
  // 同样用 -sS:正文之外还要 stderr 里的 TLS 失败线索(被 stderr 收集,不外泄给用户)。
292
- // --compressed:目录 JSON 约 3.6MB,源站支持 gzip,压缩后弱网下也能在超时内拉完。
293
- const args = ['-sS', '--compressed', '--max-time', String(Math.max(1, Math.round(timeoutMs / 1000))), url]
294
- return curlWithTlsFallback(args, proxyEnv(proxy), timeoutMs).then((r) => ({
295
- ok: r.code === 0,
296
- body: r.stdout,
297
- reason: r.code !== 0 && tlsRevocationBlocked(r.stderr) ? 'tlsRevocation' : null,
298
- }))
292
+ // 编码不用 --compressed 的自动协商,而是明确只接受 gzip:协商会让服务端按本机 curl 声明的
293
+ // 能力回 br / zstd,解不开时 curl 非零退出且正文为空,目录拉取直接失败;而 gzip 的解码器
294
+ // 每个 curl 都有,对 JSON 的压缩率也够。
295
+ const gzip = ['-H', 'Accept-Encoding: gzip', '--compressed']
296
+ const attempt = (extra: string[]): string[] => [
297
+ '-sS', ...gzip, ...extra, '--max-time', String(Math.max(1, Math.round(timeoutMs / 1000))), url,
298
+ ]
299
+ return curlWithTlsFallback(attempt([]), proxyEnv(proxy), timeoutMs).then(async (first) => {
300
+ // 首轮失败(连接被重置 / 编码解不开 / HTTP/2 流异常)时退到 HTTP/1.1 再试一次:
301
+ // Windows 自带 curl 与大响应 + HTTP/2 的组合偶发流级失败,换协议版本代价最小;
302
+ // 只在真失败时才多一次请求。
303
+ const r = first.code === 0
304
+ ? first
305
+ : await curlWithTlsFallback(attempt(['--http1.1']), proxyEnv(proxy), timeoutMs)
306
+ const failed = r.code !== 0 || r.stdout === ''
307
+ return {
308
+ ok: !failed,
309
+ body: r.stdout,
310
+ reason: failed && tlsRevocationBlocked(r.stderr) ? 'tlsRevocation' : null,
311
+ // 只留退出码与 stderr 末行:正文不进错误详情,且要够短才能在界面上完整显示
312
+ detail: failed ? `curl exit ${r.code ?? 'null'}: ${r.stderr.trim().split('\n').slice(-1)[0] ?? ''}`.slice(0, 300) : null,
313
+ }
314
+ })
299
315
  }