dsh-plugin 1.6.7 → 1.6.9

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.
@@ -42,16 +42,34 @@ function hostDshVersion() {
42
42
  }
43
43
  return null;
44
44
  }
45
+ /** 从命令输出里抽出版本号本体:pnpm/npm 直接打印 `11.7.0`,而 `git --version` 打印
46
+ * `git version 2.55.0.windows.5` —— 原样取首行会让 issue 正文(统一再加 `v` 前缀)拼成
47
+ * `vgit version 2.55.0.windows.5`。只取版本号,顺带兼容 `v1.2.3` 这类自带前缀的输出。 */
48
+ function versionOf(output) {
49
+ const first = output.trim().split(/\r?\n/)[0]?.trim() ?? '';
50
+ if (!first)
51
+ return null;
52
+ const match = /\d+\.\d+[0-9A-Za-z.\-+]*/.exec(first);
53
+ return match === null ? null : match[0];
54
+ }
45
55
  /** 探测命令行工具的版本号(pnpm --version / npm --version / git --version):
46
56
  * 未安装 / 不在 PATH / 超时一律返回 null —— issue 环境快照如实写 unknown,绝不挂起。
47
- * (同步探测仅用于启动期/请求期的低频率 /env 快照,安装通道不经过这里。) */
57
+ * (同步探测仅用于启动期/请求期的低频率 /env 快照,安装通道不经过这里。)
58
+ *
59
+ * Windows 上 pnpm/npm 是 .cmd shim,不带 shell 的 spawnSync 起不来 —— 上报的 issue 里
60
+ * 因此长期是 `pnpm: unknown` / `npm: unknown`,而 pnpm 版本恰恰是 allowBuilds 那类问题的
61
+ * 关键信息(门禁行为随版本变)。故 win32 走 shell 让 cmd 解析 shim;参数是固定的
62
+ * `--version`,没有注入面。 */
48
63
  function toolVersion(cmd, args) {
49
64
  try {
50
- const r = spawnSync(cmd, args, { timeout: 1500, encoding: 'utf8' });
65
+ const r = spawnSync(cmd, args, {
66
+ timeout: 1500,
67
+ encoding: 'utf8',
68
+ shell: process.platform === 'win32',
69
+ });
51
70
  if (r.error || r.status !== 0)
52
71
  return null;
53
- const v = (r.stdout ?? '').trim().split(/\r?\n/)[0];
54
- return v || null;
72
+ return versionOf(r.stdout ?? '');
55
73
  }
56
74
  catch {
57
75
  return null;
@@ -208,16 +208,15 @@ export function registerInstallRoutes(webServer, profile, loader) {
208
208
  return;
209
209
  }
210
210
  }
211
- // 重复安装防护:非更新请求命中已安装目标时直接拒绝,不重复跑 CLI
212
- // (否则 CLI 会因「已存在」失败,且用户看到的是莫名其妙的报错);
213
- // 更新请求(mode: 'update')放行,CLI 的 add 对已存在依赖是 pnpm 原位覆盖重装。
214
- // 命中判定双通道:npm 包名按 dependencies 键直接命中,GitHub 目标按仓库身份匹配。
211
+ // 命中已安装目标时一律按「更新」语义执行(pnpm add 对已存在依赖本就是原位覆盖重装),
212
+ // 不再抛 409:客户端的已装列表与服务端实读可能不同步(如页面列表滞后),此时用户点
213
+ // 「安装」会撞上重复安装守卫,直接覆盖重装比让用户看到一句看不懂的 already installed
214
+ // 报错友好,最终状态也一致(都是已安装)。命中判定双通道:npm 包名按 dependencies
215
+ // 键直接命中,GitHub 目标按仓库身份匹配。
215
216
  const installed = readInstalled(profile);
216
217
  const already = installedByRepo(installed, target) ?? (installed[target] !== undefined ? target : null);
217
- if (already !== null && mode !== 'update') {
218
- sendJson(response, 409, { error: `already installed: ${already}` });
219
- return;
220
- }
218
+ if (already !== null)
219
+ mode = 'update';
221
220
  // 队列级去重:同一目标已在排队/执行中时拒绝,避免客户端防重竞态导致重复入队
222
221
  if (hasQueuedTarget(target)) {
223
222
  sendJson(response, 409, { error: `already queued: ${target}` });
@@ -0,0 +1,49 @@
1
+ /**
2
+ * DSH Plugin Hub — the community plugin marketplace for DeepSeek Harness.
3
+ * Website: https://dsh-plugin.org
4
+ * GitHub: https://github.com/dshplugin/dsh-plugin-hub
5
+ *
6
+ * 失败回捞:DSH 有时只把一行摘要(`dsh: plugin command failed; diagnostics: <path>`)
7
+ * 回显到控制台,真正的 pnpm 报错写进该路径的 operation 日志。这里从输出里解析出该路径
8
+ * 并读取其尾部,补进失败信息 —— 否则前端拿不到任何错误特征,只能把失败归为「未知」。
9
+ * 只允许读 DSH 自己的 operation 日志(路径含 .plugin-manager 且以 pnpm.log 结尾),
10
+ * 防止插件把任意路径打印进日志造成越权读取;任何异常都静默降级(返回 null),
11
+ * 不改变原有失败处理。
12
+ */
13
+ import { readFileSync } from 'node:fs';
14
+ import { CAPTURE_LIMIT_BYTES } from './install-types.js';
15
+ /**
16
+ * 从 DSH 输出里解析 operation 日志路径(`diagnostics: <path>`)。
17
+ * 只认 DSH 的 operation 日志:路径含 `.plugin-manager` 且以 `pnpm.log` 结尾;
18
+ * 不是这种路径(或压根没有该行)返回 null,调用方据此跳过回捞。
19
+ */
20
+ export function operationLogPathOf(output) {
21
+ for (const raw of output.split(/\r?\n/)) {
22
+ const m = /diagnostics:\s*"?(.+?)"?\s*$/i.exec(raw.trim());
23
+ if (m === null)
24
+ continue;
25
+ const path = m[1].trim();
26
+ if (!path.includes('.plugin-manager') || !path.toLowerCase().endsWith('pnpm.log'))
27
+ continue;
28
+ return path;
29
+ }
30
+ return null;
31
+ }
32
+ /**
33
+ * 回捞 operation 日志尾部,作为失败详情返回;拿不到(无路径 / 文件不存在 / 读失败 /
34
+ * 内容为空)返回 null。尾部截到与子进程输出相同的上限,避免超大日志撑爆内存与消息。
35
+ */
36
+ export function backfillOperationLog(output) {
37
+ const path = operationLogPathOf(output);
38
+ if (path === null)
39
+ return null;
40
+ try {
41
+ const text = readFileSync(path, 'utf8').trim();
42
+ if (text === '')
43
+ return null;
44
+ return text.length > CAPTURE_LIMIT_BYTES ? text.slice(-CAPTURE_LIMIT_BYTES) : text;
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
@@ -13,6 +13,7 @@ import { existsSync, readFileSync, statSync } from 'node:fs';
13
13
  import { dirname, join, resolve } from 'node:path';
14
14
  import { CAPTURE_LIMIT_BYTES, MAX_TASKS, MAX_TASK_LINES } from './install-types.js';
15
15
  import { npmTooLowMarker } from './npm-check.js';
16
+ import { backfillOperationLog } from './operation-log.js';
16
17
  import { cleanLine, estimateProgress } from '../profile/progress.js';
17
18
  import { hasClientUi, isDshPlugin, mountLoadedEntry, removeLoadedEntry } from '../loader.js';
18
19
  import { addPendingRestart, clearPendingRestart } from '../profile/pending-restart.js';
@@ -525,6 +526,9 @@ function spawnMutation(options) {
525
526
  });
526
527
  });
527
528
  return (async () => {
529
+ // 超时复核用基线:本次任务开始前目标是否已在 profile 里。已安装的话,超时后核对无法区分
530
+ // 「旧版本还在」与「刚装上」,那种情形(更新超时)不冒认成功。
531
+ const wasInstalled = action !== 'remove' && !isGlobalNpm && verifyInstalledEntry(profile, target).name !== null;
528
532
  let result = await spawnOnce(args);
529
533
  // npm 11 的 arborist peer-set bug(`Cannot read properties of null (reading 'children')`)
530
534
  // 会让 `npm install -g` 在解析阶段直接崩溃。首次失败后自动带 `--legacy-peer-deps` 重试一次:
@@ -538,6 +542,17 @@ function spawnMutation(options) {
538
542
  pushLine(task, '[npm] peer-set resolution crashed npm (known npm bug) — retrying with --legacy-peer-deps');
539
543
  result = await spawnOnce(retryArgs);
540
544
  }
545
+ // 超时被强杀 ≠ 没装上:命中的安装可能已经落盘(日志里 pnpm 已打印 Done,只是 dsh 进程迟迟不退出
546
+ // 而被我们超时掐掉)。判失败之前先复核 profile —— 目标已在依赖里、且声明分发的入口文件都在,
547
+ // 就按成功收尾,避免「其实装好了却报安装失败」并被自动归档成未知失败。
548
+ if (result.timedOut && !wasInstalled && action !== 'remove' && !isGlobalNpm && task?.status !== 'cancelled') {
549
+ const verified = verifyInstalledEntry(profile, target);
550
+ if (verified.name !== null && verified.missing === null) {
551
+ result = { ...result, timedOut: false, exitCode: 0, error: null };
552
+ if (task)
553
+ pushLine(task, '[timeout] the install had already completed — treating it as a success');
554
+ }
555
+ }
541
556
  // 统一终态后处理:exitCode/status/needsRestart 以最后一次尝试为准
542
557
  if (task) {
543
558
  task.exitCode = result.exitCode;
@@ -734,6 +749,22 @@ export async function runPluginMutation(options) {
734
749
  return { exitCode: 1, timedOut: false, error: `declared file missing: ${verified.missing}`, stdout: result.stdout, stderr: `${result.stderr}\n${detail}` };
735
750
  }
736
751
  }
752
+ // DSH 只把一行摘要(`dsh: plugin command failed; diagnostics: <path>`)回显到控制台时,
753
+ // 控制台里没有任何错误特征,前端只能把失败归为「未知」(issue #192)。这里按该路径回捞
754
+ // operation 日志尾部补进失败信息:用户直接看到真实报错,自动归类也有据可依。
755
+ // 只在本就不含任何错误码时回捞 —— pnpm 已把报错回显出来时无需多读一次文件。
756
+ if (result.exitCode !== 0 && !result.timedOut && !/\bERR_[A-Z0-9_]+\b/.test(`${result.stderr}\n${result.stdout}`)) {
757
+ const backfilled = backfillOperationLog(`${result.stderr}\n${result.stdout}`);
758
+ if (backfilled !== null) {
759
+ if (options.task && options.task.status !== 'cancelled') {
760
+ pushLine(options.task, '[pnpm log] DSH did not echo pnpm output — recovered the operation log from disk');
761
+ for (const line of backfilled.split(/\r?\n/))
762
+ if (line.trim() !== '')
763
+ pushLine(options.task, line.trimEnd());
764
+ }
765
+ result = { ...result, stderr: `${result.stderr}\n${backfilled}` };
766
+ }
767
+ }
737
768
  // npm arborist edgesOut 崩溃 + 本机 npm 版本过低 → 追加 [npm-too-low] 标记,前端据此给准确原因
738
769
  return annotateNpmTooLow(result, options);
739
770
  }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * 从 DSH 输出里解析 operation 日志路径(`diagnostics: <path>`)。
3
+ * 只认 DSH 的 operation 日志:路径含 `.plugin-manager` 且以 `pnpm.log` 结尾;
4
+ * 不是这种路径(或压根没有该行)返回 null,调用方据此跳过回捞。
5
+ */
6
+ export declare function operationLogPathOf(output: string): string | null;
7
+ /**
8
+ * 回捞 operation 日志尾部,作为失败详情返回;拿不到(无路径 / 文件不存在 / 读失败 /
9
+ * 内容为空)返回 null。尾部截到与子进程输出相同的上限,避免超大日志撑爆内存与消息。
10
+ */
11
+ export declare function backfillOperationLog(output: string): string | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin",
3
- "version": "1.6.7",
3
+ "version": "1.6.9",
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",
@@ -651,8 +651,8 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
651
651
  langPath,
652
652
  restarting,
653
653
  update: confirmIsUpdate,
654
- // 仅命令行插件(webInstallable=false):弹窗只展示命令供复制,不提供一键安装
655
- cliOnly: confirmPlugin.install?.webInstallable === false,
654
+ // 仅命令行插件(webInstallable=false / 人工干预 manualInstall):弹窗只展示命令供复制,不提供一键安装
655
+ cliOnly: confirmPlugin.install?.webInstallable === false || confirmPlugin.install?.manualInstall === true,
656
656
  submitting: queue.submitting,
657
657
  needsRestart: installNeedsRestart,
658
658
  needsReload: installNeedsReload,
@@ -89,9 +89,9 @@ export function PluginCard({ plugin: p, copied, installedName, installedVersion,
89
89
  disabled: true,
90
90
  title: t('installed'),
91
91
  }, t('installed')))
92
- // 未安装:按钮恒为「安装」,点击先弹信任确认;AI 识别为「可能需要命令行辅助」
93
- // 的插件(webInstallable=false)不拦截,允许尝试一键安装,弹窗内会给出提示与
94
- // 复制命令通道,安装失败时引导去 dsh 终端手动执行
92
+ // 未安装:按钮恒为「安装」,点击先弹信任确认;需在终端手动安装的插件
93
+ //(webInstallable=false)按钮不额外变形,但弹窗内只给提示与复制命令通道,
94
+ // 不提供一键安装按钮
95
95
  : h('button', {
96
96
  className: isCopied ? styles.installBtnCopied : styles.installBtn,
97
97
  // 文字恒定避免按钮宽度变化导致卡片跳动;点击先弹信任确认,
@@ -375,6 +375,30 @@ export function NotificationsModal({ records, tasks, pendingRestarts, t, env, on
375
375
  rel: 'noopener noreferrer',
376
376
  title: t('failIssueHint'),
377
377
  }, t('failIssueBig')) : null,
378
+ // 次要入口:这条分支的判据([packaging] 预检/装后校验、prepare 摘要)是插件中心
379
+ // 自己产出的,不是 pnpm 原文 —— 判错时用户会被送去作者仓库、我们永远收不到反馈
380
+ r.repo && shownRepo ? h('div', null, [
381
+ h('div', { className: styles.failPrepareHint }, t('failMisjudgeHint')),
382
+ h('a', {
383
+ className: styles.failMisjudgeLink,
384
+ href: hubIssueUrl(shownRepo, r.message, env, r.command, r.attempts, 'misjudge'),
385
+ target: '_blank',
386
+ rel: 'noopener noreferrer',
387
+ title: t('failHubIssueHint'),
388
+ }, t('failMisjudgeLink')),
389
+ ]) : null,
390
+ ])
391
+ }
392
+ if (kind === 'hubState') {
393
+ // 插件中心的列表状态已过期(安装器自身守卫消息):非插件问题、也不是 bug,只给可行动提示,不引导提 Issue
394
+ return h('div', null, [
395
+ h('div', { className: styles.failPrepareHint }, t('failHubStateHint')),
396
+ ])
397
+ }
398
+ if (kind === 'timeout') {
399
+ // 安装命令超时被强杀(日志里 pnpm 可能已 Done):可能其实已装成功 —— 只给可行动提示,不引导提 Issue
400
+ return h('div', null, [
401
+ h('div', { className: styles.failPrepareHint }, t('failTimeoutHint')),
378
402
  ])
379
403
  }
