dsh-plugin 0.1.1 → 1.1.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.
Files changed (67) hide show
  1. package/README.en.md +56 -28
  2. package/README.md +56 -28
  3. package/client/client.js +2282 -230
  4. package/client/client.js.map +1 -1
  5. package/lib/http/routes.js +432 -0
  6. package/lib/index.js +17 -8
  7. package/lib/services/install-types.js +11 -0
  8. package/lib/services/install.js +4 -0
  9. package/lib/services/installed-versions.js +66 -0
  10. package/lib/services/loader.js +98 -0
  11. package/lib/services/npm-resolve.js +67 -0
  12. package/lib/services/pending-restart.js +34 -0
  13. package/lib/services/preflight.js +94 -0
  14. package/lib/services/profile.js +88 -0
  15. package/lib/services/progress.js +50 -0
  16. package/lib/services/task-queue.js +457 -0
  17. package/lib/types/http/routes.d.ts +23 -0
  18. package/lib/types/index.d.ts +27 -0
  19. package/lib/types/services/install-types.d.ts +69 -0
  20. package/lib/types/services/install.d.ts +18 -0
  21. package/lib/types/services/installed-versions.d.ts +24 -0
  22. package/lib/types/services/loader.d.ts +43 -0
  23. package/lib/types/services/npm-resolve.d.ts +6 -0
  24. package/lib/types/services/pending-restart.d.ts +13 -0
  25. package/lib/types/services/preflight.d.ts +6 -0
  26. package/lib/types/services/profile.d.ts +24 -0
  27. package/lib/types/services/progress.d.ts +17 -0
  28. package/lib/types/services/task-queue.d.ts +82 -0
  29. package/package.json +27 -6
  30. package/src/client/components/CatalogControls.tsx +104 -0
  31. package/src/client/components/CatalogHeader.tsx +78 -0
  32. package/src/client/components/CatalogList.tsx +75 -0
  33. package/src/client/components/CategoryTabs.tsx +35 -0
  34. package/src/client/components/Dropdown.tsx +80 -0
  35. package/src/client/components/NotificationsModal.tsx +243 -0
  36. package/src/client/components/PluginCard.tsx +104 -0
  37. package/src/client/components/PluginHubSection.tsx +342 -0
  38. package/src/client/components/ProgressView.tsx +23 -0
  39. package/src/client/components/icons.tsx +125 -0
  40. package/src/client/components/modals.tsx +368 -0
  41. package/src/client/hooks/useCatalog.ts +202 -0
  42. package/src/client/hooks/useTaskQueue.ts +502 -0
  43. package/src/client/index.tsx +8 -422
  44. package/src/client/lib/catalog.ts +240 -0
  45. package/src/client/lib/env.ts +16 -0
  46. package/src/client/lib/failures.ts +165 -0
  47. package/src/client/lib/format.ts +22 -0
  48. package/src/client/locales.ts +188 -18
  49. package/src/client/styles/Dropdown.module.css +123 -0
  50. package/src/client/styles/Header.module.css +432 -0
  51. package/src/client/styles/List.module.css +324 -0
  52. package/src/client/styles/Modal.module.css +1006 -0
  53. package/src/client/styles/tokens.module.css +91 -0
  54. package/src/client/types.ts +80 -0
  55. package/src/server/http/routes.ts +432 -0
  56. package/src/server/index.ts +35 -0
  57. package/src/server/services/install-types.ts +69 -0
  58. package/src/server/services/install.ts +18 -0
  59. package/src/server/services/installed-versions.ts +84 -0
  60. package/src/server/services/loader.ts +110 -0
  61. package/src/server/services/npm-resolve.ts +67 -0
  62. package/src/server/services/pending-restart.ts +36 -0
  63. package/src/server/services/preflight.ts +97 -0
  64. package/src/server/services/profile.ts +89 -0
  65. package/src/server/services/progress.ts +48 -0
  66. package/src/server/services/task-queue.ts +492 -0
  67. package/src/client/Section.module.css +0 -589
