dsh-plugin 0.1.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +52 -27
- package/README.md +52 -27
- package/client/client.js +2148 -229
- package/client/client.js.map +1 -1
- package/lib/http/routes.js +396 -0
- package/lib/index.js +17 -8
- package/lib/services/install-types.js +11 -0
- package/lib/services/install.js +4 -0
- package/lib/services/installed-versions.js +51 -0
- package/lib/services/loader.js +98 -0
- package/lib/services/pending-restart.js +34 -0
- package/lib/services/preflight.js +94 -0
- package/lib/services/profile.js +88 -0
- package/lib/services/progress.js +50 -0
- package/lib/services/task-queue.js +417 -0
- package/lib/types/http/routes.d.ts +23 -0
- package/lib/types/index.d.ts +27 -0
- package/lib/types/services/install-types.d.ts +63 -0
- package/lib/types/services/install.d.ts +18 -0
- package/lib/types/services/installed-versions.d.ts +14 -0
- package/lib/types/services/loader.d.ts +43 -0
- package/lib/types/services/pending-restart.d.ts +13 -0
- package/lib/types/services/preflight.d.ts +6 -0
- package/lib/types/services/profile.d.ts +24 -0
- package/lib/types/services/progress.d.ts +17 -0
- package/lib/types/services/task-queue.d.ts +76 -0
- package/package.json +27 -6
- package/src/client/components/CatalogControls.tsx +104 -0
- package/src/client/components/CatalogHeader.tsx +78 -0
- package/src/client/components/CatalogList.tsx +75 -0
- package/src/client/components/CategoryTabs.tsx +35 -0
- package/src/client/components/Dropdown.tsx +80 -0
- package/src/client/components/NotificationsModal.tsx +230 -0
- package/src/client/components/PluginCard.tsx +104 -0
- package/src/client/components/PluginHubSection.tsx +339 -0
- package/src/client/components/ProgressView.tsx +23 -0
- package/src/client/components/icons.tsx +125 -0
- package/src/client/components/modals.tsx +365 -0
- package/src/client/hooks/useCatalog.ts +192 -0
- package/src/client/hooks/useTaskQueue.ts +405 -0
- package/src/client/index.tsx +8 -422
- package/src/client/lib/catalog.ts +204 -0
- package/src/client/lib/env.ts +16 -0
- package/src/client/lib/failures.ts +153 -0
- package/src/client/lib/format.ts +22 -0
- package/src/client/locales.ts +186 -18
- package/src/client/styles/Dropdown.module.css +123 -0
- package/src/client/styles/Header.module.css +432 -0
- package/src/client/styles/List.module.css +324 -0
- package/src/client/styles/Modal.module.css +965 -0
- package/src/client/styles/tokens.module.css +86 -0
- package/src/client/types.ts +80 -0
- package/src/server/http/routes.ts +397 -0
- package/src/server/index.ts +35 -0
- package/src/server/services/install-types.ts +63 -0
- package/src/server/services/install.ts +18 -0
- package/src/server/services/installed-versions.ts +65 -0
- package/src/server/services/loader.ts +110 -0
- package/src/server/services/pending-restart.ts +36 -0
- package/src/server/services/preflight.ts +97 -0
- package/src/server/services/profile.ts +89 -0
- package/src/server/services/progress.ts +48 -0
- package/src/server/services/task-queue.ts +450 -0
- package/src/client/Section.module.css +0 -569
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local HTTP routes exposing real installs to the in-app Plugin Hub UI.
|
|
3
|
+
* The client fetches the same-origin `/dsh-plugin-hub/*` endpoints; the
|
|
4
|
+
* install handler validates the target, then spawns the official dsh CLI
|
|
5
|
+
* (see services/install.ts) and reports the captured result back.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { homedir, release } from 'node:os';
|
|
9
|
+
import { dirname, join, resolve } from 'node:path';
|
|
10
|
+
import { spawn } from 'node:child_process';
|
|
11
|
+
import { activeTask, cancelTask, dumpLoaderEntries, getTask, githubTarget, hasQueuedTarget, listPendingRestarts, readProfileArg, startPluginMutation, validPackageName } from '../services/install.js';
|
|
12
|
+
import { recordInstalledVersion, readInstalledVersions, removeInstalledVersion } from '../services/installed-versions.js';
|
|
13
|
+
import { preflightTarget } from '../services/preflight.js';
|
|
14
|
+
const PROFILE_RE = /^[A-Za-z0-9_-]+$/;
|
|
15
|
+
const BODY_LIMIT_BYTES = 4 * 1024;
|
|
16
|
+
const COMMAND_TIMEOUT_MS = 5 * 60 * 1000;
|
|
17
|
+
function profileDirectory(profile) {
|
|
18
|
+
return join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'profiles', profile);
|
|
19
|
+
}
|
|
20
|
+
/** Read non-official dependencies installed into one profile. */
|
|
21
|
+
export function readInstalled(profile) {
|
|
22
|
+
try {
|
|
23
|
+
const manifest = JSON.parse(readFileSync(join(profileDirectory(profile), 'package.json'), 'utf8'));
|
|
24
|
+
return Object.fromEntries(Object.entries(manifest.dependencies ?? {}).filter(([name]) => !name.startsWith('@deepseek-ai/')));
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** 宿主 dsh CLI 版本:从入口(process.argv[1])向上找最近的 dsh/harness 相关 package.json;找不到返回 null。 */
|
|
31
|
+
function hostDshVersion() {
|
|
32
|
+
const entry = process.argv[1];
|
|
33
|
+
if (entry === undefined)
|
|
34
|
+
return null;
|
|
35
|
+
let dir = dirname(resolve(entry));
|
|
36
|
+
for (let depth = 0; depth < 4; depth++) {
|
|
37
|
+
try {
|
|
38
|
+
const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
|
|
39
|
+
const name = manifest.name ?? '';
|
|
40
|
+
if (typeof manifest.version === 'string' && (name === 'dsh' || name.includes('dsh') || name.includes('harness'))) {
|
|
41
|
+
return manifest.version;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
/* not a package root — keep walking up */
|
|
46
|
+
}
|
|
47
|
+
const parent = dirname(dir);
|
|
48
|
+
if (parent === dir)
|
|
49
|
+
break;
|
|
50
|
+
dir = parent;
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
/** 宿主环境快照:提交 bug 时随 issue 附上,便于作者复现(前端 /env 端点返回)。 */
|
|
55
|
+
function hostEnv(profile) {
|
|
56
|
+
return {
|
|
57
|
+
dshVersion: hostDshVersion(),
|
|
58
|
+
nodeVersion: process.version,
|
|
59
|
+
platform: process.platform,
|
|
60
|
+
arch: process.arch,
|
|
61
|
+
release: release(),
|
|
62
|
+
profile,
|
|
63
|
+
dshHome: process.env.DSH_HOME ?? join(homedir(), '.dsh'),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/** Match an install target (`github:<owner>/<repo>`) against the installed table; returns the package name when already installed. */
|
|
67
|
+
function installedByRepo(installed, target) {
|
|
68
|
+
const needle = target.toLowerCase();
|
|
69
|
+
for (const [name, spec] of Object.entries(installed)) {
|
|
70
|
+
if (spec.toLowerCase().includes(needle))
|
|
71
|
+
return name;
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
function sendJson(response, status, value) {
|
|
76
|
+
response.writeHead(status, {
|
|
77
|
+
'cache-control': 'no-store',
|
|
78
|
+
'content-type': 'application/json; charset=utf-8',
|
|
79
|
+
});
|
|
80
|
+
response.end(JSON.stringify(value));
|
|
81
|
+
}
|
|
82
|
+
/** POST mutations are only accepted from the local web server origin. */
|
|
83
|
+
function isSameOrigin(request) {
|
|
84
|
+
const origin = request.headers.origin;
|
|
85
|
+
const host = request.headers.host;
|
|
86
|
+
if (origin === undefined || host === undefined)
|
|
87
|
+
return false;
|
|
88
|
+
try {
|
|
89
|
+
const url = new URL(origin);
|
|
90
|
+
const localHostnames = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
91
|
+
return url.host === host && localHostnames.has(url.hostname);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function requireMethod(request, response, method) {
|
|
98
|
+
if (request.method === method)
|
|
99
|
+
return true;
|
|
100
|
+
response.writeHead(405, { allow: method });
|
|
101
|
+
response.end();
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
function requireTrustedPost(request, response) {
|
|
105
|
+
if (!requireMethod(request, response, 'POST'))
|
|
106
|
+
return false;
|
|
107
|
+
if (isSameOrigin(request))
|
|
108
|
+
return true;
|
|
109
|
+
sendJson(response, 403, { error: 'untrusted origin' });
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
async function readJsonBody(request) {
|
|
113
|
+
const chunks = [];
|
|
114
|
+
let size = 0;
|
|
115
|
+
for await (const chunk of request) {
|
|
116
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
117
|
+
size += buffer.length;
|
|
118
|
+
if (size > BODY_LIMIT_BYTES)
|
|
119
|
+
throw new Error('request body too large');
|
|
120
|
+
chunks.push(buffer);
|
|
121
|
+
}
|
|
122
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Register the Plugin Hub API on the host web server and return a disposer.
|
|
126
|
+
* @param webServer - DSH web server service.
|
|
127
|
+
* @param profile - profile that owns plugin mutations.
|
|
128
|
+
* @param loader - running loader (optional): lets uninstall remove the entry
|
|
129
|
+
* immediately so the page survives a refresh without a host restart.
|
|
130
|
+
*/
|
|
131
|
+
export function mountPluginHubRoutes(webServer, profile, loader) {
|
|
132
|
+
if (!PROFILE_RE.test(profile))
|
|
133
|
+
throw new Error(`invalid profile name: ${profile}`);
|
|
134
|
+
console.log(`[hub] routes mounted (profile=${profile}, loader=${loader === undefined ? 'undefined' : 'provided'})`);
|
|
135
|
+
const disposers = [
|
|
136
|
+
webServer.register({
|
|
137
|
+
kind: 'exact',
|
|
138
|
+
path: '/dsh-plugin-hub/debug/loader-entries',
|
|
139
|
+
handler: (request, response) => {
|
|
140
|
+
if (!requireMethod(request, response, 'GET'))
|
|
141
|
+
return;
|
|
142
|
+
sendJson(response, 200, { entries: dumpLoaderEntries(loader) });
|
|
143
|
+
},
|
|
144
|
+
}),
|
|
145
|
+
webServer.register({
|
|
146
|
+
kind: 'exact',
|
|
147
|
+
path: '/dsh-plugin-hub/install',
|
|
148
|
+
handler: async (request, response) => {
|
|
149
|
+
if (!requireTrustedPost(request, response))
|
|
150
|
+
return;
|
|
151
|
+
// 任务自动入队:即使已有插件操作在跑也接受请求(FIFO 串行执行),不再 409 拒绝
|
|
152
|
+
try {
|
|
153
|
+
const body = await readJsonBody(request);
|
|
154
|
+
// mode: 'update' = 已安装目标的覆盖更新(放行 add,pnpm 对已存在依赖原位覆盖重装)
|
|
155
|
+
const mode = body !== null && typeof body === 'object' && body.mode === 'update'
|
|
156
|
+
? 'update'
|
|
157
|
+
: 'install';
|
|
158
|
+
const rawRepo = typeof body === 'object' && body !== null && typeof body.repo === 'string'
|
|
159
|
+
? body.repo.trim()
|
|
160
|
+
: '';
|
|
161
|
+
// 目标语法两态:owner/repo → github 源(走装前预检);npm 包名(@scope/name 或 name)→ 信任 registry 直接安装
|
|
162
|
+
const repoTarget = githubTarget(rawRepo);
|
|
163
|
+
const target = repoTarget ?? (repoTarget === null && validPackageName(rawRepo) ? rawRepo : null);
|
|
164
|
+
if (target === null) {
|
|
165
|
+
sendJson(response, 400, { error: 'unsupported install target' });
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
// 重复安装防护:非更新请求命中已安装目标时直接拒绝,不重复跑 CLI
|
|
169
|
+
// (否则 CLI 会因「已存在」失败,且用户看到的是莫名其妙的报错);
|
|
170
|
+
// 更新请求(mode: 'update')放行,CLI 的 add 对已存在依赖是 pnpm 原位覆盖重装
|
|
171
|
+
const already = installedByRepo(readInstalled(profile), target);
|
|
172
|
+
if (already !== null && mode !== 'update') {
|
|
173
|
+
sendJson(response, 409, { error: `already installed: ${already}` });
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
// 队列级去重:同一目标已在排队/执行中时拒绝,避免客户端防重竞态导致重复入队
|
|
177
|
+
if (hasQueuedTarget(target)) {
|
|
178
|
+
sendJson(response, 409, { error: `already queued: ${target}` });
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
// 安装前预检:仅新安装走预检(github 源分发改入口文件缺失时直接拦截)。
|
|
182
|
+
// 更新是已信任目标的覆盖重装,跳过预检避免重复下载 tarball。
|
|
183
|
+
// 只支持官方默认安装方式(dsh plugin add github:owner/repo),不做任何 npm 兜底降级;
|
|
184
|
+
// [packaging] 前缀供客户端归类为「插件未适配官方默认安装方式」并引导去仓库提 Issue
|
|
185
|
+
if (already === null) {
|
|
186
|
+
const preflight = await preflightTarget(target);
|
|
187
|
+
if (!preflight.ok) {
|
|
188
|
+
sendJson(response, 400, {
|
|
189
|
+
error: `[packaging] ${target}: does not support the official default install method — the git distribution lacks build output (the entry file ${preflight.missing ?? '?'} declared in package.json is not in the repository). Report to the author.`,
|
|
190
|
+
});
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// 入队前复检(与上方检查同语义,但必须在同步块内):preflight 的 await 期间并发请求
|
|
195
|
+
// 可能已通过上方检查、任务却尚未入队——此处检查与 startPluginMutation 之间无 await,
|
|
196
|
+
// JS 单线程保证同一目标至多一个任务,杜绝双击/并发产生双任务
|
|
197
|
+
if (hasQueuedTarget(target)) {
|
|
198
|
+
sendJson(response, 409, { error: `already queued: ${target}` });
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
// Kick off the CLI in the background; the client polls /status for progress
|
|
202
|
+
const task = startPluginMutation({
|
|
203
|
+
action: 'add',
|
|
204
|
+
profile,
|
|
205
|
+
target,
|
|
206
|
+
timeoutMs: COMMAND_TIMEOUT_MS,
|
|
207
|
+
env: { ...process.env, CI: 'true' },
|
|
208
|
+
});
|
|
209
|
+
sendJson(response, 200, { ok: true, task: task.id });
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
}),
|
|
216
|
+
webServer.register({
|
|
217
|
+
kind: 'exact',
|
|
218
|
+
path: '/dsh-plugin-hub/uninstall',
|
|
219
|
+
handler: async (request, response) => {
|
|
220
|
+
if (!requireTrustedPost(request, response))
|
|
221
|
+
return;
|
|
222
|
+
// 与安装一致:卸载也进入同一队列,FIFO 串行执行
|
|
223
|
+
try {
|
|
224
|
+
const body = await readJsonBody(request);
|
|
225
|
+
const name = typeof body === 'object' && body !== null && typeof body.name === 'string'
|
|
226
|
+
? body.name
|
|
227
|
+
: '';
|
|
228
|
+
// 展示用的 owner/repo:卸载命令只需要 npm 包名,但待重启行要与安装行一致地显示仓库名
|
|
229
|
+
const repo = typeof body === 'object' && body !== null && typeof body.repo === 'string'
|
|
230
|
+
? body.repo
|
|
231
|
+
: '';
|
|
232
|
+
if (!validPackageName(name) || name === 'dsh-plugin') {
|
|
233
|
+
sendJson(response, 400, { error: 'plugin cannot be uninstalled here' });
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (readInstalled(profile)[name] === undefined) {
|
|
237
|
+
sendJson(response, 400, { error: 'plugin is not installed' });
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
// 队列级去重:同一包已在排队/执行中卸载时拒绝
|
|
241
|
+
if (hasQueuedTarget(name)) {
|
|
242
|
+
sendJson(response, 409, { error: `already queued: ${name}` });
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const task = startPluginMutation({
|
|
246
|
+
action: 'remove',
|
|
247
|
+
profile,
|
|
248
|
+
target: name,
|
|
249
|
+
// 卸载的 mutation 目标是 npm 包名;待重启行要显示 owner/repo(与安装行一致),非法/缺失时回退包名
|
|
250
|
+
displayTarget: githubTarget(repo) ?? undefined,
|
|
251
|
+
// 运行中 loader:卸载成功后主动移除条目,立即生效、无需重启
|
|
252
|
+
uninstallLoader: loader,
|
|
253
|
+
timeoutMs: COMMAND_TIMEOUT_MS,
|
|
254
|
+
env: { ...process.env, CI: 'true' },
|
|
255
|
+
});
|
|
256
|
+
sendJson(response, 200, { ok: true, task: task.id });
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
}),
|
|
263
|
+
webServer.register({
|
|
264
|
+
kind: 'exact',
|
|
265
|
+
path: '/dsh-plugin-hub/status',
|
|
266
|
+
handler: (request, response) => {
|
|
267
|
+
if (!requireMethod(request, response, 'GET'))
|
|
268
|
+
return;
|
|
269
|
+
const url = new URL(request.url ?? '/', 'http://localhost');
|
|
270
|
+
const id = Number(url.searchParams.get('task'));
|
|
271
|
+
const task = Number.isInteger(id) ? getTask(id) : undefined;
|
|
272
|
+
if (!task) {
|
|
273
|
+
sendJson(response, 404, { error: 'task not found' });
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
sendJson(response, 200, { task });
|
|
277
|
+
},
|
|
278
|
+
}),
|
|
279
|
+
webServer.register({
|
|
280
|
+
kind: 'exact',
|
|
281
|
+
path: '/dsh-plugin-hub/active',
|
|
282
|
+
handler: (request, response) => {
|
|
283
|
+
if (!requireMethod(request, response, 'GET'))
|
|
284
|
+
return;
|
|
285
|
+
// 整个任务队列(running 在前、pending 在后)+ 待重启列表:客户端刷新后据此恢复
|
|
286
|
+
// 进行中/排队任务与「装完没重启」的持久提醒
|
|
287
|
+
sendJson(response, 200, { tasks: activeTask(), pendingRestarts: listPendingRestarts() });
|
|
288
|
+
},
|
|
289
|
+
}),
|
|
290
|
+
webServer.register({
|
|
291
|
+
kind: 'exact',
|
|
292
|
+
path: '/dsh-plugin-hub/env',
|
|
293
|
+
handler: (request, response) => {
|
|
294
|
+
if (!requireMethod(request, response, 'GET'))
|
|
295
|
+
return;
|
|
296
|
+
// 宿主机器环境快照:提交 bug 的 issue 正文会带上,便于作者复现
|
|
297
|
+
sendJson(response, 200, hostEnv(profile));
|
|
298
|
+
},
|
|
299
|
+
}),
|
|
300
|
+
webServer.register({
|
|
301
|
+
kind: 'exact',
|
|
302
|
+
path: '/dsh-plugin-hub/cancel',
|
|
303
|
+
handler: async (request, response) => {
|
|
304
|
+
if (!requireTrustedPost(request, response))
|
|
305
|
+
return;
|
|
306
|
+
try {
|
|
307
|
+
const body = await readJsonBody(request);
|
|
308
|
+
const id = typeof body === 'object' && body !== null && typeof body.id === 'number'
|
|
309
|
+
? body.id
|
|
310
|
+
: NaN;
|
|
311
|
+
const ok = Number.isInteger(id) && cancelTask(id);
|
|
312
|
+
sendJson(response, 200, { ok });
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
}),
|
|
319
|
+
webServer.register({
|
|
320
|
+
kind: 'exact',
|
|
321
|
+
path: '/dsh-plugin-hub/restart',
|
|
322
|
+
handler: (request, response) => {
|
|
323
|
+
if (!requireTrustedPost(request, response))
|
|
324
|
+
return;
|
|
325
|
+
// 当前宿主监听端口来自请求 Host 头(localhost:7923),解析失败回退 7923
|
|
326
|
+
const host = request.headers.host ?? '';
|
|
327
|
+
const portMatch = host.match(/:(\d+)$/);
|
|
328
|
+
const port = portMatch ? Number(portMatch[1]) : 7923;
|
|
329
|
+
// detached 子 shell 完成「停旧进程 → 等端口释放 → 后台拉起 dsh web」,
|
|
330
|
+
// 即使当前宿主(即本插件所在进程)被 kill,重启仍会继续执行
|
|
331
|
+
const script = [
|
|
332
|
+
`pids=$(lsof -ti tcp:${port} -sTCP:LISTEN 2>/dev/null)`,
|
|
333
|
+
'[ -n "$pids" ] && kill -TERM $pids 2>/dev/null || true',
|
|
334
|
+
`i=0; while [ -n "$(lsof -ti tcp:${port} -sTCP:LISTEN 2>/dev/null)" ] && [ $i -lt 20 ]; do sleep 0.25; i=$((i + 1)); done`,
|
|
335
|
+
'mkdir -p "$HOME/.dsh/logs"',
|
|
336
|
+
`nohup dsh web --port ${port} >>"$HOME/.dsh/logs/dsh-web-${port}.log" 2>&1 &`,
|
|
337
|
+
].join('\n');
|
|
338
|
+
spawn('/bin/sh', ['-c', script], { detached: true, stdio: 'ignore' }).unref();
|
|
339
|
+
sendJson(response, 200, { ok: true, port });
|
|
340
|
+
},
|
|
341
|
+
}),
|
|
342
|
+
webServer.register({
|
|
343
|
+
kind: 'exact',
|
|
344
|
+
path: '/dsh-plugin-hub/installed-version',
|
|
345
|
+
handler: async (request, response) => {
|
|
346
|
+
if (!requireTrustedPost(request, response))
|
|
347
|
+
return;
|
|
348
|
+
try {
|
|
349
|
+
const body = await readJsonBody(request);
|
|
350
|
+
const repo = typeof body === 'object' && body !== null && typeof body.repo === 'string'
|
|
351
|
+
? body.repo
|
|
352
|
+
: '';
|
|
353
|
+
if (githubTarget(repo) === null) {
|
|
354
|
+
sendJson(response, 400, { error: 'invalid install target' });
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
// version 为空 = 卸载清理记录;否则记录安装时的目录信号(最新版本 + 仓库更新时间),
|
|
358
|
+
// 供「有更新」比对:有版本比版本,无版本比更新时间
|
|
359
|
+
const version = body !== null && typeof body.version === 'string'
|
|
360
|
+
? body.version
|
|
361
|
+
: null;
|
|
362
|
+
if (version === null) {
|
|
363
|
+
removeInstalledVersion(profile, repo);
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
const updatedAt = body !== null && typeof body.updatedAt === 'string'
|
|
367
|
+
? body.updatedAt
|
|
368
|
+
: '';
|
|
369
|
+
recordInstalledVersion(profile, repo, version, updatedAt);
|
|
370
|
+
}
|
|
371
|
+
sendJson(response, 200, { ok: true });
|
|
372
|
+
}
|
|
373
|
+
catch (error) {
|
|
374
|
+
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
}),
|
|
378
|
+
webServer.register({
|
|
379
|
+
kind: 'exact',
|
|
380
|
+
path: '/dsh-plugin-hub/installed',
|
|
381
|
+
handler: (request, response) => {
|
|
382
|
+
if (!requireMethod(request, response, 'GET'))
|
|
383
|
+
return;
|
|
384
|
+
// installed:依赖表(包名 → spec);versions:安装时记录的目录版本(repo → 版本),
|
|
385
|
+
// 客户端合并两者判断「是否有更新」
|
|
386
|
+
sendJson(response, 200, { profile, installed: readInstalled(profile), versions: readInstalledVersions(profile) });
|
|
387
|
+
},
|
|
388
|
+
}),
|
|
389
|
+
];
|
|
390
|
+
return () => {
|
|
391
|
+
for (const dispose of disposers)
|
|
392
|
+
dispose();
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
/** Profile resolution shared with the client route docs. */
|
|
396
|
+
export { readProfileArg };
|
package/lib/index.js
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-plugin host entry
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* the Settings panel and fetches it live from dsh-plugin.org — no bundled
|
|
6
|
-
* data, no local routes. This node half is intentionally a no-op.
|
|
2
|
+
* dsh-plugin host entry: mounts the Plugin Hub HTTP routes once the host
|
|
3
|
+
* web server service is available. The browser bundle (src/client) talks to
|
|
4
|
+
* these same-origin routes to perform real installs from inside the app.
|
|
7
5
|
*/
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
export
|
|
6
|
+
import { readProfileArg } from './services/install.js';
|
|
7
|
+
import { mountPluginHubRoutes } from './http/routes.js';
|
|
8
|
+
export const name = 'dsh-plugin';
|
|
9
|
+
/**
|
|
10
|
+
* Mount the Plugin Hub routes after the web server service becomes available.
|
|
11
|
+
* @param ctx - cordis host context.
|
|
12
|
+
* @param config - optional profile override.
|
|
13
|
+
*/
|
|
14
|
+
export function apply(ctx, config = {}) {
|
|
15
|
+
const profile = config.profile ?? readProfileArg('web');
|
|
16
|
+
ctx.inject(['webServer'], (host) => {
|
|
17
|
+
host.effect(() => mountPluginHubRoutes(host.webServer, profile, host.loader), 'dsh-plugin: http routes');
|
|
18
|
+
});
|
|
19
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** 安装/卸载运行时的公共类型与常量(无依赖,供各服务模块复用)。 */
|
|
2
|
+
/** Grammar of an accepted `github:<owner>/<repo>` install target. */
|
|
3
|
+
export const REPO_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
|
|
4
|
+
/** Grammar of an npm package name (used for uninstall targets). */
|
|
5
|
+
export const PACKAGE_RE = /^(?:@[a-z0-9._-]+\/)?[A-Za-z0-9._-]+$/;
|
|
6
|
+
/** 捕获输出上限:stdout/stderr 各保留尾部 N 字节。 */
|
|
7
|
+
export const CAPTURE_LIMIT_BYTES = 64 * 1024;
|
|
8
|
+
/** Max output lines kept per task (newest wins). */
|
|
9
|
+
export const MAX_TASK_LINES = 200;
|
|
10
|
+
/** Max tracked tasks; oldest finished tasks are dropped first. */
|
|
11
|
+
export const MAX_TASKS = 50;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { addPendingRestart, clearPendingRestart, listPendingRestarts } from './pending-restart.js';
|
|
2
|
+
export { dumpLoaderEntries, removeLoadedEntry } from './loader.js';
|
|
3
|
+
export { addAllowBuildsKey, githubTarget, parseAllowBuildsKey, readProfileArg, validPackageName } from './profile.js';
|
|
4
|
+
export { activeTask, cancelTask, getTask, hasQueuedTarget, hasRunningTask, runPluginMutation, startPluginMutation } from './task-queue.js';
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Installed-plugin version tracking.
|
|
3
|
+
*
|
|
4
|
+
* The installed table (`readInstalled` in routes.ts) only knows the manifest
|
|
5
|
+
* spec of each dependency (`github:<owner>/<repo>`), which carries no version.
|
|
6
|
+
* To let the Plugin Hub tell "an update is available", we record the catalog
|
|
7
|
+
* signal present at install time into a small per-profile JSON file, then
|
|
8
|
+
* compare it against the catalog's current signal later.
|
|
9
|
+
*
|
|
10
|
+
* Two signals cover every repo:
|
|
11
|
+
* - version: the catalog's latest release tag (e.g. v1.2.3). Repos that
|
|
12
|
+
* publish releases are compared on this.
|
|
13
|
+
* - updatedAt: the repo's last push time (GitHub pushed_at, `repoUpdatedAt`).
|
|
14
|
+
* Repos that never publish a release fall back to this: a newer push time
|
|
15
|
+
* means new commits → an update is available.
|
|
16
|
+
*
|
|
17
|
+
* Storage: `DSH_HOME/profiles/<profile>/gro.ngilp-hsd-versions.json`
|
|
18
|
+
* { "<owner>/<repo>" (lowercase): { version: string, updatedAt: string, installedAt: string } }
|
|
19
|
+
*/
|
|
20
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
21
|
+
import { homedir } from 'node:os';
|
|
22
|
+
import { dirname, join } from 'node:path';
|
|
23
|
+
export function versionsFilePath(profile) {
|
|
24
|
+
return join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'profiles', profile, 'gro.ngilp-hsd-versions.json');
|
|
25
|
+
}
|
|
26
|
+
export function readInstalledVersions(profile) {
|
|
27
|
+
try {
|
|
28
|
+
const raw = JSON.parse(readFileSync(versionsFilePath(profile), 'utf8'));
|
|
29
|
+
return raw !== null && typeof raw === 'object' ? raw : {};
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Record the catalog signals (version + repo updated time) present at install time for one repo. */
|
|
36
|
+
export function recordInstalledVersion(profile, repo, version, updatedAt) {
|
|
37
|
+
const table = readInstalledVersions(profile);
|
|
38
|
+
table[repo.toLowerCase()] = { version, updatedAt, installedAt: new Date().toISOString() };
|
|
39
|
+
writeVersions(profile, table);
|
|
40
|
+
}
|
|
41
|
+
/** Forget the recorded version for one repo (called on uninstall). */
|
|
42
|
+
export function removeInstalledVersion(profile, repo) {
|
|
43
|
+
const table = readInstalledVersions(profile);
|
|
44
|
+
delete table[repo.toLowerCase()];
|
|
45
|
+
writeVersions(profile, table);
|
|
46
|
+
}
|
|
47
|
+
function writeVersions(profile, table) {
|
|
48
|
+
const file = versionsFilePath(profile);
|
|
49
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
50
|
+
writeFileSync(file, JSON.stringify(table, null, 2) + '\n', 'utf8');
|
|
51
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 运行中 loader 的读取与停用:卸载成功后对匹配条目做 live-disable,
|
|
3
|
+
* 使卸载立即生效(刷新页面不再加载已卸载的 client bundle,也无需重启)。
|
|
4
|
+
*/
|
|
5
|
+
/** 运行中 loader 的全部条目(id + options.name),诊断用。 */
|
|
6
|
+
export function dumpLoaderEntries(loader) {
|
|
7
|
+
if (!loader)
|
|
8
|
+
return [];
|
|
9
|
+
const entries = [];
|
|
10
|
+
for (const entry of Array.from(loader.entries())) {
|
|
11
|
+
entries.push({ id: entry.id, name: entry.options?.name });
|
|
12
|
+
}
|
|
13
|
+
return entries;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* 卸载目标(npm 包名,如 `@scope/widget`)与 loader 条目名的匹配。
|
|
17
|
+
* loader 条目名由插件作者/工具写入,形式不可控,常见变体:
|
|
18
|
+
* - 完整 npm 名:`@scope/widget`
|
|
19
|
+
* - 去 `@` 前缀:`scope/widget`
|
|
20
|
+
* - 去 scope 的短名:`widget`
|
|
21
|
+
* - scope 分隔符变 `-`:`scope-widget`
|
|
22
|
+
* 因此归一化后比较全串与短名两个维度。profile 内包名唯一,宽松匹配不会误伤其它包。
|
|
23
|
+
*/
|
|
24
|
+
function nameMatches(entryName, target) {
|
|
25
|
+
if (!entryName)
|
|
26
|
+
return false;
|
|
27
|
+
// 归一化:去 @、非 [a-z0-9-] 一律换 `-`、小写
|
|
28
|
+
const norm = (s) => s.trim().replace(/^@+/, '').replace(/[^a-z0-9-]/gi, '-').toLowerCase();
|
|
29
|
+
const e = norm(entryName);
|
|
30
|
+
if (!e)
|
|
31
|
+
return false;
|
|
32
|
+
const t = norm(target); // @scope/pkg → scope-pkg
|
|
33
|
+
const tShort = norm(target.replace(/^@[^/]+\//, '')); // @scope/pkg → pkg(无 scope 时与原串一致)
|
|
34
|
+
// 1) 全串相等:覆盖 完整名 == 完整名 / 去@ == 去@ / scope-pkg == scope/pkg 归一化
|
|
35
|
+
if (e === t)
|
|
36
|
+
return true;
|
|
37
|
+
// 2) 条目是去 scope 的短名(bundle patch 手写短名最常见)
|
|
38
|
+
if (e === tShort)
|
|
39
|
+
return true;
|
|
40
|
+
// 3) 反向变体:目标短名可能就是条目的完整形式(条目 `scope-widget`,
|
|
41
|
+
// 目标短名 `widget` 是它的尾段)——用「条目名以短名结尾」收口,避免误伤。
|
|
42
|
+
if (tShort.length >= 2 && e.endsWith(`-${tShort}`))
|
|
43
|
+
return true;
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* 卸载成功后从运行中 loader 停用指定包的条目,使卸载立即生效(刷新页面不再
|
|
48
|
+
* 加载已卸载的 client bundle,也无需重启)。做法:按 name 扫描
|
|
49
|
+
* `loader.entries()`(含嵌套子树),对每个匹配条目
|
|
50
|
+
* `entry.update({ disabled: true })` 做 live-disable —— fiber 被 dispose,
|
|
51
|
+
* client-modules 对账后不再把它写进 `__DSH_BOOT__`,页面刷新即恢复。
|
|
52
|
+
* 返回 true = 无需重启(条目已停用,或本就没加载过);false = 未能停用,
|
|
53
|
+
* 需登记「待重启清理」兜底。宿主关键包(@deepseek-ai/* 与本插件自身)一律跳过。
|
|
54
|
+
*/
|
|
55
|
+
export async function removeLoadedEntry(loader, name) {
|
|
56
|
+
if (name === 'dsh-plugin' || name.startsWith('@deepseek-ai/'))
|
|
57
|
+
return false;
|
|
58
|
+
// 5s 超时兜底:fiber dispose 卡住时不能让卸载任务(乃至整个队列)被拖死
|
|
59
|
+
const removal = (async () => {
|
|
60
|
+
const entries = dumpLoaderEntries(loader);
|
|
61
|
+
const match = entries.find((e) => e.name === name);
|
|
62
|
+
console.log(`[hub-uninstall] loader=${entries.length} entries; target=${name}; match=${match ? match.id : 'none'}; names=${entries.map((e) => e.name ?? '').join(',')}`);
|
|
63
|
+
let found = false;
|
|
64
|
+
for (const entry of Array.from(loader.entries())) {
|
|
65
|
+
if (!nameMatches(entry.options?.name, name))
|
|
66
|
+
continue;
|
|
67
|
+
found = true;
|
|
68
|
+
// force=true:disable 可能落在 init 进行中,options 翻转但 fiber 仍会起来,
|
|
69
|
+
// 重试直到实际状态与目标一致
|
|
70
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
71
|
+
try {
|
|
72
|
+
await entry.update({ disabled: true }, false, true);
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
console.log(`[hub-uninstall] disable ${name} attempt ${attempt + 1} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
77
|
+
if (attempt === 2)
|
|
78
|
+
return false;
|
|
79
|
+
await new Promise((resolvePromise) => setTimeout(resolvePromise, 200));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (!found) {
|
|
84
|
+
// 运行中 loader 无该条目:宿主从未加载过它(装完未重启),磁盘已干净,无需重启
|
|
85
|
+
console.log(`[hub-uninstall] no live entry for ${name}; nothing to disable`);
|
|
86
|
+
}
|
|
87
|
+
return true;
|
|
88
|
+
})();
|
|
89
|
+
const result = await Promise.race([
|
|
90
|
+
removal.catch((error) => {
|
|
91
|
+
console.log(`[hub-uninstall] disable failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
92
|
+
return false;
|
|
93
|
+
}),
|
|
94
|
+
new Promise((resolve) => setTimeout(() => resolve(false), 5_000)),
|
|
95
|
+
]);
|
|
96
|
+
console.log(`[hub-uninstall] disable ${name} -> ${result ? 'ok/clean' : 'timed out or failed'}`);
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const pendingRestarts = [];
|
|
2
|
+
/**
|
|
3
|
+
* 登记一条待重启记录(同一目标去重)。
|
|
4
|
+
* - 卸载:目标刚装完未重启(已有待重启记录)→ 宿主从未加载过它,卸完磁盘已干净、
|
|
5
|
+
* loader 无残留,无需再提醒重启,直接移除该条;否则(此前已被宿主加载过)登记「卸载后待重启清理」。
|
|
6
|
+
* - 安装:已有记录则刷新为安装语义(如卸载后未重启又重装),否则新登记。
|
|
7
|
+
*/
|
|
8
|
+
export function addPendingRestart(target, kind) {
|
|
9
|
+
const existing = pendingRestarts.find((p) => p.target === target);
|
|
10
|
+
if (kind === 'uninstall') {
|
|
11
|
+
if (existing)
|
|
12
|
+
pendingRestarts.splice(pendingRestarts.indexOf(existing), 1);
|
|
13
|
+
else
|
|
14
|
+
pendingRestarts.push({ target, kind, at: Date.now() });
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (existing) {
|
|
18
|
+
existing.kind = 'install';
|
|
19
|
+
existing.at = Date.now();
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
pendingRestarts.push({ target, kind, at: Date.now() });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** 当前待重启列表快照(副本,消费方不会污染内部数组)。 */
|
|
26
|
+
export function listPendingRestarts() {
|
|
27
|
+
return pendingRestarts.map((p) => ({ ...p }));
|
|
28
|
+
}
|
|
29
|
+
/** 清除某目标的待重启记录(卸载已即时生效、无需重启时调用)。 */
|
|
30
|
+
export function clearPendingRestart(target) {
|
|
31
|
+
const index = pendingRestarts.findIndex((p) => p.target === target);
|
|
32
|
+
if (index >= 0)
|
|
33
|
+
pendingRestarts.splice(index, 1);
|
|
34
|
+
}
|