dsh-plugin 2.3.5 → 2.3.7

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.
@@ -10,6 +10,7 @@ import { join } from 'node:path';
10
10
  import { spawnSync } from 'node:child_process';
11
11
  import { loadSettings } from '../services/settings.js';
12
12
  import { hostDshVersion, toolVersion } from '../services/host-version.js';
13
+ import { normalizeRegistry } from '../services/install/registry-fallback.js';
13
14
  import { gitLsRemote, probeUrl, systemProxy } from '../services/probe.js';
14
15
  import { appendLog } from '../services/log.js';
15
16
  import { readJsonBody, requireTrustedPost, sendJson } from './shared.js';
@@ -39,12 +40,16 @@ function configuredNpmRegistry(profile) {
39
40
  }
40
41
  /** 宿主环境快照:提交 bug 时随 issue 附上,便于作者复现(前端 /env 端点返回)。 */
41
42
  export function hostEnv(profile) {
43
+ const settings = loadSettings(profile);
42
44
  return {
43
45
  dshVersion: hostDshVersion(),
44
46
  nodeVersion: process.version,
45
47
  pnpmVersion: toolVersion('pnpm', ['--version']),
46
48
  npmVersion: toolVersion('npm', ['--version']),
47
49
  gitVersion: toolVersion('git', ['--version']),
50
+ // 安装实际会用的 npm 源(设置里的镜像 → 本机 npm 配置 → 官方):装不上时第一嫌疑就是它,
51
+ // 上报正文带上它,作者不必再猜「用户到底连的哪个源」(#6118 就卡在这里)
52
+ npmRegistry: normalizeRegistry(settings.npmRegistry) || configuredNpmRegistry(profile),
48
53
  platform: process.platform,
49
54
  arch: process.arch,
50
55
  release: release(),
@@ -133,7 +133,7 @@ export function registerInstallRoutes(webServer, profile, loader) {
133
133
  // 命中则记录 repo → 包名映射,客户端据此把依赖 key 匹配回仓库、列表立即显示「已安装」。
134
134
  const catalogRepo = source === 'catalog' ? githubRepoOf(displayRepo) : null;
135
135
  if (catalogRepo !== null) {
136
- const catalogTargetIsDsh = await isDshNpmPackageForRepo(rawRepo, catalogRepo, settings.npmRegistry, effectiveProxy);
136
+ const catalogTargetIsDsh = await isDshNpmPackageForRepo(rawRepo, catalogRepo, settings.npmRegistry, effectiveProxy, pinVersion);
137
137
  if (catalogTargetIsDsh)
138
138
  recordResolvedNpmPackage(profile, catalogRepo, rawRepo);
139
139
  }
@@ -25,6 +25,12 @@ function repositoryUrlOf(pkg) {
25
25
  }
26
26
  return String(pkg.links?.repository ?? '');
27
27
  }
28
+ /** registry 路径:指定版本时取该版本的 manifest,否则取 latest。 */
29
+ export function npmMetadataPath(packageName, version = '') {
30
+ const normalized = version.trim().replace(/^v/i, '');
31
+ const encoded = encodeURIComponent(packageName);
32
+ return normalized === '' ? `${encoded}/latest` : `${encoded}/${encodeURIComponent(normalized)}`;
33
+ }
28
34
  export function isDshPackageMetadataForRepo(pkg, repo) {
29
35
  if (githubRepoOf(repositoryUrlOf(pkg))?.toLowerCase() !== repo.toLowerCase())
30
36
  return false;
@@ -34,14 +40,14 @@ export function isDshPackageMetadataForRepo(pkg, repo) {
34
40
  return keywordMarked || manifestMarked;
35
41
  }
36
42
  /**
37
- * 校验包名对应的 npm 包确实属于该仓库且声明为 DSH 插件:取 registry 上的 `latest`
38
- * 元数据比对 repository 与插件标记。网络异常、超时、非 200 一律返回 false,不阻塞调用方。
43
+ * 校验包名对应的 npm 包确实属于该仓库且声明为 DSH 插件:取 registry 上指定版本(缺省 `latest`)
44
+ * 的元数据比对 repository 与插件标记。网络异常、超时、非 200 一律返回 false,不阻塞调用方。
39
45
  */
40
- export function isDshNpmPackageForRepo(packageName, repo, registry = '', proxy = '') {
46
+ export function isDshNpmPackageForRepo(packageName, repo, registry = '', proxy = '', version = '') {
41
47
  if (packageName === '' || repo === '')
42
48
  return Promise.resolve(false);
43
49
  const base = registry === '' ? 'https://registry.npmjs.org' : registry.replace(/\/+$/, '');
44
- const url = `${base}/${encodeURIComponent(packageName)}/latest`;
50
+ const url = `${base}/${npmMetadataPath(packageName, version)}`;
45
51
  return new Promise((resolve) => {
46
52
  const req = get(url, { timeout: REQUEST_TIMEOUT_MS }, (res) => {
47
53
  if (res.statusCode !== 200) {
@@ -87,6 +87,11 @@ export function backfillOperationLog(output, profileDir) {
87
87
  return null;
88
88
  }
89
89
  }
90
+ /** 本队列自己打的标记行(退出、渠道、超时、回捞…):不含任何可排查信息。 */
91
+ const SELF_MARKER_RE = /^\[(?:exit\b|timed out\]|install-channel\]|stale pipe\]|error\]|pnpm log\]|registry-fallback\]|allow build\])/;
92
+ /** 外层包装行:`Command failed with exit code 1: pnpm add x` 只说明「子进程非零退出」,不含 pnpm 原因。
93
+ * 宿主摘要(`dsh: plugin command failed; diagnostics: <path>`)另算:带路径的那行是回捞证据,不是噪音。 */
94
+ const WRAPPER_LINE_RE = /^Command failed with exit code \d+/i;
90
95
  /**
91
96
  * 输出里是否有 dsh 自己的内容 —— 只有 `[exit N]`、`[install-channel]`、`[timed out]`
92
97
  * 这类本队列自己打的标记时返回 false。用于把「宿主什么都没回显」的失败显式留痕,
@@ -95,7 +100,19 @@ export function backfillOperationLog(output, profileDir) {
95
100
  export function hasSubstantiveOutput(output) {
96
101
  return output.split(/\r?\n/).some((raw) => {
97
102
  const line = raw.trim();
98
- return line !== '' && !/^\[(?:exit\b|timed out\]|install-channel\]|stale pipe\]|error\]|pnpm log\]|registry-fallback\]|allow build\])/.test(line);
103
+ return line !== '' && !SELF_MARKER_RE.test(line);
104
+ });
105
+ }
106
+ /**
107
+ * 失败输出里除包装噪声外没有任何可排查内容:只剩本队列标记,和
108
+ * `Command failed with exit code N: pnpm add …` 这类外层包装(#6118 的形态 ——
109
+ * pnpm 以 1 退出、operation 日志里只有这一行)。此时上报正文只会写出「安装失败」,
110
+ * 作者与维护者都无从判断,调用方据此补一条带启动参数的诊断标记。
111
+ */
112
+ export function lacksDiagnosticContent(output) {
113
+ return output.split(/\r?\n/).every((raw) => {
114
+ const line = raw.trim();
115
+ return line === '' || SELF_MARKER_RE.test(line) || WRAPPER_LINE_RE.test(line);
99
116
  });
100
117
  }
101
118
  /**
@@ -14,7 +14,7 @@ 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
16
  import { registryMissingMarker } from './registry-check.js';
17
- import { backfillOperationLog, createOperationLogTap, hasSubstantiveOutput, operationLogPathOf } from './operation-log.js';
17
+ import { backfillOperationLog, createOperationLogTap, lacksDiagnosticContent, operationLogPathOf } from './operation-log.js';
18
18
  import { cleanLine, estimateProgress } from '../profile/progress.js';
19
19
  import { hasClientUi, isDshPlugin, mountLoadedEntry, removeLoadedEntry } from '../loader.js';
20
20
  import { addPendingRestart, clearPendingRestart } from '../profile/pending-restart.js';
@@ -479,6 +479,7 @@ function spawnMutation(options) {
479
479
  error: marker === null ? detail : `${detail}\n${marker}`,
480
480
  stdout,
481
481
  stderr,
482
+ spawn: { file: invocation.file, args: attemptArgs, cwd: invocation.cwd, shell: invocation.useShell },
482
483
  });
483
484
  return;
484
485
  }
@@ -504,7 +505,8 @@ function spawnMutation(options) {
504
505
  clearTimeout(forceSettle);
505
506
  if (task)
506
507
  runningChildren.delete(task.id);
507
- resolveOnce(result);
508
+ // 附带本次实际启动参数:输出为空时,上报里至少能说明「跑了什么、在哪个目录」
509
+ resolveOnce({ ...result, spawn: { file: invocation.file, args: attemptArgs, cwd: invocation.cwd, shell: invocation.useShell } });
508
510
  };
509
511
  timer = setTimeout(() => {
510
512
  timedOut = true;
@@ -850,6 +852,24 @@ export function hostSpawnMarker(invocation, error) {
850
852
  return null;
851
853
  return `[host-spawn] the DSH host could not be launched (${invocation.file}: ENOENT)`;
852
854
  }
855
+ /**
856
+ * 失败输出只剩外层包装时的诊断标记(导出供单测覆盖字段)。内容先后即上报正文里的优先级:
857
+ * 退出方式 → 实际生效的 registry → 实际 spawn 的命令与目录 → profile 位置。
858
+ * registry 用主机名展示,避免把带凭据的私有源地址写进公开 Issue。
859
+ */
860
+ export function sparseFailureMarker(result, options) {
861
+ const verb = options.action === 'remove' ? 'uninstall' : options.action === 'update' ? 'update' : 'install';
862
+ const outcome = result.timedOut ? 'timed out' : `exited with code ${result.exitCode ?? '?'}`;
863
+ const registry = result.registry ?? options.registries?.[0] ?? 'unknown';
864
+ const spawn = result.spawn;
865
+ return [
866
+ `[no-output] the ${verb} command ${outcome} and printed nothing but its wrapper line`,
867
+ `registry: ${registryHost(registry)}`,
868
+ spawn === undefined ? '' : `spawn: ${spawn.file} ${spawn.args.join(' ')} (cwd ${spawn.cwd}, shell ${spawn.shell})`,
869
+ `profile: ${profileDirectory(options.profile)}`,
870
+ 'no pnpm log under .plugin-manager/logs — attach hub.log when reporting',
871
+ ].filter((part) => part !== '').join(' | ');
872
+ }
853
873
  /**
854
874
  * 执行插件变更,带一条恢复路径:当 `add` 因 pnpm 的 allowBuilds 限制拦下构建脚本而失败 ——
855
875
  * 或是 git 源包自己的 `prepare`(`ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED`,key 取自打印的
@@ -941,15 +961,17 @@ export async function runPluginMutation(options) {
941
961
  // 诊断路径在、日志却取不到:留一行证据,否则下一次同类上报又只剩宿主那句摘要、看不出根因
942
962
  pushLine(options.task, '[pnpm log] the host-reported diagnostics log could not be read from disk (missing, empty, or unreadable)');
943
963
  }
944
- else if (!hasSubstantiveOutput(output)) {
945
- // 宿主连 diagnostics 路径都没回显:显式留痕,避免上报正文只剩一个退出码
946
- const what = result.timedOut
947
- ? 'printed nothing before the timeout'
948
- : `exited with code ${result.exitCode ?? '?'} without printing anything`;
949
- pushLine(options.task, `[no-output] the dsh CLI ${what}, and no pnpm log was written under .plugin-manager/logs — attach ~/.dsh/profiles/${options.profile}/hub.log when reporting this`);
950
- }
951
964
  }
952
965
  }
966
+ // 失败但输出里只剩外层包装(`Command failed with exit code 1: pnpm add …`)时,上报正文会退化成
967
+ // 一句「安装失败」:既看不出跑了什么,也看不出连的是哪个源(#6118 的形态)。补一条诊断标记,
968
+ // 把实际启动参数、profile 目录与生效的 registry 带进日志和上报正文。
969
+ if ((result.exitCode !== 0 || result.timedOut) && lacksDiagnosticContent(`${result.stderr}\n${result.stdout}`)) {
970
+ const line = sparseFailureMarker(result, options);
971
+ if (options.task && options.task.status !== 'cancelled')
972
+ pushLine(options.task, line);
973
+ result = { ...result, stderr: `${result.stderr}\n${line}` };
974
+ }
953
975
  // profile 里的 YAML 配置(pnpm-workspace.yaml / pnpm-lock.yaml)解析失败 → 追加 [profile-yaml]
954
976
  // 标记,前端给「修本机文件」的步骤,不引导向插件仓库提 Issue
955
977
  result = annotateProfileYaml(result, options);
@@ -14,6 +14,13 @@ export interface InstallResult {
14
14
  stderr: string;
15
15
  /** 实际执行安装用的 npm 源(换源重试后是最终那一个):后续读元数据的注解要跟它同源 */
16
16
  registry?: string;
17
+ /** 最后一次 spawn 的实际启动参数:失败且无任何输出时,上报里要靠它说明「到底跑了什么、在哪个目录」 */
18
+ spawn?: {
19
+ file: string;
20
+ args: string[];
21
+ cwd: string;
22
+ shell: boolean;
23
+ };
17
24
  }
18
25
  export interface InstallTask {
19
26
  id: number;
@@ -7,10 +7,12 @@ type NpmSearchPackage = {
7
7
  repository?: unknown;
8
8
  dsh?: unknown;
9
9
  };
10
+ /** registry 路径:指定版本时取该版本的 manifest,否则取 latest。 */
11
+ export declare function npmMetadataPath(packageName: string, version?: string): string;
10
12
  export declare function isDshPackageMetadataForRepo(pkg: NpmSearchPackage, repo: string): boolean;
11
13
  /**
12
- * 校验包名对应的 npm 包确实属于该仓库且声明为 DSH 插件:取 registry 上的 `latest`
13
- * 元数据比对 repository 与插件标记。网络异常、超时、非 200 一律返回 false,不阻塞调用方。
14
+ * 校验包名对应的 npm 包确实属于该仓库且声明为 DSH 插件:取 registry 上指定版本(缺省 `latest`)
15
+ * 的元数据比对 repository 与插件标记。网络异常、超时、非 200 一律返回 false,不阻塞调用方。
14
16
  */
15
- export declare function isDshNpmPackageForRepo(packageName: string, repo: string, registry?: string, proxy?: string): Promise<boolean>;
17
+ export declare function isDshNpmPackageForRepo(packageName: string, repo: string, registry?: string, proxy?: string, version?: string): Promise<boolean>;
16
18
  export {};
@@ -15,6 +15,13 @@ export declare function backfillOperationLog(output: string, profileDir?: string
15
15
  * 否则上报正文只剩退出码,无从排查。
16
16
  */
17
17
  export declare function hasSubstantiveOutput(output: string): boolean;
18
+ /**
19
+ * 失败输出里除包装噪声外没有任何可排查内容:只剩本队列标记,和
20
+ * `Command failed with exit code N: pnpm add …` 这类外层包装(#6118 的形态 ——
21
+ * pnpm 以 1 退出、operation 日志里只有这一行)。此时上报正文只会写出「安装失败」,
22
+ * 作者与维护者都无从判断,调用方据此补一条带启动参数的诊断标记。
23
+ */
24
+ export declare function lacksDiagnosticContent(output: string): boolean;
18
25
  /**
19
26
  * 运行期抓取:宿主的 operation 日志只在命令运行期间存在,结束后可能被清理,
20
27
  * 退出后再按路径读往往已经拿不到。这里在安装过程中轮询 profile 下最新的
@@ -104,6 +104,16 @@ export declare function verifyInstalledEntry(profile: string, target: string): {
104
104
  * 导出供单测覆盖判据。
105
105
  */
106
106
  export declare function hostSpawnMarker(invocation: Invocation, error: unknown): string | null;
107
+ /**
108
+ * 失败输出只剩外层包装时的诊断标记(导出供单测覆盖字段)。内容先后即上报正文里的优先级:
109
+ * 退出方式 → 实际生效的 registry → 实际 spawn 的命令与目录 → profile 位置。
110
+ * registry 用主机名展示,避免把带凭据的私有源地址写进公开 Issue。
111
+ */
112
+ export declare function sparseFailureMarker(result: InstallResult, options: {
113
+ action: 'add' | 'remove' | 'update';
114
+ profile: string;
115
+ registries?: string[];
116
+ }): string;
107
117
  /**
108
118
  * 执行插件变更,带一条恢复路径:当 `add` 因 pnpm 的 allowBuilds 限制拦下构建脚本而失败 ——
109
119
  * 或是 git 源包自己的 `prepare`(`ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED`,key 取自打印的
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin",
3
- "version": "2.3.5",
3
+ "version": "2.3.7",
4
4
  "description": "A community plugin marketplace for DeepSeek Harness, built to the official plugin spec — browse, search and install 20000+ human-curated community plugins without leaving the app. · DeepSeek Harness 社区插件市场(遵循官方开发规范):20000+ 人工精选社区插件,每日更新。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -21,6 +21,7 @@ function formatEnv(env: EnvInfo): string {
21
21
  `DSH ${env.dshVersion ?? 'unknown'}`,
22
22
  `Node ${env.nodeVersion}`,
23
23
  `${env.platform} ${env.arch} (${env.release})`,
24
+ env.npmRegistry ? `npm registry ${env.npmRegistry}` : '',
24
25
  `Profile ${env.profile}`,
25
26
  env.dshHome ? `Home ${env.dshHome}` : '',
26
27
  ].filter(Boolean).join('\n')
@@ -313,9 +313,10 @@ export function classifyFailure(message: string): FailureKind {
313
313
  // 原生模块需要本机编译,缺编译器/构建工具属于用户环境,不是插件的 prepare 失败。
314
314
  // 必须排在 pluginPrepare 之前:这类日志同样带 ELIFECYCLE,否则会被推给作者。
315
315
  if (/Unable to detect compiler type|gyp ERR!|Could not find any Visual Studio|No Xcode or CLT version detected|xcode-select|python(?:3)? is (?:not set|not found)|Could not find Python|\bmake: not found\b|\bgcc: not found\b|\bg\+\+: not found\b|\bMSB\d{4}\b/i.test(message)) return 'buildToolchainMissing'
316
- // 构建脚本真的跑挂了才算插件问题。Command failed 区分大小写:只匹配 pnpm 的包装,
317
- // 不匹配宿主那句小写 dsh: plugin command failed
318
- if (/ERR_PNPM_PREPARE_PACKAGE|ELIFECYCLE|prepare-guard/i.test(message) || /Command failed/.test(message)) return 'pluginPrepare'
316
+ // 构建脚本真的跑挂了才算插件问题。必须带正向证据:`Command failed` 只是外层进程包装,
317
+ // 单凭它不能说明 prepare 失败(Electron 内置 pnpm 崩溃时日志里往往只剩这一句,
318
+ // 会把无关作者当成分发缺陷)。无证据的包装层失败交给 hub 分诊。
319
+ if (/ERR_PNPM_PREPARE_PACKAGE|ELIFECYCLE|prepare-guard|prepare script failed|Failed to prepare/i.test(message)) return 'pluginPrepare'
319
320
  // 安装器自身产生的状态类消息(未安装 / 不在此处卸载 / 已在队列中 / 已安装):这几句由本服务的
320
321
  // 守卫发出,不是 CLI 输出、也不是插件问题,而是插件中心的已安装列表与实读快照不同步。
321
322
  // 单独归类,让前端给「刷新后重试」这类可行动提示,而不是当作未知失败引导去提 Issue。
@@ -188,6 +188,8 @@ export function pluginIssueUrl(repo: string, message: string, env?: EnvInfo | nu
188
188
  // 这类环境问题,作者一眼就能对号入座;探测不到如实写 unknown
189
189
  `- pnpm: ${env.pnpmVersion ? `v${env.pnpmVersion}` : 'unknown'}`,
190
190
  `- npm: ${env.npmVersion ? `v${env.npmVersion}` : 'unknown'}`,
191
+ // 安装实际使用的源:网络/私有镜像类失败的第一嫌疑,作者据此判断是否「源不对/源不通」
192
+ `- npm registry: ${env.npmRegistry ? `\`${env.npmRegistry}\`` : 'unknown'}`,
191
193
  `- git: ${env.gitVersion ? `v${env.gitVersion}` : 'unknown'}`,
192
194
  `- OS: ${env.platform} ${env.arch} (${env.release})`,
193
195
  `- Profile: \`${env.profile}\``,
@@ -290,6 +292,8 @@ export function hubIssueUrl(
290
292
  `- Node: ${env.nodeVersion}`,
291
293
  `- pnpm: ${env.pnpmVersion ? `v${env.pnpmVersion}` : 'unknown'}`,
292
294
  `- npm: ${env.npmVersion ? `v${env.npmVersion}` : 'unknown'}`,
295
+ // 安装实际使用的源:网络/私有镜像类失败的第一嫌疑,作者据此判断是否「源不对/源不通」
296
+ `- npm registry: ${env.npmRegistry ? `\`${env.npmRegistry}\`` : 'unknown'}`,
293
297
  `- git: ${env.gitVersion ? `v${env.gitVersion}` : 'unknown'}`,
294
298
  `- OS: ${env.platform} ${env.arch} (${env.release})`,
295
299
  `- Profile: \`${env.profile}\``,
@@ -95,6 +95,8 @@ export interface EnvInfo {
95
95
  npmVersion: string | null
96
96
  /** 本机 git 版本(git --version 首行;缺失返回 null) */
97
97
  gitVersion: string | null
98
+ /** 安装实际会用的 npm 源(设置镜像 → 本机 npm 配置 → 官方):失败上报要带上它 */
99
+ npmRegistry?: string | null
98
100
  platform: string
99
101
  arch: string
100
102
  release: string
@@ -10,6 +10,7 @@ import { join } from 'node:path'
10
10
  import { spawnSync } from 'node:child_process'
11
11
  import { loadSettings } from '../services/settings.ts'
12
12
  import { hostDshVersion, toolVersion } from '../services/host-version.ts'
13
+ import { normalizeRegistry } from '../services/install/registry-fallback.ts'
13
14
  import { gitLsRemote, probeUrl, systemProxy } from '../services/probe.ts'
14
15
  import { appendLog } from '../services/log.ts'
15
16
  import { readJsonBody, requireTrustedPost, sendJson, type WebRoute, type WebServerService } from './shared.ts'
@@ -39,12 +40,16 @@ function configuredNpmRegistry(profile: string): string {
39
40
 
40
41
  /** 宿主环境快照:提交 bug 时随 issue 附上,便于作者复现(前端 /env 端点返回)。 */
41
42
  export function hostEnv(profile: string): Record<string, string | null> {
43
+ const settings = loadSettings(profile)
42
44
  return {
43
45
  dshVersion: hostDshVersion(),
44
46
  nodeVersion: process.version,
45
47
  pnpmVersion: toolVersion('pnpm', ['--version']),
46
48
  npmVersion: toolVersion('npm', ['--version']),
47
49
  gitVersion: toolVersion('git', ['--version']),
50
+ // 安装实际会用的 npm 源(设置里的镜像 → 本机 npm 配置 → 官方):装不上时第一嫌疑就是它,
51
+ // 上报正文带上它,作者不必再猜「用户到底连的哪个源」(#6118 就卡在这里)
52
+ npmRegistry: normalizeRegistry(settings.npmRegistry) || configuredNpmRegistry(profile),
48
53
  platform: process.platform,
49
54
  arch: process.arch,
50
55
  release: release(),
@@ -138,7 +138,7 @@ export function registerInstallRoutes(webServer: WebServerService, profile: stri
138
138
  // 命中则记录 repo → 包名映射,客户端据此把依赖 key 匹配回仓库、列表立即显示「已安装」。
139
139
  const catalogRepo = source === 'catalog' ? githubRepoOf(displayRepo) : null
140
140
  if (catalogRepo !== null) {
141
- const catalogTargetIsDsh = await isDshNpmPackageForRepo(rawRepo, catalogRepo, settings.npmRegistry, effectiveProxy)
141
+ const catalogTargetIsDsh = await isDshNpmPackageForRepo(rawRepo, catalogRepo, settings.npmRegistry, effectiveProxy, pinVersion)
142
142
  if (catalogTargetIsDsh) recordResolvedNpmPackage(profile, catalogRepo, rawRepo)
143
143
  }
144
144
  // 命令行安装(目录外)受「启用 NPM 安装」开关控制;目录插件安装走目录白名单,不受开关影响。
@@ -15,6 +15,8 @@ export interface InstallResult {
15
15
  stderr: string
16
16
  /** 实际执行安装用的 npm 源(换源重试后是最终那一个):后续读元数据的注解要跟它同源 */
17
17
  registry?: string
18
+ /** 最后一次 spawn 的实际启动参数:失败且无任何输出时,上报里要靠它说明「到底跑了什么、在哪个目录」 */
19
+ spawn?: { file: string; args: string[]; cwd: string; shell: boolean }
18
20
  }
19
21
 
20
22
  export interface InstallTask {
@@ -34,6 +34,13 @@ function repositoryUrlOf(pkg: NpmSearchPackage): string {
34
34
  return String(pkg.links?.repository ?? '')
35
35
  }
36
36
 
37
+ /** registry 路径:指定版本时取该版本的 manifest,否则取 latest。 */
38
+ export function npmMetadataPath(packageName: string, version = ''): string {
39
+ const normalized = version.trim().replace(/^v/i, '')
40
+ const encoded = encodeURIComponent(packageName)
41
+ return normalized === '' ? `${encoded}/latest` : `${encoded}/${encodeURIComponent(normalized)}`
42
+ }
43
+
37
44
  export function isDshPackageMetadataForRepo(pkg: NpmSearchPackage, repo: string): boolean {
38
45
  if (githubRepoOf(repositoryUrlOf(pkg))?.toLowerCase() !== repo.toLowerCase()) return false
39
46
  const keywords = Array.isArray(pkg.keywords) ? pkg.keywords : []
@@ -43,13 +50,13 @@ export function isDshPackageMetadataForRepo(pkg: NpmSearchPackage, repo: string)
43
50
  }
44
51
 
45
52
  /**
46
- * 校验包名对应的 npm 包确实属于该仓库且声明为 DSH 插件:取 registry 上的 `latest`
47
- * 元数据比对 repository 与插件标记。网络异常、超时、非 200 一律返回 false,不阻塞调用方。
53
+ * 校验包名对应的 npm 包确实属于该仓库且声明为 DSH 插件:取 registry 上指定版本(缺省 `latest`)
54
+ * 的元数据比对 repository 与插件标记。网络异常、超时、非 200 一律返回 false,不阻塞调用方。
48
55
  */
49
- export function isDshNpmPackageForRepo(packageName: string, repo: string, registry = '', proxy = ''): Promise<boolean> {
56
+ export function isDshNpmPackageForRepo(packageName: string, repo: string, registry = '', proxy = '', version = ''): Promise<boolean> {
50
57
  if (packageName === '' || repo === '') return Promise.resolve(false)
51
58
  const base = registry === '' ? 'https://registry.npmjs.org' : registry.replace(/\/+$/, '')
52
- const url = `${base}/${encodeURIComponent(packageName)}/latest`
59
+ const url = `${base}/${npmMetadataPath(packageName, version)}`
53
60
  return new Promise((resolve) => {
54
61
  const req = get(url, { timeout: REQUEST_TIMEOUT_MS }, (res) => {
55
62
  if (res.statusCode !== 200) {
@@ -81,6 +81,12 @@ export function backfillOperationLog(output: string, profileDir?: string): strin
81
81
  }
82
82
  }
83
83
 
84
+ /** 本队列自己打的标记行(退出、渠道、超时、回捞…):不含任何可排查信息。 */
85
+ const SELF_MARKER_RE = /^\[(?:exit\b|timed out\]|install-channel\]|stale pipe\]|error\]|pnpm log\]|registry-fallback\]|allow build\])/
86
+ /** 外层包装行:`Command failed with exit code 1: pnpm add x` 只说明「子进程非零退出」,不含 pnpm 原因。
87
+ * 宿主摘要(`dsh: plugin command failed; diagnostics: <path>`)另算:带路径的那行是回捞证据,不是噪音。 */
88
+ const WRAPPER_LINE_RE = /^Command failed with exit code \d+/i
89
+
84
90
  /**
85
91
  * 输出里是否有 dsh 自己的内容 —— 只有 `[exit N]`、`[install-channel]`、`[timed out]`
86
92
  * 这类本队列自己打的标记时返回 false。用于把「宿主什么都没回显」的失败显式留痕,
@@ -89,7 +95,20 @@ export function backfillOperationLog(output: string, profileDir?: string): strin
89
95
  export function hasSubstantiveOutput(output: string): boolean {
90
96
  return output.split(/\r?\n/).some((raw) => {
91
97
  const line = raw.trim()
92
- return line !== '' && !/^\[(?:exit\b|timed out\]|install-channel\]|stale pipe\]|error\]|pnpm log\]|registry-fallback\]|allow build\])/.test(line)
98
+ return line !== '' && !SELF_MARKER_RE.test(line)
99
+ })
100
+ }
101
+
102
+ /**
103
+ * 失败输出里除包装噪声外没有任何可排查内容:只剩本队列标记,和
104
+ * `Command failed with exit code N: pnpm add …` 这类外层包装(#6118 的形态 ——
105
+ * pnpm 以 1 退出、operation 日志里只有这一行)。此时上报正文只会写出「安装失败」,
106
+ * 作者与维护者都无从判断,调用方据此补一条带启动参数的诊断标记。
107
+ */
108
+ export function lacksDiagnosticContent(output: string): boolean {
109
+ return output.split(/\r?\n/).every((raw) => {
110
+ const line = raw.trim()
111
+ return line === '' || SELF_MARKER_RE.test(line) || WRAPPER_LINE_RE.test(line)
93
112
  })
94
113
  }
95
114
 
@@ -15,7 +15,7 @@ import type { InstallResult, InstallTask, Invocation, QueueItem } from './instal
15
15
  import { CAPTURE_LIMIT_BYTES, MAX_TASKS, MAX_TASK_LINES } from './install-types.ts'
16
16
  import { npmTooLowMarker } from './npm-check.ts'
17
17
  import { registryMissingMarker } from './registry-check.ts'
18
- import { backfillOperationLog, createOperationLogTap, hasSubstantiveOutput, operationLogPathOf } from './operation-log.ts'
18
+ import { backfillOperationLog, createOperationLogTap, lacksDiagnosticContent, operationLogPathOf } from './operation-log.ts'
19
19
  import { cleanLine, estimateProgress } from '../profile/progress.ts'
20
20
  import type { LoaderHandle } from '../loader.ts'
21
21
  import { hasClientUi, isDshPlugin, mountLoadedEntry, removeLoadedEntry } from '../loader.ts'
@@ -536,6 +536,7 @@ function spawnMutation(options: {
536
536
  error: marker === null ? detail : `${detail}\n${marker}`,
537
537
  stdout,
538
538
  stderr,
539
+ spawn: { file: invocation.file, args: attemptArgs, cwd: invocation.cwd, shell: invocation.useShell },
539
540
  })
540
541
  return
541
542
  }
@@ -556,7 +557,8 @@ function spawnMutation(options: {
556
557
  if (exitWatch !== undefined) clearTimeout(exitWatch)
557
558
  if (forceSettle !== undefined) clearTimeout(forceSettle)
558
559
  if (task) runningChildren.delete(task.id)
559
- resolveOnce(result)
560
+ // 附带本次实际启动参数:输出为空时,上报里至少能说明「跑了什么、在哪个目录」
561
+ resolveOnce({ ...result, spawn: { file: invocation.file, args: attemptArgs, cwd: invocation.cwd, shell: invocation.useShell } })
560
562
  }
561
563
 
562
564
  timer = setTimeout(() => {
@@ -886,6 +888,29 @@ export function hostSpawnMarker(invocation: Invocation, error: unknown): string
886
888
  return `[host-spawn] the DSH host could not be launched (${invocation.file}: ENOENT)`
887
889
  }
888
890
 
891
+ /**
892
+ * 失败输出只剩外层包装时的诊断标记(导出供单测覆盖字段)。内容先后即上报正文里的优先级:
893
+ * 退出方式 → 实际生效的 registry → 实际 spawn 的命令与目录 → profile 位置。
894
+ * registry 用主机名展示,避免把带凭据的私有源地址写进公开 Issue。
895
+ */
896
+ export function sparseFailureMarker(result: InstallResult, options: {
897
+ action: 'add' | 'remove' | 'update'
898
+ profile: string
899
+ registries?: string[]
900
+ }): string {
901
+ const verb = options.action === 'remove' ? 'uninstall' : options.action === 'update' ? 'update' : 'install'
902
+ const outcome = result.timedOut ? 'timed out' : `exited with code ${result.exitCode ?? '?'}`
903
+ const registry = result.registry ?? options.registries?.[0] ?? 'unknown'
904
+ const spawn = result.spawn
905
+ return [
906
+ `[no-output] the ${verb} command ${outcome} and printed nothing but its wrapper line`,
907
+ `registry: ${registryHost(registry)}`,
908
+ spawn === undefined ? '' : `spawn: ${spawn.file} ${spawn.args.join(' ')} (cwd ${spawn.cwd}, shell ${spawn.shell})`,
909
+ `profile: ${profileDirectory(options.profile)}`,
910
+ 'no pnpm log under .plugin-manager/logs — attach hub.log when reporting',
911
+ ].filter((part) => part !== '').join(' | ')
912
+ }
913
+
889
914
  /**
890
915
  * 执行插件变更,带一条恢复路径:当 `add` 因 pnpm 的 allowBuilds 限制拦下构建脚本而失败 ——
891
916
  * 或是 git 源包自己的 `prepare`(`ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED`,key 取自打印的
@@ -988,15 +1013,17 @@ export async function runPluginMutation(options: {
988
1013
  if (operationLogPathOf(output) !== null) {
989
1014
  // 诊断路径在、日志却取不到:留一行证据,否则下一次同类上报又只剩宿主那句摘要、看不出根因
990
1015
  pushLine(options.task, '[pnpm log] the host-reported diagnostics log could not be read from disk (missing, empty, or unreadable)')
991
- } else if (!hasSubstantiveOutput(output)) {
992
- // 宿主连 diagnostics 路径都没回显:显式留痕,避免上报正文只剩一个退出码
993
- const what = result.timedOut
994
- ? 'printed nothing before the timeout'
995
- : `exited with code ${result.exitCode ?? '?'} without printing anything`
996
- pushLine(options.task, `[no-output] the dsh CLI ${what}, and no pnpm log was written under .plugin-manager/logs — attach ~/.dsh/profiles/${options.profile}/hub.log when reporting this`)
997
1016
  }
998
1017
  }
999
1018
  }
1019
+ // 失败但输出里只剩外层包装(`Command failed with exit code 1: pnpm add …`)时,上报正文会退化成
1020
+ // 一句「安装失败」:既看不出跑了什么,也看不出连的是哪个源(#6118 的形态)。补一条诊断标记,
1021
+ // 把实际启动参数、profile 目录与生效的 registry 带进日志和上报正文。
1022
+ if ((result.exitCode !== 0 || result.timedOut) && lacksDiagnosticContent(`${result.stderr}\n${result.stdout}`)) {
1023
+ const line = sparseFailureMarker(result, options)
1024
+ if (options.task && options.task.status !== 'cancelled') pushLine(options.task, line)
1025
+ result = { ...result, stderr: `${result.stderr}\n${line}` }
1026
+ }
1000
1027
  // profile 里的 YAML 配置(pnpm-workspace.yaml / pnpm-lock.yaml)解析失败 → 追加 [profile-yaml]
1001
1028
  // 标记,前端给「修本机文件」的步骤,不引导向插件仓库提 Issue
1002
1029
  result = annotateProfileYaml(result, options)