dsh-plugin 2.2.7 → 2.3.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.
@@ -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.1",
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",
@@ -39,11 +39,25 @@ export function PluginCard({ plugin: p, copied, installedName, installedVersion,
39
39
  const pendingUntil = isInstalled && !update ? updatePendingUntil(p) : null
40
40
  // 版本号展示:已安装 → 当前已装版本(最能反映用户状态);未安装 → 目录最新版本
41
41
  const versionShown = isInstalled ? installedVersion(p) : p.version
42
+ // 目录只下发作者 GitHub 账号,缺省时从仓库地址推导
43
+ const author = p.ownerSlug ?? repo.split('/')[0]?.toLowerCase() ?? ''
42
44
 
43
45
  return h('div', { className: styles.card },
44
46
  h('div', { className: styles.cardMain },
45
- h('div', { className: styles.cardHead },
46
- h('div', { className: styles.cardTitle, title: p.description ?? '' }, p.displayName ?? p.slug),
47
+ // 第一行只放名称 + 版本 + 更新状态:这三样是判断「这是什么、我装没装、要不要更新」的全部依据,
48
+ // 必须始终可见;长仓库名在此行内省略号收尾,不把版本与更新徽标挤出去
49
+ h('div', { className: styles.cardTitleRow },
50
+ // 名称本身是通往主站详情页的链接:悬停变色即可点,省掉「先找按钮再点」的一步。
51
+ // 无仓库地址时退回纯文本(没有可跳转的目标)
52
+ repo
53
+ ? h('a', {
54
+ className: styles.cardTitle,
55
+ href: pluginDetailUrl(p, langPath),
56
+ target: '_blank',
57
+ rel: 'noopener noreferrer',
58
+ title: p.description ?? '',
59
+ }, p.displayName ?? p.slug)
60
+ : h('div', { className: styles.cardTitle, title: p.description ?? '' }, p.displayName ?? p.slug),
47
61
  // 版本号紧跟插件名称:有版本才显示(无 release 的仓库不展示版本号)
48
62
  versionShown ? h('span', { className: styles.versionBadge, title: t('version') }, versionShown) : null,
49
63
  // 已安装且有更新:醒目提示(有 release 比版本号、无 release 比仓库最近更新时间)
@@ -51,11 +65,17 @@ export function PluginCard({ plugin: p, copied, installedName, installedVersion,
51
65
  pendingUntil !== null
52
66
  ? h('span', { className: styles.updateBadge, title: t('updatePendingHint') }, t('updatePending', { time: clockTime(pendingUntil) }))
53
67
  : null,
54
- p.category ? h('span', { className: styles.categoryBadge }, categoryLabel(CATEGORY_LABELS, p.category, langKey)) : null,
55
- p.compatibility?.status === 'verified'
56
- ? h('span', { className: styles.verified }, t('verified'))
57
- : null,
58
68
  ),
69
+ // 作者与分类同属背景信息:单独一行,不与名称抢宽度,也不参与是否要装/更新的判断
70
+ (author || p.category || p.compatibility?.status === 'verified')
71
+ ? h('div', { className: styles.cardBadgeRow },
72
+ author ? h('span', { className: styles.authorBadge, title: t('author') }, `@${author}`) : null,
73
+ p.category ? h('span', { className: styles.categoryBadge }, categoryLabel(CATEGORY_LABELS, p.category, langKey)) : null,
74
+ p.compatibility?.status === 'verified'
75
+ ? h('span', { className: styles.verified }, t('verified'))
76
+ : null,
77
+ )
78
+ : null,
59
79
  // 描述始终显示:站点英文数据缺翻译时 description 回退成中文也照常展示 ——
60
80
  // 描述是了解插件的第一入口,语言不一致也不该省略。
61
81
  p.description ? h('p', { className: styles.desc }, p.description) : null,
@@ -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 内的安装都会失败 ——
@@ -17,7 +17,8 @@ import styles from '../../styles/InstalledView.module.css'
17
17
  import type { LocaleId, Translate } from '../../types.ts'
18
18
  import type { InstalledItem } from '../../logic/installed.ts'
19
19
  import { CATEGORY_LABELS, categoryLabel } from '../../logic/constants.ts'
20
- import { clockTime } from '../../logic/format.ts'
20
+ import { clockTime, fmtStars } from '../../logic/format.ts'
21
+ import { pluginSiteUrl } from '../../logic/urls.ts'
21
22
  import { SortArrowIcon } from '../ui/icons.tsx'
22
23
 
23
24
  const INSTALLED_SORTS = ['sortName', 'sortInstalledAt', 'sortStars', 'sortForks'] as const
@@ -101,9 +102,20 @@ function InstalledRow({ item, t, langKey, canReveal, revealLabel, pendingRestart
101
102
  : item.dshCapable ? t('statusInactive')
102
103
  : t('exampleHint'),
103
104
  }),
104
- // 主信息:名称 + 已装版本 + 有更新徽标(flex 撑满,超长省略)
105
+ // 主信息:名称 + 已装版本 + 有更新徽标(同一行,名称超长时省略,徽标不被挤走)。
106
+ // 名称本身是主站详情页链接:点它直接跳官网,点行内其它位置仍是本地面板。
107
+ // 阻止冒泡,避免一次点击同时打开本地详情弹窗和新标签页
105
108
  h('div', { className: styles.rowMain },
106
- h('span', { className: styles.rowTitle }, name),
109
+ repo
110
+ ? h('a', {
111
+ className: styles.rowTitle,
112
+ href: pluginSiteUrl(repo),
113
+ target: '_blank',
114
+ rel: 'noopener noreferrer',
115
+ title: t('detail'),
116
+ onClick: (e: MouseEvent<HTMLAnchorElement>) => e.stopPropagation(),
117
+ }, name)
118
+ : h('span', { className: styles.rowTitle }, name),
107
119
  item.installedVersion
108
120
  ? h('span', { className: styles.versionBadge, title: t('installedVersionLabel') }, item.installedVersion)
109
121
  : null,
@@ -114,15 +126,51 @@ function InstalledRow({ item, t, langKey, canReveal, revealLabel, pendingRestart
114
126
  ? h('span', { className: styles.updateBadge, title: t('updatePendingHint') }, t('updatePending', { time: clockTime(item.pendingUntil) }))
115
127
  : null,
116
128
  ),
117
- // 来源标签列(行尾、操作按钮前,右对齐固定):目录插件 →「插件市场」(品牌色),
118
- // 命令行安装 →「手动安装」(琥珀色);两词 4 字等宽,所有条目标签同列对齐
129
+ // 右上角 Star / Fork:与插件市场列表同一口径,装之前看过的热度在已安装里也能对照。
130
+ // 自定义安装没有目录元数据(plugin === null),没有可展示的统计
131
+ item.plugin
132
+ ? h('div', { className: styles.rowStats },
133
+ h('span', { className: styles.rowStar }, '\u2605 ', fmtStars(item.plugin.stats?.stargazers_count)),
134
+ h('span', { className: styles.rowFork }, t('fork'), ' ', fmtStars(item.plugin.stats?.forks_count)),
135
+ )
136
+ : null,
137
+ ),
138
+ // 示例项目徽标单独一行(非 dsh 插件):标签较长也能完整显示,不挤压名称行
139
+ !item.dshCapable
140
+ ? h('div', { className: styles.exampleRow },
141
+ h('span', { className: styles.exampleBadge, title: t('exampleHint') }, t('exampleLabel')),
142
+ )
143
+ : null,
144
+ // 第二行:仓库 · 分类 · 来源(与名称左对齐缩进,独立成行不再挤压名称)。
145
+ // 来源标签恒定存在,故此行恒渲染:仓库与分类缺失时仍要能看到它是从哪装进来的
146
+ h('div', { className: styles.rowMeta },
147
+ // 仓库名同样指向主站详情页;与名称一样阻止冒泡,不连带打开本地详情弹窗
148
+ item.repo
149
+ ? h('a', {
150
+ className: styles.rowRepo,
151
+ href: pluginSiteUrl(item.repo),
152
+ target: '_blank',
153
+ rel: 'noopener noreferrer',
154
+ title: t('detail'),
155
+ onClick: (e: MouseEvent<HTMLAnchorElement>) => e.stopPropagation(),
156
+ }, item.repo)
157
+ : null,
158
+ category ? h('span', { className: styles.rowCategory },
159
+ categoryLabel(CATEGORY_LABELS, category, langKey)) : null,
160
+ // 来源标签:目录插件 →「插件市场」(品牌色),命令行安装 →「手动安装」(琥珀色)
119
161
  h('span', {
120
162
  className: isCustom
121
163
  ? `${styles.rowSourceTag} ${styles.rowSourceTagManual}`
122
164
  : `${styles.rowSourceTag} ${styles.rowSourceTagHub}`,
123
165
  title: isCustom ? t('manualInstallHint') : t('hubInstallHint'),
124
166
  }, isCustom ? t('manualInstall') : t('hubInstall')),
125
- // 行操作:重启(dsh 插件待重启时)/ 详情 / 更新(有更新时)/ 卸载 —— 按钮点击不触发行打开详情
167
+ ),
168
+ // 第三行(仅目录插件):简介单独一行展示,超长以省略号收尾
169
+ desc ? h('div', { className: styles.rowDesc, title: desc }, desc) : null,
170
+ // 最后一行:操作按钮单独成行、靠右,名称与版本号那条线不再被按钮挤窄
171
+ h('div', { className: styles.rowActionLine },
172
+ // 行操作:重启(dsh 插件待重启时)/ 在 Finder 中显示 / 安装详情 / 更新(有更新时)/ 卸载
173
+ // —— 按钮点击不触发行打开详情
126
174
  h('div', { className: styles.rowActions },
127
175
  // 装完未挂载、且在待重启列表里的 dsh 插件:给「重启」入口,点了宿主重启后插件即生效;
128
176
  // 已重启但仍加载失败(不在待重启列表)不显示「重启」,改为灰点「未激活」
@@ -134,8 +182,7 @@ function InstalledRow({ item, t, langKey, canReveal, revealLabel, pendingRestart
134
182
  onClick: (e: MouseEvent<HTMLButtonElement>) => { e.stopPropagation(); onRestart() },
135
183
  }, t('restart'))
136
184
  : null,
137
- // 支持文件管理器定位的平台(macOS / Linux):行内该位改为打开安装目录按钮;
138
- // 行点击本身即打开详情弹窗,故不支持的平台(如 Windows)不显示此按钮
185
+ // 支持文件管理器定位的平台(macOS / Linux)才显示打开安装目录按钮
139
186
  canReveal
140
187
  ? h('button', {
141
188
  className: styles.rowDetail,
@@ -143,6 +190,13 @@ function InstalledRow({ item, t, langKey, canReveal, revealLabel, pendingRestart
143
190
  onClick: (e: MouseEvent<HTMLButtonElement>) => { e.stopPropagation(); onReveal(item) },
144
191
  }, revealLabel)
145
192
  : null,
193
+ // 安装详情:显式入口,打开与点行内空白处同一个本地详情弹窗
194
+ h('button', {
195
+ className: styles.rowDetail,
196
+ type: 'button',
197
+ title: t('detail'),
198
+ onClick: (e: MouseEvent<HTMLButtonElement>) => { e.stopPropagation(); onOpenDetail(item) },
199
+ }, t('installDetail')),
146
200
  item.hasUpdate && item.plugin
147
201
  ? h('button', {
148
202
  className: styles.rowUpdate,
@@ -158,22 +212,6 @@ function InstalledRow({ item, t, langKey, canReveal, revealLabel, pendingRestart
158
212
  }, t('uninstall')),
159
213
  ),
160
214
  ),
161
- // 示例项目徽标单独一行(非 dsh 插件):标签较长也能完整显示,不挤压名称行
162
- !item.dshCapable
163
- ? h('div', { className: styles.exampleRow },
164
- h('span', { className: styles.exampleBadge, title: t('exampleHint') }, t('exampleLabel')),
165
- )
166
- : null,
167
- // 第二行:仓库 · 分类(与名称左对齐缩进,独立成行不再挤压名称)
168
- repo || category
169
- ? h('div', { className: styles.rowMeta },
170
- item.repo ? h('span', { className: styles.rowRepo }, item.repo) : null,
171
- category ? h('span', { className: styles.rowCategory },
172
- categoryLabel(CATEGORY_LABELS, category, langKey)) : null,
173
- )
174
- : null,
175
- // 第三行(仅目录插件):简介单独一行展示,超长以省略号收尾
176
- desc ? h('div', { className: styles.rowDesc, title: desc }, desc) : null,
177
215
  )
178
216
  }
179
217
 
@@ -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})',
@@ -468,6 +471,9 @@ export const zh = {
468
471
  installEntryLine: '安装入口:{c}。',
469
472
  reportIssue: '去插件仓库提交 Issue',
470
473
  detail: '打开详情',
474
+ // 已安装行按钮:打开与点行内空白处相同的本地详情弹窗
475
+ installDetail: '安装详情',
476
+ author: '作者',
471
477
  noResult: '没有找到匹配的插件',
472
478
  noResultDesc: '换个关键词试试,或前往网站浏览全部插件。',
473
479
  today: '今天更新',
@@ -491,16 +497,19 @@ export const zh = {
491
497
  updatePendingHint: '新版本发布未满 24 小时,pnpm 供应链门禁会拦下;到时间后再更新即可',
492
498
  hubUpdateHint: '检测到 DSH Plugin Hub 新版本,点击查看更新内容并更新',
493
499
  hubUpdateTitle: 'DSH Plugin Hub 有新版本',
494
- hubUpdateDesc: 'v{version} 已发布,点击下方按钮即可更新,完成后需重启宿主生效。',
500
+ hubUpdateDesc: '点击下方按钮即可更新,完成后需重启宿主生效。',
495
501
  hubUpdatePublished: '发布于',
496
502
  hubUpdateNotes: '更新记录',
497
503
  hubUpdateLater: '稍后再说',
498
504
  versionHint: '查看版本信息与更新记录',
499
505
  hubCurrentTitle: '当前版本',
500
- hubCurrentDesc: '你正在使用 v{version},已是最新版本。',
506
+ // 状态胶囊:版本号旁边一处说清当前处于哪种状态,不再靠多处版本号对照
507
+ hubVersionStateLatest: '已是最新',
508
+ hubVersionStateAvailable: '可更新',
509
+ hubVersionStatePending: '暂缓提示',
510
+ close: '关闭',
501
511
  // 暂缓提示:远端确有新版本,但发布未满 24 小时(pnpm 供应链安全门槛),与「已是最新」严格区分
502
- hubPendingDesc: '最新版 v{version} 已发布,但发布尚未满 24 小时:此时更新会被 pnpm 静默回退到旧版本,因此暂不提示。稍后这里会自动出现更新入口。',
503
- hubUpToDate: '已是最新版本',
512
+ hubPendingDesc: '最新版已发布,但发布尚未满 24 小时:此时更新会被 pnpm 静默回退到旧版本,因此暂不提示。稍后这里会自动出现更新入口。',
504
513
  // 头部「关注我们」弹窗:平台介绍 + 用户反馈群二维码(内容由 Worker /about Markdown 推送,非写死)
505
514
  followUs: '关注我们',
506
515
  aboutTitle: '关注我们',
@@ -856,6 +865,7 @@ export const en = {
856
865
  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
866
  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
867
  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.',
868
+ 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
869
  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
870
  // 缺失依赖:点名 404 的包(解析不出时跳过该行),再给删除指引
861
871
  failPnpmMissingDepTarget: '{pkg} was not found on the npm registry (404)',
@@ -894,6 +904,8 @@ export const en = {
894
904
  failReason_pnpmUnusedPatch: 'The profile keeps a patch for an older dsh-plugin version, so pnpm aborted for safety.',
895
905
  failKind_profileYamlCorrupted: 'profile YAML config is corrupted',
896
906
  failReason_profileYamlCorrupted: 'A YAML file in the profile directory has a duplicate key, so pnpm aborts at parse time.',
907
+ failKind_profileLocalBuild: 'a local plugin already in the profile fails its build',
908
+ 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
909
  failKind_profileState: 'profile pnpm metadata does not match this machine',
898
910
  failReason_profileState: 'The profile lockfile / package.json / packageManager version disagrees with the current pnpm, so the install aborts before starting.',
899
911
  failKind_nodeEngine: 'Node / pnpm on this machine is below the required version',
@@ -916,8 +928,8 @@ export const en = {
916
928
  failReason_accessDenied: 'The system denied pnpm write access to files inside the profile.',
917
929
  failKind_fsUnavailable: 'the disk is not writable',
918
930
  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.',
931
+ failKind_pnpmIgnoredBuild: 'build scripts blocked by pnpm',
932
+ 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
933
  failKind_pluginPrepare: 'plugin distribution is incomplete',
922
934
  failReason_pluginPrepare: 'The published package is missing files, or its build script failed.',
923
935
  failKind_hostIncompatible: 'plugin does not support the DSH version in use ({version})',
@@ -970,6 +982,9 @@ export const en = {
970
982
  installEntryLine: 'Install entry: {c}.',
971
983
  reportIssue: 'Report an Issue to the plugin repo',
972
984
  detail: 'Open details',
985
+ // Installed-row local details button: opens the same dialog as clicking the row body
986
+ installDetail: 'Install details',
987
+ author: 'Author',
973
988
  noResult: 'No plugins match your search',
974
989
  noResultDesc: 'Try another keyword, or browse all plugins on the website.',
975
990
  today: 'updated today',
@@ -991,16 +1006,19 @@ export const en = {
991
1006
  updatePendingHint: 'The new release is less than 24 hours old, so the pnpm supply-chain hold rejects it until then',
992
1007
  hubUpdateHint: 'A new DSH Plugin Hub version is available — click to view the changelog and update',
993
1008
  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.',
1009
+ hubUpdateDesc: 'Click below to update, then restart the host to apply it.',
995
1010
  hubUpdatePublished: 'Published',
996
1011
  hubUpdateNotes: "What's new",
997
1012
  hubUpdateLater: 'Later',
998
1013
  versionHint: 'View version info and changelog',
999
1014
  hubCurrentTitle: 'Current version',
1000
- hubCurrentDesc: 'You are on v{version} — this is the latest version.',
1015
+ // 状态胶囊:版本号旁边一处说清当前处于哪种状态,不再靠多处版本号对照
1016
+ hubVersionStateLatest: 'Up to date',
1017
+ hubVersionStateAvailable: 'Update available',
1018
+ hubVersionStatePending: 'Held for 24h',
1019
+ close: 'Close',
1001
1020
  // 暂缓提示:确有新版本,但仍在 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',
1021
+ 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
1022
  // 头部「关注我们」弹窗:平台介绍 + 用户反馈群二维码(内容由 Worker /about 以 Markdown 推送)
1005
1023
  followUs: 'Follow us',
1006
1024
  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'