@@ -0,0 +1,457 @@
1
+ /**
2
+ * 后台安装/卸载任务队列:内存任务注册表 + FIFO 串行 worker + 子进程生命周期管理。
3
+ * 每个任务一次 `dsh plugin --profile <profile> <action> <target>` 子进程,
4
+ * 输出流式收集到任务上;卸载成功时尝试从运行中 loader 即时停用(见 loader.ts)。
5
+ */
6
+ import { spawn } from 'node:child_process';
7
+ import { readFileSync, statSync } from 'node:fs';
8
+ import { dirname, join, resolve } from 'node:path';
9
+ import { CAPTURE_LIMIT_BYTES, MAX_TASKS, MAX_TASK_LINES } from './install-types.js';
10
+ import { cleanLine, estimateProgress } from './progress.js';
11
+ import { removeLoadedEntry } from './loader.js';
12
+ import { addPendingRestart, clearPendingRestart } from './pending-restart.js';
13
+ import { addAllowBuildsKey, parseAllowBuildsKey, profileDirectory } from './profile.js';
14
+ /** In-memory task registry, keyed by task id. */
15
+ const tasks = new Map();
16
+ let nextTaskId = 1;
17
+ /** Pending tasks awaiting their turn (FIFO). The queue worker runs at most one mutation at a time. */
18
+ const queue = [];
19
+ /** Child processes of currently running tasks (for cancel). */
20
+ const runningChildren = new Map();
21
+ /** Snapshot of a task (keeps the live object untouched by consumers). */
22
+ export function getTask(id) {
23
+ const task = tasks.get(id);
24
+ if (!task)
25
+ return undefined;
26
+ return { ...task, lines: task.lines.slice(0, MAX_TASK_LINES) };
27
+ }
28
+ /** All non-terminal tasks in queue order (running first, then pending). Lets the client resume a queue after a page reload. */
29
+ export function activeTask() {
30
+ const found = [];
31
+ // 展示目标用 displayTarget(owner/repo):安装 target 可能被服务端切成 npm 包名
32
+ // (npm 反查命中),队列恢复/待重启展示对用户保持仓库名无感知
33
+ const show = (task) => task.displayTarget ?? task.target;
34
+ for (const item of queue) {
35
+ const task = item.task;
36
+ if (task.status === 'pending') {
37
+ found.push({ id: task.id, target: show(task), action: task.action, status: 'pending', progress: task.progress, lines: task.lines.slice(0, MAX_TASK_LINES), attempts: task.attempts });
38
+ }
39
+ }
40
+ tasks.forEach((task) => {
41
+ if (task.status === 'running') {
42
+ found.unshift({ id: task.id, target: show(task), action: task.action, status: 'running', progress: task.progress, lines: task.lines.slice(0, MAX_TASK_LINES), attempts: task.attempts });
43
+ }
44
+ });
45
+ return found;
46
+ }
47
+ /**
48
+ * True while a mutation child process is still alive. The concurrent slot is
49
+ * keyed off the live child set rather than task.status: cancelling a running
50
+ * task flips its status immediately, but the child may still be shutting down
51
+ * (the close event lands later) — the slot must stay held until then, otherwise
52
+ * a new task would start while the old pnpm still runs in the same profile dir.
53
+ */
54
+ export function hasRunningTask() {
55
+ return runningChildren.size > 0;
56
+ }
57
+ /** Whether the target already has a non-terminal task in the queue (install/uninstall dedupe). */
58
+ export function hasQueuedTarget(target) {
59
+ return queue.some((it) => it.task.target === target && (it.task.status === 'pending' || it.task.status === 'running'));
60
+ }
61
+ /**
62
+ * Cancel a queued or running task.
63
+ * - pending: removed from the queue immediately (never spawned).
64
+ * - running: kills the child process; the queue worker then picks the next one.
65
+ * Returns false when the task is unknown or already finished.
66
+ */
67
+ export function cancelTask(id) {
68
+ const task = tasks.get(id);
69
+ if (!task)
70
+ return false;
71
+ if (task.status === 'pending') {
72
+ const index = queue.findIndex((it) => it.task === task);
73
+ if (index >= 0)
74
+ queue.splice(index, 1);
75
+ task.status = 'cancelled';
76
+ pushLine(task, '[cancelled]');
77
+ return true;
78
+ }
79
+ if (task.status === 'running') {
80
+ task.status = 'cancelled';
81
+ pushLine(task, '[cancelled]');
82
+ const child = runningChildren.get(id);
83
+ if (child)
84
+ stopChild(child);
85
+ return true;
86
+ }
87
+ return false;
88
+ }
89
+ function pushLine(task, line) {
90
+ const text = cleanLine(line);
91
+ // pnpm 的 `Progress:` 行用 \r 原地刷新同一行:内容一变化就追加,
92
+ // 日志像终端一样实时滚动(downloaded 0→1→2…);内容与最新一条完全相同则跳过,
93
+ // 避免同一进度反复刷新时无意义的重复堆积
94
+ if (text !== task.lines[0]) {
95
+ task.lines.unshift(text);
96
+ if (task.lines.length > MAX_TASK_LINES)
97
+ task.lines.length = MAX_TASK_LINES;
98
+ }
99
+ const estimate = estimateProgress(text);
100
+ if (estimate > 0) {
101
+ task.progress = Math.max(task.progress, estimate);
102
+ }
103
+ else if (task.status === 'running' && task.progress < 85) {
104
+ // 兜底:行无解析格式也随输出量推进,保证进度条始终在动(不会卡在 0)
105
+ task.progress = Math.max(task.progress, 6 + Math.min(79, task.lines.length * 2));
106
+ }
107
+ }
108
+ function cliInvocation() {
109
+ const entry = process.argv[1];
110
+ if (entry !== undefined && /[\\/](?:bin\.(?:js|ts)|dsh)$/.test(entry)) {
111
+ const absoluteEntry = resolve(entry);
112
+ return {
113
+ file: process.execPath,
114
+ prefixArgs: [...process.execArgv, absoluteEntry],
115
+ cwd: dirname(absoluteEntry),
116
+ useShell: false,
117
+ };
118
+ }
119
+ return { file: 'dsh', prefixArgs: [], cwd: process.cwd(), useShell: process.platform === 'win32' };
120
+ }
121
+ function stopChild(child) {
122
+ if (process.platform === 'win32' && child.pid !== undefined) {
123
+ const killer = spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' });
124
+ killer.once('error', () => child.kill('SIGKILL'));
125
+ return;
126
+ }
127
+ if (child.pid !== undefined) {
128
+ // POSIX:dsh 内部会 fork pnpm,只杀 dsh 会让 pnpm 变成孤儿继续攥着管道,
129
+ // close 事件永不到来、队列卡死。detached 让子进程成为进程组组长,
130
+ // 对整组 SIGKILL 才能把 dsh + pnpm(及后代)一起清掉,close 随即触发。
131
+ try {
132
+ process.kill(-child.pid, 'SIGKILL');
133
+ return;
134
+ }
135
+ catch { /* 进程已退出:忽略 */ }
136
+ }
137
+ child.kill('SIGKILL');
138
+ }
139
+ /**
140
+ * Enqueue a plugin mutation and return its task. Tasks run strictly serially:
141
+ * the queue worker starts the next one only after the previous finishes.
142
+ * Progress is visible through `getTask(id)` / `activeTask()` until done.
143
+ */
144
+ export function startPluginMutation(options) {
145
+ const task = { id: nextTaskId, target: options.target, displayTarget: options.displayTarget, action: options.action, status: 'pending', timedOut: false, exitCode: null, progress: 0, lines: [], attempts: options.attempts ?? [] };
146
+ nextTaskId = nextTaskId >= Number.MAX_SAFE_INTEGER ? 1 : nextTaskId + 1;
147
+ tasks.set(task.id, task);
148
+ if (tasks.size > MAX_TASKS) {
149
+ let removed = false;
150
+ tasks.forEach((candidate, id) => {
151
+ if (!removed && candidate.status !== 'running' && candidate.status !== 'pending') {
152
+ tasks.delete(id);
153
+ removed = true;
154
+ }
155
+ });
156
+ }
157
+ queue.push({ task, options: { ...options } });
158
+ pumpQueue();
159
+ return task;
160
+ }
161
+ /** Queue worker: at most one mutation runs at a time; pick the next pending task when the slot frees up. */
162
+ function pumpQueue() {
163
+ if (hasRunningTask())
164
+ return;
165
+ const item = queue.find((it) => it.task.status === 'pending');
166
+ if (!item)
167
+ return;
168
+ const { task, options } = item;
169
+ task.status = 'running';
170
+ void runPluginMutation({ ...options, task }).finally(() => {
171
+ runningChildren.delete(task.id);
172
+ // 已终态的任务移出队列:既防 queue 数组无限增长(内存泄漏),也让下一轮 pump 干净
173
+ const idx = queue.findIndex((it) => it.task === task);
174
+ if (idx >= 0)
175
+ queue.splice(idx, 1);
176
+ pumpQueue();
177
+ });
178
+ }
179
+ /**
180
+ * Run one `dsh plugin --profile <profile> <action> <target>` spawn, stream
181
+ * its output onto the tracked task and resolve with the captured result.
182
+ * Never rejects; failures surface through the result / task state.
183
+ */
184
+ function spawnMutation(options) {
185
+ const { action, profile, target, timeoutMs = 5 * 60 * 1000, env, task, displayTarget, uninstallLoader } = options;
186
+ const invocation = cliInvocation();
187
+ const args = [...invocation.prefixArgs, 'plugin', '--profile', profile, action, target];
188
+ // 记录实际执行的命令(npm 通道显示包名,git 通道显示 github: 源):失败提 Issue 时
189
+ // 「已尝试的安装方式」如实展示完整命令链,作者一眼看清我们跑过什么
190
+ const executed = `dsh plugin --profile ${profile} ${action} ${target}`;
191
+ if (task && !task.attempts.includes(executed)) {
192
+ task.attempts.push(executed);
193
+ }
194
+ return new Promise((resolvePromise) => {
195
+ let stdout = '';
196
+ let stderr = '';
197
+ let timedOut = false;
198
+ let settled = false;
199
+ let child;
200
+ try {
201
+ child = spawn(invocation.file, args, {
202
+ cwd: invocation.cwd,
203
+ env,
204
+ shell: invocation.useShell,
205
+ // detached 使子进程成为独立进程组组长:取消时能整组清理(见 stopChild),
206
+ // 且宿主进程崩溃后安装任务不会被连带误杀
207
+ detached: true,
208
+ stdio: ['ignore', 'pipe', 'pipe'],
209
+ });
210
+ }
211
+ catch (error) {
212
+ if (task) {
213
+ task.status = 'failed';
214
+ task.exitCode = null;
215
+ pushLine(task, `[error] ${error instanceof Error ? error.message : String(error)}`);
216
+ }
217
+ resolvePromise({
218
+ exitCode: null,
219
+ timedOut: false,
220
+ error: error instanceof Error ? error.message : String(error),
221
+ stdout,
222
+ stderr,
223
+ });
224
+ return;
225
+ }
226
+ if (task)
227
+ runningChildren.set(task.id, child);
228
+ // 兜底定时器句柄:整段超时 / exit 后 close 宽限 / 强制收尾
229
+ let timer;
230
+ let progressTimer;
231
+ let exitWatch;
232
+ let forceSettle;
233
+ // 唯一收尾入口:close / error / 兜底超时 三者只放行一次,
234
+ // 保证 runPluginMutation 必然 resolve、队列 worker 必然继续推进
235
+ const settle = (result) => {
236
+ if (settled)
237
+ return;
238
+ settled = true;
239
+ if (timer !== undefined)
240
+ clearTimeout(timer);
241
+ if (progressTimer !== undefined)
242
+ clearInterval(progressTimer);
243
+ if (exitWatch !== undefined)
244
+ clearTimeout(exitWatch);
245
+ if (forceSettle !== undefined)
246
+ clearTimeout(forceSettle);
247
+ if (task)
248
+ runningChildren.delete(task.id);
249
+ resolvePromise(result);
250
+ };
251
+ timer = setTimeout(() => {
252
+ timedOut = true;
253
+ stopChild(child);
254
+ }, timeoutMs);
255
+ // 时间兜底:即使 CLI 静默不吐进度行,进度条也单调推进,不会卡在 0
256
+ progressTimer = setInterval(() => {
257
+ if (task && task.status === 'running' && task.progress < 85)
258
+ task.progress += 1;
259
+ }, 500);
260
+ const collect = (kind, chunk) => {
261
+ const text = chunk.toString();
262
+ if (kind === 'stdout')
263
+ stdout = (stdout + text).slice(-CAPTURE_LIMIT_BYTES);
264
+ else
265
+ stderr = (stderr + text).slice(-CAPTURE_LIMIT_BYTES);
266
+ if (task) {
267
+ // 原生透传 pnpm 输出:stdout/stderr 原样入日志,不做格式化,保证报错信息完整可见
268
+ for (const line of text.split(/\r?\n|\r/)) {
269
+ if (line.trim() !== '')
270
+ pushLine(task, line.trimEnd());
271
+ }
272
+ }
273
+ };
274
+ child.stdout?.on('data', (chunk) => collect('stdout', chunk));
275
+ child.stderr?.on('data', (chunk) => collect('stderr', chunk));
276
+ child.once('exit', () => {
277
+ // dsh 内部会 fork pnpm:pnpm 退出后仍攥着 stdout/stderr 管道时,
278
+ // close 事件永不到来(历史队列卡死根因——取消路径曾因此卡死,用整组 SIGKILL 解决;
279
+ // 自然失败路径没有兜底)。exit 后给 close 一段宽限,未按时触发则整组清理强制结束,
280
+ // 保证任务必然进入终态、后续排队任务自动启动。
281
+ exitWatch = setTimeout(() => {
282
+ if (settled)
283
+ return;
284
+ if (task && task.status !== 'cancelled')
285
+ pushLine(task, '[stale pipe] force closing child');
286
+ stopChild(child);
287
+ // 清理后极端情况下 close 仍未触发(kill 失败):再兜底强制收尾
288
+ forceSettle = setTimeout(() => {
289
+ if (settled)
290
+ return;
291
+ if (task && task.status !== 'cancelled') {
292
+ task.status = 'failed';
293
+ task.exitCode = null;
294
+ task.timedOut = true;
295
+ pushLine(task, '[timed out]');
296
+ }
297
+ settle({ exitCode: null, timedOut: true, error: 'child failed to close', stdout, stderr });
298
+ }, 5_000);
299
+ }, 3_000);
300
+ });
301
+ child.once('error', (error) => {
302
+ if (task && task.status !== 'cancelled') {
303
+ task.status = 'failed';
304
+ task.exitCode = null;
305
+ pushLine(task, `[error] ${error.message}`);
306
+ }
307
+ settle({ exitCode: null, timedOut: false, error: error.message, stdout, stderr });
308
+ });
309
+ child.once('close', async (code) => {
310
+ try {
311
+ if (task) {
312
+ task.exitCode = code;
313
+ task.timedOut = timedOut;
314
+ if (task.status !== 'cancelled') {
315
+ task.status = timedOut || code !== 0 ? 'failed' : 'done';
316
+ if (task.status === 'done') {
317
+ task.progress = 100;
318
+ if (action === 'remove' && uninstallLoader) {
319
+ // 卸载成功 → 主动从运行中 loader 移除该包条目,立即生效、无需重启;
320
+ // 移除失败(或从未加载过但 loader 不可用)才登记「待重启清理」兜底
321
+ const removed = await removeLoadedEntry(uninstallLoader, target);
322
+ if (removed)
323
+ clearPendingRestart(displayTarget ?? target);
324
+ else
325
+ addPendingRestart(displayTarget ?? target, 'uninstall');
326
+ }
327
+ else {
328
+ // 安装成功(或卸载但无 loader 引用)→ 登记待重启:
329
+ // 插件要宿主重启才会挂载/卸载干净,重启前一直提醒;展示目标用 displayTarget(owner/repo)
330
+ addPendingRestart(displayTarget ?? target, action === 'add' ? 'install' : 'uninstall');
331
+ }
332
+ }
333
+ pushLine(task, timedOut ? '[timed out]' : `[exit ${code ?? '?'}]`);
334
+ }
335
+ }
336
+ }
337
+ finally {
338
+ settle({ exitCode: code, timedOut, error: null, stdout, stderr });
339
+ }
340
+ });
341
+ });
342
+ }
343
+ /**
344
+ * 安装后校验已装包的入口文件(package.json 的 main / exports["."].default)是否真实存在。
345
+ * git: 分发常不提交构建产物(lib/ 等),pnpm 装完没有报错,但宿主重启加载插件树时会
346
+ * ERR_MODULE_NOT_FOUND 直接崩溃、网页打不开 —— 这里在登记「待重启」前就把这类残缺包拦下。
347
+ * 返回 { name, missing }:name 为空表示无法定位目标包(跳过校验);missing 为缺失的入口路径。
348
+ */
349
+ export function verifyInstalledEntry(profile, target) {
350
+ let profilePkg = null;
351
+ try {
352
+ profilePkg = JSON.parse(readFileSync(join(profileDirectory(profile), 'package.json'), 'utf8'));
353
+ }
354
+ catch {
355
+ return { name: null, missing: null };
356
+ }
357
+ const deps = (profilePkg?.dependencies ?? {});
358
+ // target 自身是依赖名(npm 包),或通过 github: specifier 反查依赖名
359
+ const name = deps[target] !== undefined ? target : Object.keys(deps).find((k) => deps[k] === target);
360
+ if (!name)
361
+ return { name: null, missing: null };
362
+ const pkgDir = join(profileDirectory(profile), 'node_modules', name);
363
+ let entry = null;
364
+ try {
365
+ const meta = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8'));
366
+ const dot = meta.exports?.['.'];
367
+ const resolved = typeof dot === 'string' ? dot
368
+ : dot !== null && typeof dot === 'object'
369
+ ? dot.default
370
+ : undefined;
371
+ entry = typeof resolved === 'string' ? resolved : typeof meta.main === 'string' ? meta.main : 'index.js';
372
+ }
373
+ catch {
374
+ return { name, missing: null };
375
+ }
376
+ try {
377
+ return { name, missing: statSync(join(pkgDir, entry)).isFile() ? null : entry };
378
+ }
379
+ catch {
380
+ return { name, missing: entry };
381
+ }
382
+ }
383
+ /** 从 pnpm `Ignored build scripts:` 提示行解析被拦截的构建脚本 key(name@version,逗号分隔)。 */
384
+ function ignoredBuildKeys(output) {
385
+ const m = /Ignored build scripts:\s*(.+)/i.exec(output);
386
+ if (m === null)
387
+ return [];
388
+ return m[1]
389
+ .split(',')
390
+ .map((s) => s.trim())
391
+ .filter((s) => s !== '');
392
+ }
393
+ /**
394
+ * Run a plugin mutation with one recovery path: when `add` fails because
395
+ * pnpm's allowBuilds gate blocks a build script — either the git-hosted
396
+ * package's own `prepare` (`ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED`, key from
397
+ * the printed key@url hint) or a dependency's native build
398
+ * (`ERR_PNPM_IGNORED_BUILDS`, key = the listed name@version) — write the
399
+ * exact keys into the profile's pnpm-workspace.yaml and retry once.
400
+ * Other failures fall through to the single-spawn result.
401
+ */
402
+ export async function runPluginMutation(options) {
403
+ const result = await spawnMutation(options);
404
+ if (options.action === 'add' && result.exitCode !== 0 && !result.timedOut) {
405
+ const output = `${result.stderr}\n${result.stdout}`;
406
+ // git 源:pnpm 打印的 key@url 提示
407
+ if (output.includes('ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED')) {
408
+ const key = parseAllowBuildsKey(output);
409
+ if (key !== null) {
410
+ if (options.task) {
411
+ // First attempt already marked the task failed; reset it so the
412
+ // retry streams progress as a running task again.
413
+ options.task.status = 'running';
414
+ options.task.exitCode = null;
415
+ pushLine(options.task, `[allow build] ${key}`);
416
+ }
417
+ addAllowBuildsKey(options.profile, key);
418
+ return spawnMutation(options);
419
+ }
420
+ }
421
+ // 依赖原生模块/构建脚本被 pnpm v11 拦截(ERR_PNPM_IGNORED_BUILDS):git 与 npm 源都会出现。
422
+ // allowBuilds 的 key 是输出里列出的 name@version(如 node-pty@1.1.0),逐个放行后重试一次;
423
+ // 输出解析不到时回退用目标名(插件自身构建脚本被拦的 npm 场景)。
424
+ if (output.includes('ERR_PNPM_IGNORED_BUILDS')) {
425
+ const keys = ignoredBuildKeys(output);
426
+ if (keys.length === 0 && !options.target.startsWith('github:'))
427
+ keys.push(options.target);
428
+ if (keys.length > 0) {
429
+ if (options.task) {
430
+ options.task.status = 'running';
431
+ options.task.exitCode = null;
432
+ pushLine(options.task, `[allow build] ${keys.join(', ')}`);
433
+ }
434
+ for (const key of keys)
435
+ addAllowBuildsKey(options.profile, key);
436
+ return spawnMutation(options);
437
+ }
438
+ }
439
+ }
440
+ // pnpm 安装本身成功,但目标包入口文件缺失(git 分发缺构建产物)→ 判为失败:
441
+ // 撤销刚登记的待重启,避免用户重启时宿主加载残缺包直接崩溃
442
+ if (options.action === 'add' && result.exitCode === 0 && !result.timedOut) {
443
+ const verified = verifyInstalledEntry(options.profile, options.target);
444
+ if (verified.name !== null && verified.missing !== null) {
445
+ clearPendingRestart(options.displayTarget ?? options.target);
446
+ const detail = `[packaging] ${verified.name}: entry file missing: ${verified.missing} (git distribution lacks build output — install the npm version or report to the author)`;
447
+ if (options.task && options.task.status !== 'cancelled') {
448
+ options.task.status = 'failed';
449
+ options.task.exitCode = null;
450
+ options.task.progress = 0;
451
+ pushLine(options.task, detail);
452
+ }
453
+ return { exitCode: 1, timedOut: false, error: `entry file missing: ${verified.missing}`, stdout: result.stdout, stderr: `${result.stderr}\n${detail}` };
454
+ }
455
+ }
456
+ return result;
457
+ }
@@ -0,0 +1,23 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import { readProfileArg, type LoaderHandle } from '../services/install.ts';
3
+ export interface WebRoute {
4
+ kind: 'exact';
5
+ path: string;
6
+ handler: (request: IncomingMessage, response: ServerResponse) => void | Promise<void>;
7
+ }
8
+ /** The subset of the host web-server service this plugin touches. */
9
+ export interface WebServerService {
10
+ register(route: WebRoute): () => void;
11
+ }
12
+ /** Read non-official dependencies installed into one profile. */
13
+ export declare function readInstalled(profile: string): Record<string, string>;
14
+ /**
15
+ * Register the Plugin Hub API on the host web server and return a disposer.
16
+ * @param webServer - DSH web server service.
17
+ * @param profile - profile that owns plugin mutations.
18
+ * @param loader - running loader (optional): lets uninstall remove the entry
19
+ * immediately so the page survives a refresh without a host restart.
20
+ */
21
+ export declare function mountPluginHubRoutes(webServer: WebServerService, profile: string, loader?: LoaderHandle): () => void;
22
+ /** Profile resolution shared with the client route docs. */
23
+ export { readProfileArg };
@@ -0,0 +1,27 @@
1
+ /**
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.
5
+ */
6
+ import { type LoaderHandle } from './services/install.ts';
7
+ import { type WebServerService } from './http/routes.ts';
8
+ export declare const name = "dsh-plugin";
9
+ export interface Config {
10
+ /** DSH profile that owns plugin mutations. Defaults to the booted profile. */
11
+ profile?: string;
12
+ }
13
+ /** The subset of the cordis host context this plugin touches. */
14
+ interface HostContext {
15
+ webServer: WebServerService;
16
+ /** 运行中 loader:卸载成功后主动移除条目、立即生效(宿主未提供时卸载仍需重启) */
17
+ loader?: LoaderHandle;
18
+ inject(services: string[], callback: (host: HostContext) => void): void;
19
+ effect(callback: () => void, label?: string): void;
20
+ }
21
+ /**
22
+ * Mount the Plugin Hub routes after the web server service becomes available.
23
+ * @param ctx - cordis host context.
24
+ * @param config - optional profile override.
25
+ */
26
+ export declare function apply(ctx: HostContext, config?: Config): void;
27
+ export {};
@@ -0,0 +1,69 @@
1
+ /** 安装/卸载运行时的公共类型与常量(无依赖,供各服务模块复用)。 */
2
+ /** 一个 pnpm 安装子进程的结果快照。 */
3
+ export interface InstallResult {
4
+ exitCode: number | null;
5
+ timedOut: boolean;
6
+ error: string | null;
7
+ stdout: string;
8
+ stderr: string;
9
+ }
10
+ export interface InstallTask {
11
+ id: number;
12
+ /** 操作目标:`github:<owner>/<repo>`(安装)或 npm 包名(卸载),冲突/恢复时需要展示给用户 */
13
+ target: string;
14
+ /** 展示用目标(owner/repo):npm 安装时 target 是包名,前端恢复用此字段显示仓库名,保持用户无感知 */
15
+ displayTarget?: string;
16
+ /** 尝试过的安装方式(npm registry 反查 + 实际执行的 CLI 命令,按先后顺序):
17
+ * 失败提 Issue 时如实贴给作者,作者据此反推正确的 npm 包名(组织 scope 与 GitHub 用户名不一致时
18
+ * 仅凭仓库名猜不到,作者看到我们查过/试过的命令就能直接指认)。 */
19
+ attempts: string[];
20
+ action: 'add' | 'remove';
21
+ /** 队列语义:pending 排队中 / running 执行中 / done / failed / cancelled(用户取消) */
22
+ status: 'pending' | 'running' | 'done' | 'failed' | 'cancelled';
23
+ timedOut: boolean;
24
+ exitCode: number | null;
25
+ /** 0-100 估算进度:解析 pnpm 的 `Progress: resolved…` 输出,阶段行兜底 */
26
+ progress: number;
27
+ /** Newest output lines first (consumer shows the tail). */
28
+ lines: string[];
29
+ }
30
+ /** 子进程启动参数(复用宿主 dsh 入口,或回退到 PATH 上的 `dsh`)。 */
31
+ export interface Invocation {
32
+ file: string;
33
+ prefixArgs: string[];
34
+ cwd: string;
35
+ useShell: boolean;
36
+ }
37
+ /** 排队中的变更任务及其启动参数。 */
38
+ export interface QueueItem {
39
+ task: InstallTask;
40
+ options: {
41
+ action: 'add' | 'remove';
42
+ profile: string;
43
+ target: string;
44
+ timeoutMs?: number;
45
+ env?: NodeJS.ProcessEnv;
46
+ };
47
+ }
48
+ /**
49
+ * 安装成功但宿主尚未重启的插件(待重启后生效)。纯内存态:宿主进程一重启
50
+ * 它自然清空,正好等于「重启后提醒消失」的语义,无需任何重启探测。
51
+ */
52
+ export interface PendingRestart {
53
+ /** `github:<owner>/<repo>` 安装目标(与任务一致;卸载时为展示用的 owner/repo) */
54
+ target: string;
55
+ /** 待重启语义:install 装完等挂载生效 / uninstall 卸完等清理 loader 残留 */
56
+ kind: 'install' | 'uninstall';
57
+ /** 任务完成时刻(epoch ms) */
58
+ at: number;
59
+ }
60
+ /** Grammar of an accepted `github:<owner>/<repo>` install target. */
61
+ export declare const REPO_RE: RegExp;
62
+ /** Grammar of an npm package name (used for uninstall targets). */
63
+ export declare const PACKAGE_RE: RegExp;
64
+ /** 捕获输出上限:stdout/stderr 各保留尾部 N 字节。 */
65
+ export declare const CAPTURE_LIMIT_BYTES: number;
66
+ /** Max output lines kept per task (newest wins). */
67
+ export declare const MAX_TASK_LINES = 200;
68
+ /** Max tracked tasks; oldest finished tasks are dropped first. */
69
+ export declare const MAX_TASKS = 50;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Install runner 的统一出口:执行真实插件变更(spawn 官方 dsh CLI 子进程)。
3
+ * 实现按职责拆分在同目录子模块,本文件只做公共 API 的 re-export,保持外部
4
+ * import 面稳定(routes.ts / index.ts 仍从 `services/install.ts` 引入)。
5
+ *
6
+ * 模块划分:
7
+ * - install-types.ts 公共类型与常量
8
+ * - pending-restart.ts 待重启列表内存态管理
9
+ * - loader.ts 运行中 loader 的读取与卸载即时停用
10
+ * - profile.ts 宿主 profile / 目标校验 / allowBuilds 写入
11
+ * - task-queue.ts 任务注册表 + FIFO 队列 worker + 子进程生命周期
12
+ */
13
+ export type { InstallResult, InstallTask, Invocation, PendingRestart, QueueItem } from './install-types.ts';
14
+ export type { LoaderHandle } from './loader.ts';
15
+ export { addPendingRestart, clearPendingRestart, listPendingRestarts } from './pending-restart.ts';
16
+ export { dumpLoaderEntries, removeLoadedEntry } from './loader.ts';
17
+ export { addAllowBuildsKey, githubTarget, parseAllowBuildsKey, readProfileArg, validPackageName } from './profile.ts';
18
+ export { activeTask, cancelTask, getTask, hasQueuedTarget, hasRunningTask, runPluginMutation, startPluginMutation } from './task-queue.ts';
@@ -0,0 +1,24 @@
1
+ export interface InstalledVersionRecord {
2
+ /** 安装时的目录最新 release 版本(无 release 的仓库为空串) */
3
+ version: string;
4
+ /** 安装时的仓库最近更新时间(ISO,GitHub pushed_at);无 release 仓库的更新依据 */
5
+ updatedAt: string;
6
+ installedAt: string;
7
+ /** npm 优先通道反查命中的官方 npm 包名(repo → 包名映射):目录数据可能未下发
8
+ * npmPackage(组织 scope 与 GitHub 用户名不一致),客户端据此把依赖 key 匹配回仓库 */
9
+ npmPackage?: string;
10
+ }
11
+ export type InstalledVersionTable = Record<string, InstalledVersionRecord>;
12
+ export declare function versionsFilePath(profile: string): string;
13
+ export declare function readInstalledVersions(profile: string): InstalledVersionTable;
14
+ /** Record the catalog signals (version + repo updated time) present at install time for one repo. */
15
+ export declare function recordInstalledVersion(profile: string, repo: string, version: string, updatedAt: string): void;
16
+ /**
17
+ * Record the npm package name resolved for a repo (npm-first install channel).
18
+ * Called at `/install` time so the mapping exists even before the task finishes;
19
+ * harmless when the repo is not (yet) installed — the client only treats a repo
20
+ * as installed when its dependency key actually exists.
21
+ */
22
+ export declare function recordResolvedNpmPackage(profile: string, repo: string, npmPackage: string): void;
23
+ /** Forget the recorded version for one repo (called on uninstall). */
24
+ export declare function removeInstalledVersion(profile: string, repo: string): void;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * 运行中 loader 的读取与停用:卸载成功后对匹配条目做 live-disable,
3
+ * 使卸载立即生效(刷新页面不再加载已卸载的 client bundle,也无需重启)。
4
+ */
5
+ /**
6
+ * 运行中 loader 的最小接口:卸载成功后即时移除条目用。
7
+ * 对应官方 `ctx.loader`(cordis-plugin-loader 的 Loader 服务)的读取面与移除面。
8
+ * 移除方式:对匹配条目 live-disable(`entry.update({ disabled: true })`),
9
+ * 而不是 `loader.remove(id)` —— disable 走 Entry.update 的 disabled 分支,不触发
10
+ * tree.write()(不会把运行态条目烘焙回 cordis.yml),也不依赖嵌套子树的 id 解析。
11
+ */
12
+ export interface LoaderHandle {
13
+ /** 遍历当前插件树的全部条目(含嵌套子树)。 */
14
+ entries(): Iterable<{
15
+ id: string;
16
+ options: {
17
+ id?: string;
18
+ name?: string;
19
+ disabled?: boolean | null;
20
+ };
21
+ /** live-disable 一个条目(官方 Entry.update,force=true 覆盖初始化竞态)。 */
22
+ update(options: {
23
+ disabled: boolean | null;
24
+ }, create?: boolean, force?: boolean): Promise<void>;
25
+ }>;
26
+ /** 停止并移除一个条目(官方 Loader API:resolve → EntryGroup.remove → tree.write)。 */
27
+ remove(id: string): Promise<void>;
28
+ }
29
+ /** 运行中 loader 的全部条目(id + options.name),诊断用。 */
30
+ export declare function dumpLoaderEntries(loader: LoaderHandle | undefined): Array<{
31
+ id: string;
32
+ name?: string;
33
+ }>;
34
+ /**
35
+ * 卸载成功后从运行中 loader 停用指定包的条目,使卸载立即生效(刷新页面不再
36
+ * 加载已卸载的 client bundle,也无需重启)。做法:按 name 扫描
37
+ * `loader.entries()`(含嵌套子树),对每个匹配条目
38
+ * `entry.update({ disabled: true })` 做 live-disable —— fiber 被 dispose,
39
+ * client-modules 对账后不再把它写进 `__DSH_BOOT__`,页面刷新即恢复。
40
+ * 返回 true = 无需重启(条目已停用,或本就没加载过);false = 未能停用,
41
+ * 需登记「待重启清理」兜底。宿主关键包(@deepseek-ai/* 与本插件自身)一律跳过。
42
+ */
43
+ export declare function removeLoadedEntry(loader: LoaderHandle, name: string): Promise<boolean>;