@dshfly/remote-connector 0.2.1

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/http-api.js ADDED
@@ -0,0 +1,531 @@
1
+ // packages/remote-connector/http-api.js
2
+ // /dshfly 控制路由(loopback-only):状态 / 配对 / 确认 / 解除 / 权限 / 中继 / 插件。
3
+ // 对齐 orbis http-api.ts:独立前缀路由挂到 webServer,自带 loopback/Host/Origin
4
+ // 栅栏(DNS-rebound 防护),刻意不碰 DSH 的 /api Typert 协议区。
5
+ // 隧道数据面不走本路由——它走插件到中继的出站 WSS;本路由只服务本机 dsh web UI。
6
+
7
+ import { execFile as defaultExecFile } from 'node:child_process';
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { createRequire } from 'node:module';
11
+
12
+ const require = createRequire(import.meta.url);
13
+
14
+ const MAX_BODY_BYTES = 32 * 1024;
15
+ const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', '[::1]', 'localhost']);
16
+
17
+ function header(req, name) {
18
+ const value = req.headers[name];
19
+ return Array.isArray(value) ? value[0] : value;
20
+ }
21
+
22
+ /** loopback 信任栅栏:Host ∈ {127.0.0.1, ::1, localhost} + Origin 同源 + 非 cross-site。 */
23
+ export function isTrustedLoopbackRequest(req) {
24
+ const host = header(req, 'host');
25
+ if (!host) return false;
26
+ let authority;
27
+ try {
28
+ authority = new URL('http://' + host);
29
+ } catch {
30
+ return false;
31
+ }
32
+ if (
33
+ authority.pathname !== '/' ||
34
+ authority.search !== '' ||
35
+ authority.hash !== '' ||
36
+ authority.username !== '' ||
37
+ authority.password !== ''
38
+ ) {
39
+ return false;
40
+ }
41
+ if (!LOOPBACK_HOSTS.has(authority.hostname.toLowerCase())) return false;
42
+ if (header(req, 'sec-fetch-site') === 'cross-site') return false;
43
+ const origin = header(req, 'origin');
44
+ if (!origin) return true;
45
+ try {
46
+ return new URL(origin).host === authority.host;
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+
52
+ function send(res, status, value) {
53
+ res.writeHead(status, {
54
+ 'cache-control': 'no-store',
55
+ 'content-type': 'application/json; charset=utf-8',
56
+ });
57
+ res.end(JSON.stringify(value));
58
+ }
59
+
60
+ function fail(res, status, error) {
61
+ send(res, status, { error: error?.message || 'dshfly operation failed' });
62
+ }
63
+
64
+ async function readJson(req) {
65
+ let size = 0;
66
+ const chunks = [];
67
+ for await (const chunk of req) {
68
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
69
+ size += bytes.length;
70
+ if (size > MAX_BODY_BYTES) {
71
+ const e = new Error('body too large');
72
+ e.status = 413;
73
+ throw e;
74
+ }
75
+ chunks.push(bytes);
76
+ }
77
+ if (!chunks.length) return {};
78
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
79
+ }
80
+
81
+ /**
82
+ * 创建 /dshfly 前缀路由。core 是 ConnectorCore 实例;opts.autoConfirm 为测试
83
+ * profile 开启"轮询到 completed 自动确认"(生产必须 false,走设置页二次确认)。
84
+ */
85
+ export function createDshflyHttpRoute(core, opts = {}) {
86
+ const {
87
+ // M4.x-c 之后新增注入(apply 传入):一键安装插件用
88
+ bridgeCore = null, // MobileBridgeCore 实例(枚举/注册表)
89
+ loader = null, // cordis loader(运行时热加载新插件)
90
+ recommendedPlugins = [], // 推荐插件白名单 [{name, spec, id, title, description, icon}]
91
+ dshBin = '', // dsh CLI 路径(空 = PATH 的 'dsh')
92
+ profileName = 'web',
93
+ execFile = defaultExecFile, // 测试注入
94
+ version = '', // 本插件本地版本(package.json)
95
+ registryFetch = fetch, // registry 查询用(测试注入)
96
+ } = opts;
97
+ const handler = async (req, res) => {
98
+ if (!isTrustedLoopbackRequest(req)) {
99
+ return send(res, 403, { error: 'loopback only' });
100
+ }
101
+ try {
102
+ const url = new URL(req.url, 'http://x');
103
+ const p = url.pathname;
104
+ const m = req.method || 'GET';
105
+
106
+ if (m === 'GET' && p === '/dshfly/status') {
107
+ return send(res, 200, {
108
+ ...core.getState(),
109
+ relayUrl: core.cfg.relayUrl,
110
+ version, // 本插件本地版本(设置页显示,2026-08)
111
+ });
112
+ }
113
+
114
+ if (m === 'POST' && p === '/dshfly/pair') {
115
+ const pair = await core.createPairing();
116
+ return send(res, 200, pair);
117
+ }
118
+
119
+ // PC 二次确认(仅 completed 态可确认;幂等——done 后返回当前状态)
120
+ // body.fullDisk:完整磁盘权限(缺省 false=不允许;配对后可在设备列表修改)
121
+ const confirmMatch = p.match(/^\/dshfly\/pair\/([^/]+)\/confirm$/);
122
+ if (confirmMatch && m === 'POST') {
123
+ const id = decodeURIComponent(confirmMatch[1]);
124
+ const st = await core.getPairingStatus(id);
125
+ if (st.status === 'done') return send(res, 200, st);
126
+ if (st.status !== 'completed') {
127
+ return send(res, 409, { error: `pairing not confirmable (${st.status})`, status: st.status });
128
+ }
129
+ const body = await readJson(req);
130
+ const phonePk = await core.confirmPairing(id, { fullDisk: body.fullDisk === true });
131
+ return send(res, 200, { status: 'done', phonePk, permission: core.getPermission(phonePk) });
132
+ }
133
+
134
+ // PC 拒绝配对(仅 completed 态可拒绝)
135
+ const rejectMatch = p.match(/^\/dshfly\/pair\/([^/]+)\/reject$/);
136
+ if (rejectMatch && m === 'POST') {
137
+ const id = decodeURIComponent(rejectMatch[1]);
138
+ const st = await core.getPairingStatus(id);
139
+ if (st.status !== 'completed') {
140
+ return send(res, 409, { error: `pairing not rejectable (${st.status})`, status: st.status });
141
+ }
142
+ await core.rejectPairing(id);
143
+ return send(res, 200, { status: 'rejected' });
144
+ }
145
+
146
+ const pairMatch = p.match(/^\/dshfly\/pair\/([^/]+)$/);
147
+ if (pairMatch && m === 'GET') {
148
+ const id = decodeURIComponent(pairMatch[1]);
149
+ const st = await core.getPairingStatus(id);
150
+ if (opts.autoConfirm && st.status === 'completed') {
151
+ try {
152
+ const phonePk = await core.confirmPairing(id);
153
+ st.status = 'done';
154
+ st.phonePk = phonePk;
155
+ } catch (e) {
156
+ st.error = e.message;
157
+ }
158
+ }
159
+ return send(res, 200, st);
160
+ }
161
+
162
+ if (m === 'POST' && p === '/dshfly/unpair') {
163
+ const body = await readJson(req);
164
+ if (!body.phonePk) return send(res, 400, { error: 'phonePk required' });
165
+ await core.unpair(body.phonePk);
166
+ return send(res, 200, { ok: true });
167
+ }
168
+
169
+ // 修改已配对手机的完整磁盘权限(2026-08;配对后可在设置页设备列表调整)
170
+ if (m === 'POST' && p === '/dshfly/permissions') {
171
+ const body = await readJson(req);
172
+ if (!body.phonePk) return send(res, 400, { error: 'phonePk required' });
173
+ const paired = core.getState().devices.some((d) => d.phonePk === body.phonePk);
174
+ if (!paired) return send(res, 404, { error: 'device not paired' });
175
+ core.setPermission(body.phonePk, { fullDisk: body.fullDisk === true });
176
+ return send(res, 200, { ok: true, permission: core.getPermission(body.phonePk) });
177
+ }
178
+
179
+ // 更换中继地址端点已移除(2026-08 安全收紧:防篡改中继服务器——UI 与后端同时关闭;
180
+ // 换中继走 keys.relayUrl 文件配置,启动时优先读取,connector-core 的 setRelayUrl 保留未调用)
181
+
182
+ // 推荐插件清单(设置页展示):白名单 + 安装状态(bridge 枚举到的 = 已安装)
183
+ if (m === 'GET' && p === '/dshfly/plugins/recommended') {
184
+ const installed = (id) => !!bridgeCore?.entries?.has?.(id);
185
+ const plugins = (recommendedPlugins || []).map((rp) => ({
186
+ name: rp.name,
187
+ id: rp.id,
188
+ title: rp.title,
189
+ description: rp.description,
190
+ icon: rp.icon,
191
+ installed: installed(rp.id),
192
+ }));
193
+ return send(res, 200, { plugins });
194
+ }
195
+
196
+ // 一键安装推荐插件(白名单内):dsh plugin add(装依赖 + reconcile)→
197
+ // 重新枚举 → loader.create 运行时热加载(无需重启 dsh web;loader 不可用则提示重启)。
198
+ if (m === 'POST' && p === '/dshfly/plugins/install') {
199
+ const body = await readJson(req);
200
+ try {
201
+ const r = await installRecommendedPlugin({
202
+ id: body.id,
203
+ recommendedPlugins,
204
+ bridgeCore,
205
+ loader,
206
+ dshBin,
207
+ profileName,
208
+ execFile,
209
+ });
210
+ return send(res, 200, r);
211
+ } catch (e) {
212
+ return send(res, e?.code === 'PLUGIN_NOT_FOUND' ? 400 : 500, { error: e?.message || String(e), spec: e?.spec });
213
+ }
214
+ }
215
+
216
+ // 一键卸载插件(PC 设置页对称入口;App 端走 mobile.plugins.uninstall 同一实现)
217
+ if (m === 'POST' && p === '/dshfly/plugins/uninstall') {
218
+ const body = await readJson(req);
219
+ try {
220
+ const r = await uninstallPlugin({
221
+ id: body.id,
222
+ recommendedPlugins,
223
+ bridgeCore,
224
+ loader,
225
+ dshBin,
226
+ profileName,
227
+ execFile,
228
+ });
229
+ return send(res, 200, r);
230
+ } catch (e) {
231
+ return send(res, e?.code === 'PLUGIN_NOT_FOUND' ? 404 : 500, { error: e?.message || String(e) });
232
+ }
233
+ }
234
+
235
+ // 检查本插件更新(2026-08):npm registry 最新版 vs 本地版本(失败 → latest=null,不阻塞)
236
+ if (m === 'GET' && p === '/dshfly/plugins/checkUpdate') {
237
+ let latest = null;
238
+ let error = null;
239
+ try {
240
+ const pkgName = require('./package.json').name;
241
+ const r = await registryFetch(`https://registry.npmjs.org/${encodeURIComponent(pkgName)}/latest`, { signal: AbortSignal.timeout(8000) });
242
+ if (r.ok) {
243
+ const j = await r.json();
244
+ latest = typeof j?.version === 'string' ? j.version : null;
245
+ } else {
246
+ error = `registry HTTP ${r.status}`;
247
+ }
248
+ } catch (e) {
249
+ error = e?.message || 'registry 查询失败';
250
+ }
251
+ return send(res, 200, {
252
+ current: version,
253
+ latest,
254
+ updateAvailable: !!latest && latest !== version,
255
+ error,
256
+ });
257
+ }
258
+
259
+ // 一键升级本插件(2026-08):dsh plugin add <pkg>@latest + 提示重启(升级不能热加载——替换已加载插件需清模块缓存)
260
+ if (m === 'POST' && p === '/dshfly/plugins/upgrade') {
261
+ try {
262
+ const pkgName = require('./package.json').name;
263
+ await runDshPluginAdd(dshBin, profileName, `${pkgName}@latest`, execFile);
264
+ return send(res, 200, { ok: true, package: pkgName, rebootNeeded: true });
265
+ } catch (e) {
266
+ return send(res, 500, { error: `升级失败:${e.message}` });
267
+ }
268
+ }
269
+
270
+ return send(res, 404, { error: 'not found' });
271
+ } catch (e) {
272
+ return fail(res, e.status || 500, e);
273
+ }
274
+ };
275
+
276
+ return { kind: 'prefix', path: '/dshfly', handler };
277
+ }
278
+
279
+ /**
280
+ * 解析 dsh CLI 的 bin.js 绝对路径(`node <bin.js>` 直跑,不依赖 PATH/平台 sh/cmd):
281
+ * ① 配置显式 dshBin(可指 bin.js 或可执行文件);② 自动探测常见 npm 全局位置;
282
+ * ③ 兜底 null(走 PATH 的 'dsh')。
283
+ * 修复(2026-08):GUI 启动的 dsh web 进程 PATH 不含 dsh(Windows spawn ENOENT)——
284
+ * 一律改用 node 跑 bin.js,跨平台稳定。
285
+ */
286
+ /** 在 PATH 上找 `dsh` 可执行(当前实际使用的 dsh,`which dsh` 指向),realpath 解析符号链接
287
+ * 到 @deepseek-ai/dsh/lib/bin.js。找不到返回 null。 */
288
+ function findDshOnPath() {
289
+ const dirs = (process.env.PATH || '').split(path.delimiter);
290
+ for (const dir of dirs) {
291
+ if (!dir) continue;
292
+ const candidate = path.join(dir, 'dsh');
293
+ try {
294
+ if (!fs.existsSync(candidate) || !fs.statSync(candidate).isFile()) continue;
295
+ const real = fs.realpathSync(candidate);
296
+ // 仅接受确属 @deepseek-ai/dsh 的 bin.js,避免误抓同名命令
297
+ if (real.includes(`${path.sep}@deepseek-ai${path.sep}dsh${path.sep}`)) return real;
298
+ } catch {}
299
+ }
300
+ return null;
301
+ }
302
+
303
+ export function resolveDshBinJs(dshBin) {
304
+ if (typeof dshBin === 'string' && dshBin) {
305
+ return fs.existsSync(dshBin) ? dshBin : null;
306
+ }
307
+ // ① 当前实际使用的 dsh(PATH 上 `dsh` 的 realpath)——最可靠,命中运行实例
308
+ const onPath = findDshOnPath();
309
+ if (onPath) return onPath;
310
+ // ② 常见全局安装位置(兜底;可能命中非当前实例,仅探测)
311
+ const candidates = [
312
+ // Windows npm 全局(%APPDATA%\npm)
313
+ process.env.APPDATA ? path.join(process.env.APPDATA, 'npm', 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js') : null,
314
+ // Homebrew / usr-local / usr(macOS、Linux)
315
+ '/opt/homebrew/lib/node_modules/@deepseek-ai/dsh/lib/bin.js',
316
+ '/usr/local/lib/node_modules/@deepseek-ai/dsh/lib/bin.js',
317
+ '/usr/lib/node_modules/@deepseek-ai/dsh/lib/bin.js',
318
+ ];
319
+ for (const c of candidates) {
320
+ if (c && fs.existsSync(c)) return c;
321
+ }
322
+ return null;
323
+ }
324
+
325
+ /** 从 startDir 向上找到 name==='@deepseek-ai/dsh' 的 package.json 路径;找不到返回 null。 */
326
+ function findDshPkgJson(startDir) {
327
+ let dir = startDir;
328
+ while (dir && dir !== path.dirname(dir)) {
329
+ const pkgJson = path.join(dir, 'package.json');
330
+ try {
331
+ if (fs.existsSync(pkgJson)) {
332
+ const j = JSON.parse(fs.readFileSync(pkgJson, 'utf8'));
333
+ if (j?.name === '@deepseek-ai/dsh') return pkgJson;
334
+ }
335
+ } catch {}
336
+ dir = path.dirname(dir);
337
+ }
338
+ return null;
339
+ }
340
+
341
+ function readPkgVersion(pkgJson) {
342
+ try {
343
+ const j = JSON.parse(fs.readFileSync(pkgJson, 'utf8'));
344
+ return typeof j.version === 'string' ? j.version : null;
345
+ } catch {
346
+ return null;
347
+ }
348
+ }
349
+
350
+ /**
351
+ * 解析**当前运行中**的 DSH 版本:最可靠是取 dsh web 进程入口(process.argv[1] = dsh 的
352
+ * lib/bin.js)相邻的 @deepseek-ai/dsh package.json —— 命中正在跑的实例,而非其它安装
353
+ * (此前磁盘候选列 `/opt/homebrew/...` 先命中无关的 0.1.1-rc.2 实例,导致 App 显示错版本)。
354
+ * 依序:① argv[1] 反查;② require.resolve;③ resolveDshBinJs 磁盘兜底。
355
+ * 失败返回 null(App 显示空/兜底,不阻塞)。
356
+ */
357
+ export function resolveDshVersion(dshBin) {
358
+ // ① 取运行进程入口并解析符号链接:dsh 的 npm 全局 shim(bin/dsh)是指向 <dsh>/lib/bin.js 的
359
+ // 符号链接,process.argv[1] 是链接路径(bin/ 下祖先链无 @deepseek-ai/dsh)→ 须先 realpath。
360
+ try {
361
+ const argv1 = process.argv?.[1];
362
+ if (argv1) {
363
+ const real = fs.realpathSync(argv1);
364
+ const pkgJson = findDshPkgJson(path.dirname(real));
365
+ if (pkgJson) return readPkgVersion(pkgJson);
366
+ }
367
+ } catch {}
368
+ try {
369
+ const pkg = require.resolve('@deepseek-ai/dsh/package.json');
370
+ return readPkgVersion(pkg);
371
+ } catch {}
372
+ try {
373
+ const binJs = resolveDshBinJs(dshBin);
374
+ return binJs ? readPkgVersion(path.resolve(path.dirname(binJs), '..', 'package.json')) : null;
375
+ } catch {
376
+ return null;
377
+ }
378
+ }
379
+
380
+ /**
381
+ * 定位**运行中 DSH 应用**的 node_modules 目录(DSH 内置运行时 bundle @deepseek-ai/dsh-* 所在)。
382
+ * 与 resolveDshVersion 同法(argv[1] realpath → @deepseek-ai/dsh 包根 → node_modules)。
383
+ * 供移动插件枚举作为"额外解析根"(profile node_modules 之外),避免把 DSH 内置包误报为解析失败。
384
+ * 失败返回 null。
385
+ */
386
+ export function resolveDshNodeModules() {
387
+ try {
388
+ const argv1 = process.argv?.[1];
389
+ if (argv1) {
390
+ const real = fs.realpathSync(argv1);
391
+ const pkgJson = findDshPkgJson(path.dirname(real));
392
+ if (pkgJson) return path.join(path.dirname(pkgJson), 'node_modules');
393
+ }
394
+ } catch {}
395
+ try {
396
+ const pkg = require.resolve('@deepseek-ai/dsh/package.json');
397
+ return path.join(path.dirname(pkg), 'node_modules');
398
+ } catch {}
399
+ return null;
400
+ }
401
+
402
+ /** 执行 `dsh plugin --profile <name> add <spec>`(装依赖 + reconcile 进 bundles)。 */
403
+ /** 一键安装推荐插件(白名单内):dsh plugin add → 重新枚举 → loader 热加载(无需重启;
404
+ * loader 不可用 → rebootNeeded)。PC 设置页(/dshfly/plugins/install)与
405
+ * App 插件页空状态(mobile.plugins.install,经 bridge 注入)共用同一实现。 */
406
+ export async function installRecommendedPlugin({
407
+ id, recommendedPlugins = [], bridgeCore = null, loader = null,
408
+ dshBin = '', profileName = 'web', execFile = defaultExecFile,
409
+ }) {
410
+ const rp = (recommendedPlugins || []).find((x) => x.id === id || x.name === id);
411
+ if (!rp) {
412
+ const err = new Error(`未知插件 id(仅推荐白名单可安装): ${id}`);
413
+ err.code = 'PLUGIN_NOT_FOUND';
414
+ throw err;
415
+ }
416
+ if (bridgeCore?.entries?.has?.(rp.id)) return { ok: true, already: true, id: rp.id };
417
+ const spec = rp.spec || rp.name;
418
+ // file: 预检:目录不存在时给出明确错误(避免 pnpm 去 registry 拉 npm 名)
419
+ if (typeof spec === 'string' && spec.startsWith('file:')) {
420
+ const dir = spec.slice('file:'.length);
421
+ if (!fs.existsSync(dir)) {
422
+ const err = new Error(`安装失败:file: 目录不存在(${dir})——请检查 connector 配置 recommendedPlugins[].spec`);
423
+ err.code = 'INSTALL_SPEC_MISSING';
424
+ err.spec = spec;
425
+ throw err;
426
+ }
427
+ }
428
+ await runDshPluginAdd(dshBin, profileName, spec, execFile);
429
+ if (bridgeCore?.enumerate) await bridgeCore.enumerate();
430
+ let loaded = false;
431
+ if (loader?.create) {
432
+ try {
433
+ await loader.create({ name: rp.name });
434
+ loaded = true;
435
+ } catch (e) {
436
+ console.error(`[dshfly] 热加载 ${rp.name} 失败(需重启 dsh web 生效):`, e?.message);
437
+ }
438
+ }
439
+ return { ok: true, id: rp.id, spec, loaded, rebootNeeded: !loaded };
440
+ }
441
+
442
+ /** 一键卸载插件:dsh plugin remove(pnpm remove,reconcile 自动清 dsh.profile.bundles)→
443
+ * 重新枚举 → loader.remove 热卸载(失败 → rebootNeeded 提示重启,重启后依赖已移除自然消失)。
444
+ * PC 设置页(/dshfly/plugins/uninstall)与 App 插件详情页(mobile.plugins.uninstall)共用。
445
+ * 目标解析:推荐白名单 id → name 优先;否则 bridge 注册表 staticSource(可卸任意已装插件)。 */
446
+ export async function uninstallPlugin({
447
+ id, recommendedPlugins = [], bridgeCore = null, loader = null,
448
+ dshBin = '', profileName = 'web', execFile = defaultExecFile,
449
+ }) {
450
+ if (typeof id !== 'string' || !id) {
451
+ const err = new Error(`插件 id 必填: ${id}`);
452
+ err.code = 'PLUGIN_NOT_FOUND';
453
+ throw err;
454
+ }
455
+ const pkgName =
456
+ (recommendedPlugins || []).find((x) => x.id === id)?.name
457
+ ?? bridgeCore?.entries?.get?.(id)?.staticSource;
458
+ if (!pkgName) {
459
+ const err = new Error(`未知插件 id(未安装或无静态来源): ${id}`);
460
+ err.code = 'PLUGIN_NOT_FOUND';
461
+ throw err;
462
+ }
463
+ // 幂等:bridge 条目已移除(此前卸载过、依赖已删)→ 不再执行 pnpm remove
464
+ // (否则 ERR_PNPM_CANNOT_REMOVE_MISSING_DEPS)
465
+ if (bridgeCore && !bridgeCore.entries.has(id)) {
466
+ return { ok: true, already: true, id, pkgName };
467
+ }
468
+ try {
469
+ await runDshPluginRemove(dshBin, profileName, pkgName, execFile);
470
+ } catch (e) {
471
+ // 依赖已被移除(重复卸载/外部删过)→ 视为已卸载继续(幂等兜底)
472
+ if (!/ERR_PNPM_CANNOT_REMOVE_MISSING_DEPS/.test(`${e?.message || ''}`)) throw e;
473
+ console.warn(`[dshfly] 卸载 ${pkgName}:依赖已不存在(幂等容错)`);
474
+ }
475
+ // 彻底移除 bridge 条目(静态+动态)→ App 列表立即消失(enumerate 只 upsert 静态、不清动态注册)
476
+ bridgeCore?.removeEntry?.(id);
477
+ if (bridgeCore?.enumerate) await bridgeCore.enumerate();
478
+ // 热卸载:loader.create({name}) 不传 id 时 entry id 是随机的(cordis ensureId),
479
+ // 从 loader 配置按 name 反查;失败则 rebootNeeded(重启后依赖移除自然消失)
480
+ let loaded = false;
481
+ if (loader?.remove && loader?.data) {
482
+ try {
483
+ const entry = (loader.data || []).find((o) => o.name === pkgName);
484
+ if (entry?.id) {
485
+ await loader.remove(entry.id);
486
+ loaded = true;
487
+ }
488
+ } catch (e) {
489
+ console.error(`[dshfly] 热卸载 ${pkgName} 失败(需重启 dsh web 生效):`, e?.message);
490
+ }
491
+ }
492
+ return { ok: true, id, pkgName, loaded, rebootNeeded: !loaded };
493
+ }
494
+
495
+ /** 插件一键安装/升级共用的 dsh plugin add 执行(超时 120s)。 */
496
+ function runDshPluginAdd(dshBin, profileName, spec, execFile) {
497
+ const binJs = resolveDshBinJs(dshBin);
498
+ const bin = binJs ? process.execPath : (dshBin || 'dsh');
499
+ const args = binJs
500
+ ? [binJs, 'plugin', '--profile', profileName, 'add', spec]
501
+ : ['plugin', '--profile', profileName, 'add', spec];
502
+ return new Promise((resolve, reject) => {
503
+ execFile(bin, args, { timeout: 120_000 }, (err, stdout, stderr) => {
504
+ if (err) {
505
+ const detail = `${stdout || ''}${stderr || ''}`.trim().slice(0, 400);
506
+ reject(new Error(`${detail || err.message}`));
507
+ return;
508
+ }
509
+ resolve({ stdout, stderr });
510
+ });
511
+ });
512
+ }
513
+
514
+ /** 一键卸载的 dsh plugin remove 执行(转发 pnpm remove;reconcile 自动清 bundles)。 */
515
+ function runDshPluginRemove(dshBin, profileName, pkgName, execFile) {
516
+ const binJs = resolveDshBinJs(dshBin);
517
+ const bin = binJs ? process.execPath : (dshBin || 'dsh');
518
+ const args = binJs
519
+ ? [binJs, 'plugin', '--profile', profileName, 'remove', pkgName]
520
+ : ['plugin', '--profile', profileName, 'remove', pkgName];
521
+ return new Promise((resolve, reject) => {
522
+ execFile(bin, args, { timeout: 120_000 }, (err, stdout, stderr) => {
523
+ if (err) {
524
+ const detail = `${stdout || ''}${stderr || ''}`.trim().slice(0, 400);
525
+ reject(new Error(`${detail || err.message}`));
526
+ return;
527
+ }
528
+ resolve({ stdout, stderr });
529
+ });
530
+ });
531
+ }