dsh-plugin 1.4.5 → 1.4.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 +331 -302
- package/client/client.js.map +1 -1
- package/lib/http/routes.js +30 -8
- package/lib/services/probe.js +92 -63
- package/lib/types/services/probe.d.ts +25 -2
- package/package.json +1 -1
- package/src/client/components/modals/NotificationsModal.tsx +5 -0
- package/src/client/components/modals/modals.tsx +4 -0
- package/src/client/components/views/DiagnosticsView.tsx +11 -4
- package/src/client/components/views/SettingsView.tsx +14 -5
- package/src/client/locales.ts +12 -0
- package/src/client/logic/failures.ts +14 -1
- package/src/client/logic/urls.ts +4 -1
- package/src/server/http/routes.ts +29 -8
- package/src/server/services/probe.ts +113 -63
package/lib/http/routes.js
CHANGED
|
@@ -84,12 +84,13 @@ const CATALOG_CACHE_TTL_MS = 60 * 60 * 1000;
|
|
|
84
84
|
function catalogCacheFile(profile, key) {
|
|
85
85
|
return join(profileDirectory(profile), 'cache', `catalog-${key}.json`);
|
|
86
86
|
}
|
|
87
|
-
/** 读缓存:文件存在、JSON
|
|
88
|
-
|
|
87
|
+
/** 读缓存:文件存在、JSON 合法、未超过 maxAgeMs → 返回原始响应字符串;否则 null(视为 miss)。
|
|
88
|
+
* maxAgeMs 传 Infinity 即「任意年龄的缓存」,供实时拉取失败时兜底使用。 */
|
|
89
|
+
function readCatalogCache(file, maxAgeMs) {
|
|
89
90
|
try {
|
|
90
91
|
const entry = JSON.parse(readFileSync(file, 'utf8'));
|
|
91
92
|
if (entry && typeof entry.at === 'number' && typeof entry.body === 'string'
|
|
92
|
-
&& Date.now() - entry.at <
|
|
93
|
+
&& Date.now() - entry.at < maxAgeMs) {
|
|
93
94
|
return entry.body;
|
|
94
95
|
}
|
|
95
96
|
}
|
|
@@ -563,8 +564,16 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
|
|
|
563
564
|
? await gitLsRemote(c.url, c.proxy ?? effectiveProxy, 6000)
|
|
564
565
|
: await probe(c.url, c.proxy);
|
|
565
566
|
results.push({ key: c.key, ok: r.ok });
|
|
566
|
-
// git 行的 status 是退出码,客户端 git 行只显示 OK / 不可达,不显示成 HTTP
|
|
567
|
-
|
|
567
|
+
// git 行的 status 是退出码,客户端 git 行只显示 OK / 不可达,不显示成 HTTP 码。
|
|
568
|
+
// reason 透出失败细分原因(如 Windows 证书吊销受阻),客户端据此给对症提示而非笼统「不可达」。
|
|
569
|
+
send({
|
|
570
|
+
type: r.ok ? 'ok' : 'fail',
|
|
571
|
+
key: c.key,
|
|
572
|
+
display: c.display,
|
|
573
|
+
ms: r.ms,
|
|
574
|
+
status: c.git ? null : r.status,
|
|
575
|
+
reason: r.ok ? null : (r.reason ?? null),
|
|
576
|
+
});
|
|
568
577
|
}
|
|
569
578
|
send({ type: 'end', at: Date.now() });
|
|
570
579
|
response.end();
|
|
@@ -609,9 +618,10 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
|
|
|
609
618
|
sendJson(response, 200, { ok: false, ms: null, status: null, target, reason: 'empty' });
|
|
610
619
|
return;
|
|
611
620
|
}
|
|
612
|
-
// 探测走该代理本身(不是 effectiveProxy
|
|
621
|
+
// 探测走该代理本身(不是 effectiveProxy):验证的就是用户填的这个地址。
|
|
622
|
+
// reason 带上失败细分原因(如证书吊销受阻),设置页才能提示「不是代理地址的问题」。
|
|
613
623
|
const r = await probeUrl(target, proxy, 6000);
|
|
614
|
-
sendJson(response, 200, { ok: r.ok, ms: r.ms, status: r.status, target });
|
|
624
|
+
sendJson(response, 200, { ok: r.ok, ms: r.ms, status: r.status, target, reason: r.reason ?? null });
|
|
615
625
|
},
|
|
616
626
|
}),
|
|
617
627
|
webServer.register({
|
|
@@ -636,7 +646,7 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
|
|
|
636
646
|
: `https://api.dsh-plugin.org/plugins.${lang}.json`;
|
|
637
647
|
// 1 小时本地缓存:命中直接返回(跨重启生效,插件市场秒开),未命中才走 curl 拉远程
|
|
638
648
|
const cacheFile = catalogCacheFile(profile, isStats ? 'stats' : `plugins-${lang}`);
|
|
639
|
-
const cached = readCatalogCache(cacheFile);
|
|
649
|
+
const cached = readCatalogCache(cacheFile, CATALOG_CACHE_TTL_MS);
|
|
640
650
|
if (cached !== null) {
|
|
641
651
|
try {
|
|
642
652
|
sendJson(response, 200, JSON.parse(cached));
|
|
@@ -648,6 +658,18 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
|
|
|
648
658
|
}
|
|
649
659
|
const r = await fetchViaCurl(target, proxy, 20000);
|
|
650
660
|
if (!r.ok || r.body === '') {
|
|
661
|
+
// 实时拉取失败:退回任意年龄的本地缓存(上次成功数据),
|
|
662
|
+
// 避免证书吊销受阻 / 网络抖动时整个插件市场打不开 —— 数据可能过期,但好过空白。
|
|
663
|
+
const stale = readCatalogCache(cacheFile, Number.POSITIVE_INFINITY);
|
|
664
|
+
if (stale !== null) {
|
|
665
|
+
try {
|
|
666
|
+
sendJson(response, 200, JSON.parse(stale));
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
catch {
|
|
670
|
+
// 兜底缓存也损坏:落到下面的 502
|
|
671
|
+
}
|
|
672
|
+
}
|
|
651
673
|
sendJson(response, 502, { error: 'catalog fetch failed' });
|
|
652
674
|
return;
|
|
653
675
|
}
|
package/lib/services/probe.js
CHANGED
|
@@ -14,6 +14,38 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { spawn, spawnSync } from 'node:child_process';
|
|
16
16
|
const probeFail = () => ({ ok: false, ms: null, status: null });
|
|
17
|
+
/**
|
|
18
|
+
* Windows 的 curl 走 Schannel 做 TLS:握手阶段强制获取证书吊销信息(CRL/OCSP),
|
|
19
|
+
* 代理受限或吊销服务不可达时整个握手直接失败(CRYPT_E_NO_REVOCATION_CHECK),
|
|
20
|
+
* 表现为 `curl: (35) schannel: next InitializeSecurityContext failed` —— 而 Node/npm/
|
|
21
|
+
* 浏览器各有自己的 TLS 实现,照常可用,用户只会看到「插件市场打不开」。
|
|
22
|
+
* --ssl-revoke-best-effort(curl ≥ 7.70)把吊销检查降级为尽力而为,握手不再被它拦死;
|
|
23
|
+
* 其它平台后端(SecureTransport / OpenSSL)没有这个强制行为,不加参数。
|
|
24
|
+
* 运行时读 process.platform(而非模块级常量),便于测试覆盖两条分支。
|
|
25
|
+
*/
|
|
26
|
+
export function curlTlsArgs() {
|
|
27
|
+
return process.platform === 'win32' ? ['--ssl-revoke-best-effort'] : [];
|
|
28
|
+
}
|
|
29
|
+
/** 本机 curl 太老、不认识 --ssl-revoke-best-effort(curl < 7.70):退出码 2 + stderr 指向该参数。 */
|
|
30
|
+
export function curlRevokeFlagUnsupported(code, stderr) {
|
|
31
|
+
return code === 2 && /ssl-revoke-best-effort/i.test(stderr) && /unknown|unrecognized/i.test(stderr);
|
|
32
|
+
}
|
|
33
|
+
/** stderr 是否指向 Schannel 取不到证书吊销信息(据此归因为环境 TLS 问题,而非网络不通)。 */
|
|
34
|
+
export function tlsRevocationBlocked(stderr) {
|
|
35
|
+
return /CRYPT_E_NO_REVOCATION_CHECK|CRYPT_E_REVOCATION_OFFLINE|0x80092012/i.test(stderr);
|
|
36
|
+
}
|
|
37
|
+
/** 探测/安装同一套代理口径:curl 与 git 子进程都读 HTTP(S)_PROXY 环境变量。 */
|
|
38
|
+
function proxyEnv(proxy) {
|
|
39
|
+
if (proxy === '')
|
|
40
|
+
return { ...process.env };
|
|
41
|
+
return {
|
|
42
|
+
...process.env,
|
|
43
|
+
HTTP_PROXY: proxy,
|
|
44
|
+
HTTPS_PROXY: proxy,
|
|
45
|
+
http_proxy: proxy,
|
|
46
|
+
https_proxy: proxy,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
17
49
|
/** macOS 系统代理:scutil --proxy 输出里的 HTTP(S) 代理;未开启返回 null。 */
|
|
18
50
|
function macSystemProxy() {
|
|
19
51
|
try {
|
|
@@ -69,46 +101,65 @@ export function systemProxy() {
|
|
|
69
101
|
return winSystemProxy();
|
|
70
102
|
return null;
|
|
71
103
|
}
|
|
72
|
-
/**
|
|
73
|
-
|
|
74
|
-
* curl 读 HTTP_PROXY/HTTPS_PROXY 是原生行为,与 pnpm/git 安装通道同一套代理口径;
|
|
75
|
-
* 失败(curl 不存在 / 连接失败 / 超时)都归为不可达,全程不抛错。
|
|
76
|
-
*/
|
|
77
|
-
function curlProbe(url, proxy, timeoutMs) {
|
|
104
|
+
/** 跑一次 curl 子进程,收集 stdout 与 stderr(stderr 是识别 Schannel 类 TLS 失败的唯一线索)。 */
|
|
105
|
+
function runCurl(args, env, timeoutMs) {
|
|
78
106
|
return new Promise((resolve) => {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (proxy !== '') {
|
|
82
|
-
env.HTTP_PROXY = proxy;
|
|
83
|
-
env.HTTPS_PROXY = proxy;
|
|
84
|
-
env.http_proxy = proxy;
|
|
85
|
-
env.https_proxy = proxy;
|
|
86
|
-
}
|
|
87
|
-
const discard = process.platform === 'win32' ? 'NUL' : '/dev/null';
|
|
88
|
-
const child = spawn('curl', [
|
|
89
|
-
'-s', '-o', discard, '-w', '%{http_code}',
|
|
90
|
-
'--max-time', String(Math.max(1, Math.round(timeoutMs / 1000))),
|
|
91
|
-
url,
|
|
92
|
-
], { env });
|
|
93
|
-
let code = '';
|
|
94
|
-
child.stdout.on('data', (c) => { code += c.toString(); });
|
|
107
|
+
let stdout = '';
|
|
108
|
+
let stderr = '';
|
|
95
109
|
let done = false;
|
|
96
|
-
const finish = (
|
|
110
|
+
const finish = (code) => {
|
|
97
111
|
if (done)
|
|
98
112
|
return;
|
|
99
113
|
done = true;
|
|
100
114
|
clearTimeout(timer);
|
|
101
|
-
resolve(
|
|
115
|
+
resolve({ code, stdout, stderr });
|
|
102
116
|
};
|
|
103
|
-
child
|
|
104
|
-
child.on('
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
117
|
+
const child = spawn('curl', args, { env });
|
|
118
|
+
child.stdout.on('data', (c) => { stdout += c.toString(); });
|
|
119
|
+
child.stderr.on('data', (c) => { stderr += c.toString(); });
|
|
120
|
+
child.on('error', () => finish(null));
|
|
121
|
+
child.on('close', (code) => finish(code));
|
|
108
122
|
// 超时兜底:curl --max-time 到点自行退出,此处再设一个进程级超时防止悬挂
|
|
109
|
-
const timer = setTimeout(() => { child.kill(); finish(
|
|
123
|
+
const timer = setTimeout(() => { child.kill(); finish(null); }, timeoutMs + 1000);
|
|
110
124
|
});
|
|
111
125
|
}
|
|
126
|
+
/** 带 TLS 兜底的 curl 调用:先按平台参数跑(Windows 带吊销检查降级),
|
|
127
|
+
* 若本机 curl 不认这个参数(curl < 7.70)则去掉参数重跑一次,保证老版本不因此直接失败。 */
|
|
128
|
+
async function curlWithTlsFallback(args, env, timeoutMs) {
|
|
129
|
+
const tlsArgs = curlTlsArgs();
|
|
130
|
+
const first = await runCurl([...tlsArgs, ...args], env, timeoutMs);
|
|
131
|
+
if (tlsArgs.length === 0 || !curlRevokeFlagUnsupported(first.code, first.stderr))
|
|
132
|
+
return first;
|
|
133
|
+
return runCurl(args, env, timeoutMs);
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* curl 子进程探测:spawn curl -s -w '%{http_code}' 并注入代理 env。
|
|
137
|
+
* curl 读 HTTP_PROXY/HTTPS_PROXY 是原生行为,与 pnpm/git 安装通道同一套代理口径;
|
|
138
|
+
* 失败(curl 不存在 / 连接失败 / 超时)都归为不可达,全程不抛错。
|
|
139
|
+
* 证书吊销检查受阻(Windows Schannel)另记 reason,供诊断给出对症提示。
|
|
140
|
+
*/
|
|
141
|
+
async function curlProbe(url, proxy, timeoutMs) {
|
|
142
|
+
const started = Date.now();
|
|
143
|
+
const discard = process.platform === 'win32' ? 'NUL' : '/dev/null';
|
|
144
|
+
// -sS(而非仅 -s):silent 会把连接/TLS 错误一并吞掉,而识别证书吊销受阻正需要 stderr;
|
|
145
|
+
// 子进程 stderr 由我们收集、不会回显给用户,所以补 -S 不产生噪音。
|
|
146
|
+
const r = await curlWithTlsFallback([
|
|
147
|
+
'-sS', '-o', discard, '-w', '%{http_code}',
|
|
148
|
+
'--max-time', String(Math.max(1, Math.round(timeoutMs / 1000))),
|
|
149
|
+
url,
|
|
150
|
+
], proxyEnv(proxy), timeoutMs);
|
|
151
|
+
// 子进程没起来 / 超时被杀:没有任何可诊断信息,按不可达处理
|
|
152
|
+
if (r.code === null)
|
|
153
|
+
return probeFail();
|
|
154
|
+
const status = /^\d+$/.test(r.stdout.trim()) ? Number(r.stdout.trim()) : null;
|
|
155
|
+
const ok = status !== null && status >= 100 && status < 400;
|
|
156
|
+
return {
|
|
157
|
+
ok,
|
|
158
|
+
ms: Date.now() - started,
|
|
159
|
+
status,
|
|
160
|
+
reason: !ok && tlsRevocationBlocked(r.stderr) ? 'tlsRevocation' : null,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
112
163
|
/**
|
|
113
164
|
* 探测一个 HTTPS 目标:proxy 非空注入给 curl 子进程走代理,否则直连。
|
|
114
165
|
* 供系统诊断 /diagnostics 使用;目标 URL 非法返回不可达。
|
|
@@ -136,13 +187,7 @@ export function probeUrl(url, proxy, timeoutMs) {
|
|
|
136
187
|
export function gitLsRemote(url, proxy, timeoutMs) {
|
|
137
188
|
return new Promise((resolve) => {
|
|
138
189
|
const started = Date.now();
|
|
139
|
-
const env = { ...
|
|
140
|
-
if (proxy !== '') {
|
|
141
|
-
env.HTTP_PROXY = proxy;
|
|
142
|
-
env.HTTPS_PROXY = proxy;
|
|
143
|
-
env.http_proxy = proxy;
|
|
144
|
-
env.https_proxy = proxy;
|
|
145
|
-
}
|
|
190
|
+
const env = { ...proxyEnv(proxy), GIT_TERMINAL_PROMPT: '0' };
|
|
146
191
|
const child = spawn('git', ['ls-remote', '--exit-code', url, 'HEAD'], { env });
|
|
147
192
|
let out = '';
|
|
148
193
|
child.stdout.on('data', (c) => { out += c.toString(); });
|
|
@@ -167,34 +212,18 @@ export function gitLsRemote(url, proxy, timeoutMs) {
|
|
|
167
212
|
});
|
|
168
213
|
}
|
|
169
214
|
/**
|
|
170
|
-
* curl 子进程抓取响应体(与 probeUrl 同一套代理 env
|
|
215
|
+
* curl 子进程抓取响应体(与 probeUrl 同一套代理 env 注入与 TLS 参数兜底)。
|
|
171
216
|
* 供服务端 /catalog 代理路由使用:目录/统计数据经此拉到服务端再转给浏览器,
|
|
172
217
|
* 使「目录数据请求走设置里的代理」与 npm / git 安装通道口径一致。
|
|
173
|
-
* 失败(curl 不存在 / 连接失败 / 超时 / 非零退出)时 ok=false
|
|
218
|
+
* 失败(curl 不存在 / 连接失败 / 超时 / 非零退出)时 ok=false,全程不抛错;
|
|
219
|
+
* reason 区分「证书吊销受阻」这类环境 TLS 问题,供调用方决定兜底策略。
|
|
174
220
|
*/
|
|
175
221
|
export function fetchViaCurl(url, proxy, timeoutMs) {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
}
|
|
184
|
-
const child = spawn('curl', ['-s', '--max-time', String(Math.max(1, Math.round(timeoutMs / 1000))), url], { env });
|
|
185
|
-
let body = '';
|
|
186
|
-
child.stdout.on('data', (c) => { body += c.toString(); });
|
|
187
|
-
let done = false;
|
|
188
|
-
const finish = (ok) => {
|
|
189
|
-
if (done)
|
|
190
|
-
return;
|
|
191
|
-
done = true;
|
|
192
|
-
clearTimeout(timer);
|
|
193
|
-
resolve({ ok, body });
|
|
194
|
-
};
|
|
195
|
-
child.on('error', () => finish(false));
|
|
196
|
-
child.on('close', (code) => finish(code === 0));
|
|
197
|
-
// 超时兜底:curl --max-time 到点自行退出,此处再设一个进程级超时防止悬挂
|
|
198
|
-
const timer = setTimeout(() => { child.kill(); finish(false); }, timeoutMs + 1000);
|
|
199
|
-
});
|
|
222
|
+
// 同样用 -sS:正文之外还要 stderr 里的 TLS 失败线索(被 stderr 收集,不外泄给用户)
|
|
223
|
+
const args = ['-sS', '--max-time', String(Math.max(1, Math.round(timeoutMs / 1000))), url];
|
|
224
|
+
return curlWithTlsFallback(args, proxyEnv(proxy), timeoutMs).then((r) => ({
|
|
225
|
+
ok: r.code === 0,
|
|
226
|
+
body: r.stdout,
|
|
227
|
+
reason: r.code !== 0 && tlsRevocationBlocked(r.stderr) ? 'tlsRevocation' : null,
|
|
228
|
+
}));
|
|
200
229
|
}
|
|
@@ -5,7 +5,28 @@ export interface ProbeResult {
|
|
|
5
5
|
ms: number | null;
|
|
6
6
|
/** HTTP 状态码;失败为 null */
|
|
7
7
|
status: number | null;
|
|
8
|
+
/** 失败细分原因:仅当能确认为环境侧 TLS 问题时才有值,普通不可达为 null */
|
|
9
|
+
reason?: ProbeFailReason | null;
|
|
8
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* 不可达的细分原因。把「证书吊销检查被挡」这类本机环境问题与「网络不通」分开,
|
|
13
|
+
* 诊断列表才能给出对症提示,而不是笼统显示不可达。
|
|
14
|
+
*/
|
|
15
|
+
export type ProbeFailReason = 'tlsRevocation';
|
|
16
|
+
/**
|
|
17
|
+
* Windows 的 curl 走 Schannel 做 TLS:握手阶段强制获取证书吊销信息(CRL/OCSP),
|
|
18
|
+
* 代理受限或吊销服务不可达时整个握手直接失败(CRYPT_E_NO_REVOCATION_CHECK),
|
|
19
|
+
* 表现为 `curl: (35) schannel: next InitializeSecurityContext failed` —— 而 Node/npm/
|
|
20
|
+
* 浏览器各有自己的 TLS 实现,照常可用,用户只会看到「插件市场打不开」。
|
|
21
|
+
* --ssl-revoke-best-effort(curl ≥ 7.70)把吊销检查降级为尽力而为,握手不再被它拦死;
|
|
22
|
+
* 其它平台后端(SecureTransport / OpenSSL)没有这个强制行为,不加参数。
|
|
23
|
+
* 运行时读 process.platform(而非模块级常量),便于测试覆盖两条分支。
|
|
24
|
+
*/
|
|
25
|
+
export declare function curlTlsArgs(): string[];
|
|
26
|
+
/** 本机 curl 太老、不认识 --ssl-revoke-best-effort(curl < 7.70):退出码 2 + stderr 指向该参数。 */
|
|
27
|
+
export declare function curlRevokeFlagUnsupported(code: number | null, stderr: string): boolean;
|
|
28
|
+
/** stderr 是否指向 Schannel 取不到证书吊销信息(据此归因为环境 TLS 问题,而非网络不通)。 */
|
|
29
|
+
export declare function tlsRevocationBlocked(stderr: string): boolean;
|
|
9
30
|
/**
|
|
10
31
|
* 操作系统级代理(macOS 系统网络设置 / Windows Internet 设置)。
|
|
11
32
|
* Node 内置 http(s) 不读系统代理,浏览器挂的代理 Node 看不见 —— 这里读出来
|
|
@@ -30,12 +51,14 @@ export declare function probeUrl(url: string, proxy: string, timeoutMs: number):
|
|
|
30
51
|
*/
|
|
31
52
|
export declare function gitLsRemote(url: string, proxy: string, timeoutMs: number): Promise<ProbeResult>;
|
|
32
53
|
/**
|
|
33
|
-
* curl 子进程抓取响应体(与 probeUrl 同一套代理 env
|
|
54
|
+
* curl 子进程抓取响应体(与 probeUrl 同一套代理 env 注入与 TLS 参数兜底)。
|
|
34
55
|
* 供服务端 /catalog 代理路由使用:目录/统计数据经此拉到服务端再转给浏览器,
|
|
35
56
|
* 使「目录数据请求走设置里的代理」与 npm / git 安装通道口径一致。
|
|
36
|
-
* 失败(curl 不存在 / 连接失败 / 超时 / 非零退出)时 ok=false
|
|
57
|
+
* 失败(curl 不存在 / 连接失败 / 超时 / 非零退出)时 ok=false,全程不抛错;
|
|
58
|
+
* reason 区分「证书吊销受阻」这类环境 TLS 问题,供调用方决定兜底策略。
|
|
37
59
|
*/
|
|
38
60
|
export declare function fetchViaCurl(url: string, proxy: string, timeoutMs: number): Promise<{
|
|
39
61
|
ok: boolean;
|
|
40
62
|
body: string;
|
|
63
|
+
reason: ProbeFailReason | null;
|
|
41
64
|
}>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.7",
|
|
4
4
|
"description": "A community plugin marketplace for DeepSeek Harness, built to the official plugin spec — browse, search and install 9000+ human-curated community plugins without leaving the app. · DeepSeek Harness 社区插件市场(遵循官方开发规范):9000+ 人工精选社区插件,每日更新。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -318,6 +318,11 @@ export function NotificationsModal({ records, tasks, pendingRestarts, t, env, on
|
|
|
318
318
|
// 占用、只读属性、目录 ACL 或杀软拦截 —— 给退出宿主 + 检查权限的指引,不引导提 Issue(#50)
|
|
319
319
|
return h('div', { className: styles.failPrepareHint }, t('failAccessDeniedHint'))
|
|
320
320
|
}
|
|
321
|
+
if (kind === 'fsUnavailable') {
|
|
322
|
+
// 本机磁盘写不进去(ENOSPC / EROFS / EMFILE 及 Windows os error 112、19):
|
|
323
|
+
// 空间不足、盘只读、句柄耗尽,任何插件都装不进来 —— 按子场景给指引,不引导提 Issue
|
|
324
|
+
return h('div', { className: styles.failPrepareHint }, t('failFsUnavailableHint'))
|
|
325
|
+
}
|
|
321
326
|
if (kind === 'network') {
|
|
322
327
|
// 安装前预检 / 安装日志里的连接失败([network] / git fetch 超时 / DNS / TLS):
|
|
323
328
|
// 是本机网络不通或代理有问题,不是插件问题 —— 提示检查网络 + 直达「系统诊断」,
|
|
@@ -429,6 +429,10 @@ export function ErrorModal({ message, repo, kind, command, attempts, t, env, onC
|
|
|
429
429
|
// profile 里的文件被系统拒绝写入(Windows os error 5「拒绝访问」/ EPERM / EACCES):
|
|
430
430
|
// 占用、只读属性、目录 ACL 或杀软拦截 —— 同样给退出宿主/检查权限的指引,不引导提 Issue(#50)
|
|
431
431
|
? h('div', { className: styles.failPrepareHint }, t('failAccessDeniedHint'))
|
|
432
|
+
: failureKind === 'fsUnavailable'
|
|
433
|
+
// 本机磁盘写不进去(ENOSPC / EROFS / EMFILE 及 Windows os error 112、19):
|
|
434
|
+
// 空间不足、盘只读、句柄耗尽,任何插件都装不进来 —— 按子场景给指引,不引导提 Issue
|
|
435
|
+
? h('div', { className: styles.failPrepareHint }, t('failFsUnavailableHint'))
|
|
432
436
|
: failureKind === 'network'
|
|
433
437
|
// 安装前预检 / 安装日志里的连接失败([network] / git fetch 超时 / DNS / TLS):
|
|
434
438
|
// 是本机网络不通或代理有问题,不是插件问题 —— 提示检查网络 + 直达「系统诊断」,
|
|
@@ -37,6 +37,8 @@ interface RowState {
|
|
|
37
37
|
status: 'idle' | 'running' | 'ok' | 'fail'
|
|
38
38
|
ms: number | null
|
|
39
39
|
statusCode: number | null
|
|
40
|
+
/** 失败细分原因:'tlsRevocation' = 证书吊销检查受阻(本机 TLS 环境问题,不是网络不通) */
|
|
41
|
+
reason?: string | null
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
const INITIAL_ROWS: RowState[] = [
|
|
@@ -74,7 +76,7 @@ export function DiagnosticsView({ t, env, proxy, onCopy }: {
|
|
|
74
76
|
runningRef.current?.abort()
|
|
75
77
|
const controller = new AbortController()
|
|
76
78
|
runningRef.current = controller
|
|
77
|
-
patchRows(key, { status: 'running', ms: null, statusCode: null })
|
|
79
|
+
patchRows(key, { status: 'running', ms: null, statusCode: null, reason: null })
|
|
78
80
|
|
|
79
81
|
void (async () => {
|
|
80
82
|
try {
|
|
@@ -98,7 +100,7 @@ export function DiagnosticsView({ t, env, proxy, onCopy }: {
|
|
|
98
100
|
const raw = buf.slice(0, nl).trim()
|
|
99
101
|
buf = buf.slice(nl + 1)
|
|
100
102
|
if (raw !== '') {
|
|
101
|
-
const ev = JSON.parse(raw) as { type?: string; key?: string; ms?: number | null; status?: number | null; display?: string }
|
|
103
|
+
const ev = JSON.parse(raw) as { type?: string; key?: string; ms?: number | null; status?: number | null; display?: string; reason?: string | null }
|
|
102
104
|
if (ev.type === 'ok' || ev.type === 'fail') {
|
|
103
105
|
patchRows(ev.key as ProbeKey, {
|
|
104
106
|
status: ev.type === 'ok' ? 'ok' : 'fail',
|
|
@@ -106,6 +108,8 @@ export function DiagnosticsView({ t, env, proxy, onCopy }: {
|
|
|
106
108
|
statusCode: typeof ev.status === 'number' ? ev.status : null,
|
|
107
109
|
// 服务端带出实际使用的源地址(配置镜像 → 镜像地址;未配置 → 空串)
|
|
108
110
|
display: typeof ev.display === 'string' ? ev.display : '',
|
|
111
|
+
// 失败细分原因:证书吊销受阻时给对症提示,而非笼统「不可达」
|
|
112
|
+
reason: typeof ev.reason === 'string' ? ev.reason : null,
|
|
109
113
|
})
|
|
110
114
|
}
|
|
111
115
|
}
|
|
@@ -155,13 +159,16 @@ export function DiagnosticsView({ t, env, proxy, onCopy }: {
|
|
|
155
159
|
}, t('diagRunAll')),
|
|
156
160
|
),
|
|
157
161
|
rows.map((r) => {
|
|
162
|
+
// 证书吊销检查受阻:badge 与悬浮提示都换成对症文案,避免用户朝「网络不通」方向排查
|
|
163
|
+
const revokeBlocked = r.status === 'fail' && r.reason === 'tlsRevocation'
|
|
158
164
|
const badge = r.status === 'running'
|
|
159
165
|
? h('span', { className: `${styles.badge} ${styles.badgeRunning}` }, t('diagChecking'))
|
|
160
166
|
: r.status === 'ok'
|
|
161
167
|
? h('span', { className: `${styles.badge} ${styles.badgeOk}` },
|
|
162
168
|
r.statusCode !== null ? `HTTP ${r.statusCode} ${t('diagOk')}` : t('diagOk'))
|
|
163
169
|
: r.status === 'fail'
|
|
164
|
-
? h('span', { className: `${styles.badge} ${styles.badgeFail}` },
|
|
170
|
+
? h('span', { className: `${styles.badge} ${styles.badgeFail}` },
|
|
171
|
+
revokeBlocked ? t('diagFailRevocation') : t('settingsDiagFail'))
|
|
165
172
|
: h('span', { className: `${styles.badge} ${styles.badgeIdle}` }, t('diagIdle'))
|
|
166
173
|
return h('button', {
|
|
167
174
|
key: r.key,
|
|
@@ -169,7 +176,7 @@ export function DiagnosticsView({ t, env, proxy, onCopy }: {
|
|
|
169
176
|
className: styles.row,
|
|
170
177
|
disabled: r.status === 'running',
|
|
171
178
|
onClick: () => run(r.key),
|
|
172
|
-
title: t('diagRecheck'),
|
|
179
|
+
title: revokeBlocked ? t('diagFailRevocationHint') : t('diagRecheck'),
|
|
173
180
|
},
|
|
174
181
|
h('span', { className: styles.name }, t(r.nameKey)),
|
|
175
182
|
// npm 行未配置镜像:display 为空 → 显示「未配置(跟随本机)」,不误导成官方源;
|
|
@@ -94,6 +94,8 @@ export function SettingsView({ t, settings, update, reset, env, onCopy, openSect
|
|
|
94
94
|
// —— HTTP 代理连通性:输入停笔后防抖实时探测(服务端 /proxy-check 用该代理打 github.com)。
|
|
95
95
|
// 结果只作提示、绝不阻断保存 —— 用户可能是先填地址后开代理,保存后再去开代理完全合法。
|
|
96
96
|
const [proxyProbe, setProxyProbe] = useState<'idle' | 'checking' | 'ok' | 'fail'>('idle')
|
|
97
|
+
// 失败细分原因(如 'tlsRevocation'):证书吊销受阻时提示「不是代理地址不通」,避免误导排查方向
|
|
98
|
+
const [proxyProbeReason, setProxyProbeReason] = useState<string | null>(null)
|
|
97
99
|
const proxyProbeTimer = useRef<number | null>(null)
|
|
98
100
|
const proxyProbeSeq = useRef(0)
|
|
99
101
|
// 卸载时清掉未触发的防抖计时器,避免卸载后 setState
|
|
@@ -103,25 +105,31 @@ export function SettingsView({ t, settings, update, reset, env, onCopy, openSect
|
|
|
103
105
|
}, [])
|
|
104
106
|
// 设置被外部重置(恢复默认/清空代理)时同步清掉探测状态,避免残留过期的「不通」提示
|
|
105
107
|
useEffect(() => {
|
|
106
|
-
if (settings.proxy === '') setProxyProbe('idle')
|
|
108
|
+
if (settings.proxy === '') { setProxyProbe('idle'); setProxyProbeReason(null) }
|
|
107
109
|
}, [settings.proxy])
|
|
108
110
|
|
|
109
111
|
const probeProxyNow = (value: string) => {
|
|
110
112
|
const v = value.trim()
|
|
111
|
-
if (v === '') { setProxyProbe('idle'); return }
|
|
113
|
+
if (v === '') { setProxyProbe('idle'); setProxyProbeReason(null); return }
|
|
112
114
|
const seq = ++proxyProbeSeq.current
|
|
113
115
|
setProxyProbe('checking')
|
|
116
|
+
setProxyProbeReason(null)
|
|
114
117
|
void fetch('/dsh-plugin-hub/proxy-check', {
|
|
115
118
|
method: 'POST',
|
|
116
119
|
headers: { 'content-type': 'application/json' },
|
|
117
120
|
body: JSON.stringify({ proxy: v }),
|
|
118
121
|
cache: 'no-store',
|
|
119
122
|
}).then((res) => res.json())
|
|
120
|
-
.then((data: { ok?: boolean }) => {
|
|
123
|
+
.then((data: { ok?: boolean; reason?: string | null }) => {
|
|
121
124
|
if (seq !== proxyProbeSeq.current) return
|
|
122
125
|
setProxyProbe(data.ok ? 'ok' : 'fail')
|
|
126
|
+
setProxyProbeReason(typeof data.reason === 'string' ? data.reason : null)
|
|
127
|
+
})
|
|
128
|
+
.catch(() => {
|
|
129
|
+
if (seq !== proxyProbeSeq.current) return
|
|
130
|
+
setProxyProbe('fail')
|
|
131
|
+
setProxyProbeReason(null)
|
|
123
132
|
})
|
|
124
|
-
.catch(() => { if (seq === proxyProbeSeq.current) setProxyProbe('fail') })
|
|
125
133
|
}
|
|
126
134
|
|
|
127
135
|
const onProxyChange = (e: ChangeEvent<HTMLInputElement>) => {
|
|
@@ -191,7 +199,8 @@ export function SettingsView({ t, settings, update, reset, env, onCopy, openSect
|
|
|
191
199
|
: proxyProbe === 'ok'
|
|
192
200
|
? h('div', { className: `${styles.proxyHint} ${styles.proxyHintOk}` }, t('proxyCheckOk'))
|
|
193
201
|
: proxyProbe === 'fail'
|
|
194
|
-
? h('div', { className: `${styles.proxyHint} ${styles.proxyHintFail}` },
|
|
202
|
+
? h('div', { className: `${styles.proxyHint} ${styles.proxyHintFail}` },
|
|
203
|
+
proxyProbeReason === 'tlsRevocation' ? t('proxyCheckRevocation') : t('proxyCheckFail'))
|
|
195
204
|
: null,
|
|
196
205
|
),
|
|
197
206
|
}),
|
package/src/client/locales.ts
CHANGED
|
@@ -116,6 +116,8 @@ export const zh = {
|
|
|
116
116
|
proxyCheckChecking: '正在测试该代理地址…',
|
|
117
117
|
proxyCheckOk: '测试可达,安装与目录请求将统一走该代理',
|
|
118
118
|
proxyCheckFail: '此地址测试不通,仍可保存;请确认代理已开启、地址与端口无误',
|
|
119
|
+
// 失败细分原因:Windows Schannel 必须取到证书吊销信息,取不到即中断握手 —— 不是代理地址不通
|
|
120
|
+
proxyCheckRevocation: '证书吊销检查受阻(Windows Schannel 取不到 CRL/OCSP),握手被中断;属本机 TLS 环境问题,非代理地址不通',
|
|
119
121
|
settingsMirror: 'npm 镜像源',
|
|
120
122
|
settingsMirrorDesc: '默认跟随本机 npm 配置;官方源慢可换国内镜像',
|
|
121
123
|
mirrorNone: '未配置(跟随本机 npm 配置)',
|
|
@@ -203,6 +205,9 @@ export const zh = {
|
|
|
203
205
|
diagSummaryRunning: '正在检测通道…',
|
|
204
206
|
diagSummaryOk: '全部通道正常',
|
|
205
207
|
diagSummaryFail: '存在不可达通道,点击对应行可重测',
|
|
208
|
+
// 失败细分原因:Windows Schannel 必须取到证书吊销信息才能握手,取不到即中断(易被误当成网络不通)
|
|
209
|
+
diagFailRevocation: '证书校验受阻',
|
|
210
|
+
diagFailRevocationHint: '系统 curl 取不到证书吊销信息(Windows Schannel 必需 CRL/OCSP),TLS 握手被中断 —— 这不是网络不通,npm/git 可能照常可用。升级 curl 到 7.70+ 可自动按「尽力而为」处理,或换一个能访问吊销服务的网络。',
|
|
206
211
|
settingsEnvSnapshot: '系统版本',
|
|
207
212
|
settingsEnvSnapshotDesc: '复制宿主版本与系统信息,提交 Issue 时粘贴到正文便于复现',
|
|
208
213
|
settingsEnvCopy: '复制',
|
|
@@ -320,6 +325,7 @@ export const zh = {
|
|
|
320
325
|
failPnpmUnusedPatchHint: '安装器调用 pnpm 时报「有些补丁没有被使用」(ERR_PNPM_UNUSED_PATCH):你的 profile 里留着一条指向旧版本 dsh-plugin 的补丁声明(例如 dsh-plugin@1.4.2),而本次安装解析到的版本已经不是它,pnpm 出于安全直接中止整次安装,因此任何插件都装不进来。这不是插件本身的问题——请在 profile 目录(~/.dsh/profiles/<profile>)的 pnpm-workspace.yaml(pnpm 11 及以上)或 package.json 的 pnpm.patchedDependencies(pnpm 10)里删除该 dsh-plugin 补丁条目后重试。',
|
|
321
326
|
failFileLockedHint: '安装过程中 pnpm 无法替换 profile 里的文件:有程序正占用着它(Windows 常见报错 os error 32「另一个程序正在使用此文件」)。通常是 DeepSeek Harness 自身或杀毒软件的实时扫描持有文件句柄。这不是插件本身的问题——请完全退出 DeepSeek Harness(Windows 可在任务管理器确认进程已结束,必要时临时关闭杀毒软件实时防护)后重试。',
|
|
322
327
|
failAccessDeniedHint: '安装过程中 pnpm 写入 profile 里的文件被系统拒绝访问(Windows 常见报错 os error 5「拒绝访问」,英文为 Access is denied;Node 下的 EPERM / EACCES 同理)。常见原因是该文件或目录正被其他进程占用、文件带只读属性、目录权限受限,或杀毒软件实时防护拦截了写入——这与「文件正被占用」(os error 32)同属本机文件系统问题,任何插件装进该目录都会一样失败,不是插件本身的问题。请完全退出 DeepSeek Harness(Windows 可在任务管理器确认进程已结束,必要时临时关闭杀毒软件实时防护)后重试;若仍然失败,请检查 profile 目录及其文件是否被设为只读,并以管理员身份重新运行。',
|
|
328
|
+
failFsUnavailableHint: '安装过程中 pnpm 无法把文件写进本机磁盘(这类报错与装哪个插件无关,任何插件装在同一目录都会失败,不是插件本身的问题)。请按报错对号入座:① 磁盘空间不足(ENOSPC / no space left on device,Windows 为 os error 112)——请清理 profile 所在磁盘的剩余空间后重试;② 磁盘或挂载点只读、带写保护(EROFS / read-only file system,Windows 为 os error 19)——请确认 profile 目录所在磁盘可写(外接盘/U 盘、带写保护的移动盘或只读挂载的分区都会触发),必要时把插件目录换到系统盘的可写位置;③ 进程可用的文件句柄被耗尽(EMFILE / ENFILE / too many open files)——请关闭占用大量文件的程序并重启 DeepSeek Harness 后重试。',
|
|
323
329
|
failPnpmWorkspaceHint: '安装器调用 pnpm 时报「不能把依赖装到 workspace 根目录」(ERR_PNPM_ADDING_TO_ROOT):你的 profile 目录被 pnpm 视为 workspace 根目录,而本次安装命令没有声明在根目录操作,pnpm 直接拒绝执行,因此任何插件装进来都会失败。这不是插件本身的问题——可在 profile 目录(~/.dsh/profiles/<profile>)的 .npmrc 里加一行 ignore-workspace-root-check=true 后重试,或把 DeepSeek Harness 升级到最新版再试。',
|
|
324
330
|
failPnpmPolicyHint: '安装器调用 pnpm 时被其供应链安全策略拦截——装任何插件都会撞墙,不是插件本身的问题。分两种情况处理:① 提示「Minimum release age」(ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION):包发布还不满 24 小时,pnpm 11 出于安全会拒收这种刚发布的新包。可在 pnpm 配置(pnpm-workspace.yaml)里加 minimumReleaseAge: 0 豁免,或等满 24 小时后重试。② 提示「untrusted origin」(ERR_PNPM_UNTRUSTED_ORIGIN):依赖来源不被本机 pnpm 信任,通常是该 profile 里之前用 github: 或非官方 npm 源装过插件,锁文件记录了不受信任的来源。请删除该 profile 目录下的 node_modules 与 pnpm-lock.yaml 后重新安装(可先到「系统日志」或 ~/.dsh/profiles/<profile>/hub.log 查看完整报错)。',
|
|
325
331
|
// 自定义安装三卡片入口的渠道名(报错/日志溯源:本次安装从哪个入口发起)
|
|
@@ -482,6 +488,8 @@ export const en = {
|
|
|
482
488
|
proxyCheckChecking: 'Testing this proxy address…',
|
|
483
489
|
proxyCheckOk: 'Reachable — installs and catalog requests will route through it',
|
|
484
490
|
proxyCheckFail: 'Unreachable, but you can still save it; make sure the proxy is running and the address and port are correct',
|
|
491
|
+
// Failure reason: Windows Schannel aborts the handshake when revocation info (CRL/OCSP) is unreachable — not a bad proxy address
|
|
492
|
+
proxyCheckRevocation: 'Certificate revocation check blocked (Windows Schannel could not fetch CRL/OCSP), so the handshake was aborted — a local TLS issue, not a bad proxy address',
|
|
485
493
|
settingsMirror: 'npm mirror',
|
|
486
494
|
settingsMirrorDesc: 'Unset by default — follows local npm config; pick a domestic mirror if slow',
|
|
487
495
|
mirrorNone: 'Unset (follow local npm config)',
|
|
@@ -569,6 +577,9 @@ export const en = {
|
|
|
569
577
|
diagSummaryRunning: 'Checking channels…',
|
|
570
578
|
diagSummaryOk: 'All channels reachable',
|
|
571
579
|
diagSummaryFail: 'Unreachable channel(s) — click a row to re-check',
|
|
580
|
+
// Failure reason: Windows Schannel aborts the handshake when revocation info (CRL/OCSP) is unreachable (easily mistaken for a network outage)
|
|
581
|
+
diagFailRevocation: 'Cert check blocked',
|
|
582
|
+
diagFailRevocationHint: 'The system curl cannot fetch certificate revocation info (CRL/OCSP, required by Windows Schannel), so the TLS handshake is aborted — this is not a network outage, and npm/git may still work. curl 7.70+ downgrades the check to best-effort, or switch to a network that can reach the revocation service.',
|
|
572
583
|
settingsEnvSnapshot: 'System version',
|
|
573
584
|
settingsEnvSnapshotDesc: 'Copy host version and system info for pasting into issue reports',
|
|
574
585
|
settingsEnvCopy: 'Copy',
|
|
@@ -684,6 +695,7 @@ export const en = {
|
|
|
684
695
|
failPnpmUnusedPatchHint: 'pnpm reported that some patches were not used (ERR_PNPM_UNUSED_PATCH): your profile still holds a patch entry for an older dsh-plugin version (for example dsh-plugin@1.4.2), while this install resolved a different version, so pnpm aborts the whole install for safety — no plugin can be installed into that profile. This is not a problem with the plugin itself. Remove that dsh-plugin patch entry from pnpm-workspace.yaml (pnpm 11+) or from pnpm.patchedDependencies in package.json (pnpm 10) inside the profile directory (~/.dsh/profiles/<profile>), then retry.',
|
|
685
696
|
failFileLockedHint: 'During install, pnpm could not replace a file inside the profile because another program is holding it (on Windows this shows as os error 32, "the process cannot access the file because it is being used by another process"). Usually DeepSeek Harness itself or a real-time antivirus scan holds the handle. This is not a problem with the plugin itself — fully quit DeepSeek Harness (on Windows, confirm in Task Manager that the process has ended, and temporarily disable real-time antivirus protection if needed), then retry.',
|
|
686
697
|
failAccessDeniedHint: 'During install, Windows denied pnpm access to a file inside the profile (this shows as os error 5, "Access is denied"; on Node the equivalent codes are EPERM / EACCES). Typical causes: the file or directory is held by another process, the file is marked read-only, the directory permissions are restricted, or a real-time antivirus scan blocked the write. This is the same class of local file-system problem as a locked file (os error 32) — no plugin can be installed into that directory, so it is not a problem with the plugin itself. Fully quit DeepSeek Harness (on Windows, confirm in Task Manager that the process has ended, and temporarily disable real-time antivirus protection if needed), then retry. If it still fails, check whether the profile directory or its files are read-only and run as administrator.',
|
|
698
|
+
failFsUnavailableHint: 'During install, pnpm could not write files to the local disk — this has nothing to do with which plugin you are installing: any plugin installed into the same directory would fail the same way, so it is not a problem with the plugin itself. Match the error you see: ① Out of space (ENOSPC / no space left on device, os error 112 on Windows) — free up space on the drive that holds the profile and retry. ② Read-only or write-protected volume (EROFS / read-only file system, os error 19 on Windows) — make sure the drive holding the profile directory is writable (external or USB drives, write-protected media, and read-only mounts all trigger this); move the plugin directory to a writable location on the system drive if needed. ③ File handles exhausted (EMFILE / ENFILE / too many open files) — close programs that hold many files open, restart DeepSeek Harness, then retry.',
|
|
687
699
|
failPnpmWorkspaceHint: 'pnpm refused to add the dependency to the workspace root (ERR_PNPM_ADDING_TO_ROOT): the profile directory is treated as a pnpm workspace root, and this install command did not declare that it operates at the root, so pnpm rejected it outright — no plugin can be installed into that profile. This is not a problem with the plugin itself. Add `ignore-workspace-root-check=true` to the `.npmrc` inside the profile directory (~/.dsh/profiles/<profile>) and retry, or upgrade DeepSeek Harness and try again.',
|
|
688
700
|
failPnpmPolicyHint: 'The installer was blocked by pnpm supply-chain security policy — every plugin install would hit the same wall, so this is not a problem with the plugin itself. Two cases: ① "Minimum release age" (ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION): the package was published less than 24 hours ago, and pnpm 11 refuses freshly-published packages for safety. Add minimumReleaseAge: 0 to your pnpm config (pnpm-workspace.yaml) to opt out, or wait until the 24 hours have passed and retry. ② "untrusted origin" (ERR_PNPM_UNTRUSTED_ORIGIN): a dependency source is not trusted by this machine, usually because plugins were previously installed via github: or a non-official npm registry, so the lockfile records an untrusted source. Delete node_modules and pnpm-lock.yaml under the profile directory (see System Logs or ~/.dsh/profiles/<profile>/hub.log for the full output), then reinstall.',
|
|
689
701
|
// Custom install entry channel names (the three cards) — used in error/log traceability
|
|
@@ -151,7 +151,7 @@ export function removeNotification(id: number): NotificationRecord[] {
|
|
|
151
151
|
return next
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
-
export type FailureKind = 'npmTooOld' | 'dshMissing' | 'gitMissing' | 'pnpmMissing' | 'npmMissing' | 'pnpmStore' | 'pnpmWorkspace' | 'pnpmPolicy' | 'pnpmUnusedPatch' | 'fileLocked' | 'accessDenied' | 'pnpmIgnoredBuild' | 'pluginPrepare' | 'network' | 'repo'
|
|
154
|
+
export type FailureKind = 'npmTooOld' | 'dshMissing' | 'gitMissing' | 'pnpmMissing' | 'npmMissing' | 'pnpmStore' | 'pnpmWorkspace' | 'pnpmPolicy' | 'pnpmUnusedPatch' | 'fileLocked' | 'accessDenied' | 'fsUnavailable' | 'pnpmIgnoredBuild' | 'pluginPrepare' | 'network' | 'repo'
|
|
155
155
|
|
|
156
156
|
/**
|
|
157
157
|
* 失败归类,七态。无论底层机制如何(pnpm 白名单拦截 / 构建脚本被忽略 / prepare 失败),
|
|
@@ -210,6 +210,12 @@ export type FailureKind = 'npmTooOld' | 'dshMissing' | 'gitMissing' | 'pnpmMissi
|
|
|
210
210
|
* (dsh-plugin-hub#50:Win 下 `swap: 拒绝访问。 (os error 5)` 漏判,被误报成插件侧安装失败并
|
|
211
211
|
* 自动提了 issue)→ 提示退出宿主/关杀软后重试,仍失败则检查只读属性并以管理员身份运行,
|
|
212
212
|
* 不引导提 Issue
|
|
213
|
+
* - fsUnavailable:本机文件系统层面根本写不进去 —— 磁盘/分区空间耗尽(`ENOSPC` /
|
|
214
|
+
* `no space left on device`,Windows 为 `os error 112`)、目标分卷或挂载点为只读
|
|
215
|
+
* (`EROFS` / `read-only file system`,Windows 写保护为 `os error 19`)、进程可用的文件句柄
|
|
216
|
+
* 被耗尽(`EMFILE` / `ENFILE` / `too many open files`)。这三类都在 pnpm 落盘阶段发生,
|
|
217
|
+
* 与具体装哪个插件无关:空间不足、盘只读、句柄不够时,任何插件都装不进来,不是插件问题
|
|
218
|
+
* → 提示按报错代码对号入座(清空间 / 换可写目录 / 重启宿主释放句柄),不引导提 Issue
|
|
213
219
|
* - network:安装前连通性预检拦截(服务端 `[network]` 标记)、底层连接失败
|
|
214
220
|
* (ERR_PNPM_GIT_FETCH_FAILED / ETIMEDOUT / DNS 解析 / TLS 握手 / 代理拒绝),或
|
|
215
221
|
* registry tarball 拉取失败(`fetch failed` / `GET …/-/…tgz error (n)` —— 常见于本机
|
|
@@ -292,6 +298,13 @@ export function classifyFailure(message: string): FailureKind {
|
|
|
292
298
|
// 中文原文经 GBK→UTF-8 解码会残缺,不能依赖「拒绝访问」四个字
|
|
293
299
|
// (dsh-plugin-hub#50:swap 阶段 `os error 5` 漏判,落 repo 兜底被误报成插件侧失败)
|
|
294
300
|
if (/os error 5\b|ERROR_ACCESS_DENIED|\bEPERM\b|\bEACCES\b|access is denied/i.test(message)) return 'accessDenied'
|
|
301
|
+
// 本机文件系统层面根本写不进去:空间耗尽(ENOSPC / no space left on device / Windows os error 112)、
|
|
302
|
+
// 目标盘或挂载点只读(EROFS / read-only file system / Windows 写保护 os error 19)、
|
|
303
|
+
// 文件句柄耗尽(EMFILE / ENFILE / too many open files)。都在 pnpm 落盘阶段发生,
|
|
304
|
+
// 空间不足、盘只读或句柄不够时任何插件都装不进来,不是插件问题 ——
|
|
305
|
+
// 必须在 network / prepare 之前判:磁盘满时 pnpm 会在日志尾部混着重试与超时特征,
|
|
306
|
+
// 先判网络会把「本机磁盘写不进去」误报成「你的网络不通」
|
|
307
|
+
if (/os error 112\b|os error 19\b|ERROR_DISK_FULL|ERROR_WRITE_PROTECT|\bENOSPC\b|\bEROFS\b|\bEMFILE\b|\bENFILE\b|no space left on device|read-only file system|too many open files/i.test(message)) return 'fsUnavailable'
|
|
295
308
|
// 构建脚本被 pnpm 白名单(allowBuilds)拦截:插件的 prepare 脚本或依赖里的原生模块构建
|
|
296
309
|
// 被默认拒绝(ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED / ERR_PNPM_IGNORED_BUILDS)。
|
|
297
310
|
// 这类错误出现即说明 pnpm 已成功 fetch 到 tarball(网络是通的),主因是插件构建脚本
|
package/src/client/logic/urls.ts
CHANGED
|
@@ -54,6 +54,7 @@ function reasonTitleOf(kind: FailureKind): string {
|
|
|
54
54
|
case 'pnpmUnusedPatch': return 'stale pnpm patch entry on the user machine'
|
|
55
55
|
case 'fileLocked': return 'file locked by another process on the user machine'
|
|
56
56
|
case 'accessDenied': return 'file write access denied on the user machine'
|
|
57
|
+
case 'fsUnavailable': return 'local file system unavailable on the user machine'
|
|
57
58
|
case 'pnpmIgnoredBuild': return 'build scripts blocked by pnpm allowlist'
|
|
58
59
|
case 'pluginPrepare': return 'plugin distribution incomplete'
|
|
59
60
|
case 'network': return 'network failure on the user side'
|
|
@@ -93,7 +94,9 @@ export function pluginIssueUrl(repo: string, message: string, env?: EnvInfo | nu
|
|
|
93
94
|
? 'a file in the profile is locked by another process on the user machine (local environment issue)'
|
|
94
95
|
: kind === 'accessDenied'
|
|
95
96
|
? 'the system denied write access to a file in the profile on the user machine (local environment issue)'
|
|
96
|
-
:
|
|
97
|
+
: kind === 'fsUnavailable'
|
|
98
|
+
? 'the local file system could not accept the write — no space left, read-only volume, or too many open file handles on the user machine (local environment issue)'
|
|
99
|
+
: 'plugin-side install failure'
|
|
97
100
|
const code = coreErrorCode(message)
|
|
98
101
|
// 按给定核心摘要预算构建完整预填 URL(含 URL 编码);预算可调,供超长时逐档缩小
|
|
99
102
|
const build = (coreChars: number): string => {
|