dsh-plugin 1.6.1 → 1.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/client.js +377 -336
- package/client/client.js.map +1 -1
- package/lib/http/routes.js +38 -2
- package/lib/services/install/task-queue.js +32 -15
- package/lib/services/loader.js +67 -21
- package/lib/types/http/routes.d.ts +5 -0
- package/lib/types/services/install/install-types.d.ts +8 -1
- package/lib/types/services/install/task-queue.d.ts +5 -4
- package/lib/types/services/loader.d.ts +45 -7
- package/package.json +1 -1
- package/src/client/components/PluginHubSection.tsx +59 -11
- package/src/client/components/modals/NotificationsModal.tsx +2 -5
- package/src/client/components/modals/modals.tsx +34 -22
- package/src/client/hooks/useTaskQueue.ts +16 -11
- package/src/client/locales.ts +17 -4
- package/src/client/types.ts +1 -1
- package/src/server/http/routes.ts +39 -2
- package/src/server/services/install/install-types.ts +8 -1
- package/src/server/services/install/task-queue.ts +36 -20
- package/src/server/services/loader.ts +77 -23
package/lib/http/routes.js
CHANGED
|
@@ -26,6 +26,7 @@ import { appendLog, clearLog, readLog, logFilePath, defaultLogFilePath, customLo
|
|
|
26
26
|
* 不用 `/bin/sh -c`:Windows 无 /bin/sh,且 lsof/nohup 仅 POSIX 存在。
|
|
27
27
|
* 由 `spawn(process.execPath, ['-e', SCRIPT, port], { detached, stdio:'ignore' })` 孵化,
|
|
28
28
|
* 宿主进程被 kill 后仍能完成「停旧 → 等端口释放 → 拉起新 dsh web」。
|
|
29
|
+
* 端口探测不到监听进程时不再假装重启(记一行日志后退出,见脚本内注释)。
|
|
29
30
|
*/
|
|
30
31
|
const HOST_RESTART_SCRIPT = String.raw `const C = require('node:child_process')
|
|
31
32
|
const F = require('node:fs')
|
|
@@ -52,7 +53,16 @@ function findPids() {
|
|
|
52
53
|
return String(out.stdout || '').split(/\s+/).filter(Boolean)
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
|
|
56
|
+
const targets = findPids()
|
|
57
|
+
// 一个监听进程都没找到 = 端口探测本身就失败了(netstat 输出格式 / 本地化差异),
|
|
58
|
+
// 旧宿主仍占着端口。此时再拉一个 dsh web 只会在日志里以 EADDRINUSE 静默退出,
|
|
59
|
+
// 用户却看到「重启完成」——不制造这种假象,留一行可诊断的记录后退出。
|
|
60
|
+
if (targets.length === 0) {
|
|
61
|
+
logLine('restart aborted: no listener found on port ' + port)
|
|
62
|
+
process.exit(0)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const pid of targets) {
|
|
56
66
|
try {
|
|
57
67
|
if (win) C.spawnSync('taskkill', ['/pid', pid, '/t', '/f'])
|
|
58
68
|
else process.kill(Number(pid), 'SIGTERM')
|
|
@@ -65,6 +75,14 @@ let tries = 0
|
|
|
65
75
|
setTimeout(waitForStop, 250)
|
|
66
76
|
})()
|
|
67
77
|
|
|
78
|
+
function logLine(msg) {
|
|
79
|
+
try {
|
|
80
|
+
const dir = P.join(O.homedir(), '.dsh', 'logs')
|
|
81
|
+
F.mkdirSync(dir, { recursive: true })
|
|
82
|
+
F.appendFileSync(P.join(dir, 'dsh-web-' + port + '.log'), '[' + new Date().toISOString() + '] ' + msg + '\n')
|
|
83
|
+
} catch {}
|
|
84
|
+
}
|
|
85
|
+
|
|
68
86
|
function startHost() {
|
|
69
87
|
const dir = P.join(O.homedir(), '.dsh', 'logs')
|
|
70
88
|
F.mkdirSync(dir, { recursive: true })
|
|
@@ -76,6 +94,13 @@ function startHost() {
|
|
|
76
94
|
})
|
|
77
95
|
child.unref()
|
|
78
96
|
}`;
|
|
97
|
+
/**
|
|
98
|
+
* 宿主是否由桌面应用壳托管(`ELECTRON_RUN_AS_NODE=1`:壳用 Electron 二进制以 Node 模式跑宿主)。
|
|
99
|
+
* 桌面端的宿主归应用壳管,插件重启不了它,也不该去 kill —— 详见 /restart 路由。
|
|
100
|
+
*/
|
|
101
|
+
export function isDesktopHost() {
|
|
102
|
+
return process.env.ELECTRON_RUN_AS_NODE === '1';
|
|
103
|
+
}
|
|
79
104
|
const PROFILE_RE = /^[A-Za-z0-9_-]+$/;
|
|
80
105
|
const BODY_LIMIT_BYTES = 4 * 1024;
|
|
81
106
|
const COMMAND_TIMEOUT_MS = 5 * 60 * 1000;
|
|
@@ -1014,6 +1039,8 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
|
|
|
1014
1039
|
env: mutationEnv(settings),
|
|
1015
1040
|
// 覆盖更新:npm 通道命令显式 @latest(pnpm add 无版本对已存在依赖是幂等的,不加版本不会真更新)
|
|
1016
1041
|
updateNpm: mode === 'update',
|
|
1042
|
+
// 运行中 loader:安装成功后把新插件热挂进 loader,非桌面端 + 纯服务端插件无需重启即生效
|
|
1043
|
+
loader,
|
|
1017
1044
|
// 自定义安装入口渠道(NPM 包 / GitHub 源码 / DSH 命令卡片):日志/报错据此溯源
|
|
1018
1045
|
installChannel,
|
|
1019
1046
|
});
|
|
@@ -1060,7 +1087,7 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
|
|
|
1060
1087
|
// 卸载的 mutation 目标是 npm 包名;待重启行要显示 owner/repo(与安装行一致),非法/缺失时回退包名
|
|
1061
1088
|
displayTarget: githubRepoOf(repo) ?? undefined,
|
|
1062
1089
|
// 运行中 loader:卸载成功后主动移除条目,立即生效、无需重启
|
|
1063
|
-
|
|
1090
|
+
loader,
|
|
1064
1091
|
timeoutMs: COMMAND_TIMEOUT_MS,
|
|
1065
1092
|
env: mutationEnv(loadSettings(profile)),
|
|
1066
1093
|
});
|
|
@@ -1133,6 +1160,15 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
|
|
|
1133
1160
|
handler: (request, response) => {
|
|
1134
1161
|
if (!requireTrustedPost(request, response))
|
|
1135
1162
|
return;
|
|
1163
|
+
// 桌面端:宿主由应用壳托管,插件重启不了它。Windows 上 `taskkill /T` 会把发起
|
|
1164
|
+
// 重启的助手一起结束(助手是宿主的子进程),壳再把这次外部 kill 判成宿主崩溃
|
|
1165
|
+
// 弹「应用无法启动或已意外停止」;退一步说,脱离壳拉起的 `dsh web` 也服务不了
|
|
1166
|
+
// 桌面 UI(缺壳注入的 desktop:boot)。故不 kill、不拉新宿主,由客户端改提示
|
|
1167
|
+
// 用户从菜单/托盘退出后重新打开。
|
|
1168
|
+
if (isDesktopHost()) {
|
|
1169
|
+
sendJson(response, 200, { ok: false, desktop: true });
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1136
1172
|
// 当前宿主监听端口来自请求 Host 头(localhost:7923),解析失败回退 7923
|
|
1137
1173
|
const host = request.headers.host ?? '';
|
|
1138
1174
|
const portMatch = host.match(/:(\d+)$/);
|
|
@@ -14,7 +14,7 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
14
14
|
import { CAPTURE_LIMIT_BYTES, MAX_TASKS, MAX_TASK_LINES } from './install-types.js';
|
|
15
15
|
import { npmTooLowMarker } from './npm-check.js';
|
|
16
16
|
import { cleanLine, estimateProgress } from '../profile/progress.js';
|
|
17
|
-
import { removeLoadedEntry } from '../loader.js';
|
|
17
|
+
import { hasClientUi, isDshPlugin, mountLoadedEntry, removeLoadedEntry } from '../loader.js';
|
|
18
18
|
import { addPendingRestart, clearPendingRestart } from '../profile/pending-restart.js';
|
|
19
19
|
import { addAllowBuildsKey, githubRepoOf, parseAllowBuildsKey, profileDirectory } from '../profile/profile.js';
|
|
20
20
|
import { appendLog } from '../log.js';
|
|
@@ -230,7 +230,10 @@ function channelLabel(channel) {
|
|
|
230
230
|
* Progress is visible through `getTask(id)` / `activeTask()` until done.
|
|
231
231
|
*/
|
|
232
232
|
export function startPluginMutation(options) {
|
|
233
|
-
|
|
233
|
+
// 卸载入队时先快照「该包是否带客户端 UI」:卸载完成后包已从磁盘删除,读不到 package.json,
|
|
234
|
+
// 只能在此刻取;卸载成功且曾存活时据此决定是否要刷新页面摘除插件面板
|
|
235
|
+
const hadClientUi = options.action === 'remove' ? hasClientUi(options.profile, options.target) : undefined;
|
|
236
|
+
const task = { id: nextTaskId, target: options.target, displayTarget: options.displayTarget, globalNpm: options.globalNpm, action: options.action, status: 'pending', timedOut: false, exitCode: null, progress: 0, lines: [], attempts: options.attempts ?? [], needsRestart: false, hadClientUi, installChannel: options.installChannel };
|
|
234
237
|
nextTaskId = nextTaskId >= Number.MAX_SAFE_INTEGER ? 1 : nextTaskId + 1;
|
|
235
238
|
tasks.set(task.id, task);
|
|
236
239
|
if (tasks.size > MAX_TASKS) {
|
|
@@ -326,7 +329,7 @@ function pumpQueue() {
|
|
|
326
329
|
* Never rejects; failures surface through the result / task state.
|
|
327
330
|
*/
|
|
328
331
|
function spawnMutation(options) {
|
|
329
|
-
const { action, profile, target, timeoutMs = 5 * 60 * 1000, env, task, displayTarget,
|
|
332
|
+
const { action, profile, target, timeoutMs = 5 * 60 * 1000, env, task, displayTarget, loader, globalNpm, updateNpm } = options;
|
|
330
333
|
// 全局 npm 安装:直接 spawn 本机 npm(`npm install -g <pkgs>`),不是 dsh plugin 命令
|
|
331
334
|
const isGlobalNpm = (globalNpm ?? []).length > 0;
|
|
332
335
|
const invocation = isGlobalNpm
|
|
@@ -518,21 +521,35 @@ function spawnMutation(options) {
|
|
|
518
521
|
// 全局 npm 安装:装的是本机 CLI 工具(如 dsh-tui),不进任何 profile,无需宿主重启
|
|
519
522
|
task.needsRestart = false;
|
|
520
523
|
}
|
|
521
|
-
else if (action === 'remove' &&
|
|
522
|
-
// 卸载成功 → 主动从运行中 loader
|
|
523
|
-
//
|
|
524
|
-
//
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
task.needsRestart
|
|
529
|
-
if (removed)
|
|
530
|
-
clearPendingRestart(displayTarget ?? target);
|
|
531
|
-
else
|
|
524
|
+
else if (action === 'remove' && loader) {
|
|
525
|
+
// 卸载成功 → 主动从运行中 loader 停用该包条目:
|
|
526
|
+
// - 停用成功:服务端即时卸载;带客户端 UI 的插件面板要页面刷新才摘除(无需重启宿主)
|
|
527
|
+
// - 停用失败 / 超时:回退「待重启」兜底清理(刷新解决不了,登记常驻提醒)
|
|
528
|
+
const removal = await removeLoadedEntry(loader, target);
|
|
529
|
+
task.needsRestart = !removal.ok;
|
|
530
|
+
task.needsReload = removal.ok && removal.live && task.hadClientUi === true;
|
|
531
|
+
if (task.needsRestart)
|
|
532
532
|
addPendingRestart(displayTarget ?? target, 'uninstall');
|
|
533
|
+
else
|
|
534
|
+
clearPendingRestart(displayTarget ?? target);
|
|
535
|
+
}
|
|
536
|
+
else if (action !== 'remove' && !isUpdate && loader) {
|
|
537
|
+
// 安装成功 → 尝试把新插件热挂进运行中 loader,让插件无需重启宿主即生效。
|
|
538
|
+
// 挂上了:服务端即时生效;带客户端 UI 的插件还需页面重新加载(宿主按请求现场
|
|
539
|
+
// 拼装 __DSH_BOOT__ 注入清单,网页端与桌面端一致,刷新即可)。
|
|
540
|
+
// 挂不上 / 非 dsh 插件 / 无法定位包名:回退「待重启」提醒(保证不静默失效)。
|
|
541
|
+
const { name } = verifyInstalledEntry(profile, target);
|
|
542
|
+
if (name !== null && isDshPlugin(profile, name) && await mountLoadedEntry(loader, name)) {
|
|
543
|
+
task.needsReload = hasClientUi(profile, name);
|
|
544
|
+
task.needsRestart = false;
|
|
545
|
+
}
|
|
546
|
+
else {
|
|
547
|
+
task.needsRestart = true;
|
|
548
|
+
addPendingRestart(displayTarget ?? target, 'install');
|
|
549
|
+
}
|
|
533
550
|
}
|
|
534
551
|
else {
|
|
535
|
-
//
|
|
552
|
+
// 更新(ESM 缓存下热挂取不到新代码)/ 无 loader 的安装或卸载 → 登记待重启:
|
|
536
553
|
// 插件要宿主重启才会挂载/卸载干净,重启前一直提醒;展示目标用 displayTarget(owner/repo)
|
|
537
554
|
task.needsRestart = true;
|
|
538
555
|
addPendingRestart(displayTarget ?? target, action === 'remove' ? 'uninstall' : 'install');
|
package/lib/services/loader.js
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
* Website: https://dsh-plugin.org
|
|
4
4
|
* GitHub: https://github.com/dshplugin/dsh-plugin-hub
|
|
5
5
|
*
|
|
6
|
-
* 运行中 loader
|
|
7
|
-
*
|
|
6
|
+
* 运行中 loader 的读取、停用与热挂载:卸载成功后对匹配条目做 live-disable、
|
|
7
|
+
* 安装成功后把新插件条目热挂进运行中的 loader,让两类操作都尽量立即生效
|
|
8
|
+
* (刷新页面即可,无需重启宿主)。
|
|
8
9
|
*/
|
|
9
10
|
import { readFileSync } from 'node:fs';
|
|
10
11
|
import { join } from 'node:path';
|
|
@@ -77,15 +78,16 @@ function nameMatches(entryName, target) {
|
|
|
77
78
|
* `loader.entries()`(含嵌套子树),对每个匹配条目
|
|
78
79
|
* `entry.update({ disabled: true })` 做 live-disable —— fiber 被 dispose,
|
|
79
80
|
* client-modules 对账后不再把它写进 `__DSH_BOOT__`,页面刷新即恢复。
|
|
80
|
-
* 返回
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
81
|
+
* 返回 `{ ok, live }`:
|
|
82
|
+
* - `{ ok: true, live: false }`:宿主从未加载过它(磁盘已干净),无需刷新/重启;
|
|
83
|
+
* - `{ ok: true, live: true }`:曾在 loader 中存活且已 live-disable —— 服务端即时卸载;
|
|
84
|
+
* 带客户端 UI 的插件面板要页面刷新才摘除(由调用方按 `hadClientUi` 决定 needsReload);
|
|
85
|
+
* - `{ ok: false, live: true }`:disable 失败 / 超时,需宿主重启兜底清理。
|
|
84
86
|
* 宿主关键包(@deepseek-ai/* 与本插件自身)一律跳过。
|
|
85
87
|
*/
|
|
86
88
|
export async function removeLoadedEntry(loader, name) {
|
|
87
89
|
if (name === 'dsh-plugin' || name.startsWith('@deepseek-ai/'))
|
|
88
|
-
return false;
|
|
90
|
+
return { ok: false, live: true };
|
|
89
91
|
// 5s 超时兜底:fiber dispose 卡住时不能让卸载任务(乃至整个队列)被拖死
|
|
90
92
|
const removal = (async () => {
|
|
91
93
|
const entries = dumpLoaderEntries(loader);
|
|
@@ -106,32 +108,77 @@ export async function removeLoadedEntry(loader, name) {
|
|
|
106
108
|
catch (error) {
|
|
107
109
|
console.log(`[hub-uninstall] disable ${name} attempt ${attempt + 1} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
108
110
|
if (attempt === 2)
|
|
109
|
-
return false;
|
|
111
|
+
return { ok: false, live: true };
|
|
110
112
|
await new Promise((resolvePromise) => setTimeout(resolvePromise, 200));
|
|
111
113
|
}
|
|
112
114
|
}
|
|
113
115
|
}
|
|
114
116
|
if (!found) {
|
|
115
|
-
// 运行中 loader
|
|
117
|
+
// 运行中 loader 无该条目:宿主从未加载过它(装完未重启),磁盘已干净,无需刷新/重启
|
|
116
118
|
console.log(`[hub-uninstall] no live entry for ${name}; nothing to disable`);
|
|
117
|
-
return true;
|
|
119
|
+
return { ok: true, live: false };
|
|
118
120
|
}
|
|
119
121
|
// 找到并停用了 live entry:逻辑层已卸载(刷新页面不再加载其 client bundle),
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
return false;
|
|
122
|
+
// 带 UI 的插件面板由调用方按 hadClientUi 决定是否需要刷新摘除
|
|
123
|
+
console.log(`[hub-uninstall] disabled live entry for ${name}`);
|
|
124
|
+
return { ok: true, live: true };
|
|
124
125
|
})();
|
|
125
126
|
const result = await Promise.race([
|
|
126
127
|
removal.catch((error) => {
|
|
127
128
|
console.log(`[hub-uninstall] disable failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
128
|
-
return false;
|
|
129
|
+
return { ok: false, live: true };
|
|
129
130
|
}),
|
|
130
|
-
new Promise((resolve) => setTimeout(() => resolve(false), 5_000)),
|
|
131
|
+
new Promise((resolve) => setTimeout(() => resolve({ ok: false, live: true }), 5_000)),
|
|
131
132
|
]);
|
|
132
|
-
console.log(`[hub-uninstall] disable ${name} -> ${result ? '
|
|
133
|
+
console.log(`[hub-uninstall] disable ${name} -> ${result.ok ? (result.live ? 'disabled live entry' : 'nothing live') : 'timed out or failed'}`);
|
|
133
134
|
return result;
|
|
134
135
|
}
|
|
136
|
+
/**
|
|
137
|
+
* 安装成功后把新插件热挂进运行中 loader(等价 profile bundle patch 的
|
|
138
|
+
* `- insert: - name: <pkg>` 行),让插件无需重启宿主即可生效。
|
|
139
|
+
* 做法:在 Loader 根组新建条目并启动 —— Loader.write() 为空实现,不写盘;
|
|
140
|
+
* 重启后由 profile 的 bundles 清单重新挂载,不会重复。
|
|
141
|
+
* `Entry._init` 的 import 失败只 `logger.error` 后 return(不抛),条目会建出来但
|
|
142
|
+
* fiber 为空 / 条目可能被标记 disabled —— 必须用 `loader.resolve(id)` 校验真的起来了。
|
|
143
|
+
* 返回 true = 已在运行中(本次新建或此前已加载)。
|
|
144
|
+
*/
|
|
145
|
+
export async function mountLoadedEntry(loader, name) {
|
|
146
|
+
if (isEntryLoaded(loader, name))
|
|
147
|
+
return true;
|
|
148
|
+
const mounting = (async () => {
|
|
149
|
+
const id = await loader.create({ name });
|
|
150
|
+
const entry = loader.resolve(id);
|
|
151
|
+
if (entry?.fiber !== undefined && entry?.fiber !== null && !entry.options?.disabled) {
|
|
152
|
+
console.log(`[hub-install] hot mounted ${name} (entry ${id})`);
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
console.log(`[hub-install] hot mount ${name}: entry ${id} created but not running (import failed?)`);
|
|
156
|
+
return false;
|
|
157
|
+
})();
|
|
158
|
+
// 10s 超时兜底:import 卡住时不能让装完收尾(乃至整个队列)被拖死
|
|
159
|
+
return Promise.race([
|
|
160
|
+
mounting.catch((error) => {
|
|
161
|
+
console.log(`[hub-install] hot mount ${name} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
162
|
+
return false;
|
|
163
|
+
}),
|
|
164
|
+
new Promise((resolve) => setTimeout(() => resolve(false), 10_000)),
|
|
165
|
+
]);
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* 已安装包是否带客户端 UI(package.json 的 `dsh.client` 声明):client-modules 据它把
|
|
169
|
+
* 客户端 bundle 注入宿主页面。带 UI 的插件热挂载后需要页面重新加载才会渲染,
|
|
170
|
+
* 网页端与桌面端一致 —— `__DSH_BOOT__` 注入清单由宿主服务端按请求现场拼装,
|
|
171
|
+
* 壳不缓存,所以桌面端刷新页面同样能拿到新清单,无需重开 App。
|
|
172
|
+
*/
|
|
173
|
+
export function hasClientUi(profile, name) {
|
|
174
|
+
try {
|
|
175
|
+
const pkg = JSON.parse(readFileSync(join(profileDirectory(profile), 'node_modules', name, 'package.json'), 'utf8'));
|
|
176
|
+
return pkg.dsh?.client !== undefined && pkg.dsh?.client !== null;
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
135
182
|
/**
|
|
136
183
|
* 判断某已安装包是否是真正的 dsh 插件(宿主会加载 / 值得提示「待重启」):
|
|
137
184
|
* 1) 包名已写进 profile 的 `dsh.profile.bundles`(宿主启动时按清单加载);
|
|
@@ -143,10 +190,9 @@ export function isDshPlugin(profile, name) {
|
|
|
143
190
|
// 1) profile 级 bundles 清单:包名在清单里 → 宿主启动会加载它
|
|
144
191
|
try {
|
|
145
192
|
const profilePkg = JSON.parse(readFileSync(join(profileDirectory(profile), 'package.json'), 'utf8'));
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
}
|
|
193
|
+
const bundles = profilePkg.dsh?.profile?.bundles;
|
|
194
|
+
if (Array.isArray(bundles) && bundles.includes(name))
|
|
195
|
+
return true;
|
|
150
196
|
}
|
|
151
197
|
catch { /* profile 读取失败按无 bundles 处理 */ }
|
|
152
198
|
// 2) 包内声明:dsh 字段(bundle patch / profile 配置)或 dsh 关键字
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import { readProfileArg, type LoaderHandle } from '../services/install/install.ts';
|
|
3
|
+
/**
|
|
4
|
+
* 宿主是否由桌面应用壳托管(`ELECTRON_RUN_AS_NODE=1`:壳用 Electron 二进制以 Node 模式跑宿主)。
|
|
5
|
+
* 桌面端的宿主归应用壳管,插件重启不了它,也不该去 kill —— 详见 /restart 路由。
|
|
6
|
+
*/
|
|
7
|
+
export declare function isDesktopHost(): boolean;
|
|
3
8
|
export interface WebRoute {
|
|
4
9
|
kind: 'exact';
|
|
5
10
|
path: string;
|
|
@@ -37,8 +37,15 @@ export interface InstallTask {
|
|
|
37
37
|
progress: number;
|
|
38
38
|
/** Newest output lines first (consumer shows the tail). */
|
|
39
39
|
lines: string[];
|
|
40
|
-
/**
|
|
40
|
+
/** 完成后是否仍需宿主重启才生效:仅「热挂载失败 / 非 dsh 插件 / 更新(ESM 缓存取不到新代码)/
|
|
41
|
+
* 无 loader」等刷新解决不了的兜底场景为 true(弹窗给「立即重启」);其余一律 false。 */
|
|
41
42
|
needsRestart: boolean;
|
|
43
|
+
/** 完成后是否只需刷新页面即生效(插件已热挂进运行中 loader,仅剩客户端 UI 待页面重新加载,
|
|
44
|
+
* 网页端与桌面端一致):true 时关闭结果弹窗即自动刷新页面,不给手动刷新按钮。 */
|
|
45
|
+
needsReload?: boolean;
|
|
46
|
+
/** 卸载入队时该包是否带客户端 UI(`dsh.client`):卸载后包已从磁盘删除,只能入队前快照,
|
|
47
|
+
* 卸载成功且曾存活时据此决定是否需要刷新页面摘除插件面板。 */
|
|
48
|
+
hadClientUi?: boolean;
|
|
42
49
|
}
|
|
43
50
|
/** 子进程启动参数(复用宿主 dsh 入口,或回退到 PATH 上的 `dsh`)。 */
|
|
44
51
|
export interface Invocation {
|
|
@@ -55,8 +55,9 @@ export declare function startPluginMutation(options: {
|
|
|
55
55
|
globalNpm?: string[];
|
|
56
56
|
/** 待重启行的展示目标(owner/repo):卸载时用于把 npm 包名映射回仓库名 */
|
|
57
57
|
displayTarget?: string;
|
|
58
|
-
/** 运行中 loader
|
|
59
|
-
|
|
58
|
+
/** 运行中 loader:安装成功后热挂新条目、卸载成功后停用旧条目,两类操作都尽量立即生效
|
|
59
|
+
* (缺失时安装/卸载仍需重启) */
|
|
60
|
+
loader?: LoaderHandle;
|
|
60
61
|
/** 入队前已尝试的安装方式(npm registry 反查等):失败提 Issue 时如实展示;实际执行的命令由 spawn 时追加 */
|
|
61
62
|
attempts?: string[];
|
|
62
63
|
/** 更新已安装的 npm 包(mode=update):命令显式带 @latest,让 pnpm 真正解析最新版本 */
|
|
@@ -93,8 +94,8 @@ export declare function runPluginMutation(options: {
|
|
|
93
94
|
task?: InstallTask;
|
|
94
95
|
/** 待重启行的展示目标(owner/repo):卸载时用于把 npm 包名映射回仓库名 */
|
|
95
96
|
displayTarget?: string;
|
|
96
|
-
/** 运行中 loader
|
|
97
|
-
|
|
97
|
+
/** 运行中 loader:安装成功后热挂新条目、卸载成功后停用旧条目(缺失时仍需重启) */
|
|
98
|
+
loader?: LoaderHandle;
|
|
98
99
|
/** 全局 npm 安装(官方 `npm install -g <pkgs>`):非空时执行全局安装,不进任何 profile、无需宿主重启 */
|
|
99
100
|
globalNpm?: string[];
|
|
100
101
|
}): Promise<InstallResult>;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 运行中 loader
|
|
3
|
-
* 对应官方 `ctx.loader`(cordis-plugin-loader 的 Loader
|
|
2
|
+
* 运行中 loader 的最小接口:卸载即时停用 / 安装即时热挂载用。
|
|
3
|
+
* 对应官方 `ctx.loader`(cordis-plugin-loader 的 Loader 服务)的读取、新建与移除面。
|
|
4
4
|
* 移除方式:对匹配条目 live-disable(`entry.update({ disabled: true })`),
|
|
5
5
|
* 而不是 `loader.remove(id)` —— disable 走 Entry.update 的 disabled 分支,不触发
|
|
6
6
|
* tree.write()(不会把运行态条目烘焙回 cordis.yml),也不依赖嵌套子树的 id 解析。
|
|
@@ -21,6 +21,23 @@ export interface LoaderHandle {
|
|
|
21
21
|
}>;
|
|
22
22
|
/** 停止并移除一个条目(官方 Loader API:resolve → EntryGroup.remove → tree.write)。 */
|
|
23
23
|
remove(id: string): Promise<void>;
|
|
24
|
+
/** 在运行中 loader 里新建一个条目并启动它。等价于 profile bundle patch 的
|
|
25
|
+
* `- insert: - name: <pkg>` 行;Loader.write() 为空实现,不会写盘(重启后由
|
|
26
|
+
* profile 的 bundles 清单重新挂载,不会重复挂载)。返回新条目的 id。 */
|
|
27
|
+
create(options: {
|
|
28
|
+
id?: string;
|
|
29
|
+
name: string;
|
|
30
|
+
}): Promise<string>;
|
|
31
|
+
/** 按 id 解析条目:热挂载后确认 fiber 真的起来了(import 失败时条目在、但 fiber 为空)。 */
|
|
32
|
+
resolve(id: string): {
|
|
33
|
+
id: string;
|
|
34
|
+
options?: {
|
|
35
|
+
name?: string;
|
|
36
|
+
disabled?: boolean | null;
|
|
37
|
+
};
|
|
38
|
+
fiber?: unknown;
|
|
39
|
+
subgroup?: unknown;
|
|
40
|
+
};
|
|
24
41
|
}
|
|
25
42
|
/** 运行中 loader 的全部条目(id + options.name),诊断用。 */
|
|
26
43
|
export declare function dumpLoaderEntries(loader: LoaderHandle | undefined): Array<{
|
|
@@ -38,13 +55,34 @@ export declare function isEntryLoaded(loader: LoaderHandle | undefined, name: st
|
|
|
38
55
|
* `loader.entries()`(含嵌套子树),对每个匹配条目
|
|
39
56
|
* `entry.update({ disabled: true })` 做 live-disable —— fiber 被 dispose,
|
|
40
57
|
* client-modules 对账后不再把它写进 `__DSH_BOOT__`,页面刷新即恢复。
|
|
41
|
-
* 返回
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
58
|
+
* 返回 `{ ok, live }`:
|
|
59
|
+
* - `{ ok: true, live: false }`:宿主从未加载过它(磁盘已干净),无需刷新/重启;
|
|
60
|
+
* - `{ ok: true, live: true }`:曾在 loader 中存活且已 live-disable —— 服务端即时卸载;
|
|
61
|
+
* 带客户端 UI 的插件面板要页面刷新才摘除(由调用方按 `hadClientUi` 决定 needsReload);
|
|
62
|
+
* - `{ ok: false, live: true }`:disable 失败 / 超时,需宿主重启兜底清理。
|
|
45
63
|
* 宿主关键包(@deepseek-ai/* 与本插件自身)一律跳过。
|
|
46
64
|
*/
|
|
47
|
-
export declare function removeLoadedEntry(loader: LoaderHandle, name: string): Promise<
|
|
65
|
+
export declare function removeLoadedEntry(loader: LoaderHandle, name: string): Promise<{
|
|
66
|
+
ok: boolean;
|
|
67
|
+
live: boolean;
|
|
68
|
+
}>;
|
|
69
|
+
/**
|
|
70
|
+
* 安装成功后把新插件热挂进运行中 loader(等价 profile bundle patch 的
|
|
71
|
+
* `- insert: - name: <pkg>` 行),让插件无需重启宿主即可生效。
|
|
72
|
+
* 做法:在 Loader 根组新建条目并启动 —— Loader.write() 为空实现,不写盘;
|
|
73
|
+
* 重启后由 profile 的 bundles 清单重新挂载,不会重复。
|
|
74
|
+
* `Entry._init` 的 import 失败只 `logger.error` 后 return(不抛),条目会建出来但
|
|
75
|
+
* fiber 为空 / 条目可能被标记 disabled —— 必须用 `loader.resolve(id)` 校验真的起来了。
|
|
76
|
+
* 返回 true = 已在运行中(本次新建或此前已加载)。
|
|
77
|
+
*/
|
|
78
|
+
export declare function mountLoadedEntry(loader: LoaderHandle, name: string): Promise<boolean>;
|
|
79
|
+
/**
|
|
80
|
+
* 已安装包是否带客户端 UI(package.json 的 `dsh.client` 声明):client-modules 据它把
|
|
81
|
+
* 客户端 bundle 注入宿主页面。带 UI 的插件热挂载后需要页面重新加载才会渲染,
|
|
82
|
+
* 网页端与桌面端一致 —— `__DSH_BOOT__` 注入清单由宿主服务端按请求现场拼装,
|
|
83
|
+
* 壳不缓存,所以桌面端刷新页面同样能拿到新清单,无需重开 App。
|
|
84
|
+
*/
|
|
85
|
+
export declare function hasClientUi(profile: string, name: string): boolean;
|
|
48
86
|
/**
|
|
49
87
|
* 判断某已安装包是否是真正的 dsh 插件(宿主会加载 / 值得提示「待重启」):
|
|
50
88
|
* 1) 包名已写进 profile 的 `dsh.profile.bundles`(宿主启动时按清单加载);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.3",
|
|
4
4
|
"description": "A community plugin marketplace for DeepSeek Harness, built to the official plugin spec — browse, search and install 10000+ human-curated community plugins without leaving the app. · DeepSeek Harness 社区插件市场(遵循官方开发规范):10000+ 人工精选社区插件,每日更新。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -103,10 +103,15 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
|
|
|
103
103
|
/** 安装/卸载完成后的结果视图:停留弹窗内,点「完成」关闭 */
|
|
104
104
|
const [installDone, setInstallDone] = useState(false)
|
|
105
105
|
const [uninstallDone, setUninstallDone] = useState(false)
|
|
106
|
-
/**
|
|
107
|
-
* true
|
|
108
|
-
|
|
109
|
-
const [
|
|
106
|
+
/** 结果视图是否给「立即重启」:服务端任务终态带出。仅刷新解决不了的兜底场景
|
|
107
|
+
* (热挂载失败 / 非 dsh 插件 / 更新)为 true;其余(热挂载成功、卸载已即时停用)为 false,
|
|
108
|
+
* 结果视图只给「完成」,关闭时自动刷新页面生效 */
|
|
109
|
+
const [installNeedsRestart, setInstallNeedsRestart] = useState(false)
|
|
110
|
+
const [uninstallNeedsRestart, setUninstallNeedsRestart] = useState(false)
|
|
111
|
+
/** 结果视图是否显示「关闭后自动刷新」说明:插件已热挂进运行中 loader(安装)、或卸载后
|
|
112
|
+
* 插件面板仍残留在当前页面(卸载)。关闭弹窗即整页刷新,不给手动刷新按钮。 */
|
|
113
|
+
const [installNeedsReload, setInstallNeedsReload] = useState(false)
|
|
114
|
+
const [uninstallNeedsReload, setUninstallNeedsReload] = useState(false)
|
|
110
115
|
/** 结果视图「立即重启」:请求宿主重启后进入等待,服务回来后整页刷新 */
|
|
111
116
|
const [restarting, setRestarting] = useState(false)
|
|
112
117
|
/** 操作失败完整信息 + 所属插件仓库 + 失败类型(决定弹窗标题「安装失败/卸载失败」)+ 实际执行的安装命令 + 尝试过的安装方式(issue 预填用) */
|
|
@@ -129,23 +134,39 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
|
|
|
129
134
|
// 挂载与每次切换视图时比对全局 CSS 清单,把缺失的样式补注入回去 —— 用户无需再手动点击恢复。
|
|
130
135
|
useEffect(() => { ensurePluginCss() }, [view])
|
|
131
136
|
|
|
137
|
+
/** 后台任务(弹窗已关)完成且只需刷新页面即生效时,等结果 Toast 读完再整页刷新:
|
|
138
|
+
* 新插件(或卸载后残留)的客户端资源要重新加载页面才会生效,无需重启宿主。 */
|
|
139
|
+
const scheduleAutoReload = () => {
|
|
140
|
+
window.setTimeout(() => window.location.reload(), 1600)
|
|
141
|
+
}
|
|
142
|
+
|
|
132
143
|
const queue = useTaskQueue({
|
|
133
144
|
t,
|
|
134
145
|
// 界面语言带给服务端:网络预检等错误消息按用户语言提示(中文界面给中文,英文界面给英文)
|
|
135
146
|
langKey,
|
|
136
147
|
refreshInstalled: catalog.refreshInstalled,
|
|
137
|
-
onInstallDone: (viaModal, repo, needsRestart, update) => {
|
|
148
|
+
onInstallDone: (viaModal, repo, needsRestart, update, needsReload) => {
|
|
149
|
+
const reload = needsReload === true
|
|
138
150
|
setInstallNeedsRestart(needsRestart)
|
|
151
|
+
setInstallNeedsReload(reload)
|
|
139
152
|
if (viaModal) setInstallDone(true)
|
|
140
|
-
|
|
153
|
+
// 弹窗外的后台安装:给结果 toast;需要页面重载时(热挂载的客户端 UI)读完后自动刷新
|
|
154
|
+
else setToast({
|
|
155
|
+
id: Date.now(),
|
|
156
|
+
kind: reload ? 'installedReload' : needsRestart ? 'done' : 'installedLive',
|
|
157
|
+
})
|
|
158
|
+
if (!viaModal && reload) scheduleAutoReload()
|
|
141
159
|
// 安装成功也写入通知记录:通知中心里成功与失败都能看到。
|
|
142
160
|
// action 区分「安装成功」与「更新成功」(更新 = 覆盖重装,同一执行通道)
|
|
143
161
|
setNotifications(addSuccess({ kind: 'install', action: update ? 'update' : 'install', repo: repo ?? '' }))
|
|
144
162
|
},
|
|
145
|
-
onUninstallDone: (viaModal, repo, needsRestart) => {
|
|
163
|
+
onUninstallDone: (viaModal, repo, needsRestart, needsReload) => {
|
|
164
|
+
const reload = needsReload === true
|
|
146
165
|
setUninstallNeedsRestart(needsRestart)
|
|
166
|
+
setUninstallNeedsReload(reload)
|
|
147
167
|
if (viaModal) setUninstallDone(true)
|
|
148
168
|
else setToast({ id: Date.now(), kind: 'removed' })
|
|
169
|
+
if (!viaModal && reload) scheduleAutoReload()
|
|
149
170
|
setNotifications(addSuccess({ kind: 'uninstall', repo: repo ?? '' }))
|
|
150
171
|
},
|
|
151
172
|
onError: (message, repo, kind, command, attempts, update) => {
|
|
@@ -311,16 +332,27 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
|
|
|
311
332
|
if (!ok) setToast({ id: Date.now(), kind: 'revealFail' })
|
|
312
333
|
}
|
|
313
334
|
|
|
314
|
-
/** 「立即重启」:POST 同源 /restart,宿主进程自杀重启;随后轮询服务恢复后整页刷新。
|
|
335
|
+
/** 「立即重启」:POST 同源 /restart,宿主进程自杀重启;随后轮询服务恢复后整页刷新。
|
|
336
|
+
* 只在刷新解决不了的兜底场景(热挂载失败 / 非 dsh 插件 / 更新)出现。
|
|
337
|
+
* 桌面端宿主归应用壳管(服务端回 `desktop: true` 且不重启):不轮询,改提示退出重开。 */
|
|
315
338
|
const requestRestart = async () => {
|
|
316
339
|
if (restarting) return
|
|
317
340
|
setRestarting(true)
|
|
341
|
+
let desktop = false
|
|
318
342
|
try {
|
|
319
343
|
// 响应可能在宿主进程被 kill 前返回,也可能直接断连 —— 两种都属正常
|
|
320
|
-
await fetch('/dsh-plugin-hub/restart', { method: 'POST' })
|
|
344
|
+
const res = await fetch('/dsh-plugin-hub/restart', { method: 'POST' })
|
|
345
|
+
const data = await res.json() as { desktop?: boolean }
|
|
346
|
+
desktop = data.desktop === true
|
|
321
347
|
} catch {
|
|
322
348
|
/* 服务已终止,无需处理 */
|
|
323
349
|
}
|
|
350
|
+
if (desktop) {
|
|
351
|
+
// 宿主不会重启,轮询等不到新服务:直接给出正确口径
|
|
352
|
+
setRestarting(false)
|
|
353
|
+
setToast({ id: Date.now(), kind: 'restartDesktop' })
|
|
354
|
+
return
|
|
355
|
+
}
|
|
324
356
|
// 轮询同源端点,服务恢复后整页 reload(让新挂载的 bundle 完全初始化)
|
|
325
357
|
let attempts = 0
|
|
326
358
|
const poll = async () => {
|
|
@@ -618,7 +650,13 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
|
|
|
618
650
|
cliOnly: confirmPlugin.install?.webInstallable === false,
|
|
619
651
|
submitting: queue.submitting,
|
|
620
652
|
needsRestart: installNeedsRestart,
|
|
621
|
-
|
|
653
|
+
needsReload: installNeedsReload,
|
|
654
|
+
// 结果弹窗关闭即生效:安装热挂载的插件要页面重载才渲染客户端 UI → 自动刷新,无需用户点按钮
|
|
655
|
+
onClose: () => {
|
|
656
|
+
const reload = installDone && installNeedsReload
|
|
657
|
+
setConfirmPlugin(null)
|
|
658
|
+
if (reload) window.location.reload()
|
|
659
|
+
},
|
|
622
660
|
onCopy: () => copyCommand(confirmPlugin),
|
|
623
661
|
onInstall: () => queue.installNow(confirmPlugin, confirmIsUpdate ? { update: true } : undefined),
|
|
624
662
|
// 重启:结果视图「立即重启」也会中断进行中的任务 → 先弹重启确认弹窗
|
|
@@ -638,10 +676,13 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
|
|
|
638
676
|
cliOnly: false,
|
|
639
677
|
submitting: queue.submitting,
|
|
640
678
|
needsRestart: installNeedsRestart,
|
|
679
|
+
needsReload: installNeedsReload,
|
|
641
680
|
onClose: () => {
|
|
681
|
+
const reload = installDone && installNeedsReload
|
|
642
682
|
setInstallDone(false)
|
|
643
683
|
setConfirmCustomTarget(null)
|
|
644
684
|
setConfirmCustomChannel(null)
|
|
685
|
+
if (reload) window.location.reload()
|
|
645
686
|
},
|
|
646
687
|
onCopy: () => {
|
|
647
688
|
doCopy(`pnpm add ${confirmCustomTarget}`)
|
|
@@ -669,10 +710,13 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
|
|
|
669
710
|
cliOnly: false,
|
|
670
711
|
submitting: queue.submitting,
|
|
671
712
|
needsRestart: installNeedsRestart,
|
|
713
|
+
needsReload: installNeedsReload,
|
|
672
714
|
onClose: () => {
|
|
715
|
+
const reload = installDone && installNeedsReload
|
|
673
716
|
setInstallDone(false)
|
|
674
717
|
setConfirmGlobalNpm(null)
|
|
675
718
|
setConfirmCustomChannel(null)
|
|
719
|
+
if (reload) window.location.reload()
|
|
676
720
|
},
|
|
677
721
|
onCopy: () => {
|
|
678
722
|
doCopy(`npm install -g ${confirmGlobalNpm.pkgs.join(' ')}`)
|
|
@@ -692,10 +736,14 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
|
|
|
692
736
|
restarting,
|
|
693
737
|
submitting: queue.submitting,
|
|
694
738
|
needsRestart: uninstallNeedsRestart,
|
|
739
|
+
needsReload: uninstallNeedsReload,
|
|
740
|
+
// 卸载后插件面板要页面重载才摘除(带 UI 的插件)→ 关闭结果弹窗即自动刷新
|
|
695
741
|
onClose: () => {
|
|
742
|
+
const reload = uninstallDone && uninstallNeedsReload
|
|
696
743
|
setUninstallDone(false)
|
|
697
744
|
setUninstallPlugin(null)
|
|
698
745
|
setUninstallItem(null)
|
|
746
|
+
if (reload) window.location.reload()
|
|
699
747
|
},
|
|
700
748
|
onCancel: () => {
|
|
701
749
|
setUninstallPlugin(null)
|
|
@@ -753,7 +801,7 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
|
|
|
753
801
|
onRunDiagnostics: openDiagnostics,
|
|
754
802
|
onClose: () => setErrorMsg(null),
|
|
755
803
|
}),
|
|
756
|
-
//
|
|
804
|
+
// 待重启确认弹窗:已安装列表行内「重启」按钮点击后弹出(兜底场景),单个「立即重启」,关闭即视为稍后
|
|
757
805
|
showRestartConfirm && h(RestartConfirmModal, {
|
|
758
806
|
t,
|
|
759
807
|
restarting,
|
|
@@ -122,11 +122,8 @@ export function NotificationsModal({ records, tasks, pendingRestarts, t, env, on
|
|
|
122
122
|
h('span', { className: styles.pendingRowStatus },
|
|
123
123
|
p.kind === 'uninstall' ? t('restartPendingHintUninstall') : t('restartPendingHint')),
|
|
124
124
|
h('span', { className: styles.pendingRowActions },
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
disabled: restarting,
|
|
128
|
-
onClick: onClose,
|
|
129
|
-
}, t('restartLater')),
|
|
125
|
+
// 兜底项:刷新解决不了(热挂载失败 / 非 dsh 插件 / 更新)才登记待重启;
|
|
126
|
+
// 关闭本弹窗即视为「稍后」,只在真正需要时给单个「立即重启」
|
|
130
127
|
h('button', {
|
|
131
128
|
className: styles.restartNow,
|
|
132
129
|
disabled: restarting,
|