380
404
  // pnpm allowBuilds 拦截与未知失败:不归到插件作者,改为引导到插件中心仓库排查
@@ -193,8 +193,10 @@ export function InstallModal(props: InstallModalProps) {
193
193
  globalNpm !== undefined && globalNpm.length > 0
194
194
  ? h('div', { className: styles.cliOnlyHint }, t('globalNpmHint'))
195
195
  : null,
196
- // AI 识别可能需要命令行辅助的插件(webInstallable=false):提示但不拦截,仍允许尝试一键安装
197
- cliOnly ? h('div', { className: styles.cliOnlyHint }, t('cliOnlyHint')) : null,
196
+ // 需在终端手动安装的插件(webInstallable=false):按钮位只给提示与复制通道,不提供一键安装;
197
+ // 人工干预标记(manualInstall)另给一条更明确的终端引导
198
+ cliOnly ? h('div', { className: styles.cliOnlyHint },
199
+ t(plugin?.install?.manualInstall === true ? 'manualInstallOnlyHint' : 'cliOnlyHint')) : null,
198
200
  task && task.status === 'pending' ? h('div', { className: styles.queuedHint }, t('queuedHint')) : null,
199
201
  task ? h(ProgressView, { task }) : null,
200
202
  // 安装失败:引导用户复制上方命令到 dsh 终端手动安装(如 node-pty 等原生依赖构建脚本需人工放行)
@@ -496,6 +498,29 @@ export function ErrorModal({ message, repo, kind, command, attempts, t, env, onC
496
498
  rel: 'noopener noreferrer',
497
499
  title: t('failIssueHint'),
498
500
  }, t('failIssueBig')) : null,
501
+ // 次要入口:这条分支的判据([packaging] 预检/装后校验、prepare 摘要)是插件中心
502
+ // 自己产出的,不是 pnpm 原文 —— 判错时用户会被送去作者仓库、我们永远收不到反馈,
503
+ // 故另给一条「可能是市场误判」的反馈通道(正文自我说明它来自哪条分支)
504
+ repo ? h('div', null, [
505
+ h('div', { className: styles.failPrepareHint }, t('failMisjudgeHint')),
506
+ h('a', {
507
+ className: styles.failMisjudgeLink,
508
+ href: hubIssueUrl(repo, message, env, command, attempts, 'misjudge'),
509
+ target: '_blank',
510
+ rel: 'noopener noreferrer',
511
+ title: t('failHubIssueHint'),
512
+ }, t('failMisjudgeLink')),
513
+ ]) : null,
514
+ ])
515
+ : failureKind === 'hubState'
516
+ ? h('div', null, [
517
+ // 插件中心的列表状态已过期(安装器自身守卫消息):不是插件问题、也不是 bug,只给可行动提示,不引导提 Issue
518
+ h('div', { className: styles.failPrepareHint }, t('failHubStateHint')),
519
+ ])
520
+ : failureKind === 'timeout'
521
+ ? h('div', null, [
522
+ // 安装命令超时被强杀(日志里 pnpm 可能已 Done):可能其实已装成功 —— 给可行动提示,不引导提 Issue
523
+ h('div', { className: styles.failPrepareHint }, t('failTimeoutHint')),
499
524
  ])
