dsh-plugin 1.6.6 → 1.6.8

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.
@@ -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}` });
@@ -272,17 +271,17 @@ export function registerInstallRoutes(webServer, profile, loader) {
272
271
  return;
273
272
  }
274
273
  }
275
- // 安装前预检:仅新安装走预检(github 源分发改入口文件缺失时直接拦截)。
274
+ // 安装前预检:仅新安装走预检(github 源分发缺 package.json 声明的文件时直接拦截)。
276
275
  // 更新是已信任目标的覆盖重装,跳过预检避免重复下载 tarball。
277
276
  // npm 包信任 registry 直接放行;github 源拦截时标记 [packaging] 前缀,
278
277
  // 供客户端归类为「插件分发不完整」并引导去仓库提 Issue
279
278
  if (already === null) {
280
279
  const preflight = await preflightTarget(target);
281
280
  if (!preflight.ok) {
282
- // 预检拦截(git 分发缺入口文件):同步 400 返回,附上已尝试的安装方式,
281
+ // 预检拦截(git 分发缺作者声明的文件):同步 400 返回,附上已尝试的安装方式,
283
282
  // 客户端失败弹窗提 Issue 时一并贴给作者
284
283
  sendJson(response, 400, {
285
- error: `[packaging] ${target}: plugin distribution is incomplete — the entry file ${preflight.missing ?? '?'} declared in package.json is not present in the git distribution (build output not committed). Report to the author.`,
284
+ error: `[packaging] ${target}: plugin distribution is incomplete — the file ${preflight.missing ?? '?'} the package needs (its declared entry, an entry a bundled patch mounts, or the patch itself) is not present in the git distribution (build output not committed). Report to the author.`,
286
285
  attempts,
287
286
  });
288
287
  return;
@@ -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
+ }
@@ -1,21 +1,31 @@
1
1
  /**
2
- * DSH-Plugin Hub — the community plugin marketplace for DeepSeek Harness.
2
+ * DSH Plugin Hub — the community plugin marketplace for DeepSeek Harness.
3
3
  * Website: https://dsh-plugin.org
4
4
  * GitHub: https://github.com/dshplugin/dsh-plugin-hub
5
5
  *
6
- * 包入口解析:从 package.json 的 exports["."] / main 推出用于校验的入口文件。
7
- * git 通道(tar 预检)与 npm 通道(装后 statSync 校验)共用,保证两条通道
8
- * 对同一个包判定一致。
6
+ * 分发文件解析:从 package.json 推出装完后必须真实存在的文件 —— 作者声明的入口
7
+ * (exports["."] / main)与 bundle 声明的补丁(dsh.bundle.patch)。git 通道(tar 预检)
8
+ * 与 npm 通道(装后 statSync 校验)共用,保证两条通道对同一个包判定一致。
9
+ *
10
+ * 这里不替作者补一个 index.js:宿主加载一个 bundle 读的是它的 package.json 和它声明的
11
+ * 补丁文件(官方 bundleManifest 的注释即「Read bundle metadata without loading its
12
+ * JavaScript」),从不 import 包本身。凭空要求一个作者没声明的入口,只会把完整的
13
+ * 技能包判成分发残缺,并把一条假缺陷推到作者仓库(见 QingYunA/answer-me-with-html#209)。
14
+ *
15
+ * 例外是补丁自己 mount 了本包:dsh 的《打包与安装插件》里组合包的常规写法就是
16
+ * `- insert: - name: <本包名>`,宿主随后按包名 import 本包,入口那时必须存在。
17
+ * 所以只有补丁真的写了自己的包名,才把这个入口算进分发要求。
9
18
  */
10
19
  /** exports 条件对象里按优先级尝试的键;Node 语义下 default 最宽,import/require 次之。 */
11
20
  const EXPORT_CONDITION_KEYS = ['default', 'import', 'require'];
12
21
  /**
13
- * 解析包的入口文件(相对包根,不带 `./` 前缀)。
22
+ * 解析作者声明的入口文件(相对包根,不带 `./` 前缀)。manifest 未声明入口时返回 null:
23
+ * 没有声明就没有可缺失的入口,是否可 import 交给真实安装与宿主加载去暴露。
14
24
  *
15
25
  * Node 的 exports 规范要求字符串 target 以 `./` 开头(如 `"./lib/index.js"`),
16
26
  * 而 tar 成员名与磁盘路径都不带该前缀,因此统一剥掉;条件对象
17
27
  * (`{ "import": ..., "require": ... }`)按 default → import → require 取第一
18
- * 个字符串值。无 exports 时回退 main,再回退 index.js。
28
+ * 个字符串值。无 exports 时回退 main;两者都没有则没有入口。
19
29
  */
20
30
  export function resolvePackageEntry(meta) {
21
31
  const dot = meta.exports?.['.'];
@@ -26,8 +36,48 @@ export function resolvePackageEntry(meta) {
26
36
  : undefined;
27
37
  const candidate = typeof fromExports === 'string' ? fromExports
28
38
  : typeof meta.main === 'string' ? meta.main
29
- : 'index.js';
30
- return candidate.replace(/^\.\//u, '');
39
+ : null;
40
+ return candidate === null ? null : stripDotSlash(candidate);
41
+ }
42
+ /**
43
+ * 解析 `dsh.bundle.patch` 声明的补丁文件(相对包根,不带 `./` 前缀)。
44
+ * 形状不合规范(既不是字符串也不是字符串数组)在这里不判定:那是 dsh 启动时
45
+ * bundlePatchFiles 的报错职责,预检只负责「声明了就要在包里存在」。
46
+ */
47
+ export function resolveBundlePatchFiles(meta) {
48
+ const patch = meta.dsh?.bundle?.patch;
49
+ const declared = typeof patch === 'string' ? [patch] : Array.isArray(patch) ? patch : [];
50
+ return declared
51
+ .filter((file) => typeof file === 'string' && file !== '')
52
+ .map(stripDotSlash);
53
+ }
54
+ /** 分发里必须存在的全部文件:作者声明的入口,加上 bundle 声明的补丁,入口在前。
55
+ * manifest 没声明入口时才看补丁:补丁按本包名 mount 自己(下面 mountsItself),
56
+ * 宿主就会 import 本包,Node 的旧式解析落到 index.js —— 那个入口同样必须有。 */
57
+ export function requiredDistributionFiles(meta, patchTexts = []) {
58
+ const declared = resolvePackageEntry(meta);
59
+ const entry = declared ?? (mountsItself(meta, patchTexts) ? 'index.js' : null);
60
+ return [...(entry === null ? [] : [entry]), ...resolveBundlePatchFiles(meta)];
61
+ }
62
+ /**
63
+ * 补丁文本里插件行的 `name:` 值(DSH 补丁按行书写:`- name: <pkg>` / ` name: <pkg>`)。
64
+ * 刻意不做 YAML 解析:只认这一种行形状,认不出来就不判定 —— 漏判只是少一条要求,
65
+ * 误判会拦下能装的包,两边的代价不对称。
66
+ */
67
+ export function patchRowNamesOf(text) {
68
+ return text
69
+ .split(/\r?\n/)
70
+ .map((line) => /^\s*(?:-\s*)?name:\s*(.+?)\s*$/u.exec(line)?.[1])
71
+ .filter((value) => value !== undefined)
72
+ .map((value) => value.replace(/^(['"])(.*)\1$/u, '$2'))
73
+ .filter((value) => value !== '');
74
+ }
75
+ /** 补丁是否把本包自己 mount 进树(dsh 文档里组合包的常规写法),是则宿主会 import 本包。 */
76
+ export function mountsItself(meta, patchTexts) {
77
+ const name = meta.name;
78
+ if (typeof name !== 'string' || name === '')
79
+ return false;
80
+ return patchTexts.some((text) => patchRowNamesOf(text).includes(name));
31
81
  }
32
82
  function firstStringOf(conditions) {
33
83
  for (const key of EXPORT_CONDITION_KEYS) {
@@ -37,3 +87,4 @@ function firstStringOf(conditions) {
37
87
  }
38
88
  return undefined;
39
89
  }
90
+ const stripDotSlash = (file) => file.replace(/^\.\//u, '');
@@ -3,10 +3,10 @@
3
3
  * Website: https://dsh-plugin.org
4
4
  * GitHub: https://github.com/dshplugin/dsh-plugin-hub
5
5
  *
6
- * 安装前预检:对 GitHub 源的插件,检查其分发(codeload tarball)里是否
7
- * 真的包含 package.json 声明的入口文件(main / exports["."].default)。
8
- * git 分发常不提交构建产物(lib/ 等),这类残缺包装完会让宿主重启加载插件树时
9
- * ERR_MODULE_NOT_FOUND 直接崩溃 —— 预检在动手安装前就把它们拦下,避免白装一次。
6
+ * 安装前预检:对 GitHub 源的插件,检查其分发(codeload tarball)里是否真的包含
7
+ * package.json 声明的文件(main / exports["."] / dsh.bundle.patch)。git 分发常不提交
8
+ * 构建产物(lib/ 等),这类残缺包装完会让宿主重启加载插件树时 ERR_MODULE_NOT_FOUND
9
+ * 直接崩溃 —— 预检在动手安装前就把它们拦下,避免白装一次。
10
10
  * npm 包信任 registry 的完整性,直接放行;任何无法预检的情况也放行,交给装后
11
11
  * 校验(verifyInstalledEntry)兜底,保证预检自身失败不会卡死安装流程。
12
12
  */
@@ -19,7 +19,7 @@ import { pipeline } from 'node:stream/promises';
19
19
  import { promisify } from 'node:util';
20
20
  import { githubRefOf, githubRepoOf } from '../profile/profile.js';
21
21
  import { githubReleaseTarget } from './release-target.js';
22
- import { resolvePackageEntry } from './package-entry.js';
22
+ import { requiredDistributionFiles, resolveBundlePatchFiles } from './package-entry.js';
23
23
  const execFileAsync = promisify(execFile);
24
24
  /** 单次网络操作超时(ms):git ls-remote / codeload 下载。网络不可达时不能无限挂起,
25
25
  * 否则 `/install` 请求永远不返回、任务不入队、前端进度卡 0%。超时后放行(交给装后校验)。 */
@@ -49,9 +49,18 @@ export async function preflightTarget(target) {
49
49
  const meta = await readTarJson(tar, rootPackage);
50
50
  if (meta === null)
51
51
  return { ok: true, missing: null, name: null };
52
- const entry = resolvePackageEntry(meta);
53
- const inTar = await hasTarEntry(tar, `${rootPrefix}${entry}`);
54
- return { ok: inTar, missing: inTar ? null : entry, name: typeof meta.name === 'string' ? meta.name : null };
52
+ // 只校验作者声明过的文件:没声明入口的包(只挂技能的 bundle)没有可缺失的入口,
53
+ // 它的分发是否完整由 dsh.bundle.patch 是否在包里决定;补丁若按本包名 mount 自己,
54
+ // 入口又变成必须的(宿主会 import 本包)—— 所以判入口前先把补丁读出来。
55
+ const patchTexts = [];
56
+ for (const file of resolveBundlePatchFiles(meta)) {
57
+ const text = await readTarText(tar, `${rootPrefix}${file}`);
58
+ if (text !== null)
59
+ patchTexts.push(text);
60
+ }
61
+ const required = requiredDistributionFiles(meta, patchTexts).map((file) => `${rootPrefix}${file}`);
62
+ const missing = missingDistributionFileOfTarList(await tarListing(tar), required);
63
+ return { ok: missing === null, missing, name: typeof meta.name === 'string' ? meta.name : null };
55
64
  }
56
65
  catch {
57
66
  return { ok: true, missing: null, name: null };
@@ -117,32 +126,53 @@ export function rootPackageEntryOfTarList(listing) {
117
126
  .filter((line) => /^[^/]+\/package\.json$/u.test(line));
118
127
  return candidates.length === 1 ? candidates[0] : null;
119
128
  }
129
+ /**
130
+ * 清单里第一个缺失的必需文件(成员名带包根前缀,由调用方拼好),全部都在则为 null。
131
+ * 清单不可读(tar 缺失/解压失败)时按第一个必需文件缺失处理:与逐文件查询时
132
+ * 「查不到就是缺」的判定一致,宁可拦下一次可疑安装,也不放一个空包过去。
133
+ * Exported for a small parser regression test.
134
+ */
135
+ export function missingDistributionFileOfTarList(listing, required) {
136
+ if (required.length === 0)
137
+ return null;
138
+ if (listing === null)
139
+ return required[0];
140
+ const present = new Set(listing.split(/\r?\n/));
141
+ return required.find((file) => !present.has(file)) ?? null;
142
+ }
120
143
  async function rootPackageEntry(tar) {
144
+ const listing = await tarListing(tar);
145
+ return listing === null ? null : rootPackageEntryOfTarList(listing);
146
+ }
147
+ /** tar 成员清单;`tar` 执行失败时返回 null(调用方按无法判定处理)。 */
148
+ async function tarListing(tar) {
121
149
  try {
122
150
  const { stdout } = await execFileAsync('tar', ['-tzf', tar], { maxBuffer: 16 * 1024 * 1024 });
123
- return rootPackageEntryOfTarList(stdout);
151
+ return stdout;
124
152
  }
125
153
  catch {
126
154
  return null;
127
155
  }
128
156
  }
129
- /** 从 tarball 提取并解析一个 JSON 文件;文件不存在或解析失败返回 null。 */
130
- async function readTarJson(tar, entry) {
157
+ /** 从 tarball 提取一个文件的内容;文件不存在或提取失败返回 null。 */
158
+ async function readTarText(tar, entry) {
131
159
  try {
132
160
  const { stdout } = await execFileAsync('tar', ['-xzOf', tar, entry], { maxBuffer: 4 * 1024 * 1024 });
133
- return JSON.parse(stdout);
161
+ return stdout;
134
162
  }
135
163
  catch {
136
164
  return null;
137
165
  }
138
166
  }
139
- /** 检查 tarball 内是否存在指定 entry。 */
140
- async function hasTarEntry(tar, entry) {
167
+ /** 从 tarball 提取并解析一个 JSON 文件;文件不存在或解析失败返回 null。 */
168
+ async function readTarJson(tar, entry) {
169
+ const text = await readTarText(tar, entry);
170
+ if (text === null)
171
+ return null;
141
172
  try {
142
- const { stdout } = await execFileAsync('tar', ['-tzf', tar, entry], { maxBuffer: 1024 * 1024 });
143
- return stdout.split(/\r?\n/).some((line) => line === entry);
173
+ return JSON.parse(text);
144
174
  }
145
175
  catch {
146
- return false;
176
+ return null;
147
177
  }
148
178
  }
@@ -8,11 +8,12 @@
8
8
  * 输出流式收集到任务上;卸载成功时尝试从运行中 loader 即时停用(见 loader.ts)。
9
9
  */
10
10
  import { spawn, spawnSync } from 'node:child_process';
11
- import { resolvePackageEntry } from './package-entry.js';
11
+ import { requiredDistributionFiles, resolveBundlePatchFiles } from './package-entry.js';
12
12
  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';
@@ -591,10 +592,12 @@ function spawnMutation(options) {
591
592
  })();
592
593
  }
593
594
  /**
594
- * 安装后校验已装包的入口文件(package.json 的 main / exports["."].default)是否真实存在。
595
- * git: 分发常不提交构建产物(lib/ 等),pnpm 装完没有报错,但宿主重启加载插件树时会
596
- * ERR_MODULE_NOT_FOUND 直接崩溃、网页打不开 —— 这里在登记「待重启」前就把这类残缺包拦下。
597
- * 返回 { name, missing }:name 为空表示无法定位目标包(跳过校验);missing 为缺失的入口路径。
595
+ * 安装后校验已装包里 package.json 声明的文件(main / exports["."].default 与
596
+ * dsh.bundle.patch)是否真实存在。git: 分发常不提交构建产物(lib/ 等),pnpm 装完没有
597
+ * 报错,但宿主重启加载插件树时会 ERR_MODULE_NOT_FOUND 直接崩溃、网页打不开 —— 这里在
598
+ * 登记「待重启」前就把这类残缺包拦下。没有声明过的文件不在这里追究:没声明入口的
599
+ * bundle 包(只挂技能)本来就不需要入口,宿主读的是它的 package.json 与声明的补丁。
600
+ * 返回 { name, missing }:name 为空表示无法定位目标包(跳过校验);missing 为缺失文件的路径。
598
601
  */
599
602
  export function verifyInstalledEntry(profile, target) {
600
603
  let profilePkg = null;
@@ -613,20 +616,33 @@ export function verifyInstalledEntry(profile, target) {
613
616
  if (!name)
614
617
  return { name: null, missing: null };
615
618
  const pkgDir = join(profileDirectory(profile), 'node_modules', name);
616
- let entry = null;
619
+ let required = [];
617
620
  try {
618
621
  const meta = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8'));
619
- entry = resolvePackageEntry(meta);
622
+ // 补丁按本包名 mount 自己时入口也是必须的,所以先读已装包里的补丁(读不到就当没有)。
623
+ const patchTexts = resolveBundlePatchFiles(meta).flatMap((file) => {
624
+ try {
625
+ return [readFileSync(join(pkgDir, file), 'utf8')];
626
+ }
627
+ catch {
628
+ return [];
629
+ }
630
+ });
631
+ required = requiredDistributionFiles(meta, patchTexts);
620
632
  }
621
633
  catch {
622
634
  return { name, missing: null };
623
635
  }
624
- try {
625
- return { name, missing: statSync(join(pkgDir, entry)).isFile() ? null : entry };
626
- }
627
- catch {
628
- return { name, missing: entry };
636
+ for (const file of required) {
637
+ try {
638
+ if (!statSync(join(pkgDir, file)).isFile())
639
+ return { name, missing: file };
640
+ }
641
+ catch {
642
+ return { name, missing: file };
643
+ }
629
644
  }
645
+ return { name, missing: null };
630
646
  }
631
647
  /** 从 pnpm `Ignored build scripts:` 提示行解析被拦截的构建脚本 key(name@version,逗号分隔)。 */
632
648
  function ignoredBuildKeys(output) {
@@ -702,21 +718,37 @@ export async function runPluginMutation(options) {
702
718
  }
703
719
  }
704
720
  }
705
- // pnpm 安装本身成功,但目标包入口文件缺失(git 分发缺构建产物)→ 判为失败:
721
+ // pnpm 安装本身成功,但目标包声明分发的文件缺失(git 分发缺构建产物 / 缺声明的补丁)→ 判为失败:
706
722
  // 撤销刚登记的待重启,避免用户重启时宿主加载残缺包直接崩溃。
707
723
  // 全局 npm 安装不进 profile(profile deps 里没有这些包),跳过入口校验
708
724
  if (options.action !== 'remove' && result.exitCode === 0 && !result.timedOut && (options.globalNpm ?? []).length === 0) {
709
725
  const verified = verifyInstalledEntry(options.profile, options.target);
710
726
  if (verified.name !== null && verified.missing !== null) {
711
727
  clearPendingRestart(options.displayTarget ?? options.target);
712
- const detail = `[packaging] ${verified.name}: entry file missing: ${verified.missing} (git distribution lacks build output — install the npm version or report to the author)`;
728
+ const detail = `[packaging] ${verified.name}: declared file missing: ${verified.missing} (git distribution lacks build output — install the npm version or report to the author)`;
713
729
  if (options.task && options.task.status !== 'cancelled') {
714
730
  options.task.status = 'failed';
715
731
  options.task.exitCode = null;
716
732
  options.task.progress = 0;
717
733
  pushLine(options.task, detail);
718
734
  }
719
- return { exitCode: 1, timedOut: false, error: `entry file missing: ${verified.missing}`, stdout: result.stdout, stderr: `${result.stderr}\n${detail}` };
735
+ return { exitCode: 1, timedOut: false, error: `declared file missing: ${verified.missing}`, stdout: result.stdout, stderr: `${result.stderr}\n${detail}` };
736
+ }
737
+ }
738
+ // DSH 只把一行摘要(`dsh: plugin command failed; diagnostics: <path>`)回显到控制台时,
739
+ // 控制台里没有任何错误特征,前端只能把失败归为「未知」(issue #192)。这里按该路径回捞
740
+ // operation 日志尾部补进失败信息:用户直接看到真实报错,自动归类也有据可依。
741
+ // 只在本就不含任何错误码时回捞 —— pnpm 已把报错回显出来时无需多读一次文件。
742
+ if (result.exitCode !== 0 && !result.timedOut && !/\bERR_[A-Z0-9_]+\b/.test(`${result.stderr}\n${result.stdout}`)) {
743
+ const backfilled = backfillOperationLog(`${result.stderr}\n${result.stdout}`);
744
+ if (backfilled !== null) {
745
+ if (options.task && options.task.status !== 'cancelled') {
746
+ pushLine(options.task, '[pnpm log] DSH did not echo pnpm output — recovered the operation log from disk');
747
+ for (const line of backfilled.split(/\r?\n/))
748
+ if (line.trim() !== '')
749
+ pushLine(options.task, line.trimEnd());
750
+ }
751
+ result = { ...result, stderr: `${result.stderr}\n${backfilled}` };
720
752
  }
721
753
  }
722
754
  // npm arborist edgesOut 崩溃 + 本机 npm 版本过低 → 追加 [npm-too-low] 标记,前端据此给准确原因
@@ -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;
@@ -1,21 +1,53 @@
1
1
  /**
2
- * DSH-Plugin Hub — the community plugin marketplace for DeepSeek Harness.
2
+ * DSH Plugin Hub — the community plugin marketplace for DeepSeek Harness.
3
3
  * Website: https://dsh-plugin.org
4
4
  * GitHub: https://github.com/dshplugin/dsh-plugin-hub
5
5
  *
6
- * 包入口解析:从 package.json 的 exports["."] / main 推出用于校验的入口文件。
7
- * git 通道(tar 预检)与 npm 通道(装后 statSync 校验)共用,保证两条通道
8
- * 对同一个包判定一致。
6
+ * 分发文件解析:从 package.json 推出装完后必须真实存在的文件 —— 作者声明的入口
7
+ * (exports["."] / main)与 bundle 声明的补丁(dsh.bundle.patch)。git 通道(tar 预检)
8
+ * 与 npm 通道(装后 statSync 校验)共用,保证两条通道对同一个包判定一致。
9
+ *
10
+ * 这里不替作者补一个 index.js:宿主加载一个 bundle 读的是它的 package.json 和它声明的
11
+ * 补丁文件(官方 bundleManifest 的注释即「Read bundle metadata without loading its
12
+ * JavaScript」),从不 import 包本身。凭空要求一个作者没声明的入口,只会把完整的
13
+ * 技能包判成分发残缺,并把一条假缺陷推到作者仓库(见 QingYunA/answer-me-with-html#209)。
14
+ *
15
+ * 例外是补丁自己 mount 了本包:dsh 的《打包与安装插件》里组合包的常规写法就是
16
+ * `- insert: - name: <本包名>`,宿主随后按包名 import 本包,入口那时必须存在。
17
+ * 所以只有补丁真的写了自己的包名,才把这个入口算进分发要求。
9
18
  */
19
+ /** 解析所需的 manifest 字段;其余字段不读。 */
20
+ export interface PackageManifest {
21
+ name?: unknown;
22
+ main?: unknown;
23
+ exports?: unknown;
24
+ dsh?: unknown;
25
+ }
10
26
  /**
11
- * 解析包的入口文件(相对包根,不带 `./` 前缀)。
27
+ * 解析作者声明的入口文件(相对包根,不带 `./` 前缀)。manifest 未声明入口时返回 null:
28
+ * 没有声明就没有可缺失的入口,是否可 import 交给真实安装与宿主加载去暴露。
12
29
  *
13
30
  * Node 的 exports 规范要求字符串 target 以 `./` 开头(如 `"./lib/index.js"`),
14
31
  * 而 tar 成员名与磁盘路径都不带该前缀,因此统一剥掉;条件对象
15
32
  * (`{ "import": ..., "require": ... }`)按 default → import → require 取第一
16
- * 个字符串值。无 exports 时回退 main,再回退 index.js。
33
+ * 个字符串值。无 exports 时回退 main;两者都没有则没有入口。
17
34
  */
18
- export declare function resolvePackageEntry(meta: {
19
- main?: unknown;
20
- exports?: unknown;
21
- }): string;
35
+ export declare function resolvePackageEntry(meta: PackageManifest): string | null;
36
+ /**
37
+ * 解析 `dsh.bundle.patch` 声明的补丁文件(相对包根,不带 `./` 前缀)。
38
+ * 形状不合规范(既不是字符串也不是字符串数组)在这里不判定:那是 dsh 启动时
39
+ * bundlePatchFiles 的报错职责,预检只负责「声明了就要在包里存在」。
40
+ */
41
+ export declare function resolveBundlePatchFiles(meta: PackageManifest): string[];
42
+ /** 分发里必须存在的全部文件:作者声明的入口,加上 bundle 声明的补丁,入口在前。
43
+ * manifest 没声明入口时才看补丁:补丁按本包名 mount 自己(下面 mountsItself),
44
+ * 宿主就会 import 本包,Node 的旧式解析落到 index.js —— 那个入口同样必须有。 */
45
+ export declare function requiredDistributionFiles(meta: PackageManifest, patchTexts?: string[]): string[];
46
+ /**
47
+ * 补丁文本里插件行的 `name:` 值(DSH 补丁按行书写:`- name: <pkg>` / ` name: <pkg>`)。
48
+ * 刻意不做 YAML 解析:只认这一种行形状,认不出来就不判定 —— 漏判只是少一条要求,
49
+ * 误判会拦下能装的包,两边的代价不对称。
50
+ */
51
+ export declare function patchRowNamesOf(text: string): string[];
52
+ /** 补丁是否把本包自己 mount 进树(dsh 文档里组合包的常规写法),是则宿主会 import 本包。 */
53
+ export declare function mountsItself(meta: PackageManifest, patchTexts: string[]): boolean;
@@ -1,4 +1,5 @@
1
- /** 预检结果:ok=false 表示分发改入口文件缺失,missing 为该文件在包内的相对路径;
1
+ /** 预检结果:ok=false 表示分发改 package.json 声明的文件(入口或 bundle 补丁),
2
+ * missing 为该文件在包内的相对路径;
2
3
  * name 为 git 分发包 package.json 声明的包名(非 git 目标/无法确定时为 null),
3
4
  * 供安装路由做「包名冲突」检测 —— 同名包名已被其他来源占用时,pnpm 装前必然撞车,
4
5
  * 需要把晦涩的 CLI 报错转成明确的拦截。 */
@@ -13,3 +14,10 @@ export declare function preflightTarget(target: string): Promise<PreflightResult
13
14
  * single archive root; nested monorepo package manifests are intentionally
14
15
  * ignored. Exported for a small parser regression test. */
15
16
  export declare function rootPackageEntryOfTarList(listing: string): string | null;
17
+ /**
18
+ * 清单里第一个缺失的必需文件(成员名带包根前缀,由调用方拼好),全部都在则为 null。
19
+ * 清单不可读(tar 缺失/解压失败)时按第一个必需文件缺失处理:与逐文件查询时
20
+ * 「查不到就是缺」的判定一致,宁可拦下一次可疑安装,也不放一个空包过去。
21
+ * Exported for a small parser regression test.
22
+ */
23
+ export declare function missingDistributionFileOfTarList(listing: string | null, required: string[]): string | null;
@@ -67,10 +67,12 @@ export declare function startPluginMutation(options: {
67
67
  installChannel?: 'npm' | 'git' | 'dsh';
68
68
  }): InstallTask;
69
69
  /**
70
- * 安装后校验已装包的入口文件(package.json 的 main / exports["."].default)是否真实存在。
71
- * git: 分发常不提交构建产物(lib/ 等),pnpm 装完没有报错,但宿主重启加载插件树时会
72
- * ERR_MODULE_NOT_FOUND 直接崩溃、网页打不开 —— 这里在登记「待重启」前就把这类残缺包拦下。
73
- * 返回 { name, missing }:name 为空表示无法定位目标包(跳过校验);missing 为缺失的入口路径。
70
+ * 安装后校验已装包里 package.json 声明的文件(main / exports["."].default 与
71
+ * dsh.bundle.patch)是否真实存在。git: 分发常不提交构建产物(lib/ 等),pnpm 装完没有
72
+ * 报错,但宿主重启加载插件树时会 ERR_MODULE_NOT_FOUND 直接崩溃、网页打不开 —— 这里在
73
+ * 登记「待重启」前就把这类残缺包拦下。没有声明过的文件不在这里追究:没声明入口的
74
+ * bundle 包(只挂技能)本来就不需要入口,宿主读的是它的 package.json 与声明的补丁。
75
+ * 返回 { name, missing }:name 为空表示无法定位目标包(跳过校验);missing 为缺失文件的路径。
74
76
  */
75
77
  export declare function verifyInstalledEntry(profile: string, target: string): {
76
78
  name: string | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin",
3
- "version": "1.6.6",
3
+ "version": "1.6.8",
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",
@@ -377,6 +377,12 @@ export function NotificationsModal({ records, tasks, pendingRestarts, t, env, on
377
377
  }, t('failIssueBig')) : null,
378
378
  ])
379
379
  }
380
+ if (kind === 'hubState') {
381
+ // 插件中心的列表状态已过期(安装器自身守卫消息):非插件问题、也不是 bug,只给可行动提示,不引导提 Issue
382
+ return h('div', null, [
383
+ h('div', { className: styles.failPrepareHint }, t('failHubStateHint')),
384
+ ])
385
+ }
380
386
  // pnpm allowBuilds 拦截与未知失败:不归到插件作者,改为引导到插件中心仓库排查
381
387
  return h('div', null, [
382
388
  kind === 'pnpmIgnoredBuild'
@@ -497,6 +497,11 @@ export function ErrorModal({ message, repo, kind, command, attempts, t, env, onC
497
497
  title: t('failIssueHint'),
498
498
  }, t('failIssueBig')) : null,
499
499
  ])
500
+ : failureKind === 'hubState'
501
+ ? h('div', null, [
502
+ // 插件中心的列表状态已过期(安装器自身守卫消息):不是插件问题、也不是 bug,只给可行动提示,不引导提 Issue
503
+ h('div', { className: styles.failPrepareHint }, t('failHubStateHint')),
504
+ ])
500
505
  : h('div', null, [
501
506
  // pnpm allowBuilds 拦截与未知失败:不归到插件作者,改为引导到插件中心仓库排查
502
507
  failureKind === 'pnpmIgnoredBuild'
@@ -315,10 +315,12 @@ export const zh = {
315
315
  failHubIssueBig: '反馈给插件中心排查',
316
316
  // 未知失败兜底:不归到插件作者,引导到插件中心仓库排查
317
317
  failUnclassifiedHint: '安装失败,原因未能自动识别。这通常不是插件自身的问题(可能与本机环境或插件中心有关),请将错误信息反馈给插件中心,由维护者排查。',
318
+ // 插件中心自身状态类消息(插件未安装 / 不在此处卸载 / 已在队列中):已安装列表与实读快照不同步,非插件问题,不引导提 Issue
319
+ failHubStateHint: '插件列表状态已过期或与本地不一致:该插件可能已经装过了(无需重复安装,要升级请点「更新」),也可能已不在已安装列表中,或已在队列中。这不是插件本身的问题 —— 请刷新页面后重试。',
318
320
  // prepare 构建脚本实际执行失败(git 分发缺子模块/构建产物):插件打包分发问题,应提 Issue
319
321
  failPrepareHint: '该插件安装时的构建脚本执行失败(git 分发常缺失子模块或构建产物),属插件打包分发问题。请向作者仓库提交 Issue 反馈。',
320
- // 安装前预检 / 装后校验拦截([packaging]):git 分发缺构建产物、入口文件不存在 = 插件未适配官方默认安装方式
321
- failPackagingHint: '该插件不支持官方默认安装方式:其 git 分发缺少构建产物(package.json 声明的入口文件在仓库中不存在),说明作者未适配官方安装流程。请到作者仓库提交 Issue 反馈,请其提交构建产物、改用预构建 release `.tgz` 直链(`releases/download/<tag>/<asset>.tgz`),或发布完整的 npm 版。',
322
+ // 安装前预检 / 装后校验拦截([packaging]):git 分发缺插件需要的文件(入口 / 补丁 mount 的入口 / bundle 补丁)= 插件未适配官方默认安装方式
323
+ failPackagingHint: '该插件不支持官方默认安装方式:其 git 分发缺少它需要的文件(`main` / `exports` 声明的入口、补丁按本包名 mount 的入口,或 `dsh.bundle.patch` 声明的补丁),说明作者未适配官方安装流程。请到作者仓库提交 Issue 反馈,请其提交构建产物、改用预构建 release `.tgz` 直链(`releases/download/<tag>/<asset>.tgz`),或发布完整的 npm 版。',
322
324
  // 原生依赖构建脚本被 pnpm 默认拦截(如 node-pty 的 ERR_PNPM_IGNORED_BUILDS):不单独算插件问题,引导到插件中心仓库排查
323
325
  failIgnoredBuild: '该插件或其依赖需要执行构建脚本,被 pnpm 的安全白名单(allowBuilds)默认拦截导致安装失败(如原生模块 node-pty、或 git 安装时插件的 prepare 脚本)。这不一定是插件自身的问题(也可能是插件中心反查失败后回退到 git 安装所致),请先反馈给插件中心排查;若确认为插件依赖问题,可建议作者改用预编译版本(如 node-pty-prebuilt-multiarch)或移除安装期构建。',
324
326
  // npm arborist 内部崩溃(edgesOut)+ 本机 npm 版本过低:npm 自身已知缺陷,不是插件问题,升级 npm 即可
@@ -713,10 +715,12 @@ export const en = {
713
715
  failHubIssueBig: 'Report to the Plugin Hub for triage',
714
716
  // Unknown-failure fallback: not routed to the plugin author, but to the hub repo for triage
715
717
  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.',
718
+ // 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
719
+ 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.',
716
720
  // prepare script actually failed (git tarball misses submodules/build output): a packaging issue of the plugin itself — file an issue
717
721
  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.',
718
- // Pre-install / post-install guard hit ([packaging]): git distribution lacks build output, entry file missing = not adapted to the official default install method
719
- failPackagingHint: 'This plugin does not support the official default install method: its git distribution lacks the build output (the entry file declared in package.json is not in the repository), 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.',
722
+ // 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
723
+ 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
724
  // 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
725
  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.',
722
726
  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',