dsh-plugin 2.0.6 → 2.2.0
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/README.en.md +11 -11
- package/README.md +11 -11
- package/client/client.js +585 -328
- package/client/client.js.map +1 -1
- package/lib/http/diagnostics.js +2 -63
- package/lib/http/install.js +42 -11
- package/lib/http/settings.js +1 -1
- package/lib/services/host-version.js +72 -0
- package/lib/services/install/decode-output.js +23 -0
- package/lib/services/install/dsh-compat.js +105 -0
- package/lib/services/install/operation-log.js +61 -11
- package/lib/services/install/registry-fallback.js +89 -0
- package/lib/services/install/task-queue.js +95 -19
- package/lib/services/settings.js +1 -0
- package/lib/types/services/host-version.d.ts +10 -0
- package/lib/types/services/install/decode-output.d.ts +4 -0
- package/lib/types/services/install/dsh-compat.d.ts +19 -0
- package/lib/types/services/install/install-types.d.ts +4 -0
- package/lib/types/services/install/operation-log.d.ts +10 -4
- package/lib/types/services/install/registry-fallback.d.ts +18 -0
- package/lib/types/services/install/task-queue.d.ts +18 -0
- package/lib/types/services/settings.d.ts +3 -1
- package/package.json +1 -1
- package/src/client/components/PluginHubSection.tsx +11 -2
- package/src/client/components/modals/NotificationsModal.tsx +62 -5
- package/src/client/components/modals/modals.tsx +68 -5
- package/src/client/components/views/SettingsView.tsx +10 -0
- package/src/client/hooks/useCatalog.ts +10 -8
- package/src/client/hooks/useSettings.ts +4 -1
- package/src/client/hooks/useTaskQueue.ts +7 -1
- package/src/client/locales.ts +42 -0
- package/src/client/logic/failures.ts +86 -5
- package/src/client/logic/release-age.ts +40 -0
- package/src/client/logic/urls.ts +19 -4
- package/src/client/types.ts +13 -0
- package/src/server/http/diagnostics.ts +2 -59
- package/src/server/http/install.ts +46 -11
- package/src/server/http/settings.ts +1 -1
- package/src/server/services/host-version.ts +69 -0
- package/src/server/services/install/decode-output.ts +22 -0
- package/src/server/services/install/dsh-compat.ts +99 -0
- package/src/server/services/install/install-types.ts +4 -0
- package/src/server/services/install/operation-log.ts +58 -11
- package/src/server/services/install/registry-fallback.ts +89 -0
- package/src/server/services/install/task-queue.ts +107 -19
- package/src/server/services/settings.ts +4 -1
package/lib/http/diagnostics.js
CHANGED
|
@@ -5,75 +5,14 @@
|
|
|
5
5
|
*
|
|
6
6
|
* 诊断路由:流式连通性自检、代理校验,以及与任务模块 /env 路由共享的宿主环境快照。
|
|
7
7
|
*/
|
|
8
|
-
import { readFileSync } from 'node:fs';
|
|
9
8
|
import { homedir, release } from 'node:os';
|
|
10
|
-
import {
|
|
9
|
+
import { join } from 'node:path';
|
|
11
10
|
import { spawnSync } from 'node:child_process';
|
|
12
11
|
import { loadSettings } from '../services/settings.js';
|
|
12
|
+
import { hostDshVersion, toolVersion } from '../services/host-version.js';
|
|
13
13
|
import { gitLsRemote, probeUrl, systemProxy } from '../services/probe.js';
|
|
14
14
|
import { appendLog } from '../services/log.js';
|
|
15
15
|
import { readJsonBody, requireTrustedPost, sendJson } from './shared.js';
|
|
16
|
-
/** 宿主 dsh CLI 版本:优先直接跑 `dsh --version`(与 pnpm/npm/git 同款同步探测,1500ms 超时兜底,
|
|
17
|
-
* 绝不挂起);拿不到再退回从入口 `process.argv[1]` 向上找 package.json(dsh 不在 PATH 时兜底)。 */
|
|
18
|
-
function hostDshVersion() {
|
|
19
|
-
const viaCli = toolVersion('dsh', ['--version']);
|
|
20
|
-
if (viaCli)
|
|
21
|
-
return viaCli;
|
|
22
|
-
const entry = process.argv[1];
|
|
23
|
-
if (entry === undefined)
|
|
24
|
-
return null;
|
|
25
|
-
let dir = dirname(resolve(entry));
|
|
26
|
-
for (let depth = 0; depth < 4; depth++) {
|
|
27
|
-
try {
|
|
28
|
-
const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
|
|
29
|
-
const name = manifest.name ?? '';
|
|
30
|
-
if (typeof manifest.version === 'string' && (name === 'dsh' || name.includes('dsh') || name.includes('harness'))) {
|
|
31
|
-
return manifest.version;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
catch {
|
|
35
|
-
/* 不是包根,继续向上查找 */
|
|
36
|
-
}
|
|
37
|
-
const parent = dirname(dir);
|
|
38
|
-
if (parent === dir)
|
|
39
|
-
break;
|
|
40
|
-
dir = parent;
|
|
41
|
-
}
|
|
42
|
-
return null;
|
|
43
|
-
}
|
|
44
|
-
/** 从命令输出里抽出版本号本体:pnpm/npm 直接打印 `11.7.0`,而 `git --version` 打印
|
|
45
|
-
* `git version 2.55.0.windows.5` —— 原样取首行会让 issue 正文(统一再加 `v` 前缀)拼成
|
|
46
|
-
* `vgit version 2.55.0.windows.5`。只取版本号,顺带兼容 `v1.2.3` 这类自带前缀的输出。 */
|
|
47
|
-
function versionOf(output) {
|
|
48
|
-
const first = output.trim().split(/\r?\n/)[0]?.trim() ?? '';
|
|
49
|
-
if (!first)
|
|
50
|
-
return null;
|
|
51
|
-
const match = /\d+\.\d+[0-9A-Za-z.\-+]*/.exec(first);
|
|
52
|
-
return match === null ? null : match[0];
|
|
53
|
-
}
|
|
54
|
-
/** 探测命令行工具的版本号(pnpm --version / npm --version / git --version):
|
|
55
|
-
* 未安装 / 不在 PATH / 超时一律返回 null —— issue 环境快照如实写 unknown,绝不挂起。
|
|
56
|
-
* (同步探测仅用于启动期/请求期的低频率 /env 快照,安装通道不经过这里。)
|
|
57
|
-
*
|
|
58
|
-
* Windows 上 pnpm/npm 是 .cmd shim,不带 shell 的 spawnSync 起不来 —— 上报的 issue 里
|
|
59
|
-
* 因此长期是 `pnpm: unknown` / `npm: unknown`,而 pnpm 版本恰恰是 allowBuilds 那类问题的
|
|
60
|
-
* 关键信息(门禁行为随版本变)。故 win32 走 shell 让 cmd 解析 shim;参数是固定的
|
|
61
|
-
* `--version`,没有注入面。 */
|
|
62
|
-
function toolVersion(cmd, args) {
|
|
63
|
-
try {
|
|
64
|
-
const r = spawnSync(cmd, args, {
|
|
65
|
-
timeout: 1500,
|
|
66
|
-
encoding: 'utf8',
|
|
67
|
-
shell: process.platform === 'win32',
|
|
68
|
-
});
|
|
69
|
-
if (r.error || r.status !== 0)
|
|
70
|
-
return null;
|
|
71
|
-
return versionOf(r.stdout ?? '');
|
|
72
|
-
}
|
|
73
|
-
catch {
|
|
74
|
-
return null;
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
16
|
/** 本机实际生效的 npm registry:诊断与安装同口径 —— 设置里留空时读用户 npm 配置
|
|
78
17
|
* (含 profile 目录的 .npmrc),而不是硬探官方源,避免「能装但诊断说源不通」。
|
|
79
18
|
* 读不到(npm 缺失/超时/输出异常)时回退官方源。 */
|
package/lib/http/install.js
CHANGED
|
@@ -11,10 +11,12 @@ import { recordResolvedNpmPackage } from '../services/profile/installed-versions
|
|
|
11
11
|
import { isDshNpmPackageForRepo } from '../services/install/npm-resolve.js';
|
|
12
12
|
import { probeUrl, systemProxy } from '../services/probe.js';
|
|
13
13
|
import { loadSettings } from '../services/settings.js';
|
|
14
|
+
import { registryCandidates, registryHost, userNpmRegistry } from '../services/install/registry-fallback.js';
|
|
15
|
+
import { profileDirectory } from '../services/profile/profile.js';
|
|
14
16
|
import { readInstalled } from './catalog.js';
|
|
15
17
|
import { COMMAND_TIMEOUT_MS, readJsonBody, requireTrustedPost, sendJson } from './shared.js';
|
|
16
18
|
/** 安装/卸载 CLI 的子进程环境:透传宿主环境并注入 HTTP(S) 代理,
|
|
17
|
-
* 代理来源与诊断一致(设置代理 → 系统代理 → 宿主 env
|
|
19
|
+
* 代理来源与诊断一致(设置代理 → 系统代理 → 宿主 env);registry 由任务队列按候选源逐次注入。 */
|
|
18
20
|
function mutationEnv(settings) {
|
|
19
21
|
const env = { ...process.env, CI: 'true' };
|
|
20
22
|
const proxy = settings.proxy !== '' ? settings.proxy : (systemProxy() ?? '');
|
|
@@ -63,6 +65,13 @@ export function registerInstallRoutes(webServer, profile, loader) {
|
|
|
63
65
|
const lang = typeof body === 'object' && body !== null && body.lang === 'en'
|
|
64
66
|
? 'en'
|
|
65
67
|
: 'zh';
|
|
68
|
+
// 代理口径与诊断/安装通道一致:设置里的代理 → 系统代理 → 环境变量 → 直连。
|
|
69
|
+
const effectiveProxy = settings.proxy !== ''
|
|
70
|
+
? settings.proxy
|
|
71
|
+
: (systemProxy() ?? process.env.HTTPS_PROXY ?? process.env.https_proxy ?? '');
|
|
72
|
+
// 安装用的 npm 源:设置里显式选择优先;未配置时读本机 npm 配置(与诊断同口径)。
|
|
73
|
+
// 只有公共源允许失败后换源;自定义/内网源保持单一候选,避免私有包名泄露给公共源。
|
|
74
|
+
const registries = registryCandidates(settings.npmRegistry, settings.npmRegistry === '' ? userNpmRegistry(profileDirectory(profile)) : '', settings.registryFallback);
|
|
66
75
|
// 全局 npm 安装(`npm install -g <pkgs>`):不进任何 profile、无需宿主重启。
|
|
67
76
|
// 包列表优先取 body.globalNpm,兜底从 rawRepo 解析,保证直接调 API 传完整命令也能识别。
|
|
68
77
|
const bodyGlobalNpm = Array.isArray(body.globalNpm)
|
|
@@ -94,6 +103,8 @@ export function registerInstallRoutes(webServer, profile, loader) {
|
|
|
94
103
|
globalNpm,
|
|
95
104
|
timeoutMs: COMMAND_TIMEOUT_MS,
|
|
96
105
|
env: mutationEnv(settings),
|
|
106
|
+
// 候选源(首选在前):任务内遇到传输层失败时按顺序换源重试
|
|
107
|
+
registries,
|
|
97
108
|
// 全局 npm 安装由 NPM 包卡片/官方命令发起:日志体现入口渠道
|
|
98
109
|
installChannel: installChannel ?? 'npm',
|
|
99
110
|
});
|
|
@@ -106,12 +117,17 @@ export function registerInstallRoutes(webServer, profile, loader) {
|
|
|
106
117
|
return;
|
|
107
118
|
}
|
|
108
119
|
const target = rawRepo;
|
|
120
|
+
// 钉住精确版本(Hub 自我更新):最新版可能仍在 pnpm 供应链门禁内,
|
|
121
|
+
// 客户端提示的是「已过门禁」的版本,服务端必须按该版本安装而不是 @latest。
|
|
122
|
+
const pinVersion = typeof body.version === 'string'
|
|
123
|
+
? body.version.trim()
|
|
124
|
+
: '';
|
|
125
|
+
if (pinVersion !== '' && !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?$/.test(pinVersion)) {
|
|
126
|
+
sendJson(response, 400, { error: 'invalid version' });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
109
129
|
// 已尝试的安装方式(供失败提 Issue 预填):保持为空
|
|
110
130
|
const attempts = [];
|
|
111
|
-
// 代理口径与诊断/安装通道一致:设置里的代理 → 系统代理 → 环境变量 → 直连。
|
|
112
|
-
const effectiveProxy = settings.proxy !== ''
|
|
113
|
-
? settings.proxy
|
|
114
|
-
: (systemProxy() ?? process.env.HTTPS_PROXY ?? process.env.https_proxy ?? '');
|
|
115
131
|
// 目录插件安装:目录数据可能滞后于打包拆分,确认目录下发的 npm 目标确为该仓库的 DSH 包,
|
|
116
132
|
// 命中则记录 repo → 包名映射,客户端据此把依赖 key 匹配回仓库、列表立即显示「已安装」。
|
|
117
133
|
const catalogRepo = source === 'catalog' ? githubRepoOf(displayRepo) : null;
|
|
@@ -137,18 +153,30 @@ export function registerInstallRoutes(webServer, profile, loader) {
|
|
|
137
153
|
return;
|
|
138
154
|
}
|
|
139
155
|
// 安装前网络探测:探 registry 根而非具体包(404 是「包不存在」而非网络不通);仅新安装执行,更新跳过。
|
|
140
|
-
//
|
|
156
|
+
// 首选源不通时按候选顺序继续探下一个,能通的那个提到首位供实际安装使用。
|
|
157
|
+
// 全部不通则 400 拦下并打 [network] 标记,客户端据此提示网络问题,而非插件问题。
|
|
158
|
+
let ordered = registries;
|
|
141
159
|
if (already === null) {
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
160
|
+
const tried = [];
|
|
161
|
+
let chosen = null;
|
|
162
|
+
for (const candidate of registries) {
|
|
163
|
+
tried.push(candidate);
|
|
164
|
+
const net = await probeUrl(`${candidate}/`, effectiveProxy, 6000);
|
|
165
|
+
if (net.ok) {
|
|
166
|
+
chosen = candidate;
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (chosen === null) {
|
|
145
171
|
// 错误消息按界面语言输出,让用户看清是网络问题而非插件问题
|
|
172
|
+
const hosts = tried.map(registryHost).join(' → ');
|
|
146
173
|
const error = lang === 'zh'
|
|
147
|
-
? `[network] 安装已中止:安装前无法连接到 npm 源(${
|
|
148
|
-
: `[network] install aborted: cannot reach the npm registry (${
|
|
174
|
+
? `[network] 安装已中止:安装前无法连接到 npm 源(${hosts})—— 您的网络似乎不通或被拦截(断网 / DNS 解析失败 / 防火墙 / 代理配置问题)。这不是插件本身的问题,请检查网络连接或代理设置,运行「系统诊断」检测各通道连通性后重试。`
|
|
175
|
+
: `[network] install aborted: cannot reach the npm registry (${hosts}) before install — your network connection appears to be down or blocked (DNS / proxy / firewall). This is not a problem with the plugin itself. Check your connection or proxy settings, run the connectivity diagnostic, then retry.`;
|
|
149
176
|
sendJson(response, 400, { error, attempts });
|
|
150
177
|
return;
|
|
151
178
|
}
|
|
179
|
+
ordered = [chosen, ...registries.filter((r) => r !== chosen)];
|
|
152
180
|
}
|
|
153
181
|
// 入队前复检:网络探测的 await 期间并发请求可能已通过上方检查,而此处到 startPluginMutation
|
|
154
182
|
// 之间无 await,JS 单线程保证同一目标至多一个任务,杜绝双击/并发双任务。
|
|
@@ -171,6 +199,9 @@ export function registerInstallRoutes(webServer, profile, loader) {
|
|
|
171
199
|
loader,
|
|
172
200
|
timeoutMs: COMMAND_TIMEOUT_MS,
|
|
173
201
|
env: mutationEnv(settings),
|
|
202
|
+
// 候选源(首选在前):任务内遇到传输层失败时按顺序换源重试
|
|
203
|
+
registries: ordered,
|
|
204
|
+
installSpec: pinVersion !== '' ? `${target}@${pinVersion}` : undefined,
|
|
174
205
|
// 自定义安装入口渠道:日志/报错据此溯源
|
|
175
206
|
installChannel,
|
|
176
207
|
});
|
package/lib/http/settings.js
CHANGED
|
@@ -28,7 +28,7 @@ export function registerSettingsRoutes(webServer, profile) {
|
|
|
28
28
|
// 白名单字段:只接受已知设置项,防客户端把任意键写进文件
|
|
29
29
|
const patch = {};
|
|
30
30
|
const source = typeof body === 'object' && body !== null ? body : {};
|
|
31
|
-
for (const key of ['checkUpdatesOnStart', 'enableNpmInstall', 'npmRegistry', 'proxy', 'logPath']) {
|
|
31
|
+
for (const key of ['checkUpdatesOnStart', 'enableNpmInstall', 'npmRegistry', 'registryFallback', 'proxy', 'logPath']) {
|
|
32
32
|
if (source[key] !== undefined)
|
|
33
33
|
patch[key] = source[key];
|
|
34
34
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
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」判定,
|
|
7
|
+
* pnpm/npm/git 只用于诊断快照。同步探测 + 1500ms 超时兜底,拿不到一律 null,绝不挂起。
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
10
|
+
import { dirname, join, resolve } from 'node:path';
|
|
11
|
+
import { spawnSync } from 'node:child_process';
|
|
12
|
+
/** 从命令输出里抽出版本号本体:pnpm/npm 直接打印 `11.7.0`,而 `git --version` 打印
|
|
13
|
+
* `git version 2.55.0.windows.5` —— 原样取首行会让 issue 正文拼成 `vgit version …`。
|
|
14
|
+
* 只取版本号,顺带兼容 `v1.2.3` 这类自带前缀的输出。 */
|
|
15
|
+
export function versionOf(output) {
|
|
16
|
+
const first = output.trim().split(/\r?\n/)[0]?.trim() ?? '';
|
|
17
|
+
if (!first)
|
|
18
|
+
return null;
|
|
19
|
+
const match = /\d+\.\d+[0-9A-Za-z.\-+]*/.exec(first);
|
|
20
|
+
return match === null ? null : match[0];
|
|
21
|
+
}
|
|
22
|
+
/** 探测命令行工具版本:未安装 / 不在 PATH / 超时一律 null。
|
|
23
|
+
* Windows 上 pnpm/npm 是 .cmd shim,不走 shell 起不来;参数是固定的 `--version`,没有注入面。 */
|
|
24
|
+
export function toolVersion(cmd, args) {
|
|
25
|
+
try {
|
|
26
|
+
const r = spawnSync(cmd, args, {
|
|
27
|
+
timeout: 1500,
|
|
28
|
+
encoding: 'utf8',
|
|
29
|
+
shell: process.platform === 'win32',
|
|
30
|
+
});
|
|
31
|
+
if (r.error || r.status !== 0)
|
|
32
|
+
return null;
|
|
33
|
+
return versionOf(r.stdout ?? '');
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
let cachedDshVersion = null;
|
|
40
|
+
/** 宿主 dsh 版本:优先 `dsh --version`;拿不到再从入口 `process.argv[1]` 向上找 package.json
|
|
41
|
+
* (dsh 不在 PATH 时兜底)。探测成功即缓存,失败不缓存以便下次重试。 */
|
|
42
|
+
export function hostDshVersion() {
|
|
43
|
+
if (cachedDshVersion !== null)
|
|
44
|
+
return cachedDshVersion;
|
|
45
|
+
const viaCli = toolVersion('dsh', ['--version']);
|
|
46
|
+
if (viaCli !== null) {
|
|
47
|
+
cachedDshVersion = viaCli;
|
|
48
|
+
return viaCli;
|
|
49
|
+
}
|
|
50
|
+
const entry = process.argv[1];
|
|
51
|
+
if (entry === undefined)
|
|
52
|
+
return null;
|
|
53
|
+
let dir = dirname(resolve(entry));
|
|
54
|
+
for (let depth = 0; depth < 4; depth++) {
|
|
55
|
+
try {
|
|
56
|
+
const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
|
|
57
|
+
const name = manifest.name ?? '';
|
|
58
|
+
if (typeof manifest.version === 'string' && (name === 'dsh' || name.includes('dsh') || name.includes('harness'))) {
|
|
59
|
+
cachedDshVersion = manifest.version;
|
|
60
|
+
return cachedDshVersion;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
/* 不是包根,继续向上查找 */
|
|
65
|
+
}
|
|
66
|
+
const parent = dirname(dir);
|
|
67
|
+
if (parent === dir)
|
|
68
|
+
break;
|
|
69
|
+
dir = parent;
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 子进程输出解码:默认 UTF-8;出现替换字符(中文 Windows 的 GBK 输出)时改用 GBK 重解。
|
|
3
|
+
* 用 StringDecoder 跨 chunk 缓冲,避免 UTF-8 多字节字符被 chunk 边界切断后误判为 GBK。
|
|
4
|
+
*/
|
|
5
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
6
|
+
export class OutputDecoder {
|
|
7
|
+
utf8 = new StringDecoder('utf8');
|
|
8
|
+
push(chunk) {
|
|
9
|
+
const text = this.utf8.write(chunk);
|
|
10
|
+
if (!text.includes('\uFFFD'))
|
|
11
|
+
return text;
|
|
12
|
+
try {
|
|
13
|
+
const gbk = new TextDecoder('gbk').decode(chunk);
|
|
14
|
+
// GBK 能完整解出且含中文才采用:纯 ASCII 或仍有替换字符时保持 UTF-8 结果
|
|
15
|
+
if (!gbk.includes('\uFFFD') && /[\u4e00-\u9fff]/.test(gbk))
|
|
16
|
+
return gbk;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
// 无完整 ICU(TextDecoder 不支持 gbk)时退回 UTF-8 结果
|
|
20
|
+
}
|
|
21
|
+
return text;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
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 版本高于当前宿主」判定:安装失败后读一次 npm 元数据,取
|
|
7
|
+
* peerDependencies(@deepseek-ai/dsh-*)与 engines.dsh 声明的最低版本里最高的那个,
|
|
8
|
+
* 与宿主版本比较;宿主更低就产出 [dsh-too-old] 标记 —— 前端据此提示「先升级 DSH」,
|
|
9
|
+
* 不再把这类失败归成「未分类」丢给用户或插件作者。
|
|
10
|
+
*
|
|
11
|
+
* 只在失败路径调用:peer 不满足本身通常只是警告,成功安装时不该出现这个提示;
|
|
12
|
+
* 任何网络/解析异常都静默返回 null,不改变原有失败处理。
|
|
13
|
+
*/
|
|
14
|
+
import { hostDshVersion } from '../host-version.js';
|
|
15
|
+
/** 从安装目标里取 npm 包名:`@scope/pkg@1.2.3` → `@scope/pkg`。
|
|
16
|
+
* git / URL / 本地路径等非 npm 目标返回 null。 */
|
|
17
|
+
export function npmNameOf(target) {
|
|
18
|
+
const raw = target.trim();
|
|
19
|
+
if (raw === '')
|
|
20
|
+
return null;
|
|
21
|
+
if (/^(?:github:|git\+|https?:|file:|link:|\.{0,2}[\\/])/i.test(raw))
|
|
22
|
+
return null;
|
|
23
|
+
// owner/repo(非 scope):git 目标的另一种写法,不是包名
|
|
24
|
+
if (!raw.startsWith('@') && /^[^\s@]+\/[^\s]+$/.test(raw))
|
|
25
|
+
return null;
|
|
26
|
+
const at = raw.lastIndexOf('@');
|
|
27
|
+
const name = at > 0 ? raw.slice(0, at) : raw;
|
|
28
|
+
return /^(@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*$/i.test(name) ? name : null;
|
|
29
|
+
}
|
|
30
|
+
/** `0.2.1-rc.1` / `^0.2.1` / `>=0.2.0 <0.3.0` → [0,2,1];解析不出返回 null。
|
|
31
|
+
* 只取版本三段:DSH 的 prerelease 序号(rc.1)不参与「太低」判断,避免比较误差。 */
|
|
32
|
+
export function versionTriple(raw) {
|
|
33
|
+
const m = /(\d+)\.(\d+)\.(\d+)/.exec(raw);
|
|
34
|
+
return m === null ? null : [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
35
|
+
}
|
|
36
|
+
function compareTriple(a, b) {
|
|
37
|
+
for (let i = 0; i < 3; i++) {
|
|
38
|
+
if (a[i] !== b[i])
|
|
39
|
+
return a[i] - b[i];
|
|
40
|
+
}
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
/** 插件声明的最低 DSH 版本:engines.dsh 与 peerDependencies 里 @deepseek-ai/dsh-* 的下限取最大。
|
|
44
|
+
* 返回原始声明文本(如 `^0.2.1-rc.1`)供提示直接展示;没有声明返回 null。 */
|
|
45
|
+
export function dshFloorOf(manifest) {
|
|
46
|
+
const m = manifest;
|
|
47
|
+
const ranges = [];
|
|
48
|
+
const engineDsh = m?.engines?.dsh;
|
|
49
|
+
if (typeof engineDsh === 'string')
|
|
50
|
+
ranges.push(engineDsh);
|
|
51
|
+
for (const [name, range] of Object.entries(m?.peerDependencies ?? {})) {
|
|
52
|
+
if (/^@deepseek-ai\/dsh(?:-|$)/.test(name) && typeof range === 'string')
|
|
53
|
+
ranges.push(range);
|
|
54
|
+
}
|
|
55
|
+
let floor = null;
|
|
56
|
+
let floorTriple = null;
|
|
57
|
+
for (const range of ranges) {
|
|
58
|
+
const triple = versionTriple(range);
|
|
59
|
+
if (triple === null)
|
|
60
|
+
continue;
|
|
61
|
+
if (floorTriple === null || compareTriple(triple, floorTriple) > 0) {
|
|
62
|
+
floor = range;
|
|
63
|
+
floorTriple = triple;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return floor;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* 产出「插件要求更高的 DSH」标记行;没有声明、读不到元数据、宿主版本未知或已满足时返回 null。
|
|
70
|
+
* 用版本级 manifest(`/<pkg>/latest`):packument 的精简形态不带 peerDependencies。
|
|
71
|
+
*/
|
|
72
|
+
export async function dshTooOldMarker(target, opts = {}) {
|
|
73
|
+
const name = npmNameOf(target);
|
|
74
|
+
if (name === null)
|
|
75
|
+
return null;
|
|
76
|
+
const current = opts.currentVersion ?? hostDshVersion();
|
|
77
|
+
if (current === null)
|
|
78
|
+
return null;
|
|
79
|
+
const currentTriple = versionTriple(current);
|
|
80
|
+
if (currentTriple === null)
|
|
81
|
+
return null;
|
|
82
|
+
const base = (opts.registry ?? 'https://registry.npmjs.org').replace(/\/+$/, '');
|
|
83
|
+
// scope 包的路径要编码:`@scope/name` → `%40scope%2Fname`
|
|
84
|
+
const url = `${base}/${name.replace('@', '%40').replace('/', '%2F')}/latest`;
|
|
85
|
+
let manifest;
|
|
86
|
+
try {
|
|
87
|
+
const res = await (opts.fetchImpl ?? fetch)(url, {
|
|
88
|
+
headers: { accept: 'application/json' },
|
|
89
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? 4000),
|
|
90
|
+
});
|
|
91
|
+
if (!res.ok)
|
|
92
|
+
return null;
|
|
93
|
+
manifest = await res.json();
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
const floor = dshFloorOf(manifest);
|
|
99
|
+
const floorTriple = floor === null ? null : versionTriple(floor);
|
|
100
|
+
if (floor === null || floorTriple === null)
|
|
101
|
+
return null;
|
|
102
|
+
if (compareTriple(currentTriple, floorTriple) >= 0)
|
|
103
|
+
return null;
|
|
104
|
+
return `[dsh-too-old] ${name} requires DSH >= ${floor} (declared in engines / peerDependencies), but this host runs ${current} — upgrade DSH before installing`;
|
|
105
|
+
}
|
|
@@ -6,36 +6,75 @@
|
|
|
6
6
|
* 失败回捞:DSH 有时只把一行摘要(`dsh: plugin command failed; diagnostics: <path>`)
|
|
7
7
|
* 回显到控制台,真正的 pnpm 报错写进该路径的 operation 日志。这里从输出里解析出该路径
|
|
8
8
|
* 并读取其尾部,补进失败信息 —— 否则前端拿不到任何错误特征,只能把失败归为「未知」。
|
|
9
|
-
* 只允许读
|
|
9
|
+
* 只允许读 profile 目录下的 pnpm.log(dsh 自己的 operation 日志),
|
|
10
10
|
* 防止插件把任意路径打印进日志造成越权读取;任何异常都静默降级(返回 null),
|
|
11
11
|
* 不改变原有失败处理。
|
|
12
12
|
*/
|
|
13
13
|
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
14
|
-
import { join } from 'node:path';
|
|
14
|
+
import { basename, join, resolve, sep } from 'node:path';
|
|
15
15
|
import { CAPTURE_LIMIT_BYTES } from './install-types.js';
|
|
16
|
+
/** 去掉 ANSI 颜色码:宿主的摘要行可能带终端着色,直接匹配路径会失败。 */
|
|
17
|
+
function stripAnsi(text) {
|
|
18
|
+
return text.replace(/\x1b\[[0-9;]*m/g, '');
|
|
19
|
+
}
|
|
16
20
|
/**
|
|
17
21
|
* 从 DSH 输出里解析 operation 日志路径(`diagnostics: <path>`)。
|
|
18
|
-
*
|
|
19
|
-
*
|
|
22
|
+
* 去掉首尾引号与句末标点(不同版本/语言的摘要行尾部可能带 `。`、`.`、`)`);
|
|
23
|
+
* 相对路径原样返回,由读取方按 profile 目录解析。
|
|
20
24
|
*/
|
|
21
25
|
export function operationLogPathOf(output) {
|
|
22
26
|
for (const raw of output.split(/\r?\n/)) {
|
|
23
|
-
|
|
27
|
+
// 兼容纯文本与 JSON 包裹两种形态:`diagnostics: <path>` / `"diagnostics": "<path>"}`
|
|
28
|
+
const m = /"?diagnostics"?\s*:\s*(.+)$/i.exec(stripAnsi(raw).trim());
|
|
24
29
|
if (m === null)
|
|
25
30
|
continue;
|
|
26
|
-
|
|
27
|
-
|
|
31
|
+
let path = m[1]
|
|
32
|
+
.trim()
|
|
33
|
+
.replace(/[.,;:,。;、)\]}]+$/, '')
|
|
34
|
+
.trim();
|
|
35
|
+
// JSON 形态:引号包裹且反斜杠被转义过,按字符串字面量还原;普通文本里的引号直接剥掉
|
|
36
|
+
if (path.startsWith('"') && path.endsWith('"')) {
|
|
37
|
+
try {
|
|
38
|
+
const parsed = JSON.parse(path);
|
|
39
|
+
if (typeof parsed === 'string')
|
|
40
|
+
path = parsed;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
path = path.slice(1, -1);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
path = path.trim();
|
|
47
|
+
// URL 不是本地日志:避免把网络地址当文件读
|
|
48
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(path))
|
|
28
49
|
continue;
|
|
29
|
-
|
|
50
|
+
if (path.toLowerCase().endsWith('pnpm.log'))
|
|
51
|
+
return path;
|
|
30
52
|
}
|
|
31
53
|
return null;
|
|
32
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* 只允许读 profile 目录下的 pnpm.log(dsh 自己的操作日志):
|
|
57
|
+
* 绝对路径必须落在该目录内,相对路径按该目录解析。
|
|
58
|
+
* 未提供 profileDir 时退回旧口径(路径须含 .plugin-manager),防止插件打印任意路径造成越权读取。
|
|
59
|
+
*/
|
|
60
|
+
function allowedLogPath(raw, profileDir) {
|
|
61
|
+
const path = resolve(profileDir ?? '', raw);
|
|
62
|
+
if (basename(path).toLowerCase() !== 'pnpm.log')
|
|
63
|
+
return null;
|
|
64
|
+
if (profileDir === undefined)
|
|
65
|
+
return path.includes('.plugin-manager') ? path : null;
|
|
66
|
+
const root = resolve(profileDir);
|
|
67
|
+
return path.startsWith(`${root}${sep}`) ? path : null;
|
|
68
|
+
}
|
|
33
69
|
/**
|
|
34
70
|
* 回捞 operation 日志尾部,作为失败详情返回;拿不到(无路径 / 文件不存在 / 读失败 /
|
|
35
|
-
*
|
|
71
|
+
* 内容为空 / 路径越界)返回 null。尾部截到与子进程输出相同的上限。
|
|
36
72
|
*/
|
|
37
|
-
export function backfillOperationLog(output) {
|
|
38
|
-
const
|
|
73
|
+
export function backfillOperationLog(output, profileDir) {
|
|
74
|
+
const raw = operationLogPathOf(output);
|
|
75
|
+
if (raw === null)
|
|
76
|
+
return null;
|
|
77
|
+
const path = allowedLogPath(raw, profileDir);
|
|
39
78
|
if (path === null)
|
|
40
79
|
return null;
|
|
41
80
|
try {
|
|
@@ -48,6 +87,17 @@ export function backfillOperationLog(output) {
|
|
|
48
87
|
return null;
|
|
49
88
|
}
|
|
50
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* 输出里是否有 dsh 自己的内容 —— 只有 `[exit N]`、`[install-channel]`、`[timed out]`
|
|
92
|
+
* 这类本队列自己打的标记时返回 false。用于把「宿主什么都没回显」的失败显式留痕,
|
|
93
|
+
* 否则上报正文只剩退出码,无从排查。
|
|
94
|
+
*/
|
|
95
|
+
export function hasSubstantiveOutput(output) {
|
|
96
|
+
return output.split(/\r?\n/).some((raw) => {
|
|
97
|
+
const line = raw.trim();
|
|
98
|
+
return line !== '' && !/^\[(?:exit\b|timed out\]|install-channel\]|stale pipe\]|error\]|pnpm log\])/.test(line);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
51
101
|
/**
|
|
52
102
|
* 运行期抓取:宿主的 operation 日志只在命令运行期间存在,结束后可能被清理,
|
|
53
103
|
* 退出后再按路径读往往已经拿不到。这里在安装过程中轮询 profile 下最新的
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* npm 源降级:当前源是公共镜像时,传输层失败后按「当前源 → 官方 → npmmirror」
|
|
3
|
+
* 自动换源重试;自定义/内网源不降级,避免私有包名泄露给公共源。
|
|
4
|
+
*/
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
/** 允许自动切换的公共源:只放官方与大厂/高校镜像。 */
|
|
7
|
+
const PUBLIC_REGISTRIES = [
|
|
8
|
+
'https://registry.npmjs.org',
|
|
9
|
+
'https://registry.npmmirror.com',
|
|
10
|
+
'https://mirrors.cloud.tencent.com/npm',
|
|
11
|
+
'https://mirrors.tuna.tsinghua.edu.cn/npm',
|
|
12
|
+
];
|
|
13
|
+
const PUBLIC_HOSTS = new Set([
|
|
14
|
+
'registry.npmjs.org',
|
|
15
|
+
'registry.npmmirror.com',
|
|
16
|
+
'mirrors.cloud.tencent.com',
|
|
17
|
+
'mirrors.tuna.tsinghua.edu.cn',
|
|
18
|
+
]);
|
|
19
|
+
/** 去掉尾部斜杠,避免同一源因写法不同被当成两个候选。 */
|
|
20
|
+
export function normalizeRegistry(value) {
|
|
21
|
+
return value.trim().replace(/\/+$/, '');
|
|
22
|
+
}
|
|
23
|
+
/** 是否属于允许自动切换的公共源白名单。 */
|
|
24
|
+
export function isPublicRegistry(value) {
|
|
25
|
+
const v = normalizeRegistry(value);
|
|
26
|
+
if (!/^https?:\/\//i.test(v))
|
|
27
|
+
return false;
|
|
28
|
+
try {
|
|
29
|
+
return PUBLIC_HOSTS.has(new URL(v).hostname.toLowerCase());
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* 依次尝试的源:显式配置 → 本机 npm 配置 → 官方源。
|
|
37
|
+
* 只有公共源才追加后备(最多 3 个);关闭降级或源不在白名单时只返回它自己。
|
|
38
|
+
*/
|
|
39
|
+
export function registryCandidates(configured, userRegistry, enabled) {
|
|
40
|
+
const base = normalizeRegistry(configured) || normalizeRegistry(userRegistry) || PUBLIC_REGISTRIES[0];
|
|
41
|
+
if (!enabled || !isPublicRegistry(base))
|
|
42
|
+
return [base];
|
|
43
|
+
const out = [base];
|
|
44
|
+
for (const candidate of PUBLIC_REGISTRIES) {
|
|
45
|
+
if (out.length >= 3)
|
|
46
|
+
break;
|
|
47
|
+
if (!out.includes(candidate))
|
|
48
|
+
out.push(candidate);
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
/** 本机 npm 实际生效的源(含 profile 目录的 .npmrc);读不到返回空串。 */
|
|
53
|
+
export function userNpmRegistry(cwd) {
|
|
54
|
+
try {
|
|
55
|
+
const res = spawnSync('npm', ['config', 'get', 'registry'], {
|
|
56
|
+
cwd,
|
|
57
|
+
encoding: 'utf8',
|
|
58
|
+
timeout: 3000,
|
|
59
|
+
shell: process.platform === 'win32',
|
|
60
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
61
|
+
});
|
|
62
|
+
if (res.status !== 0)
|
|
63
|
+
return '';
|
|
64
|
+
const value = (res.stdout ?? '').trim();
|
|
65
|
+
return /^https?:\/\//i.test(value) ? normalizeRegistry(value) : '';
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return '';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* 传输层失败才值得换源:超时、连接重置、DNS、5xx。
|
|
73
|
+
* 404 / 版本不存在 / 鉴权失败必须保持原样 —— 换源只会掩盖作者侧问题或装到滞后版本。
|
|
74
|
+
*/
|
|
75
|
+
export function isTransportFailure(text) {
|
|
76
|
+
if (/\b(?:ERR_PNPM_FETCH_404|E404|ERR_PNPM_NO_MATCHING_VERSION|ETARGET|ERR_PNPM_FETCH_401|ERR_PNPM_FETCH_403|E401|E403)\b/i.test(text)) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
return /\b(?:ETIMEDOUT|ECONNRESET|ECONNREFUSED|EAI_AGAIN|ENOTFOUND|EHOSTUNREACH|ENETUNREACH|EPIPE)\b|socket hang up|getaddrinfo|network request failed|connect timeout|request to .+ failed|FetchError|ERR_PNPM_FETCH_5\d\d|ERR_PNPM_META_FETCH_FAIL/i.test(text);
|
|
80
|
+
}
|
|
81
|
+
/** 日志里展示用的源主机名(不打印可能带凭据的完整 URL)。 */
|
|
82
|
+
export function registryHost(value) {
|
|
83
|
+
try {
|
|
84
|
+
return new URL(value).host;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
}
|