dsh-plugin 1.4.6 → 1.4.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +1 -1
- package/README.md +1 -1
- package/client/client.js +326 -308
- package/client/client.js.map +1 -1
- package/lib/http/routes.js +41 -11
- package/lib/services/install/npm-resolve.js +14 -8
- package/lib/services/install/package-entry.js +39 -0
- package/lib/services/install/preflight.js +2 -7
- package/lib/services/install/task-queue.js +15 -7
- package/lib/services/probe.js +92 -63
- package/lib/types/services/install/npm-resolve.d.ts +3 -1
- package/lib/types/services/install/package-entry.d.ts +21 -0
- package/lib/types/services/probe.d.ts +25 -2
- package/package.json +2 -2
- package/src/client/components/views/DiagnosticsView.tsx +11 -4
- package/src/client/components/views/SettingsView.tsx +14 -5
- package/src/client/locales.ts +10 -0
- package/src/server/http/routes.ts +37 -11
- package/src/server/services/install/npm-resolve.ts +14 -9
- package/src/server/services/install/package-entry.ts +41 -0
- package/src/server/services/install/preflight.ts +2 -7
- package/src/server/services/install/task-queue.ts +15 -7
- 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
|
}
|
|
@@ -485,15 +486,23 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
|
|
|
485
486
|
const child = spawn(bin, args);
|
|
486
487
|
let out = '';
|
|
487
488
|
child.stdout.on('data', (d) => { out += String(d); });
|
|
488
|
-
|
|
489
|
-
|
|
489
|
+
// spawn 失败(如 Linux 无 zenity)会先触发 error 再触发 close(code=-2):
|
|
490
|
+
// 只允许第一个事件应答,否则第二次 sendJson 对已结束的响应再 writeHead,
|
|
491
|
+
// 抛 ERR_HTTP_HEADERS_SENT 成为宿主进程内的未捕获异常。
|
|
492
|
+
let done = false;
|
|
493
|
+
const finish = (reply) => { if (!done) {
|
|
494
|
+
done = true;
|
|
495
|
+
reply();
|
|
496
|
+
} };
|
|
497
|
+
child.on('error', () => finish(() => sendJson(response, 500, { error: 'directory picker unavailable' })));
|
|
498
|
+
child.on('close', (code) => finish(() => {
|
|
490
499
|
// macOS osascript 输出带引号(如 "/Users/x"),统一剥掉首尾空白与引号
|
|
491
500
|
const picked = out.trim().replace(/^"|"$/g, '');
|
|
492
501
|
if (code === 0 && picked !== '')
|
|
493
502
|
sendJson(response, 200, { path: picked });
|
|
494
503
|
else
|
|
495
504
|
sendJson(response, 200, { cancelled: true });
|
|
496
|
-
});
|
|
505
|
+
}));
|
|
497
506
|
}
|
|
498
507
|
catch {
|
|
499
508
|
sendJson(response, 500, { error: 'directory picker unavailable' });
|
|
@@ -563,8 +572,16 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
|
|
|
563
572
|
? await gitLsRemote(c.url, c.proxy ?? effectiveProxy, 6000)
|
|
564
573
|
: await probe(c.url, c.proxy);
|
|
565
574
|
results.push({ key: c.key, ok: r.ok });
|
|
566
|
-
// git 行的 status 是退出码,客户端 git 行只显示 OK / 不可达,不显示成 HTTP
|
|
567
|
-
|
|
575
|
+
// git 行的 status 是退出码,客户端 git 行只显示 OK / 不可达,不显示成 HTTP 码。
|
|
576
|
+
// reason 透出失败细分原因(如 Windows 证书吊销受阻),客户端据此给对症提示而非笼统「不可达」。
|
|
577
|
+
send({
|
|
578
|
+
type: r.ok ? 'ok' : 'fail',
|
|
579
|
+
key: c.key,
|
|
580
|
+
display: c.display,
|
|
581
|
+
ms: r.ms,
|
|
582
|
+
status: c.git ? null : r.status,
|
|
583
|
+
reason: r.ok ? null : (r.reason ?? null),
|
|
584
|
+
});
|
|
568
585
|
}
|
|
569
586
|
send({ type: 'end', at: Date.now() });
|
|
570
587
|
response.end();
|
|
@@ -609,9 +626,10 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
|
|
|
609
626
|
sendJson(response, 200, { ok: false, ms: null, status: null, target, reason: 'empty' });
|
|
610
627
|
return;
|
|
611
628
|
}
|
|
612
|
-
// 探测走该代理本身(不是 effectiveProxy
|
|
629
|
+
// 探测走该代理本身(不是 effectiveProxy):验证的就是用户填的这个地址。
|
|
630
|
+
// reason 带上失败细分原因(如证书吊销受阻),设置页才能提示「不是代理地址的问题」。
|
|
613
631
|
const r = await probeUrl(target, proxy, 6000);
|
|
614
|
-
sendJson(response, 200, { ok: r.ok, ms: r.ms, status: r.status, target });
|
|
632
|
+
sendJson(response, 200, { ok: r.ok, ms: r.ms, status: r.status, target, reason: r.reason ?? null });
|
|
615
633
|
},
|
|
616
634
|
}),
|
|
617
635
|
webServer.register({
|
|
@@ -636,7 +654,7 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
|
|
|
636
654
|
: `https://api.dsh-plugin.org/plugins.${lang}.json`;
|
|
637
655
|
// 1 小时本地缓存:命中直接返回(跨重启生效,插件市场秒开),未命中才走 curl 拉远程
|
|
638
656
|
const cacheFile = catalogCacheFile(profile, isStats ? 'stats' : `plugins-${lang}`);
|
|
639
|
-
const cached = readCatalogCache(cacheFile);
|
|
657
|
+
const cached = readCatalogCache(cacheFile, CATALOG_CACHE_TTL_MS);
|
|
640
658
|
if (cached !== null) {
|
|
641
659
|
try {
|
|
642
660
|
sendJson(response, 200, JSON.parse(cached));
|
|
@@ -648,6 +666,18 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
|
|
|
648
666
|
}
|
|
649
667
|
const r = await fetchViaCurl(target, proxy, 20000);
|
|
650
668
|
if (!r.ok || r.body === '') {
|
|
669
|
+
// 实时拉取失败:退回任意年龄的本地缓存(上次成功数据),
|
|
670
|
+
// 避免证书吊销受阻 / 网络抖动时整个插件市场打不开 —— 数据可能过期,但好过空白。
|
|
671
|
+
const stale = readCatalogCache(cacheFile, Number.POSITIVE_INFINITY);
|
|
672
|
+
if (stale !== null) {
|
|
673
|
+
try {
|
|
674
|
+
sendJson(response, 200, JSON.parse(stale));
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
catch {
|
|
678
|
+
// 兜底缓存也损坏:落到下面的 502
|
|
679
|
+
}
|
|
680
|
+
}
|
|
651
681
|
sendJson(response, 502, { error: 'catalog fetch failed' });
|
|
652
682
|
return;
|
|
653
683
|
}
|
|
@@ -12,14 +12,16 @@
|
|
|
12
12
|
* 避免同一会话内重复查询 registry。
|
|
13
13
|
*/
|
|
14
14
|
import { get } from 'node:https';
|
|
15
|
-
/** 反查缓存:repo 小写 → npm 包名(null 表示已确认无对应 npm
|
|
15
|
+
/** 反查缓存:repo 小写 → npm 包名(null 表示已确认无对应 npm 包;网络异常不缓存)。 */
|
|
16
16
|
const cache = new Map();
|
|
17
17
|
/** 单次 registry 查询超时(ms):慢网络下失败返回 null,不阻塞安装流程。 */
|
|
18
18
|
const REQUEST_TIMEOUT_MS = 8000;
|
|
19
19
|
/**
|
|
20
20
|
* 反查 repo(`owner/repo`)对应的官方 npm 包名;未命中、网络异常或超时返回 null。
|
|
21
21
|
* 返回 null 不代表仓库一定没有 npm 包,只代表本次未能确认 —— 调用方应保留
|
|
22
|
-
*
|
|
22
|
+
* 原有错误路径,反查只是额外的一次尝试。只有「已确认」的结果(包名,或 200
|
|
23
|
+
* 响应下确认无匹配)会进缓存;网络异常/超时/解析失败不缓存,同一会话内下次
|
|
24
|
+
* 安装仍会重试反查,避免一次瞬时故障把整个会话锁死在 git 通道。
|
|
23
25
|
* registry 参数:npm 镜像源地址,空串 = 官方源;与安装通道吃同一 registry,
|
|
24
26
|
* 保证「配置了镜像」时反查和安装走同一个源(镜像节点同步完整时结果一致)。
|
|
25
27
|
* 慢网络下失败不阻塞安装。
|
|
@@ -29,10 +31,14 @@ export function resolveNpmPackage(repo, registry = '') {
|
|
|
29
31
|
if (cache.has(key))
|
|
30
32
|
return Promise.resolve(cache.get(key) ?? null);
|
|
31
33
|
const name = searchRepo(repo, registry);
|
|
32
|
-
name.then((found) =>
|
|
33
|
-
|
|
34
|
+
void name.then((found) => {
|
|
35
|
+
if (found !== undefined)
|
|
36
|
+
cache.set(key, found);
|
|
37
|
+
});
|
|
38
|
+
return name.then((found) => found ?? null);
|
|
34
39
|
}
|
|
35
|
-
/** 用 npm search 接口按 repository 地址反查,并做铁证校验(包元数据必须指向该仓库)。
|
|
40
|
+
/** 用 npm search 接口按 repository 地址反查,并做铁证校验(包元数据必须指向该仓库)。
|
|
41
|
+
* 返回:包名 = 命中;null = 200 响应下确认无匹配;undefined = 本次未能确认(网络/超时/解析失败)。 */
|
|
36
42
|
function searchRepo(repo, registry) {
|
|
37
43
|
const [owner, name] = repo.split('/');
|
|
38
44
|
if (owner === undefined || name === undefined || name === '')
|
|
@@ -43,7 +49,7 @@ function searchRepo(repo, registry) {
|
|
|
43
49
|
const req = get(url, { timeout: REQUEST_TIMEOUT_MS }, (res) => {
|
|
44
50
|
if (res.statusCode !== 200) {
|
|
45
51
|
res.resume();
|
|
46
|
-
resolve(
|
|
52
|
+
resolve(undefined);
|
|
47
53
|
return;
|
|
48
54
|
}
|
|
49
55
|
let body = '';
|
|
@@ -65,11 +71,11 @@ function searchRepo(repo, registry) {
|
|
|
65
71
|
resolve(null);
|
|
66
72
|
}
|
|
67
73
|
catch {
|
|
68
|
-
resolve(
|
|
74
|
+
resolve(undefined);
|
|
69
75
|
}
|
|
70
76
|
});
|
|
71
77
|
});
|
|
72
78
|
req.on('timeout', () => req.destroy());
|
|
73
|
-
req.on('error', () => resolve(
|
|
79
|
+
req.on('error', () => resolve(undefined));
|
|
74
80
|
});
|
|
75
81
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
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
|
+
* 包入口解析:从 package.json 的 exports["."] / main 推出用于校验的入口文件。
|
|
7
|
+
* git 通道(tar 预检)与 npm 通道(装后 statSync 校验)共用,保证两条通道
|
|
8
|
+
* 对同一个包判定一致。
|
|
9
|
+
*/
|
|
10
|
+
/** exports 条件对象里按优先级尝试的键;Node 语义下 default 最宽,import/require 次之。 */
|
|
11
|
+
const EXPORT_CONDITION_KEYS = ['default', 'import', 'require'];
|
|
12
|
+
/**
|
|
13
|
+
* 解析包的入口文件(相对包根,不带 `./` 前缀)。
|
|
14
|
+
*
|
|
15
|
+
* Node 的 exports 规范要求字符串 target 以 `./` 开头(如 `"./lib/index.js"`),
|
|
16
|
+
* 而 tar 成员名与磁盘路径都不带该前缀,因此统一剥掉;条件对象
|
|
17
|
+
* (`{ "import": ..., "require": ... }`)按 default → import → require 取第一
|
|
18
|
+
* 个字符串值。无 exports 时回退 main,再回退 index.js。
|
|
19
|
+
*/
|
|
20
|
+
export function resolvePackageEntry(meta) {
|
|
21
|
+
const dot = meta.exports?.['.'];
|
|
22
|
+
const fromExports = typeof dot === 'string'
|
|
23
|
+
? dot
|
|
24
|
+
: dot !== null && typeof dot === 'object'
|
|
25
|
+
? firstStringOf(dot)
|
|
26
|
+
: undefined;
|
|
27
|
+
const candidate = typeof fromExports === 'string' ? fromExports
|
|
28
|
+
: typeof meta.main === 'string' ? meta.main
|
|
29
|
+
: 'index.js';
|
|
30
|
+
return candidate.replace(/^\.\//u, '');
|
|
31
|
+
}
|
|
32
|
+
function firstStringOf(conditions) {
|
|
33
|
+
for (const key of EXPORT_CONDITION_KEYS) {
|
|
34
|
+
const value = conditions[key];
|
|
35
|
+
if (typeof value === 'string' && value !== '')
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
@@ -18,6 +18,7 @@ import { join } from 'node:path';
|
|
|
18
18
|
import { pipeline } from 'node:stream/promises';
|
|
19
19
|
import { promisify } from 'node:util';
|
|
20
20
|
import { githubRepoOf } from '../profile/profile.js';
|
|
21
|
+
import { resolvePackageEntry } from './package-entry.js';
|
|
21
22
|
const execFileAsync = promisify(execFile);
|
|
22
23
|
/** 单次网络操作超时(ms):git ls-remote / codeload 下载。网络不可达时不能无限挂起,
|
|
23
24
|
* 否则 `/install` 请求永远不返回、任务不入队、前端进度卡 0%。超时后放行(交给装后校验)。 */
|
|
@@ -47,13 +48,7 @@ export async function preflightTarget(target) {
|
|
|
47
48
|
const meta = await readTarJson(tar, 'package/package.json');
|
|
48
49
|
if (meta === null)
|
|
49
50
|
return { ok: true, missing: null, name: null };
|
|
50
|
-
const
|
|
51
|
-
const resolved = typeof dot === 'string'
|
|
52
|
-
? dot
|
|
53
|
-
: dot !== null && typeof dot === 'object'
|
|
54
|
-
? dot.default
|
|
55
|
-
: undefined;
|
|
56
|
-
const entry = typeof resolved === 'string' ? resolved : typeof meta.main === 'string' ? meta.main : 'index.js';
|
|
51
|
+
const entry = resolvePackageEntry(meta);
|
|
57
52
|
const inTar = await hasTarEntry(tar, `package/${entry}`);
|
|
58
53
|
return { ok: inTar, missing: inTar ? null : entry, name: typeof meta.name === 'string' ? meta.name : null };
|
|
59
54
|
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* 输出流式收集到任务上;卸载成功时尝试从运行中 loader 即时停用(见 loader.ts)。
|
|
9
9
|
*/
|
|
10
10
|
import { spawn, spawnSync } from 'node:child_process';
|
|
11
|
+
import { resolvePackageEntry } from './package-entry.js';
|
|
11
12
|
import { readFileSync, statSync } from 'node:fs';
|
|
12
13
|
import { dirname, join, resolve } from 'node:path';
|
|
13
14
|
import { CAPTURE_LIMIT_BYTES, MAX_TASKS, MAX_TASK_LINES } from './install-types.js';
|
|
@@ -222,7 +223,19 @@ function pumpQueue() {
|
|
|
222
223
|
return;
|
|
223
224
|
const { task, options } = item;
|
|
224
225
|
task.status = 'running';
|
|
225
|
-
void runPluginMutation({ ...options, task })
|
|
226
|
+
void runPluginMutation({ ...options, task })
|
|
227
|
+
.catch((error) => {
|
|
228
|
+
// runPluginMutation 的恢复路径(如 allowBuilds 写回 pnpm-workspace.yaml)
|
|
229
|
+
// 可能抛出;落到 failed 终态并留痕,否则该 Promise 链成为未处理 rejection
|
|
230
|
+
//(Node >= 15 默认直接终止进程),而队列自身却在 finally 里继续 pump。
|
|
231
|
+
if (task.status !== 'cancelled') {
|
|
232
|
+
task.status = 'failed';
|
|
233
|
+
task.exitCode = null;
|
|
234
|
+
task.progress = 0;
|
|
235
|
+
pushLine(task, `[internal] ${error instanceof Error ? error.message : String(error)}`);
|
|
236
|
+
}
|
|
237
|
+
})
|
|
238
|
+
.finally(() => {
|
|
226
239
|
runningChildren.delete(task.id);
|
|
227
240
|
// 系统日志:任务终态(成功/失败/取消),按动作归类安装/卸载/更新日志
|
|
228
241
|
const show = task.displayTarget ?? task.target;
|
|
@@ -510,12 +523,7 @@ export function verifyInstalledEntry(profile, target) {
|
|
|
510
523
|
let entry = null;
|
|
511
524
|
try {
|
|
512
525
|
const meta = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8'));
|
|
513
|
-
|
|
514
|
-
const resolved = typeof dot === 'string' ? dot
|
|
515
|
-
: dot !== null && typeof dot === 'object'
|
|
516
|
-
? dot.default
|
|
517
|
-
: undefined;
|
|
518
|
-
entry = typeof resolved === 'string' ? resolved : typeof meta.main === 'string' ? meta.main : 'index.js';
|
|
526
|
+
entry = resolvePackageEntry(meta);
|
|
519
527
|
}
|
|
520
528
|
catch {
|
|
521
529
|
return { name, missing: null };
|
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
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 反查 repo(`owner/repo`)对应的官方 npm 包名;未命中、网络异常或超时返回 null。
|
|
3
3
|
* 返回 null 不代表仓库一定没有 npm 包,只代表本次未能确认 —— 调用方应保留
|
|
4
|
-
*
|
|
4
|
+
* 原有错误路径,反查只是额外的一次尝试。只有「已确认」的结果(包名,或 200
|
|
5
|
+
* 响应下确认无匹配)会进缓存;网络异常/超时/解析失败不缓存,同一会话内下次
|
|
6
|
+
* 安装仍会重试反查,避免一次瞬时故障把整个会话锁死在 git 通道。
|
|
5
7
|
* registry 参数:npm 镜像源地址,空串 = 官方源;与安装通道吃同一 registry,
|
|
6
8
|
* 保证「配置了镜像」时反查和安装走同一个源(镜像节点同步完整时结果一致)。
|
|
7
9
|
* 慢网络下失败不阻塞安装。
|
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
* 包入口解析:从 package.json 的 exports["."] / main 推出用于校验的入口文件。
|
|
7
|
+
* git 通道(tar 预检)与 npm 通道(装后 statSync 校验)共用,保证两条通道
|
|
8
|
+
* 对同一个包判定一致。
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* 解析包的入口文件(相对包根,不带 `./` 前缀)。
|
|
12
|
+
*
|
|
13
|
+
* Node 的 exports 规范要求字符串 target 以 `./` 开头(如 `"./lib/index.js"`),
|
|
14
|
+
* 而 tar 成员名与磁盘路径都不带该前缀,因此统一剥掉;条件对象
|
|
15
|
+
* (`{ "import": ..., "require": ... }`)按 default → import → require 取第一
|
|
16
|
+
* 个字符串值。无 exports 时回退 main,再回退 index.js。
|
|
17
|
+
*/
|
|
18
|
+
export declare function resolvePackageEntry(meta: {
|
|
19
|
+
main?: unknown;
|
|
20
|
+
exports?: unknown;
|
|
21
|
+
}): string;
|
|
@@ -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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin",
|
|
3
|
-
"version": "1.4.
|
|
4
|
-
"description": "A community plugin marketplace for DeepSeek Harness, built to the official plugin spec — browse, search and install
|
|
3
|
+
"version": "1.4.8",
|
|
4
|
+
"description": "A community plugin marketplace for DeepSeek Harness, built to the official plugin spec — browse, search and install 10000+ human-curated community plugins without leaving the app. · DeepSeek Harness 社区插件市场(遵循官方开发规范):10000+ 人工精选社区插件,每日更新。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
|
@@ -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 为空 → 显示「未配置(跟随本机)」,不误导成官方源;
|