dsh-plugin 1.6.5 → 1.6.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/README.en.md +11 -11
- package/README.md +11 -11
- package/client/client.js +421 -390
- package/client/client.js.map +1 -1
- package/lib/http/catalog.js +132 -0
- package/lib/http/diagnostics.js +201 -0
- package/lib/http/install.js +399 -0
- package/lib/http/logs.js +164 -0
- package/lib/http/routes.js +19 -1263
- package/lib/http/settings.js +77 -0
- package/lib/http/shared.js +169 -0
- package/lib/http/task.js +208 -0
- package/lib/services/install/install.js +1 -1
- package/lib/services/install/package-entry.js +59 -8
- package/lib/services/install/preflight.js +47 -17
- package/lib/services/install/task-queue.js +75 -31
- package/lib/services/probe.js +1 -1
- package/lib/types/http/catalog.d.ts +10 -0
- package/lib/types/http/diagnostics.d.ts +4 -0
- package/lib/types/http/install.d.ts +11 -0
- package/lib/types/http/logs.d.ts +2 -0
- package/lib/types/http/routes.d.ts +15 -20
- package/lib/types/http/settings.d.ts +2 -0
- package/lib/types/http/shared.d.ts +44 -0
- package/lib/types/http/task.d.ts +3 -0
- package/lib/types/services/install/install.d.ts +1 -1
- package/lib/types/services/install/package-entry.d.ts +42 -10
- package/lib/types/services/install/preflight.d.ts +9 -1
- package/lib/types/services/install/task-queue.d.ts +6 -4
- package/package.json +1 -1
- package/src/client/components/PluginHubSection.tsx +3 -0
- package/src/client/components/modals/NotificationsModal.tsx +34 -18
- package/src/client/components/modals/modals.tsx +38 -28
- package/src/client/components/views/InstalledView.tsx +23 -11
- package/src/client/hooks/useCatalog.ts +1 -1
- package/src/client/locales.ts +28 -12
- package/src/client/logic/failures.ts +36 -157
- package/src/client/logic/urls.ts +75 -4
- package/src/client/types.ts +1 -1
- package/src/server/http/catalog.ts +143 -0
- package/src/server/http/diagnostics.ts +193 -0
- package/src/server/http/install.ts +391 -0
- package/src/server/http/logs.ts +149 -0
- package/src/server/http/routes.ts +22 -1222
- package/src/server/http/settings.ts +73 -0
- package/src/server/http/shared.ts +191 -0
- package/src/server/http/task.ts +194 -0
- package/src/server/services/install/install-types.ts +9 -1
- package/src/server/services/install/install.ts +11 -1
- package/src/server/services/install/package-entry.ts +72 -9
- package/src/server/services/install/preflight.ts +47 -18
- package/src/server/services/install/task-queue.ts +73 -34
- package/src/server/services/probe.ts +1 -1
|
@@ -0,0 +1,77 @@
|
|
|
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
|
+
* Settings routes: read/write the hub settings and reset them to defaults.
|
|
7
|
+
*/
|
|
8
|
+
import { mkdirSync } from 'node:fs';
|
|
9
|
+
import { dirname } from 'node:path';
|
|
10
|
+
import { loadSettings, saveSettings, resetSettings } from '../services/settings.js';
|
|
11
|
+
import { appendLog, customLogFile } from '../services/log.js';
|
|
12
|
+
import { readJsonBody, requireTrustedPost, sendJson } from './shared.js';
|
|
13
|
+
export function registerSettingsRoutes(webServer, profile) {
|
|
14
|
+
return [
|
|
15
|
+
{
|
|
16
|
+
kind: 'exact',
|
|
17
|
+
path: '/dsh-plugin-hub/settings',
|
|
18
|
+
// GET 读 / POST 写:webServer.register 按 (kind, path) 唯一,多方法必须合并到同一个 handler 按 method 分派
|
|
19
|
+
handler: async (request, response) => {
|
|
20
|
+
if (request.method === 'GET') {
|
|
21
|
+
sendJson(response, 200, loadSettings(profile));
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (!requireTrustedPost(request, response))
|
|
25
|
+
return;
|
|
26
|
+
try {
|
|
27
|
+
const body = await readJsonBody(request);
|
|
28
|
+
// 白名单字段:只接受已知设置项,防客户端把任意键写进文件
|
|
29
|
+
const patch = {};
|
|
30
|
+
const source = typeof body === 'object' && body !== null ? body : {};
|
|
31
|
+
for (const key of ['checkUpdatesOnStart', 'enableNpmInstall', 'enableGitInstall', 'enableDshInstall', 'npmRegistry', 'proxy', 'logPath']) {
|
|
32
|
+
if (source[key] !== undefined)
|
|
33
|
+
patch[key] = source[key];
|
|
34
|
+
}
|
|
35
|
+
// 日志位置:非空时预建目录验证可写,失败直接 400 拦截(默认位置永远合法,空串 = 回默认)
|
|
36
|
+
if (typeof patch.logPath === 'string' && patch.logPath.trim() !== '') {
|
|
37
|
+
try {
|
|
38
|
+
mkdirSync(dirname(customLogFile(patch.logPath)), { recursive: true });
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
sendJson(response, 400, { error: 'log path is not writable' });
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
sendJson(response, 200, saveSettings(profile, patch));
|
|
46
|
+
// 系统日志:设置项变更
|
|
47
|
+
appendLog(profile, {
|
|
48
|
+
at: Date.now(),
|
|
49
|
+
level: 'info',
|
|
50
|
+
category: 'settings',
|
|
51
|
+
event: 'settings.update',
|
|
52
|
+
message: `更新设置:${Object.keys(patch).join(', ')}`,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
kind: 'exact',
|
|
62
|
+
path: '/dsh-plugin-hub/settings/reset',
|
|
63
|
+
handler: async (request, response) => {
|
|
64
|
+
if (!requireTrustedPost(request, response))
|
|
65
|
+
return;
|
|
66
|
+
sendJson(response, 200, resetSettings(profile));
|
|
67
|
+
appendLog(profile, {
|
|
68
|
+
at: Date.now(),
|
|
69
|
+
level: 'warn',
|
|
70
|
+
category: 'settings',
|
|
71
|
+
event: 'settings.reset',
|
|
72
|
+
message: '恢复全部默认设置',
|
|
73
|
+
});
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
];
|
|
77
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 跨平台宿主重启脚本(以 `node -e` 运行,独立于宿主进程)。
|
|
3
|
+
* 不用 `/bin/sh -c`:Windows 无 /bin/sh,且 lsof/nohup 仅 POSIX 存在。
|
|
4
|
+
* 由 `spawn(process.execPath, ['-e', SCRIPT, port], { detached, stdio:'ignore' })` 孵化,
|
|
5
|
+
* 宿主进程被 kill 后仍能完成「停旧 → 等端口释放 → 拉起新 dsh web」。
|
|
6
|
+
* 端口探测不到监听进程时不再假装重启(记一行日志后退出,见脚本内注释)。
|
|
7
|
+
*/
|
|
8
|
+
export const HOST_RESTART_SCRIPT = String.raw `const C = require('node:child_process')
|
|
9
|
+
const F = require('node:fs')
|
|
10
|
+
const P = require('node:path')
|
|
11
|
+
const O = require('node:os')
|
|
12
|
+
const win = process.platform === 'win32'
|
|
13
|
+
const port = Number(process.argv[1])
|
|
14
|
+
|
|
15
|
+
function findPids() {
|
|
16
|
+
if (win) {
|
|
17
|
+
const out = C.spawnSync('netstat', ['-ano', '-p', 'tcp'], { encoding: 'utf8' })
|
|
18
|
+
const found = []
|
|
19
|
+
for (const line of String(out.stdout || '').split(/\r?\n/)) {
|
|
20
|
+
if (!/LISTENING/i.test(line)) continue
|
|
21
|
+
const parts = line.trim().split(/\s+/)
|
|
22
|
+
const addr = parts[1] || ''
|
|
23
|
+
if (!addr.endsWith(':' + port)) continue
|
|
24
|
+
const pid = parts[parts.length - 1]
|
|
25
|
+
if (pid && /\d+/.test(pid)) found.push(pid)
|
|
26
|
+
}
|
|
27
|
+
return found
|
|
28
|
+
}
|
|
29
|
+
const out = C.spawnSync('lsof', ['-ti', 'tcp:' + port, '-sTCP:LISTEN'], { encoding: 'utf8' })
|
|
30
|
+
return String(out.stdout || '').split(/\s+/).filter(Boolean)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const targets = findPids()
|
|
34
|
+
// 一个监听进程都没找到 = 端口探测本身就失败了(netstat 输出格式 / 本地化差异),
|
|
35
|
+
// 旧宿主仍占着端口。此时再拉一个 dsh web 只会在日志里以 EADDRINUSE 静默退出,
|
|
36
|
+
// 用户却看到「重启完成」——不制造这种假象,留一行可诊断的记录后退出。
|
|
37
|
+
if (targets.length === 0) {
|
|
38
|
+
logLine('restart aborted: no listener found on port ' + port)
|
|
39
|
+
process.exit(0)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
for (const pid of targets) {
|
|
43
|
+
try {
|
|
44
|
+
if (win) C.spawnSync('taskkill', ['/pid', pid, '/t', '/f'])
|
|
45
|
+
else process.kill(Number(pid), 'SIGTERM')
|
|
46
|
+
} catch {}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let tries = 0
|
|
50
|
+
;(function waitForStop() {
|
|
51
|
+
if (findPids().length === 0 || ++tries > 20) return startHost()
|
|
52
|
+
setTimeout(waitForStop, 250)
|
|
53
|
+
})()
|
|
54
|
+
|
|
55
|
+
function logLine(msg) {
|
|
56
|
+
try {
|
|
57
|
+
const dir = P.join(O.homedir(), '.dsh', 'logs')
|
|
58
|
+
F.mkdirSync(dir, { recursive: true })
|
|
59
|
+
F.appendFileSync(P.join(dir, 'dsh-web-' + port + '.log'), '[' + new Date().toISOString() + '] ' + msg + '\n')
|
|
60
|
+
} catch {}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function startHost() {
|
|
64
|
+
const dir = P.join(O.homedir(), '.dsh', 'logs')
|
|
65
|
+
F.mkdirSync(dir, { recursive: true })
|
|
66
|
+
const fd = F.openSync(P.join(dir, 'dsh-web-' + port + '.log'), 'a')
|
|
67
|
+
const child = C.spawn('dsh', ['web', '--port', String(port)], {
|
|
68
|
+
detached: true,
|
|
69
|
+
shell: win,
|
|
70
|
+
stdio: ['ignore', fd, fd],
|
|
71
|
+
})
|
|
72
|
+
child.unref()
|
|
73
|
+
}`;
|
|
74
|
+
/**
|
|
75
|
+
* 宿主是否由桌面应用壳托管(`ELECTRON_RUN_AS_NODE=1`:壳用 Electron 二进制以 Node 模式跑宿主)。
|
|
76
|
+
* 桌面端的宿主归应用壳管,插件重启不了它,也不该去 kill —— 详见 /restart 路由。
|
|
77
|
+
*/
|
|
78
|
+
export function isDesktopHost() {
|
|
79
|
+
return process.env.ELECTRON_RUN_AS_NODE === '1';
|
|
80
|
+
}
|
|
81
|
+
export const PROFILE_RE = /^[A-Za-z0-9_-]+$/;
|
|
82
|
+
const BODY_LIMIT_BYTES = 4 * 1024;
|
|
83
|
+
export const COMMAND_TIMEOUT_MS = 5 * 60 * 1000;
|
|
84
|
+
/** 目录/统计数据代理的本地缓存时长:1 小时内重复打开插件市场直接读盘,
|
|
85
|
+
* 不重复走 curl 拉远程 —— 重启宿主后首次打开同样秒开(缓存跨重启存活)。 */
|
|
86
|
+
export const CATALOG_CACHE_TTL_MS = 60 * 60 * 1000;
|
|
87
|
+
export function sendJson(response, status, value) {
|
|
88
|
+
response.writeHead(status, {
|
|
89
|
+
'cache-control': 'no-store',
|
|
90
|
+
'content-type': 'application/json; charset=utf-8',
|
|
91
|
+
});
|
|
92
|
+
response.end(JSON.stringify(value));
|
|
93
|
+
}
|
|
94
|
+
/** 本机回环主机名:`localhost` / `127.0.0.1` / `[::1]` 指向同一台机器的同一个端口,
|
|
95
|
+
* 字符串不同但同源,必须按同一来源比较。 */
|
|
96
|
+
const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
97
|
+
/** 把 `host[:port]` 形式的 authority 归一化成 `loopback:端口` —— 三个回环别名
|
|
98
|
+
* 映射成同一个身份,端口缺省按 http 的 80 补齐;非回环主机名、以及解析不出来的值
|
|
99
|
+
* 都返回 null。 */
|
|
100
|
+
function loopbackAuthority(authority) {
|
|
101
|
+
try {
|
|
102
|
+
const parsed = new URL(`http://${authority}`);
|
|
103
|
+
if (!LOOPBACK_HOSTNAMES.has(parsed.hostname.toLowerCase()))
|
|
104
|
+
return null;
|
|
105
|
+
return `loopback:${parsed.port === '' ? '80' : parsed.port}`;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** POST mutations are only accepted from the local web server origin or the desktop host page.
|
|
112
|
+
* Exported for tests (tests/routes.test.ts) — the origin check is the whole CSRF defence. */
|
|
113
|
+
export function isSameOrigin(request) {
|
|
114
|
+
// 服务只绑定本机回环地址:Host 不是回环(局域网 IP、被改写的域名)一律拒绝,
|
|
115
|
+
// 顺带挡掉 DNS rebinding(Host 是攻击者域名,即便解析到 127.0.0.1)。
|
|
116
|
+
const host = request.headers.host;
|
|
117
|
+
const hostKey = host === undefined ? null : loopbackAuthority(host);
|
|
118
|
+
if (hostKey === null)
|
|
119
|
+
return false;
|
|
120
|
+
const origin = request.headers.origin;
|
|
121
|
+
// 桌面端宿主把 `/dsh-plugin-hub/*` 转发给本地 http server 时可能不带 Origin 头,
|
|
122
|
+
// 此时所有 POST 都会被判成 untrusted origin。浏览器对 POST 请求
|
|
123
|
+
// 必定携带 Origin(含同源 POST 与跨站表单),能省略该头的只有本机原生客户端,
|
|
124
|
+
// 而它们本来就能伪造任意 Origin,因此放行不扩大 CSRF 面。
|
|
125
|
+
if (origin === undefined || origin === '')
|
|
126
|
+
return true;
|
|
127
|
+
try {
|
|
128
|
+
const url = new URL(origin);
|
|
129
|
+
// 桌面端(Electron 宿主)的页面跑在宿主自定义协议下,origin 形如 `dsh-app://app`,
|
|
130
|
+
// 与 Host(如 localhost:3081)必然不等,因此必须显式放行该协议,否则桌面端全部 POST
|
|
131
|
+
// 都会被判成 untrusted origin。该协议只有宿主自身能产生,外部网页无法把 Origin
|
|
132
|
+
// 伪造成 `dsh-app://…`,放行不扩大 CSRF 面。
|
|
133
|
+
if (url.protocol === 'dsh-app:')
|
|
134
|
+
return true;
|
|
135
|
+
// 按归一化后的「主机名:端口」比较:`localhost` / `127.0.0.1` / `[::1]` 不再互相拒绝,
|
|
136
|
+
// 端口仍然必须一致(另一个本机进程不能被当成同一来源)。
|
|
137
|
+
return loopbackAuthority(url.host) === hostKey;
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
export function requireMethod(request, response, method) {
|
|
144
|
+
if (request.method === method)
|
|
145
|
+
return true;
|
|
146
|
+
response.writeHead(405, { allow: method });
|
|
147
|
+
response.end();
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
export function requireTrustedPost(request, response) {
|
|
151
|
+
if (!requireMethod(request, response, 'POST'))
|
|
152
|
+
return false;
|
|
153
|
+
if (isSameOrigin(request))
|
|
154
|
+
return true;
|
|
155
|
+
sendJson(response, 403, { error: 'untrusted origin' });
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
export async function readJsonBody(request) {
|
|
159
|
+
const chunks = [];
|
|
160
|
+
let size = 0;
|
|
161
|
+
for await (const chunk of request) {
|
|
162
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
163
|
+
size += buffer.length;
|
|
164
|
+
if (size > BODY_LIMIT_BYTES)
|
|
165
|
+
throw new Error('request body too large');
|
|
166
|
+
chunks.push(buffer);
|
|
167
|
+
}
|
|
168
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
169
|
+
}
|
package/lib/http/task.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
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
|
+
* Task/misc routes: task polling, host restart, loader-entry debugging, and
|
|
7
|
+
* the installed-plugin inventory the client uses to reconcile its list.
|
|
8
|
+
*/
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { activeTask, cancelTask, dumpLoaderEntries, getTask, githubTarget, listPendingRestarts, validPackageName } from '../services/install/install.js';
|
|
12
|
+
import { readInstalledVersions, recordInstalledVersion, removeInstalledVersion } from '../services/profile/installed-versions.js';
|
|
13
|
+
import { isDshPlugin, isEntryLoaded } from '../services/loader.js';
|
|
14
|
+
import { profileDirectory, readInstalled } from './catalog.js';
|
|
15
|
+
import { hostEnv } from './diagnostics.js';
|
|
16
|
+
import { HOST_RESTART_SCRIPT, isDesktopHost, readJsonBody, requireMethod, requireTrustedPost, sendJson } from './shared.js';
|
|
17
|
+
export function registerTaskRoutes(webServer, profile, loader) {
|
|
18
|
+
return [
|
|
19
|
+
{
|
|
20
|
+
kind: 'exact',
|
|
21
|
+
path: '/dsh-plugin-hub/debug/loader-entries',
|
|
22
|
+
handler: (request, response) => {
|
|
23
|
+
if (!requireMethod(request, response, 'GET'))
|
|
24
|
+
return;
|
|
25
|
+
sendJson(response, 200, { entries: dumpLoaderEntries(loader) });
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
kind: 'exact',
|
|
30
|
+
path: '/dsh-plugin-hub/status',
|
|
31
|
+
handler: (request, response) => {
|
|
32
|
+
if (!requireMethod(request, response, 'GET'))
|
|
33
|
+
return;
|
|
34
|
+
const url = new URL(request.url ?? '/', 'http://localhost');
|
|
35
|
+
const id = Number(url.searchParams.get('task'));
|
|
36
|
+
const task = Number.isInteger(id) ? getTask(id) : undefined;
|
|
37
|
+
if (!task) {
|
|
38
|
+
sendJson(response, 404, { error: 'task not found' });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
sendJson(response, 200, { task });
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
kind: 'exact',
|
|
46
|
+
path: '/dsh-plugin-hub/active',
|
|
47
|
+
handler: (request, response) => {
|
|
48
|
+
if (!requireMethod(request, response, 'GET'))
|
|
49
|
+
return;
|
|
50
|
+
// 整个任务队列(running 在前、pending 在后)+ 待重启列表:客户端刷新后据此恢复
|
|
51
|
+
// 进行中/排队任务与「装完没重启」的持久提醒
|
|
52
|
+
sendJson(response, 200, { tasks: activeTask(), pendingRestarts: listPendingRestarts() });
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
kind: 'exact',
|
|
57
|
+
path: '/dsh-plugin-hub/env',
|
|
58
|
+
handler: (request, response) => {
|
|
59
|
+
if (!requireMethod(request, response, 'GET'))
|
|
60
|
+
return;
|
|
61
|
+
// 宿主机器环境快照:提交 bug 的 issue 正文会带上,便于作者复现
|
|
62
|
+
sendJson(response, 200, hostEnv(profile));
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
kind: 'exact',
|
|
67
|
+
path: '/dsh-plugin-hub/cancel',
|
|
68
|
+
handler: async (request, response) => {
|
|
69
|
+
if (!requireTrustedPost(request, response))
|
|
70
|
+
return;
|
|
71
|
+
try {
|
|
72
|
+
const body = await readJsonBody(request);
|
|
73
|
+
const id = typeof body === 'object' && body !== null && typeof body.id === 'number'
|
|
74
|
+
? body.id
|
|
75
|
+
: NaN;
|
|
76
|
+
const ok = Number.isInteger(id) && cancelTask(id);
|
|
77
|
+
sendJson(response, 200, { ok });
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
kind: 'exact',
|
|
86
|
+
path: '/dsh-plugin-hub/restart',
|
|
87
|
+
handler: (request, response) => {
|
|
88
|
+
if (!requireTrustedPost(request, response))
|
|
89
|
+
return;
|
|
90
|
+
// 桌面端:宿主由应用壳托管,插件重启不了它。Windows 上 `taskkill /T` 会把发起
|
|
91
|
+
// 重启的助手一起结束(助手是宿主的子进程),壳再把这次外部 kill 判成宿主崩溃
|
|
92
|
+
// 弹「应用无法启动或已意外停止」;退一步说,脱离壳拉起的 `dsh web` 也服务不了
|
|
93
|
+
// 桌面 UI(缺壳注入的 desktop:boot)。故不 kill、不拉新宿主,由客户端改提示
|
|
94
|
+
// 用户从菜单/托盘退出后重新打开。
|
|
95
|
+
if (isDesktopHost()) {
|
|
96
|
+
sendJson(response, 200, { ok: false, desktop: true });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
// 当前宿主监听端口来自请求 Host 头(localhost:7923),解析失败回退 7923
|
|
100
|
+
const host = request.headers.host ?? '';
|
|
101
|
+
const portMatch = host.match(/:(\d+)$/);
|
|
102
|
+
const port = portMatch ? Number(portMatch[1]) : 7923;
|
|
103
|
+
// detached 子进程完成「停旧进程 → 等端口释放 → 后台拉起 dsh web」,
|
|
104
|
+
// 即使当前宿主(即本插件所在进程)被 kill,重启仍会继续执行。
|
|
105
|
+
// 不用 /bin/sh:Windows 无此路径,且 lsof/nohup 等仅 POSIX 存在,
|
|
106
|
+
// 故孵化 node(process.execPath) 跑跨平台脚本 —— Windows 用
|
|
107
|
+
// netstat + taskkill + shell:true 解析 dsh.cmd,POSIX 用 lsof + SIGTERM。
|
|
108
|
+
spawn(process.execPath, ['-e', HOST_RESTART_SCRIPT, String(port)], { detached: true, stdio: 'ignore' }).unref();
|
|
109
|
+
sendJson(response, 200, { ok: true, port });
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
kind: 'exact',
|
|
114
|
+
path: '/dsh-plugin-hub/installed-version',
|
|
115
|
+
handler: async (request, response) => {
|
|
116
|
+
if (!requireTrustedPost(request, response))
|
|
117
|
+
return;
|
|
118
|
+
try {
|
|
119
|
+
const body = await readJsonBody(request);
|
|
120
|
+
const repo = typeof body === 'object' && body !== null && typeof body.repo === 'string'
|
|
121
|
+
? body.repo
|
|
122
|
+
: '';
|
|
123
|
+
if (githubTarget(repo) === null) {
|
|
124
|
+
sendJson(response, 400, { error: 'invalid install target' });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
// version 为空 = 卸载清理记录;否则记录安装时的目录信号(最新版本 + 仓库更新时间),
|
|
128
|
+
// 供「有更新」比对:有版本比版本,无版本比更新时间
|
|
129
|
+
const version = body !== null && typeof body.version === 'string'
|
|
130
|
+
? body.version
|
|
131
|
+
: null;
|
|
132
|
+
if (version === null) {
|
|
133
|
+
removeInstalledVersion(profile, repo);
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
const updatedAt = body !== null && typeof body.updatedAt === 'string'
|
|
137
|
+
? body.updatedAt
|
|
138
|
+
: '';
|
|
139
|
+
recordInstalledVersion(profile, repo, version, updatedAt);
|
|
140
|
+
}
|
|
141
|
+
sendJson(response, 200, { ok: true });
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
kind: 'exact',
|
|
150
|
+
path: '/dsh-plugin-hub/open-path',
|
|
151
|
+
handler: async (request, response) => {
|
|
152
|
+
if (!requireTrustedPost(request, response))
|
|
153
|
+
return;
|
|
154
|
+
// 在系统文件管理器里定位并打开已安装插件目录(详情视图「在文件夹中显示」)。
|
|
155
|
+
// 只接受已安装依赖的包名、服务端自行拼接路径——杜绝任意路径注入
|
|
156
|
+
try {
|
|
157
|
+
const body = await readJsonBody(request);
|
|
158
|
+
const name = typeof body === 'object' && body !== null && typeof body.name === 'string'
|
|
159
|
+
? body.name
|
|
160
|
+
: '';
|
|
161
|
+
if (!validPackageName(name) || readInstalled(profile)[name] === undefined) {
|
|
162
|
+
sendJson(response, 400, { error: 'plugin is not installed' });
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const dir = join(profileDirectory(profile), 'node_modules', name);
|
|
166
|
+
// macOS Finder 用 -R 定位选中;Windows explorer 用 /select,;Linux 打开目录(xdg-open)
|
|
167
|
+
const bin = process.platform === 'darwin' ? 'open'
|
|
168
|
+
: process.platform === 'win32' ? 'explorer'
|
|
169
|
+
: 'xdg-open';
|
|
170
|
+
const args = process.platform === 'darwin' ? ['-R', dir]
|
|
171
|
+
: process.platform === 'win32' ? ['/select,', dir]
|
|
172
|
+
: [dir];
|
|
173
|
+
spawn(bin, args, { detached: true, stdio: 'ignore' }).unref();
|
|
174
|
+
sendJson(response, 200, { ok: true });
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
kind: 'exact',
|
|
183
|
+
path: '/dsh-plugin-hub/installed',
|
|
184
|
+
handler: (request, response) => {
|
|
185
|
+
if (!requireMethod(request, response, 'GET'))
|
|
186
|
+
return;
|
|
187
|
+
// installed:依赖表(包名 → spec);versions:安装时记录的目录版本(repo → 版本);
|
|
188
|
+
// paths:每个依赖在系统上的安装目录(profile/node_modules/<包名>),详情视图展示用;
|
|
189
|
+
// loaded:已加载进运行中 loader 的包名(官方 ctx.loader.entries(),未重启装的新插件不在其中);
|
|
190
|
+
// dshCapable:真正的 dsh 插件(包内声明 dsh 配置 / 在 profile bundles 清单)——
|
|
191
|
+
// 非 dsh 插件(如 GitHub 官方示例仓库)装上也不会被宿主加载,客户端据此不再提示「待重启」。
|
|
192
|
+
// 客户端合并这些判断「是否有更新」「运行状态」并展示安装路径/时间等运行时信息。
|
|
193
|
+
const installed = readInstalled(profile);
|
|
194
|
+
const paths = {};
|
|
195
|
+
const loaded = [];
|
|
196
|
+
const dshCapable = [];
|
|
197
|
+
for (const name of Object.keys(installed)) {
|
|
198
|
+
paths[name] = join(profileDirectory(profile), 'node_modules', name);
|
|
199
|
+
if (isEntryLoaded(loader, name))
|
|
200
|
+
loaded.push(name);
|
|
201
|
+
if (isDshPlugin(profile, name))
|
|
202
|
+
dshCapable.push(name);
|
|
203
|
+
}
|
|
204
|
+
sendJson(response, 200, { profile, installed, versions: readInstalledVersions(profile), paths, loaded, dshCapable });
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
];
|
|
208
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { addPendingRestart, clearPendingRestart, listPendingRestarts } from '../profile/pending-restart.js';
|
|
2
2
|
export { dumpLoaderEntries, removeLoadedEntry } from '../loader.js';
|
|
3
|
-
export { addAllowBuildsKey, githubRefOf, githubRepoOf, githubTarget, globalNpmPackagesOf, installTargetOf, parseAllowBuildsKey, readProfileArg, validPackageName } from '../profile/profile.js';
|
|
3
|
+
export { addAllowBuildsKey, githubRefOf, githubRepoOf, githubTarget, globalNpmPackagesOf, installTargetOf, parseAllowBuildsKey, readProfileArg, validPackageName, } from '../profile/profile.js';
|
|
4
4
|
export { activeTask, cancelTask, getTask, hasQueuedTarget, hasRunningTask, runPluginMutation, startPluginMutation } from './task-queue.js';
|
|
@@ -1,21 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* DSH
|
|
2
|
+
* DSH Plugin Hub — the community plugin marketplace for DeepSeek Harness.
|
|
3
3
|
* Website: https://dsh-plugin.org
|
|
4
4
|
* GitHub: https://github.com/dshplugin/dsh-plugin-hub
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* 分发文件解析:从 package.json 推出装完后必须真实存在的文件 —— 作者声明的入口
|
|
7
|
+
* (exports["."] / main)与 bundle 声明的补丁(dsh.bundle.patch)。git 通道(tar 预检)
|
|
8
|
+
* 与 npm 通道(装后 statSync 校验)共用,保证两条通道对同一个包判定一致。
|
|
9
|
+
*
|
|
10
|
+
* 这里不替作者补一个 index.js:宿主加载一个 bundle 读的是它的 package.json 和它声明的
|
|
11
|
+
* 补丁文件(官方 bundleManifest 的注释即「Read bundle metadata without loading its
|
|
12
|
+
* JavaScript」),从不 import 包本身。凭空要求一个作者没声明的入口,只会把完整的
|
|
13
|
+
* 技能包判成分发残缺,并把一条假缺陷推到作者仓库(见 QingYunA/answer-me-with-html#209)。
|
|
14
|
+
*
|
|
15
|
+
* 例外是补丁自己 mount 了本包:dsh 的《打包与安装插件》里组合包的常规写法就是
|
|
16
|
+
* `- insert: - name: <本包名>`,宿主随后按包名 import 本包,入口那时必须存在。
|
|
17
|
+
* 所以只有补丁真的写了自己的包名,才把这个入口算进分发要求。
|
|
9
18
|
*/
|
|
10
19
|
/** exports 条件对象里按优先级尝试的键;Node 语义下 default 最宽,import/require 次之。 */
|
|
11
20
|
const EXPORT_CONDITION_KEYS = ['default', 'import', 'require'];
|
|
12
21
|
/**
|
|
13
|
-
*
|
|
22
|
+
* 解析作者声明的入口文件(相对包根,不带 `./` 前缀)。manifest 未声明入口时返回 null:
|
|
23
|
+
* 没有声明就没有可缺失的入口,是否可 import 交给真实安装与宿主加载去暴露。
|
|
14
24
|
*
|
|
15
25
|
* Node 的 exports 规范要求字符串 target 以 `./` 开头(如 `"./lib/index.js"`),
|
|
16
26
|
* 而 tar 成员名与磁盘路径都不带该前缀,因此统一剥掉;条件对象
|
|
17
27
|
* (`{ "import": ..., "require": ... }`)按 default → import → require 取第一
|
|
18
|
-
* 个字符串值。无 exports 时回退 main
|
|
28
|
+
* 个字符串值。无 exports 时回退 main;两者都没有则没有入口。
|
|
19
29
|
*/
|
|
20
30
|
export function resolvePackageEntry(meta) {
|
|
21
31
|
const dot = meta.exports?.['.'];
|
|
@@ -26,8 +36,48 @@ export function resolvePackageEntry(meta) {
|
|
|
26
36
|
: undefined;
|
|
27
37
|
const candidate = typeof fromExports === 'string' ? fromExports
|
|
28
38
|
: typeof meta.main === 'string' ? meta.main
|
|
29
|
-
:
|
|
30
|
-
return candidate
|
|
39
|
+
: null;
|
|
40
|
+
return candidate === null ? null : stripDotSlash(candidate);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* 解析 `dsh.bundle.patch` 声明的补丁文件(相对包根,不带 `./` 前缀)。
|
|
44
|
+
* 形状不合规范(既不是字符串也不是字符串数组)在这里不判定:那是 dsh 启动时
|
|
45
|
+
* bundlePatchFiles 的报错职责,预检只负责「声明了就要在包里存在」。
|
|
46
|
+
*/
|
|
47
|
+
export function resolveBundlePatchFiles(meta) {
|
|
48
|
+
const patch = meta.dsh?.bundle?.patch;
|
|
49
|
+
const declared = typeof patch === 'string' ? [patch] : Array.isArray(patch) ? patch : [];
|
|
50
|
+
return declared
|
|
51
|
+
.filter((file) => typeof file === 'string' && file !== '')
|
|
52
|
+
.map(stripDotSlash);
|
|
53
|
+
}
|
|
54
|
+
/** 分发里必须存在的全部文件:作者声明的入口,加上 bundle 声明的补丁,入口在前。
|
|
55
|
+
* manifest 没声明入口时才看补丁:补丁按本包名 mount 自己(下面 mountsItself),
|
|
56
|
+
* 宿主就会 import 本包,Node 的旧式解析落到 index.js —— 那个入口同样必须有。 */
|
|
57
|
+
export function requiredDistributionFiles(meta, patchTexts = []) {
|
|
58
|
+
const declared = resolvePackageEntry(meta);
|
|
59
|
+
const entry = declared ?? (mountsItself(meta, patchTexts) ? 'index.js' : null);
|
|
60
|
+
return [...(entry === null ? [] : [entry]), ...resolveBundlePatchFiles(meta)];
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* 补丁文本里插件行的 `name:` 值(DSH 补丁按行书写:`- name: <pkg>` / ` name: <pkg>`)。
|
|
64
|
+
* 刻意不做 YAML 解析:只认这一种行形状,认不出来就不判定 —— 漏判只是少一条要求,
|
|
65
|
+
* 误判会拦下能装的包,两边的代价不对称。
|
|
66
|
+
*/
|
|
67
|
+
export function patchRowNamesOf(text) {
|
|
68
|
+
return text
|
|
69
|
+
.split(/\r?\n/)
|
|
70
|
+
.map((line) => /^\s*(?:-\s*)?name:\s*(.+?)\s*$/u.exec(line)?.[1])
|
|
71
|
+
.filter((value) => value !== undefined)
|
|
72
|
+
.map((value) => value.replace(/^(['"])(.*)\1$/u, '$2'))
|
|
73
|
+
.filter((value) => value !== '');
|
|
74
|
+
}
|
|
75
|
+
/** 补丁是否把本包自己 mount 进树(dsh 文档里组合包的常规写法),是则宿主会 import 本包。 */
|
|
76
|
+
export function mountsItself(meta, patchTexts) {
|
|
77
|
+
const name = meta.name;
|
|
78
|
+
if (typeof name !== 'string' || name === '')
|
|
79
|
+
return false;
|
|
80
|
+
return patchTexts.some((text) => patchRowNamesOf(text).includes(name));
|
|
31
81
|
}
|
|
32
82
|
function firstStringOf(conditions) {
|
|
33
83
|
for (const key of EXPORT_CONDITION_KEYS) {
|
|
@@ -37,3 +87,4 @@ function firstStringOf(conditions) {
|
|
|
37
87
|
}
|
|
38
88
|
return undefined;
|
|
39
89
|
}
|
|
90
|
+
const stripDotSlash = (file) => file.replace(/^\.\//u, '');
|
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
* Website: https://dsh-plugin.org
|
|
4
4
|
* GitHub: https://github.com/dshplugin/dsh-plugin-hub
|
|
5
5
|
*
|
|
6
|
-
* 安装前预检:对 GitHub 源的插件,检查其分发(codeload tarball
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* 安装前预检:对 GitHub 源的插件,检查其分发(codeload tarball)里是否真的包含
|
|
7
|
+
* package.json 声明的文件(main / exports["."] / dsh.bundle.patch)。git 分发常不提交
|
|
8
|
+
* 构建产物(lib/ 等),这类残缺包装完会让宿主重启加载插件树时 ERR_MODULE_NOT_FOUND
|
|
9
|
+
* 直接崩溃 —— 预检在动手安装前就把它们拦下,避免白装一次。
|
|
10
10
|
* npm 包信任 registry 的完整性,直接放行;任何无法预检的情况也放行,交给装后
|
|
11
11
|
* 校验(verifyInstalledEntry)兜底,保证预检自身失败不会卡死安装流程。
|
|
12
12
|
*/
|
|
@@ -19,7 +19,7 @@ import { pipeline } from 'node:stream/promises';
|
|
|
19
19
|
import { promisify } from 'node:util';
|
|
20
20
|
import { githubRefOf, githubRepoOf } from '../profile/profile.js';
|
|
21
21
|
import { githubReleaseTarget } from './release-target.js';
|
|
22
|
-
import {
|
|
22
|
+
import { requiredDistributionFiles, resolveBundlePatchFiles } from './package-entry.js';
|
|
23
23
|
const execFileAsync = promisify(execFile);
|
|
24
24
|
/** 单次网络操作超时(ms):git ls-remote / codeload 下载。网络不可达时不能无限挂起,
|
|
25
25
|
* 否则 `/install` 请求永远不返回、任务不入队、前端进度卡 0%。超时后放行(交给装后校验)。 */
|
|
@@ -49,9 +49,18 @@ export async function preflightTarget(target) {
|
|
|
49
49
|
const meta = await readTarJson(tar, rootPackage);
|
|
50
50
|
if (meta === null)
|
|
51
51
|
return { ok: true, missing: null, name: null };
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
// 只校验作者声明过的文件:没声明入口的包(只挂技能的 bundle)没有可缺失的入口,
|
|
53
|
+
// 它的分发是否完整由 dsh.bundle.patch 是否在包里决定;补丁若按本包名 mount 自己,
|
|
54
|
+
// 入口又变成必须的(宿主会 import 本包)—— 所以判入口前先把补丁读出来。
|
|
55
|
+
const patchTexts = [];
|
|
56
|
+
for (const file of resolveBundlePatchFiles(meta)) {
|
|
57
|
+
const text = await readTarText(tar, `${rootPrefix}${file}`);
|
|
58
|
+
if (text !== null)
|
|
59
|
+
patchTexts.push(text);
|
|
60
|
+
}
|
|
61
|
+
const required = requiredDistributionFiles(meta, patchTexts).map((file) => `${rootPrefix}${file}`);
|
|
62
|
+
const missing = missingDistributionFileOfTarList(await tarListing(tar), required);
|
|
63
|
+
return { ok: missing === null, missing, name: typeof meta.name === 'string' ? meta.name : null };
|
|
55
64
|
}
|
|
56
65
|
catch {
|
|
57
66
|
return { ok: true, missing: null, name: null };
|
|
@@ -117,32 +126,53 @@ export function rootPackageEntryOfTarList(listing) {
|
|
|
117
126
|
.filter((line) => /^[^/]+\/package\.json$/u.test(line));
|
|
118
127
|
return candidates.length === 1 ? candidates[0] : null;
|
|
119
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* 清单里第一个缺失的必需文件(成员名带包根前缀,由调用方拼好),全部都在则为 null。
|
|
131
|
+
* 清单不可读(tar 缺失/解压失败)时按第一个必需文件缺失处理:与逐文件查询时
|
|
132
|
+
* 「查不到就是缺」的判定一致,宁可拦下一次可疑安装,也不放一个空包过去。
|
|
133
|
+
* Exported for a small parser regression test.
|
|
134
|
+
*/
|
|
135
|
+
export function missingDistributionFileOfTarList(listing, required) {
|
|
136
|
+
if (required.length === 0)
|
|
137
|
+
return null;
|
|
138
|
+
if (listing === null)
|
|
139
|
+
return required[0];
|
|
140
|
+
const present = new Set(listing.split(/\r?\n/));
|
|
141
|
+
return required.find((file) => !present.has(file)) ?? null;
|
|
142
|
+
}
|
|
120
143
|
async function rootPackageEntry(tar) {
|
|
144
|
+
const listing = await tarListing(tar);
|
|
145
|
+
return listing === null ? null : rootPackageEntryOfTarList(listing);
|
|
146
|
+
}
|
|
147
|
+
/** tar 成员清单;`tar` 执行失败时返回 null(调用方按无法判定处理)。 */
|
|
148
|
+
async function tarListing(tar) {
|
|
121
149
|
try {
|
|
122
150
|
const { stdout } = await execFileAsync('tar', ['-tzf', tar], { maxBuffer: 16 * 1024 * 1024 });
|
|
123
|
-
return
|
|
151
|
+
return stdout;
|
|
124
152
|
}
|
|
125
153
|
catch {
|
|
126
154
|
return null;
|
|
127
155
|
}
|
|
128
156
|
}
|
|
129
|
-
/** 从 tarball
|
|
130
|
-
async function
|
|
157
|
+
/** 从 tarball 提取一个文件的内容;文件不存在或提取失败返回 null。 */
|
|
158
|
+
async function readTarText(tar, entry) {
|
|
131
159
|
try {
|
|
132
160
|
const { stdout } = await execFileAsync('tar', ['-xzOf', tar, entry], { maxBuffer: 4 * 1024 * 1024 });
|
|
133
|
-
return
|
|
161
|
+
return stdout;
|
|
134
162
|
}
|
|
135
163
|
catch {
|
|
136
164
|
return null;
|
|
137
165
|
}
|
|
138
166
|
}
|
|
139
|
-
/**
|
|
140
|
-
async function
|
|
167
|
+
/** 从 tarball 提取并解析一个 JSON 文件;文件不存在或解析失败返回 null。 */
|
|
168
|
+
async function readTarJson(tar, entry) {
|
|
169
|
+
const text = await readTarText(tar, entry);
|
|
170
|
+
if (text === null)
|
|
171
|
+
return null;
|
|
141
172
|
try {
|
|
142
|
-
|
|
143
|
-
return stdout.split(/\r?\n/).some((line) => line === entry);
|
|
173
|
+
return JSON.parse(text);
|
|
144
174
|
}
|
|
145
175
|
catch {
|
|
146
|
-
return
|
|
176
|
+
return null;
|
|
147
177
|
}
|
|
148
178
|
}
|