500
525
  : h('div', null, [
501
526
  // pnpm allowBuilds 拦截与未知失败:不归到插件作者,改为引导到插件中心仓库排查
@@ -30,8 +30,13 @@ export const zh = {
30
30
  failedDescProxy: '你的网络似乎不通,或设置里的 HTTP 代理地址不可达,无法获取插件目录数据。请检查代理设置与网络连接后重试;或点击右上角按钮在浏览器中打开插件市场。',
31
31
  retry: '重试',
32
32
  install: '安装',
33
- // AI 识别可能需要命令行辅助的插件(webInstallable=false):仍可尝试一键安装,失败时复制命令去 dsh 终端
34
- cliOnlyHint: '此插件需在命令行安装(网页端无法代执行),请复制上方命令到终端运行',
33
+ // 需在终端手动安装的插件(webInstallable=false):两种情形共用一句 —— 命令网页端代执行不了
34
+ //(npx 安装器 / 本地 link),或命令能跑但装机过程要在终端完成(仓库带 prepare 构建钩子、
35
+ // 需要装配子目录等)。措辞覆盖两者,不承诺"网页端一定能装"。
36
+ cliOnlyHint: '此插件的安装需要在 dsh 终端里完成(网页端无法代跑它所需的构建或装配步骤),请复制上方命令到终端执行',
37
+ // 人工干预标记(manualInstall):安装要在终端里跑构建/分步装配,网页一键安装替不了终端。
38
+ // 注意 key 不能叫 manualInstallHint —— 那个是「已安装」页来源筛选的文案(目录插件 / 手动安装)
39
+ manualInstallOnlyHint: '此插件需要你在 dsh 终端手动安装:网页端的一键安装替代不了终端里的构建与装配步骤。请复制上方命令到终端执行(Windows 用 Git Bash 或 PowerShell,macOS/Linux 用终端),完整步骤见作者仓库的安装说明。',
35
40
  failedCopyHint: '安装失败。请复制上方安装命令到 dsh 终端手动执行(原生依赖构建脚本可能需要人工放行,如 pnpm approve-builds)',
36
41
  toastCopied: '安装命令已复制,去 dsh 终端粘贴即可安装',
37
42
  installed: '已安装',
@@ -315,12 +320,20 @@ export const zh = {
315
320
  failHubIssueBig: '反馈给插件中心排查',
316
321
  // 未知失败兜底:不归到插件作者,引导到插件中心仓库排查
317
322
  failUnclassifiedHint: '安装失败,原因未能自动识别。这通常不是插件自身的问题(可能与本机环境或插件中心有关),请将错误信息反馈给插件中心,由维护者排查。',
323
+ // 插件中心自身状态类消息(插件未安装 / 不在此处卸载 / 已在队列中):已安装列表与实读快照不同步,非插件问题,不引导提 Issue
324
+ failHubStateHint: '插件列表状态已过期或与本地不一致:该插件可能已经装过了(无需重复安装,要升级请点「更新」),也可能已不在已安装列表中,或已在队列中。这不是插件本身的问题 —— 请刷新页面后重试。',
325
+ // 安装命令超时被强杀:可能其实已装成功(安装器超时后会先核对已安装列表),给可行动提示,不引导提 Issue
326
+ failTimeoutHint: '安装耗时超过本地上限、已被中断 —— 但很可能其实已经装上了(安装器在超时后会先核对本地已安装列表)。请刷新页面确认该插件是否已装上;若确实没装上,可在终端手动执行一次安装命令查看完整输出。这不是插件本身的问题。',
318
327
  // prepare 构建脚本实际执行失败(git 分发缺子模块/构建产物):插件打包分发问题,应提 Issue
319
328
  failPrepareHint: '该插件安装时的构建脚本执行失败(git 分发常缺失子模块或构建产物),属插件打包分发问题。请向作者仓库提交 Issue 反馈。',
329
+ // 「插件打包问题」分支的次要入口:该判据由插件中心自己的预检/装后校验产出(不是 pnpm 原文),
330
+ // 一旦误报,用户会被引导去作者仓库、插件中心永远收不到反馈 —— 故留一条反馈路径
331
+ failMisjudgeHint: '如果这个插件其实能正常安装(上面这个判定是插件中心误报的),请反馈给插件中心:文字提示里的判据由市场自身产出,误报只有靠你的反馈才能被发现。',
332
+ failMisjudgeLink: '插件能装却被拦下?反馈给插件中心',
320
333
  // 安装前预检 / 装后校验拦截([packaging]):git 分发缺插件需要的文件(入口 / 补丁 mount 的入口 / bundle 补丁)= 插件未适配官方默认安装方式
321
334
  failPackagingHint: '该插件不支持官方默认安装方式:其 git 分发缺少它需要的文件(`main` / `exports` 声明的入口、补丁按本包名 mount 的入口,或 `dsh.bundle.patch` 声明的补丁),说明作者未适配官方安装流程。请到作者仓库提交 Issue 反馈,请其提交构建产物、改用预构建 release `.tgz` 直链(`releases/download/<tag>/<asset>.tgz`),或发布完整的 npm 版。',
322
335
  // 原生依赖构建脚本被 pnpm 默认拦截(如 node-pty 的 ERR_PNPM_IGNORED_BUILDS):不单独算插件问题,引导到插件中心仓库排查
323
- failIgnoredBuild: '该插件或其依赖需要执行构建脚本,被 pnpm 的安全白名单(allowBuilds)默认拦截导致安装失败(如原生模块 node-pty、或 git 安装时插件的 prepare 脚本)。这不一定是插件自身的问题(也可能是插件中心反查失败后回退到 git 安装所致),请先反馈给插件中心排查;若确认为插件依赖问题,可建议作者改用预编译版本(如 node-pty-prebuilt-multiarch)或移除安装期构建。',
336
+ failIgnoredBuild: '该插件(或其依赖)需要在安装时执行构建脚本,被 pnpm 的安全白名单(allowBuilds)默认拦截,安装因此失败(如原生模块 node-pty,或 git 安装时插件自己的 prepare 脚本)。安装器已自动放行该 key 并重试过一次;仍失败通常说明构建本身没通过。可先在终端手动执行一次安装命令查看完整构建错误;若确属插件的构建/分发问题,建议作者预置构建产物、发布 npm 包或预构建 .tgz。需要协助排查时也可反馈给插件中心。',
324
337
  // npm arborist 内部崩溃(edgesOut)+ 本机 npm 版本过低:npm 自身已知缺陷,不是插件问题,升级 npm 即可
325
338
  failNpmTooLow: '怀疑本机 npm 版本过低:npm 解析插件依赖时内部崩溃(这是 npm 自身的已知缺陷,不是插件问题)。请升级 npm 后重试:npm install -g npm@latest',
326
339
  failNpmTooLowV: '本机 npm 版本过低(当前 v{v}):npm 解析插件依赖时内部崩溃(这是 npm 自身的已知缺陷,不是插件问题)。请升级 npm 后重试:npm install -g npm@latest',
@@ -426,9 +439,13 @@ export const en = {
426
439
  failedDescProxy: 'Your network seems unreachable, or the HTTP proxy in Settings is unreachable, so the plugin catalog could not be loaded. Please check the proxy address and your connection, then retry; or open the hub in your browser with the button above.',
427
440
  retry: 'Retry',
428
441
  install: 'Install',
429
- // AI-flagged plugins that may need command-line help (webInstallable=false): one-click install still allowed,
430
- // on failure the user is guided to copy the command into the dsh terminal
431
- cliOnlyHint: 'This plugin must be installed from the command line (it cannot be run from the web UI) — copy the command above and run it in your terminal',
442
+ // Plugins that must be installed by hand (webInstallable=false): one wording for both cases —
443
+ // the web UI cannot run the command at all (npx installers, local links), or it can run it but
444
+ // the install needs terminal-side work (repos with a prepare build hook, sub-directory assembly).
445
+ cliOnlyHint: 'This plugin has to be installed in your dsh terminal (the web UI cannot run the build or assembly steps it needs) — copy the command above and run it there',
446
+ // Manual-intervention flag (manualInstall): the install needs a build / step-by-step assembly in the terminal.
447
+ // The key cannot be manualInstallHint — that one is the source filter on the Installed page.
448
+ manualInstallOnlyHint: 'This plugin has to be installed by hand in your dsh terminal — one-click install from the web UI cannot run the build and assembly steps it needs. Copy the command above and run it there (Git Bash or PowerShell on Windows, Terminal on macOS/Linux); the author\'s repo has the full steps.',
432
449
  failedCopyHint: 'Install failed. Copy the command above and run it manually in your dsh terminal (native build scripts may need manual approval, e.g. pnpm approve-builds)',
433
450
  toastCopied: 'Install command copied — paste it in your dsh terminal',
434
451
  installed: 'Installed',
@@ -713,12 +730,21 @@ export const en = {
713
730
  failHubIssueBig: 'Report to the Plugin Hub for triage',
714
731
  // Unknown-failure fallback: not routed to the plugin author, but to the hub repo for triage
715
732
  failUnclassifiedHint: 'The install failed and the cause could not be identified automatically. This is usually not the plugin itself (it may relate to your local environment or the Plugin Hub). Please report the error to the Plugin Hub so the maintainers can triage it.',
733
+ // The hub's own state-guard messages (plugin not installed / cannot be uninstalled here / already queued / already installed): the installed list is out of sync with the live snapshot — not a plugin issue, no issue prompt
734
+ failHubStateHint: 'The plugin list is out of date or out of sync: this plugin may already be installed (no need to reinstall — use "Update" to upgrade), or may no longer be in the installed list, or may already be queued. This is not a plugin problem — refresh the page and retry.',
735
+ // The install command hit the local time limit and was killed: it may well have installed successfully (the installer re-checks the installed list after a timeout) — actionable hint, no issue prompt
736
+ failTimeoutHint: 'The install exceeded the local time limit and was interrupted — but it may well have completed already (the installer re-checks your installed list after a timeout). Refresh the page to see whether the plugin is installed; if it is not, run the install command in a terminal to see the full output. This is not a plugin problem.',
716
737
  // prepare script actually failed (git tarball misses submodules/build output): a packaging issue of the plugin itself — file an issue
717
738
  failPrepareHint: 'This plugin failed while running its build scripts during install (git tarballs often miss submodules or build output). This is a packaging issue of the plugin itself — please report it to the author repository.',
739
+ // Secondary entry on the "plugin packaging" branch: that verdict comes from the hub's own
740
+ // pre/post-install check, not from pnpm output — a false positive sends users to the author
741
+ // repo and the hub never hears about it, so keep a feedback path here.
742
+ failMisjudgeHint: 'If this plugin actually installs fine (the verdict above is the Plugin Hub misjudging it), please report it to the Hub: that verdict is produced by the marketplace itself, so a false positive can only be found through your feedback.',
743
+ failMisjudgeLink: 'Plugin installs fine but got blocked? Report to the Plugin Hub',
718
744
  // Pre-install / post-install guard hit ([packaging]): git distribution lacks a file the package needs (entry / entry a patch mounts / bundle patch) = not adapted to the official default install method
719
745
  failPackagingHint: 'This plugin does not support the official default install method: its git distribution lacks a file the package needs (the entry from `main` / `exports`, an entry its own patch mounts by name, or the patch declared by `dsh.bundle.patch`), so it has not been adapted to the official install flow. Please file an Issue on the author\'s repo asking for committed build output, a prebuilt release `.tgz` (`releases/download/<tag>/<asset>.tgz`), or a complete npm release.',
720
746
  // native-module build scripts ignored by pnpm by default (e.g. node-pty, ERR_PNPM_IGNORED_BUILDS): not a plugin-side issue on its own, route to the hub repo for triage
721
- failIgnoredBuild: 'This plugin (or its dependencies) needs to run build scripts, which pnpm blocks by default via its allowBuilds allowlist, so the install fails (e.g. native modules like node-pty, or the plugin\'s own prepare script on git installs). This is not necessarily the plugin itself (it may be caused by the hub falling back to a git install after npm lookup failed). Please report it to the Plugin Hub first; if it turns out to be a plugin dependency issue, the author can ship a prebuilt variant (e.g. node-pty-prebuilt-multiarch) or drop install-time builds.',
747
+ failIgnoredBuild: 'This plugin (or one of its dependencies) needs to run build scripts at install time, which pnpm blocks by default via its allowBuilds allowlist — so the install fails (e.g. native modules like node-pty, or the plugin\'s own prepare script on git installs). The installer already auto-allows that key and retries once; if it still fails, the build itself is usually the problem. Run the install command in a terminal to see the full build error; if it is indeed the plugin\'s build or distribution, the author can ship prebuilt output, an npm release, or a prebuilt .tgz. You can also report it to the Plugin Hub for triage.',
722
748
  failNpmTooLow: 'Your local npm may be too old: npm crashed internally while resolving the plugin\'s dependencies (a known npm bug — not a plugin issue). Please upgrade npm and retry: npm install -g npm@latest',
723
749
  failNpmTooLowV: 'Your local npm is too old (v{v}): npm crashed internally while resolving the plugin\'s dependencies (a known npm bug — not a plugin issue). Please upgrade npm and retry: npm install -g npm@latest',
724
750
  // Pre-install connectivity probe / connection failure in the install log ([network] / git fetch timeout / DNS / TLS): a local network or proxy issue, not a plugin issue — no issue link
@@ -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' | 'registryVersionStale' | 'pnpmLinkedDirMissing' | 'fileLocked' | 'accessDenied' | 'fsUnavailable' | 'pnpmIgnoredBuild' | 'pluginPrepare' | 'network' | 'hub'
154
+ export type FailureKind = 'npmTooOld' | 'dshMissing' | 'gitMissing' | 'pnpmMissing' | 'npmMissing' | 'pnpmStore' | 'pnpmWorkspace' | 'originRejected' | 'pnpmPolicy' | 'pnpmUnusedPatch' | 'pnpmMissingDep' | 'registryVersionStale' | 'pnpmLinkedDirMissing' | 'fileLocked' | 'accessDenied' | 'fsUnavailable' | 'pnpmIgnoredBuild' | 'pluginPrepare' | 'network' | 'hubState' | 'timeout' | '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 {
@@ -238,6 +238,16 @@ export function classifyFailure(message: string): FailureKind {
238
238
  // 构建脚本真的跑挂了才算插件问题。Command failed 区分大小写:只匹配 pnpm 的包装,
239
239
  // 不匹配宿主那句小写 dsh: plugin command failed
240
240
  if (/ERR_PNPM_PREPARE_PACKAGE|ELIFECYCLE|prepare-guard/i.test(message) || /Command failed/.test(message)) return 'pluginPrepare'
241
+ // 安装器自身产生的状态类消息(未安装 / 不在此处卸载 / 已在队列中 / 已安装):这几句由本服务的
242
+ // 守卫发出,不是 CLI 输出、也不是插件问题,而是插件中心的已安装列表与实读快照不同步。
243
+ // 单独归类,让前端给「刷新后重试」这类可行动提示,而不是当作未知失败引导去提 Issue。
244
+ // already installed 只认「整行就是它」的短形态(旧版本守卫留下的历史记录);带「——」说明的
245
+ // 包名冲突另有处置,不能按状态不同步提示。
246
+ if (/plugin is not installed|plugin cannot be uninstalled here|already queued:|^already installed: \S+$/im.test(message)) return 'hubState'
247
+ // 安装器自己的超时标记(命令跑满上限被强杀):既不是插件问题、也不是用户环境问题,而且很可能是
248
+ // 「其实已经装上了」——摆放位置在一切错误码分支之后,日志里真有 pnpm 报错时仍以真实报错为准。
249
+ // 单独归类,给「刷新确认是否已安装」这类可行动提示,不引导提 Issue。
250
+ if (/^\[timed out\]$/m.test(message)) return 'timeout'
241
251
  // 其余未知失败:引导到插件中心仓库排查
242
252
  return 'hub'
243
253
  }
@@ -29,10 +29,12 @@ export function normalize(raw: Record<string, unknown>): HubPlugin {
29
29
  }
30
30
  : undefined,
31
31
  // 安装信息:wi 仅在 false 时下发(缺省视为 true 可一键安装);
32
+ // mi 仅在 true 时下发(人工干预:网页端替不了终端,只引导用户自己去终端装);
32
33
  // ic/igc = 目录下发的权威安装命令(npm/git 通道各一条,CLI-only 插件如 dsh-tui 必须用)
33
- install: raw.wi === false || typeof raw.ic === 'string' || typeof raw.igc === 'string'
34
+ install: raw.wi === false || raw.mi === true || typeof raw.ic === 'string' || typeof raw.igc === 'string'
34
35
  ? {
35
36
  webInstallable: raw.wi === false ? false : undefined,
37
+ manualInstall: raw.mi === true ? true : undefined,
36
38
  command: typeof raw.ic === 'string' ? raw.ic : undefined,
37
39
  githubCommand: typeof raw.igc === 'string' ? raw.igc : undefined,
38
40
  }
@@ -63,6 +63,8 @@ function reasonTitleOf(kind: FailureKind): string {
63
63
  case 'pnpmIgnoredBuild': return 'build scripts blocked by pnpm allowlist'
64
64
  case 'pluginPrepare': return 'plugin distribution incomplete'
65
65
  case 'network': return 'network failure on the user side'
66
+ case 'hubState': return 'stale install state in the plugin hub'
67
+ case 'timeout': return 'install timed out'
66
68
  case 'hub': return 'install failed (cause unclassified)'
67
69
  default: return 'plugin install failed'
68
70
  }
@@ -189,11 +191,28 @@ export function pluginIssueUrl(repo: string, message: string, env?: EnvInfo | nu
189
191
  * 以及 pnpm allowBuilds 拦截这类不再归到插件作者的情形)。正文带上目标插件与完整
190
192
  * 错误摘要,供插件中心维护者分诊 —— 不再把环境问题/插件中心自身问题误投给插件作者。
191
193
  */
192
- export function hubIssueUrl(pluginRepo: string | null, message: string, env?: EnvInfo | null, command?: string, attempts?: string[]): string {
194
+ export type HubIssueVariant =
195
+ /** 缺省:安装器主动把失败送到插件中心分诊(未分类兜底 / allowBuilds 门禁) */
196
+ | 'triage'
197
+ /** 从「插件打包问题」分支的次要入口提交:该判定由插件中心自己产出,用户怀疑是误报 */
198
+ | 'misjudge'
199
+
200
+ export function hubIssueUrl(
201
+ pluginRepo: string | null,
202
+ message: string,
203
+ env?: EnvInfo | null,
204
+ command?: string,
205
+ attempts?: string[],
206
+ /** 见 HubIssueVariant;缺省 'triage',既有正文保持不变 */
207
+ variant: HubIssueVariant = 'triage',
208
+ ): string {
193
209
  const kind = classifyFailure(message)
194
210
  const executed = executedCommandOf(command, attempts)
195
211
  const repo = pluginRepo && pluginRepo.trim() !== '' ? pluginRepo.trim() : 'unknown'
196
- const title = `[dsh-plugin-hub] ${reasonTitleOf(kind)}: ${repo}`
212
+ const title = variant === 'misjudge'
213
+ // 标题就点明「可能是误判」,维护者一眼能区分它和未分类兜底
214
+ ? `[dsh-plugin-hub] possible misjudgment: ${repo}`
215
+ : `[dsh-plugin-hub] ${reasonTitleOf(kind)}: ${repo}`
197
216
  const code = coreErrorCode(message)
198
217
  const build = (coreChars: number): string => {
199
218
  const body = [
@@ -202,7 +221,15 @@ export function hubIssueUrl(pluginRepo: string | null, message: string, env?: En
202
221
  `- Target plugin: \`${repo}\``,
203
222
  ...(code ? [`- Key error: \`${code}\``] : []),
204
223
  // 为什么投递到这里而非插件仓库:只有确有证据是插件自身构建失败(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.',
224
+ ...(variant === 'misjudge'
225
+ // 这条入口的判据([packaging] 预检/装后校验、prepare 摘要)是插件中心自己产出的,
226
+ // 不是 pnpm 原文 —— 一旦我们自己误报,用户就被送去作者仓库、我们永远收不到反馈。
227
+ // 正文写清它来自哪条分支、判据是谁给的,维护者才不会把它当成未分类兜底。
228
+ ? [
229
+ '- Filed from: the plugin-prepare path (which normally routes to the plugin repo) — filed here because that verdict may be our own misjudgment.',
230
+ '- Source of that verdict: produced by dsh-plugin-hub\'s own pre/post-install check, not by pnpm output.',
231
+ ]
232
+ : ['- 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
233
  '',
207
234
  `## [DSH Plugin 插件市场](${SITE_URL}) · 安装 Plugin 失败错误信息`,
208
235
  `本错误信息由 [dsh-plugin-hub](${GITHUB_URL}) 插件市场的安装程序自动生成,随本次安装失败一并提交。`,
@@ -1264,6 +1264,22 @@
1264
1264
  box-shadow: 0 3px 10px rgba(0, 0, 0, 0.12);
1265
1265
  }
1266
1266
 
1267
+ /* 「插件打包问题」分支的次要入口(可能是市场误判):主按钮是去作者仓库,这条只在用户
1268
+ 怀疑我们判定有误时才用 —— 做成弱化的文字链接,不与主按钮抢注意力 */
1269
+ .failMisjudgeLink {
1270
+ display: inline-block;
1271
+ margin-top: 6px;
1272
+ font-size: 12px;
1273
+ line-height: 18px;
1274
+ color: var(--hub-text-tertiary);
1275
+ text-decoration: underline;
1276
+ cursor: pointer;
1277
+ }
1278
+
1279
+ .failMisjudgeLink:hover {
1280
+ color: var(--hub-text-secondary);
1281
+ }
1282
+
1267
1283
  /* 网络类失败弹窗的「无法访问 …」醒目行:红色加粗,直接告诉用户具体哪个地址连不上
1268
1284
  (URL 较长允许换行断词),与下方琥珀色说明区分主次 */
1269
1285
  .failNetworkTarget {
@@ -59,10 +59,11 @@ export interface HubPlugin {
59
59
  description?: string
60
60
  source?: { repo?: string; npmPackage?: string }
61
61
  /** 安装信息:能否网页一键安装(false = 仅支持命令行/专属 profile,缺省视为 true);
62
+ * manualInstall = 人工干预标记(网页端一键安装替代不了终端的构建/装配,必须用户自己去终端装);
62
63
  * command/githubCommand = 目录下发的权威安装命令(npm/git 通道各一条),
63
64
  * 仅供展示/复制 —— CLI-only 插件(如 dsh-tui)需专属 profile(--profile dsh-tui),
64
65
  * 无法从 repo/npm 包名推断,必须用目录命令。 */
65
- install?: { webInstallable?: boolean; command?: string; githubCommand?: string }
66
+ install?: { webInstallable?: boolean; manualInstall?: boolean; command?: string; githubCommand?: string }
66
67
  compatibility?: { status?: string }
67
68
  dates?: { repoUpdatedAt?: string; addedAt?: string }
68
69
  stats?: { stargazers_count?: number; forks_count?: number }
@@ -40,15 +40,33 @@ function hostDshVersion(): string | null {
40
40
  return null
41
41
  }
42
42
 
43
+ /** 从命令输出里抽出版本号本体:pnpm/npm 直接打印 `11.7.0`,而 `git --version` 打印
44
+ * `git version 2.55.0.windows.5` —— 原样取首行会让 issue 正文(统一再加 `v` 前缀)拼成
45
+ * `vgit version 2.55.0.windows.5`。只取版本号,顺带兼容 `v1.2.3` 这类自带前缀的输出。 */
46
+ function versionOf(output: string): string | null {
47
+ const first = output.trim().split(/\r?\n/)[0]?.trim() ?? ''
48
+ if (!first) return null
49
+ const match = /\d+\.\d+[0-9A-Za-z.\-+]*/.exec(first)
50
+ return match === null ? null : match[0]
51
+ }
52
+
43
53
  /** 探测命令行工具的版本号(pnpm --version / npm --version / git --version):
44
54
  * 未安装 / 不在 PATH / 超时一律返回 null —— issue 环境快照如实写 unknown,绝不挂起。
45
- * (同步探测仅用于启动期/请求期的低频率 /env 快照,安装通道不经过这里。) */
55
+ * (同步探测仅用于启动期/请求期的低频率 /env 快照,安装通道不经过这里。)
56
+ *
57
+ * Windows 上 pnpm/npm 是 .cmd shim,不带 shell 的 spawnSync 起不来 —— 上报的 issue 里
58
+ * 因此长期是 `pnpm: unknown` / `npm: unknown`,而 pnpm 版本恰恰是 allowBuilds 那类问题的
59
+ * 关键信息(门禁行为随版本变)。故 win32 走 shell 让 cmd 解析 shim;参数是固定的
60
+ * `--version`,没有注入面。 */
46
61
  function toolVersion(cmd: string, args: string[]): string | null {
47
62
  try {
48
- const r = spawnSync(cmd, args, { timeout: 1500, encoding: 'utf8' })
63
+ const r = spawnSync(cmd, args, {
64
+ timeout: 1500,
65
+ encoding: 'utf8',
66
+ shell: process.platform === 'win32',
67
+ })
49
68
  if (r.error || r.status !== 0) return null
50
- const v = (r.stdout ?? '').trim().split(/\r?\n/)[0]
51
- return v || null
69
+ return versionOf(r.stdout ?? '')
52
70
  } catch {
53
71
  return null
54
72
  }
@@ -206,16 +206,14 @@ export function registerInstallRoutes(webServer: WebServerService, profile: stri
206
206
  return
207
207
  }
208
208
  }
209
- // 重复安装防护:非更新请求命中已安装目标时直接拒绝,不重复跑 CLI
210
- // (否则 CLI 会因「已存在」失败,且用户看到的是莫名其妙的报错);
211
- // 更新请求(mode: 'update')放行,CLI 的 add 对已存在依赖是 pnpm 原位覆盖重装。
212
- // 命中判定双通道:npm 包名按 dependencies 键直接命中,GitHub 目标按仓库身份匹配。
209
+ // 命中已安装目标时一律按「更新」语义执行(pnpm add 对已存在依赖本就是原位覆盖重装),
210
+ // 不再抛 409:客户端的已装列表与服务端实读可能不同步(如页面列表滞后),此时用户点
211
+ // 「安装」会撞上重复安装守卫,直接覆盖重装比让用户看到一句看不懂的 already installed
212
+ // 报错友好,最终状态也一致(都是已安装)。命中判定双通道:npm 包名按 dependencies
213
+ // 键直接命中,GitHub 目标按仓库身份匹配。
213
214
  const installed = readInstalled(profile)
214
215
  const already = installedByRepo(installed, target) ?? (installed[target] !== undefined ? target : null)
215
- if (already !== null && mode !== 'update') {
216
- sendJson(response, 409, { error: `already installed: ${already}` })
217
- return
218
- }
216
+ if (already !== null) mode = 'update'
219
217
  // 队列级去重:同一目标已在排队/执行中时拒绝,避免客户端防重竞态导致重复入队
220
218
  if (hasQueuedTarget(target)) {
221
219
  sendJson(response, 409, { error: `already queued: ${target}` })