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.
- package/client/client.js +316 -313
- package/client/client.js.map +1 -1
- package/lib/http/diagnostics.js +5 -0
- package/lib/http/install.js +1 -1
- package/lib/services/install/npm-resolve.js +10 -4
- package/lib/services/install/operation-log.js +18 -1
- package/lib/services/install/task-queue.js +31 -9
- package/lib/types/services/install/install-types.d.ts +7 -0
- package/lib/types/services/install/npm-resolve.d.ts +5 -3
- package/lib/types/services/install/operation-log.d.ts +7 -0
- package/lib/types/services/install/task-queue.d.ts +10 -0
- package/package.json +1 -1
- package/src/client/components/views/DiagnosticsView.tsx +1 -0
- package/src/client/logic/failures.ts +4 -3
- package/src/client/logic/urls.ts +4 -0
- package/src/client/types.ts +2 -0
- package/src/server/http/diagnostics.ts +5 -0
- package/src/server/http/install.ts +1 -1
- package/src/server/services/install/install-types.ts +2 -0
- package/src/server/services/install/npm-resolve.ts +11 -4
- package/src/server/services/install/operation-log.ts +20 -1
- package/src/server/services/install/task-queue.ts +35 -8
package/lib/http/diagnostics.js
CHANGED
|
@@ -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(),
|
package/lib/http/install.js
CHANGED
|
@@ -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
|
|
38
|
-
*
|
|
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}/${
|
|
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 !== '' &&
|
|
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,
|
|
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
|
-
|
|
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
|
|
13
|
-
*
|
|
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.
|
|
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
|
-
//
|
|
317
|
-
//
|
|
318
|
-
|
|
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。
|
package/src/client/logic/urls.ts
CHANGED
|
@@ -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}\``,
|
package/src/client/types.ts
CHANGED
|
@@ -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
|
|
47
|
-
*
|
|
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}/${
|
|
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 !== '' &&
|
|
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,
|
|
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
|
-
|
|
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)
|