@mrrisega/dsh-remote 0.6.3 → 0.6.4-beta.10
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.md +62 -1
- package/clients/dsh-remote/dsh-bridge.mjs +85 -2
- package/clients/dsh-remote/test/harness-cookie-selfheal.test.mjs +118 -0
- package/docs/telemetry.md +145 -0
- package/dsh-setup.mjs +546 -96
- package/package.json +5 -4
- package/packages/dsh-remote-web/lib/client.js +273 -10
- package/packages/dsh-remote-web/lib/index.js +1212 -34
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/test/auto-restart.test.mjs +303 -0
- package/packages/dsh-remote-web/test/connect-loop.test.mjs +516 -0
- package/packages/dsh-remote-web/test/connect-ui.test.mjs +415 -0
- package/packages/dsh-remote-web/test/harness-restart.test.mjs +106 -40
- package/packages/dsh-remote-web/test/launchd-domain.test.mjs +78 -0
- package/packages/dsh-remote-web/test/patch-activation.test.mjs +183 -0
- package/packages/dsh-remote-web/test/setup-output.test.mjs +111 -0
- package/packages/dsh-remote-web/test/telemetry.test.mjs +607 -0
package/dsh-setup.mjs
CHANGED
|
@@ -32,8 +32,13 @@ import { childStopped } from "./clients/dsh-remote/src/lifecycle.mjs";
|
|
|
32
32
|
|
|
33
33
|
const THIS_DIR = path.dirname(fileURLToPath(import.meta.url)); // 本包目录(仓库或 node_modules)
|
|
34
34
|
const IS_NPM_INSTALL = THIS_DIR.includes(`${path.sep}node_modules${path.sep}`);
|
|
35
|
-
//
|
|
36
|
-
|
|
35
|
+
// 配置目录:**两种形态统一放 ~/.dsh-remote**(npx/npm 安装时 node_modules 内不可写,故放用户目录;
|
|
36
|
+
// 仓库开发时也放同一处)。为什么不按形态分叉:插件半的默认配置目录本来就是 ~/.dsh-remote
|
|
37
|
+
// (见 packages/dsh-remote-web/lib/index.js 的 DEFAULT_RELAY_DIR),而激活块会把这里写进 relayDir。
|
|
38
|
+
// 两边不一致会出现「安装器把 .dsh-config.json 写进仓库根,插件却去 ~/.dsh-remote 找配置」→
|
|
39
|
+
// 面板显示成未登录/空配置,且安装器的状态文件散落进仓库工作区(实测踩到)。
|
|
40
|
+
// 需要隔离(测试 / 多账号 / 多实例)时用 DSH_RELAY_DIR 显式覆盖。
|
|
41
|
+
const CONFIG_DIR = process.env.DSH_RELAY_DIR || path.join(os.homedir(), ".dsh-remote");
|
|
37
42
|
const CONFIG_PATH = path.join(CONFIG_DIR, ".dsh-config.json");
|
|
38
43
|
// 默认云端服务地址(服务商 SaaS 入口;自建用户用 --server/--key 指向自己的 router)
|
|
39
44
|
const DEFAULT_API = "https://n.risegao.cn:13443/relay-api";
|
|
@@ -83,7 +88,7 @@ function ensureRuntimeCopy() {
|
|
|
83
88
|
fs.cpSync(wsSrc, path.join(CONFIG_DIR, "node_modules", "ws"), { recursive: true, force: true });
|
|
84
89
|
}
|
|
85
90
|
fs.writeFileSync(verFile, ver);
|
|
86
|
-
console.log(`✅
|
|
91
|
+
console.log(`✅ 运行时: ${CONFIG_DIR}`);
|
|
87
92
|
}
|
|
88
93
|
try { ensureRuntimeCopy(); } catch (e) { console.warn(`⚠️ 运行时固化跳过: ${e.message}`); }
|
|
89
94
|
|
|
@@ -131,6 +136,23 @@ function preferredNode() {
|
|
|
131
136
|
}
|
|
132
137
|
const NODE_BIN = preferredNode();
|
|
133
138
|
|
|
139
|
+
/**
|
|
140
|
+
* 自启动服务里注入的 PATH。
|
|
141
|
+
* 必须含 /usr/sbin 与 /sbin:bridge 会用 ioreg 之类系统命令探测本机信息,
|
|
142
|
+
* 缺少这两个目录时 launchd 服务日志里会刷 `ioreg: command not found`(用户反馈实测)。
|
|
143
|
+
*/
|
|
144
|
+
const SERVICE_PATH = "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin";
|
|
145
|
+
|
|
146
|
+
// ---------- 安装口径(随 bridge 环境变量注入;服务端存到设备行,供运营统计) ----------
|
|
147
|
+
/**
|
|
148
|
+
* install_source:本文件即「一键安装器」→ 默认 npx(用户自己跑 `npx @mrrisega/dsh-remote` 的那条路径)。
|
|
149
|
+
* 插件市场那条路径由插件半自愈补装:插件 spawn 本安装器时显式传入
|
|
150
|
+
* DSH_BRIDGE_INSTALL_SOURCE=plugin_market,这里原样透传(外部注入 > 硬编码默认)。
|
|
151
|
+
* install_version:优先取外部注入(插件/在线更新器),否则取本包 package.json 版本。
|
|
152
|
+
*/
|
|
153
|
+
const INSTALL_SOURCE = String(process.env.DSH_BRIDGE_INSTALL_SOURCE || "").trim() || "npx";
|
|
154
|
+
const INSTALL_VERSION = String(process.env.DSH_BRIDGE_INSTALL_VERSION || "").trim() || pkgVersion();
|
|
155
|
+
|
|
134
156
|
// ---------- 配置读写 ----------
|
|
135
157
|
function loadConfig() {
|
|
136
158
|
try { return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")); }
|
|
@@ -184,79 +206,192 @@ function hasFlag(argv, name) {
|
|
|
184
206
|
}
|
|
185
207
|
|
|
186
208
|
// ---------- 自启动服务生成与热启动 ----------
|
|
209
|
+
/** 自启动服务 label(macOS LaunchAgent / Linux systemd 共用同一个名字)。 */
|
|
210
|
+
const BRIDGE_LABEL = "com.dshremote.bridge";
|
|
211
|
+
|
|
187
212
|
function autostartFilePath() {
|
|
188
213
|
if (process.platform === "darwin")
|
|
189
|
-
return path.join(os.homedir(),
|
|
214
|
+
return path.join(os.homedir(), `Library/LaunchAgents/${BRIDGE_LABEL}.plist`);
|
|
190
215
|
if (process.platform === "linux")
|
|
191
216
|
return path.join(os.homedir(), ".config/systemd/user/dsh-bridge.service");
|
|
192
217
|
return null;
|
|
193
218
|
}
|
|
194
219
|
|
|
220
|
+
/** 同步睡眠(生成 launchd 状态轮询用;不引第三方依赖)。 */
|
|
221
|
+
function sleepSync(ms) {
|
|
222
|
+
try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
|
|
223
|
+
catch { /* 极端环境不支持 → 退化为忙等一小会 */ const end = Date.now() + ms; while (Date.now() < end) { /* spin */ } }
|
|
224
|
+
}
|
|
225
|
+
|
|
195
226
|
function writeAutostartFile() {
|
|
196
227
|
const runCmd = `"${NODE_BIN}" "${runtimeSetupPath()}" run`;
|
|
197
228
|
if (process.platform === "darwin") {
|
|
198
229
|
const plistPath = autostartFilePath();
|
|
230
|
+
// LimitLoadToSessionType 是**必需项**,不是可选优化:
|
|
231
|
+
// · macOS 26 上 gui/<uid> 会进入 on-demand-only 模式,RunAtLoad/KeepAlive 全部失效
|
|
232
|
+
// (只登记不派生,runs=0,日志报 "pending spawn, domain in on-demand-only mode");
|
|
233
|
+
// 迁到 user/<uid> domain 才能恢复派生与崩溃自愈。
|
|
234
|
+
// · 而不带该键时 `launchctl bootstrap user/<uid>` 直接失败(实测 rc=5 Input/output error)。
|
|
235
|
+
// · 带上 [Aqua, Background] 后 user/<uid> 与 gui/<uid> 都能正常装载(macOS 14/26 均实测)。
|
|
236
|
+
// 仍保留 RunAtLoad/KeepAlive:user domain 下它们是真生效的(实测 kill -9 后自动重建)。
|
|
199
237
|
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
200
238
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
201
239
|
<plist version="1.0"><dict>
|
|
202
|
-
<key>Label</key><string
|
|
240
|
+
<key>Label</key><string>${BRIDGE_LABEL}</string>
|
|
203
241
|
<key>ProgramArguments</key>
|
|
204
242
|
<array><string>${NODE_BIN}</string><string>${runtimeSetupPath()}</string><string>run</string></array>
|
|
205
243
|
<key>RunAtLoad</key><true/>
|
|
206
244
|
<key>KeepAlive</key><true/>
|
|
245
|
+
<key>LimitLoadToSessionType</key><array><string>Aqua</string><string>Background</string></array>
|
|
207
246
|
<key>StandardOutPath</key><string>${path.join(CONFIG_DIR, ".dsh-bridge.log")}</string>
|
|
208
247
|
<key>StandardErrorPath</key><string>${path.join(CONFIG_DIR, ".dsh-bridge.log")}</string>
|
|
209
|
-
<key>EnvironmentVariables</key><dict><key>PATH</key><string
|
|
248
|
+
<key>EnvironmentVariables</key><dict><key>PATH</key><string>${SERVICE_PATH}</string><key>DSH_BRIDGE_INSTALL_SOURCE</key><string>${INSTALL_SOURCE}</string><key>DSH_BRIDGE_INSTALL_VERSION</key><string>${INSTALL_VERSION}</string></dict>
|
|
210
249
|
</dict></plist>`;
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
250
|
+
// ~/Library/LaunchAgents 在全新账户/精简系统上可能不存在,必须先建目录,
|
|
251
|
+
// 否则 writeFileSync 抛 ENOENT 直接把安装流程打断(用户反馈里也踩过类似的路径问题)。
|
|
252
|
+
try {
|
|
253
|
+
fs.mkdirSync(path.dirname(plistPath), { recursive: true });
|
|
254
|
+
fs.writeFileSync(plistPath, plist);
|
|
255
|
+
} catch (e) {
|
|
256
|
+
console.warn(`⚠️ 自启动服务写入失败: ${e.message}(可稍后用 \`dsh-remote install\` 重试)`);
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
return plistPath; // 路径在最终汇总里统一展示(避免同一信息打印两遍)
|
|
214
260
|
}
|
|
215
261
|
if (process.platform === "linux") {
|
|
216
262
|
const dir = path.join(os.homedir(), ".config/systemd/user");
|
|
217
263
|
fs.mkdirSync(dir, { recursive: true });
|
|
218
|
-
const unit = `[Unit]\nDescription=dsh-remote bridge (auto-starts with dsh web)\n\n[Service]\nExecStart=${runCmd}\nRestart=on-failure\nRestartSec=5\nEnvironment=PATH
|
|
264
|
+
const unit = `[Unit]\nDescription=dsh-remote bridge (auto-starts with dsh web)\n\n[Service]\nExecStart=${runCmd}\nRestart=on-failure\nRestartSec=5\nEnvironment=PATH=${SERVICE_PATH}\nEnvironment=DSH_BRIDGE_INSTALL_SOURCE=${INSTALL_SOURCE}\nEnvironment=DSH_BRIDGE_INSTALL_VERSION=${INSTALL_VERSION}\n\n[Install]\nWantedBy=default.target\n`;
|
|
219
265
|
const unitPath = autostartFilePath();
|
|
220
266
|
fs.writeFileSync(unitPath, unit);
|
|
221
|
-
|
|
222
|
-
return unitPath;
|
|
267
|
+
return unitPath; // 路径在最终汇总里统一展示
|
|
223
268
|
}
|
|
224
269
|
console.log("⚠️ 当前平台暂不支持自启动,请手动运行 `dsh-remote run`");
|
|
225
270
|
return null;
|
|
226
271
|
}
|
|
227
272
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
273
|
+
/**
|
|
274
|
+
* 读取 launchd job 状态。
|
|
275
|
+
* deferred = launchd 登记了但**从未派生**(macOS 26 的 on-demand-only 特征);
|
|
276
|
+
* 光看 "state = not running" 无法区分"被系统挂起"与"正常退出",必须单独识别才能给出正确建议。
|
|
277
|
+
*/
|
|
278
|
+
function launchdState(target) {
|
|
279
|
+
const pr = sh(`launchctl print ${target}`);
|
|
280
|
+
if (!pr.ok) return { exists: false, running: false, pid: null, deferred: false, raw: "" };
|
|
281
|
+
const raw = pr.stdout;
|
|
282
|
+
const running = /state\s*=\s*running/.test(raw);
|
|
283
|
+
const m = raw.match(/pid\s*=\s*(\d+)/);
|
|
284
|
+
const runs = raw.match(/runs\s*=\s*(\d+)/);
|
|
285
|
+
return {
|
|
286
|
+
exists: true,
|
|
287
|
+
running,
|
|
288
|
+
pid: running && m ? Number(m[1]) : null,
|
|
289
|
+
runs: runs ? Number(runs[1]) : 0,
|
|
290
|
+
deferred: /pended nondemand spawn/.test(raw) || /on-demand-only/.test(raw),
|
|
291
|
+
raw
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* 轮询等待 job 起来。
|
|
297
|
+
* launchd 派生是异步的:bootstrap 成功后立刻 print 必然看到 not running(旧实现因此误报"启动失败")。
|
|
298
|
+
* 明确"被挂起且没有派生"时提前放弃(等满也没用),否则给足 ~8 秒。
|
|
299
|
+
*/
|
|
300
|
+
function waitLaunchdRunning(target, tries = 20, gapMs = 400) {
|
|
301
|
+
let st = launchdState(target);
|
|
302
|
+
for (let i = 0; i < tries; i += 1) {
|
|
303
|
+
st = launchdState(target);
|
|
304
|
+
if (st.running) return st;
|
|
305
|
+
// 已登记、没在跑、又没被挂起(runs>0 说明派生过)→ 是启动后立刻退出,不必等满
|
|
306
|
+
if (st.exists && st.runs > 0 && !st.deferred) break;
|
|
307
|
+
if (i >= 2 && st.exists && !st.deferred && st.runs === 0) break; // 登记了却一次都没派生 → 被挂起
|
|
308
|
+
sleepSync(gapMs);
|
|
233
309
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
const
|
|
310
|
+
return st;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** 终极兜底:脱离 launchd 直接后台拉起 bridge(至少"现在能用",但没有开机自启/崩溃自愈)。 */
|
|
314
|
+
function spawnDetachedBridge() {
|
|
315
|
+
try {
|
|
316
|
+
const logPath = path.join(CONFIG_DIR, ".dsh-bridge.log");
|
|
317
|
+
const out = fs.openSync(logPath, "a");
|
|
318
|
+
const child = spawn(NODE_BIN, [runtimeSetupPath(), "run"], {
|
|
319
|
+
detached: true,
|
|
320
|
+
stdio: ["ignore", out, out],
|
|
321
|
+
env: process.env
|
|
322
|
+
});
|
|
323
|
+
child.unref();
|
|
324
|
+
fs.closeSync(out);
|
|
325
|
+
return { ok: Boolean(child.pid), pid: child.pid || null };
|
|
326
|
+
} catch (e) {
|
|
327
|
+
return { ok: false, pid: null, error: e.message };
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* macOS:按「可自愈优先」的阶梯启动 bridge。
|
|
333
|
+
* ① user/<uid> ← 首选。macOS 26 上唯一支持 RunAtLoad/KeepAlive 的 domain(可崩溃自愈)
|
|
334
|
+
* ② gui/<uid> + kickstart ← 兼容兜底。bootstrap 只登记不派生时用 kickstart 强拉一次;
|
|
335
|
+
* 但实测 kickstart 只解决"这一次",进程被杀后不会重建(KeepAlive 已被系统绕过)→ 如实告知
|
|
336
|
+
* ③ 后台进程 ← 连 launchd 都托管不了时保证可用
|
|
337
|
+
*/
|
|
338
|
+
function startBridgeDarwin(plistPath) {
|
|
339
|
+
const uid = process.getuid();
|
|
340
|
+
const q = (s) => "'" + String(s).replace(/'/g, `'\\''`) + "'";
|
|
341
|
+
let lastDetail = "";
|
|
342
|
+
for (const domain of [`user/${uid}`, `gui/${uid}`]) {
|
|
343
|
+
const target = `${domain}/${BRIDGE_LABEL}`;
|
|
344
|
+
// 清掉另一个 domain 的历史注册,避免两处并存导致重复实例
|
|
345
|
+
for (const d of [`user/${uid}`, `gui/${uid}`]) {
|
|
346
|
+
if (d !== domain) sh(`launchctl bootout ${d}/${BRIDGE_LABEL}`);
|
|
347
|
+
}
|
|
241
348
|
sh(`launchctl bootout ${target}`);
|
|
242
349
|
let boot = sh(`launchctl bootstrap ${domain} ${q(plistPath)}`);
|
|
243
350
|
if (!boot.ok) {
|
|
351
|
+
// 老写法兜底(部分系统上 load -w 仍可用)
|
|
244
352
|
sh(`launchctl unload ${q(plistPath)}`);
|
|
245
353
|
boot = sh(`launchctl load -w ${q(plistPath)}`);
|
|
246
354
|
}
|
|
247
|
-
if (!boot.ok)
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
const m = pr.stdout.match(/pid\s*=\s*(\d+)/);
|
|
251
|
-
return { ok: true, status: "running", pid: m ? Number(m[1]) : null };
|
|
355
|
+
if (!boot.ok) {
|
|
356
|
+
lastDetail = (boot.stderr || boot.stdout).trim() || "launchctl bootstrap 失败";
|
|
357
|
+
continue;
|
|
252
358
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
359
|
+
// bootstrap 只是登记;立刻 kickstart 一次,确保"这一次"一定起来
|
|
360
|
+
// (on-demand-only 的 gui domain 下这是唯一能拉起的手段)
|
|
361
|
+
sh(`launchctl kickstart -k ${target}`);
|
|
362
|
+
const st = waitLaunchdRunning(target);
|
|
363
|
+
if (st.running) {
|
|
364
|
+
// 被挂起(deferred)过一次 = 本机 launchd 不保证崩溃自愈,必须如实报告,不能报成完全成功
|
|
365
|
+
const selfHealing = !st.deferred;
|
|
366
|
+
return { ok: true, status: "running", pid: st.pid, domain, selfHealing, detail: selfHealing ? "" : "本机 launchd 处于 on-demand-only,进程退出后不会自动重建" };
|
|
258
367
|
}
|
|
259
|
-
|
|
368
|
+
lastDetail = st.deferred
|
|
369
|
+
? `launchd 拒绝派生(domain ${domain} 处于 on-demand-only 模式)`
|
|
370
|
+
: (st.exists ? `已登记但未运行(runs=${st.runs})` : "已登记但查不到状态");
|
|
371
|
+
}
|
|
372
|
+
const d = spawnDetachedBridge();
|
|
373
|
+
if (d.ok) {
|
|
374
|
+
return {
|
|
375
|
+
ok: false,
|
|
376
|
+
status: "degraded-detached",
|
|
377
|
+
pid: d.pid,
|
|
378
|
+
domain: null,
|
|
379
|
+
selfHealing: false,
|
|
380
|
+
detail: "launchd 无法托管本机 bridge,已改为后台进程运行(现在可用,但不会开机自启、崩溃后不自动恢复)"
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
return { ok: false, status: "failed", detail: `launchd 与后台进程均启动失败:${lastDetail}` };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function restartBridgeService() {
|
|
387
|
+
const svcFile = autostartFilePath();
|
|
388
|
+
if (svcFile && !fs.existsSync(svcFile)) {
|
|
389
|
+
writeAutostartFile(); // 静默补生成(调用方会汇总展示路径)
|
|
390
|
+
}
|
|
391
|
+
if (process.platform === "darwin") {
|
|
392
|
+
const plistPath = autostartFilePath();
|
|
393
|
+
if (!fs.existsSync(plistPath)) return { ok: false, status: "not-installed", detail: "plist 不存在" };
|
|
394
|
+
return startBridgeDarwin(plistPath);
|
|
260
395
|
}
|
|
261
396
|
if (process.platform === "linux") {
|
|
262
397
|
const r = sh(`systemctl --user restart dsh-bridge`);
|
|
@@ -269,14 +404,101 @@ function restartBridgeService() {
|
|
|
269
404
|
return { ok: false, status: "unsupported", detail: `平台 ${process.platform} 不支持自启动` };
|
|
270
405
|
}
|
|
271
406
|
|
|
407
|
+
/**
|
|
408
|
+
* dsh web 是否正在运行(127.0.0.1:3080)。
|
|
409
|
+
* bridge 本身依赖 dsh web 才工作:dsh web 没开时 bridge 起来也会立刻退出,
|
|
410
|
+
* 所以「装完当下 bridge 没在跑」是**正常状态**,不能当失败吓用户(见 install 汇总)。
|
|
411
|
+
*/
|
|
412
|
+
async function isDshWebUp(timeoutMs = 1200) {
|
|
413
|
+
try {
|
|
414
|
+
const ac = new AbortController();
|
|
415
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
416
|
+
await fetch("http://127.0.0.1:3080/", { signal: ac.signal });
|
|
417
|
+
clearTimeout(timer);
|
|
418
|
+
return true;
|
|
419
|
+
} catch { return false; }
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* 解析 dsh web profile 目录(安装与插件管理共用同一口径)。
|
|
424
|
+
*/
|
|
425
|
+
function resolveProfileDir(argv = []) {
|
|
426
|
+
const i = argv.indexOf("--profile");
|
|
427
|
+
return i > -1 && argv[i + 1]
|
|
428
|
+
? argv[i + 1]
|
|
429
|
+
: process.env.DSH_PROFILE_DIR || path.join(os.homedir(), ".dsh", "profiles", "web");
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** 监听 127.0.0.1:3080 的进程 pid(= dsh web)。lsof 不可用时回退 pgrep。 */
|
|
433
|
+
function dshWebPid() {
|
|
434
|
+
const l = sh("lsof -nP -iTCP:3080 -sTCP:LISTEN -t 2>/dev/null || true");
|
|
435
|
+
const pid = String(l.stdout || "").trim().split(/\s+/).filter(Boolean)[0];
|
|
436
|
+
if (pid && /^\d+$/.test(pid)) return Number(pid);
|
|
437
|
+
const p = sh("pgrep -f 'dsh web' 2>/dev/null | head -1");
|
|
438
|
+
const p2 = String(p.stdout || "").trim();
|
|
439
|
+
return /^\d+$/.test(p2) ? Number(p2) : null;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* 进程启动时刻(毫秒)。
|
|
444
|
+
* 用 `ps -o lstart=`(如 "Sun Sep 13 13:54:43 2026")——它**不含年份以外的 TZ 差异**,
|
|
445
|
+
* 比 `ps -o etimes=` 稳(后者在部分平台不可用),比 /proc 通用。
|
|
446
|
+
*/
|
|
447
|
+
function processStartMs(pid) {
|
|
448
|
+
try {
|
|
449
|
+
const r = sh(`ps -o lstart= -p ${Number(pid)}`);
|
|
450
|
+
const t = Date.parse(String(r.stdout || "").trim());
|
|
451
|
+
return Number.isFinite(t) ? t : 0;
|
|
452
|
+
} catch { return 0; }
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* 重启 dsh web。
|
|
457
|
+
* dsh web 由**用户手工或 launchd** 跑着,插件是进程启动时装载的,所以更新插件后必须重启它才有面板。
|
|
458
|
+
* 阶梯:launchd job(macOS)→ systemd user unit(Linux)→ 原命令行自拉起 → 交给用户手动。
|
|
459
|
+
*/
|
|
460
|
+
function dshWebLaunchdJob() {
|
|
461
|
+
const l = sh("launchctl list 2>/dev/null | grep -i dsh | grep -v dshremote || true");
|
|
462
|
+
for (const line of String(l.stdout || "").split("\n")) {
|
|
463
|
+
const label = line.trim().split(/\s+/).pop();
|
|
464
|
+
if (label && /dsh/i.test(label)) return label;
|
|
465
|
+
}
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function restartDshWeb() {
|
|
470
|
+
const uid = process.getuid();
|
|
471
|
+
if (process.platform === "darwin") {
|
|
472
|
+
const job = dshWebLaunchdJob();
|
|
473
|
+
if (job) {
|
|
474
|
+
const r = sh(`launchctl kickstart -k gui/${uid}/${job}`);
|
|
475
|
+
if (r.ok) return { ok: true, how: `launchctl kickstart ${job}` };
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (process.platform === "linux") {
|
|
479
|
+
const r = sh("systemctl --user restart dsh-web 2>/dev/null || systemctl --user restart dsh 2>/dev/null");
|
|
480
|
+
if (r.ok) return { ok: true, how: "systemctl --user restart dsh-web" };
|
|
481
|
+
}
|
|
482
|
+
// 兜底:沿用原命令行重启(只在能拿到真实 node 入口时做,npx 缓存路径不可靠)
|
|
483
|
+
const pid = dshWebPid();
|
|
484
|
+
if (!pid) return { ok: false, how: "", detail: "未找到 dsh web 进程" };
|
|
485
|
+
const cmd = String(sh(`ps -o command= -p ${pid}`).stdout || "").trim();
|
|
486
|
+
if (!cmd || /node_modules\/\.bin|_npx/.test(cmd)) {
|
|
487
|
+
return { ok: false, how: "", detail: "无法获取可复用的启动命令(可能是经 npx 启动),请手动重启" };
|
|
488
|
+
}
|
|
489
|
+
try {
|
|
490
|
+
const child = spawn("/bin/sh", ["-c", cmd], { detached: true, stdio: "ignore", cwd: os.homedir() });
|
|
491
|
+
child.unref();
|
|
492
|
+
return { ok: true, how: "原命令行重新拉起" };
|
|
493
|
+
} catch (e) {
|
|
494
|
+
return { ok: false, how: "", detail: e.message };
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
272
498
|
function installAutostart() {
|
|
273
499
|
const svcPath = writeAutostartFile();
|
|
274
500
|
const r = restartBridgeService();
|
|
275
|
-
|
|
276
|
-
console.log(`✅ 自启动服务已加载并运行${r.pid ? ` (pid=${r.pid})` : ""}`);
|
|
277
|
-
} else {
|
|
278
|
-
console.log(`⚠️ 自启动服务启动失败: ${r.detail || r.status}`);
|
|
279
|
-
}
|
|
501
|
+
// 不再在这里直接打印(信息在 install 汇总里给一次),身份由调用方持有
|
|
280
502
|
return { path: svcPath, status: r };
|
|
281
503
|
}
|
|
282
504
|
|
|
@@ -350,6 +572,9 @@ async function runBridge() {
|
|
|
350
572
|
...process.env,
|
|
351
573
|
DSH_BRIDGE_CONFIG: CONFIG_PATH,
|
|
352
574
|
DSH_BRIDGE_TUNNEL_URL: cfg.tunnel_url,
|
|
575
|
+
// 安装口径透传给 bridge(bridge 把它随设备登记请求一起上报给账号 API)
|
|
576
|
+
DSH_BRIDGE_INSTALL_SOURCE: INSTALL_SOURCE,
|
|
577
|
+
DSH_BRIDGE_INSTALL_VERSION: INSTALL_VERSION,
|
|
353
578
|
...(saas ? { DSH_BRIDGE_PHONE: (cfg.phone || cfg.email || ""), DSH_BRIDGE_PASSWORD: cfg.password, DSH_BRIDGE_API: apiUrl, DSH_BRIDGE_SECRET: cfg.bridge_secret || "" } : {}),
|
|
354
579
|
...(local ? { DSH_BRIDGE_LOCAL_KEY: cfg.local_key } : {})
|
|
355
580
|
};
|
|
@@ -379,6 +604,7 @@ async function setup(argv) {
|
|
|
379
604
|
const key = argValue(argv, "--key");
|
|
380
605
|
const noAutostart = hasFlag(argv, "--no-autostart");
|
|
381
606
|
const noPlugin = hasFlag(argv, "--no-plugin");
|
|
607
|
+
const noRestart = hasFlag(argv, "--no-restart");
|
|
382
608
|
const selfHosted = Boolean(server || key);
|
|
383
609
|
|
|
384
610
|
let cfg = loadConfig();
|
|
@@ -436,29 +662,147 @@ async function setup(argv) {
|
|
|
436
662
|
const st = svc.status;
|
|
437
663
|
|
|
438
664
|
// 自动安装 dsh web 插件(非致命:失败只提示,不阻断安装)
|
|
665
|
+
let pluginResult = null;
|
|
439
666
|
if (!noPlugin) {
|
|
440
667
|
try {
|
|
441
|
-
await pluginCmd([]);
|
|
668
|
+
pluginResult = await pluginCmd([]);
|
|
442
669
|
} catch (e) {
|
|
443
670
|
console.warn(`⚠️ 插件安装未完成:${e.message}`);
|
|
444
671
|
}
|
|
445
672
|
}
|
|
446
673
|
|
|
674
|
+
// 插件是**进程启动时**装载的:刚装进 profile 的插件,运行中的 dsh web 里并没有
|
|
675
|
+
// (既没有 /dsh-remote/* 路由,也没有「设置 → 远程控制」面板项)。
|
|
676
|
+
// 这里做一个确定性判断:进程启动时间早于插件落盘时间 → 必须重启 dsh web 才生效。
|
|
677
|
+
const pluginDir = path.join(resolveProfileDir(argv), PLUGIN_LOCAL_DIR);
|
|
678
|
+
const pluginMtime = fs.existsSync(path.join(pluginDir, "lib", "index.js"))
|
|
679
|
+
? fs.statSync(path.join(pluginDir, "lib", "index.js")).mtimeMs
|
|
680
|
+
: 0;
|
|
681
|
+
let webPid = null;
|
|
682
|
+
let needRestart = false;
|
|
683
|
+
let hotMounted = false;
|
|
684
|
+
const webUp = await isDshWebUp();
|
|
685
|
+
if (webUp) {
|
|
686
|
+
webPid = dshWebPid();
|
|
687
|
+
const startedAt = webPid ? processStartMs(webPid) : 0;
|
|
688
|
+
needRestart = Boolean(pluginMtime && startedAt && startedAt < pluginMtime);
|
|
689
|
+
// patch 激活形态:harness 的 HMR 监听 profile patch 文件,存盘后约 1 秒重新 compose。
|
|
690
|
+
// 所以这里**先等热挂载**,等到了就完全不需要重启(用户只需刷新页面拿浏览器半)。
|
|
691
|
+
if (needRestart && pluginResult && pluginResult.hotPatch) {
|
|
692
|
+
// HMR 通常 1~3 秒完成,但插件节点半还要起服务、注册路由,机器繁忙时会到十几秒;
|
|
693
|
+
// 给足 ~30 秒窗口,避免"其实已经热挂载成功、却报成需要重启"的误报(实测踩到)。
|
|
694
|
+
for (let i = 0; i < 45; i += 1) {
|
|
695
|
+
const r = await fetch("http://127.0.0.1:3080/dsh-remote/status", { signal: AbortSignal.timeout(900) })
|
|
696
|
+
.catch(() => null);
|
|
697
|
+
if (r && r.ok) { hotMounted = true; break; }
|
|
698
|
+
sleepSync(700);
|
|
699
|
+
}
|
|
700
|
+
if (hotMounted) needRestart = false;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
// 需要就用**最省事的方式**替用户重启:插件装完还要用户自己琢磨怎么重启,是这一步唯一的手工负担
|
|
704
|
+
let restartResult = null;
|
|
705
|
+
if (needRestart && !noRestart && webUp) {
|
|
706
|
+
console.log("");
|
|
707
|
+
console.log("ℹ 检测到运行中的 dsh web 早于本次插件安装 —— 插件只在进程启动时装载,");
|
|
708
|
+
console.log(" 所以「设置 → 远程控制」面板暂时还没出现。正在为你重启 dsh web(2 秒后执行,Ctrl-C 可跳过)…");
|
|
709
|
+
sleepSync(2000);
|
|
710
|
+
restartResult = restartDshWeb();
|
|
711
|
+
if (restartResult.ok) {
|
|
712
|
+
// 等它回来再确认端口确实在听(避免"重启完其实没起来"还要用户自己发现)
|
|
713
|
+
for (let i = 0; i < 20; i += 1) {
|
|
714
|
+
if (await isDshWebUp(800)) break;
|
|
715
|
+
sleepSync(700);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
console.log(restartResult.ok
|
|
719
|
+
? `✅ 已重启 dsh web(${restartResult.how})`
|
|
720
|
+
: `⚠️ 自动重启失败: ${restartResult.detail}`);
|
|
721
|
+
}
|
|
722
|
+
|
|
447
723
|
const pub = await fetchPublicConfig(cfg.api_url || DEFAULT_API);
|
|
448
|
-
|
|
449
|
-
|
|
724
|
+
// 服务状态字符串是给插件面板解析的稳定契约(running / 未运行),不要改口径。
|
|
725
|
+
// 三分支:running / 未运行(有原因) / 未安装(跳过或平台不支持)——最后一种既不能显示"运行中",
|
|
726
|
+
// 也不能显示"未运行(原因)",它压根没装(此前会打出自相矛盾的"(当前平台不支持) — ✅ 运行中")。
|
|
727
|
+
const svcSkipped = st.status === "skipped" || st.status === "unsupported" || !svc.path;
|
|
728
|
+
// 第三态:起来了、但本机 launchd 不保证崩溃自愈(on-demand-only 的 gui domain 只能靠 kickstart 拉起),
|
|
729
|
+
// 必须与"完全正常"分开说 —— 否则用户以为有自启,进程一崩就永久掉线且找不到原因。
|
|
730
|
+
const svcFragile = !svcSkipped && st.ok && st.selfHealing === false;
|
|
731
|
+
const svcDegraded = st.status === "degraded-detached";
|
|
732
|
+
const svcState = svcSkipped
|
|
733
|
+
? (st.status === "skipped" ? "未安装(本次显式跳过)" : "未安装(当前平台不支持自启动)")
|
|
734
|
+
: (st.ok
|
|
735
|
+
? `✅ 运行中${st.pid ? ` (pid=${st.pid})` : ""}${svcFragile ? ",但本机不支持崩溃自愈" : ""}`
|
|
736
|
+
: (svcDegraded ? "⚠️ 未被系统托管(已用后台进程兜底)" : `未运行 (${st.detail || st.status})`));
|
|
737
|
+
const L = [];
|
|
738
|
+
L.push("✅ 安装完成");
|
|
739
|
+
if (selfHosted) {
|
|
740
|
+
L.push(` 服务器: ${cfg.tunnel_url}`);
|
|
741
|
+
L.push(` 手机端: 打开 ${cfg.tunnel_url.replace(/^ws/, "https")}/app/ ,用访问密钥登录`);
|
|
742
|
+
} else {
|
|
743
|
+
L.push(` 远程控制地址: ${pub.app_url || DEFAULT_APP_URL}`);
|
|
744
|
+
}
|
|
745
|
+
L.push(svcSkipped
|
|
746
|
+
? ` 自启动服务: ${svcState},可用 \`dsh-remote run\` 手动运行 bridge`
|
|
747
|
+
: ` 自启动服务: ${svc.path} — ${svcState}`);
|
|
748
|
+
// 唯一一处"下一步"引导(上面各阶段不再重复打印同样的话)
|
|
749
|
+
if (svcSkipped) {
|
|
750
|
+
// 没装自启动服务,就没有"服务状态"可谈,更不该给出"打开 dsh web 就会自动启动"的承诺
|
|
751
|
+
L.push("");
|
|
752
|
+
L.push("ℹ 未安装自启动服务:需要时执行 `dsh-remote run`(前台运行 bridge),或重新安装以启用自启动。");
|
|
753
|
+
} else if (!st.ok && !webUp) {
|
|
754
|
+
// dsh web 没开时 bridge 起来即退,这是正常状态;要说清"什么时候会自己好",而不是甩一句"启动失败"
|
|
755
|
+
L.push("");
|
|
756
|
+
L.push("ℹ 检测到 dsh web 当前没有运行,所以 bridge 还没接上(正常,不是安装出错)。");
|
|
757
|
+
L.push(" 打开 dsh web 后 bridge 会自动启动,无需任何命令。");
|
|
758
|
+
L.push(" 如果 dsh web 已经开着但看不到本机,先重启 dsh web 让插件生效。");
|
|
759
|
+
} else if (!st.ok) {
|
|
760
|
+
L.push("");
|
|
761
|
+
if (svcDegraded) {
|
|
762
|
+
// launchd 托管失败但进程活着:能立刻用,只是没有自启/自愈。如实说明,别让用户以为有自启。
|
|
763
|
+
L.push(`⚠️ ${st.detail}`);
|
|
764
|
+
L.push(` bridge 已在后台运行${st.pid ? `(pid=${st.pid})` : ""},现在就能用;`);
|
|
765
|
+
L.push(" 但重启电脑或进程退出后不会自动恢复,需要时执行 `dsh-remote run`。");
|
|
766
|
+
} else {
|
|
767
|
+
L.push(`⚠️ bridge 未能启动: ${st.detail || st.status}`);
|
|
768
|
+
L.push(` 可查看日志: ${CONFIG_DIR}/.dsh-bridge.log`);
|
|
769
|
+
}
|
|
770
|
+
} else if (svcFragile) {
|
|
771
|
+
// 起来了但靠 kickstart 拉起的 → 明确告知"进程退出后不会自动重建"
|
|
772
|
+
L.push("");
|
|
773
|
+
L.push("ℹ 本机 launchd 处于 on-demand-only 模式(macOS 26 起会出现),系统不会自动派生自启动服务,");
|
|
774
|
+
L.push(" 安装脚本已手动把它拉起来一次,现在可用;但**进程退出后不会自动重建**。");
|
|
775
|
+
L.push(" 建议升级到最新版 dsh-remote(已针对该模式改用 user domain 启动,可恢复自动重建)。");
|
|
776
|
+
}
|
|
450
777
|
if (selfHosted) {
|
|
451
|
-
|
|
452
|
-
|
|
778
|
+
L.push("");
|
|
779
|
+
L.push(" 下一步: 手机端用访问密钥登录即可(bridge 登录后自动启动)。");
|
|
780
|
+
} else if (hotMounted) {
|
|
781
|
+
L.push("");
|
|
782
|
+
L.push(" 下一步: 插件已热加载(无需重启 dsh web)——刷新一下浏览器页面,");
|
|
783
|
+
L.push(" 再打开 设置 → 「远程控制」→ 注册/登录手机号即可。");
|
|
784
|
+
} else if (needRestart && restartResult && restartResult.ok) {
|
|
785
|
+
L.push("");
|
|
786
|
+
L.push(" 下一步: dsh web 已重启,直接打开 http://127.0.0.1:3080 → 设置 → 「远程控制」→ 注册/登录手机号。");
|
|
787
|
+
} else if (needRestart && pluginResult && pluginResult.hotPatch && pluginResult.changed) {
|
|
788
|
+
// patch 已写入但探测窗口内没等到面板接口:热加载很可能还在进行(插件节点半启动+注册路由)。
|
|
789
|
+
// 这种情况**不能**直接让用户重启 —— 先让他等几秒刷新,再给兜底方案。
|
|
790
|
+
L.push("");
|
|
791
|
+
L.push("ℹ 插件已按热加载方式登记(patch 已写入),若「设置 → 远程控制」还没出现:");
|
|
792
|
+
L.push(" 等几秒后刷新页面即可;仍未出现再重启一次 dsh web(命令见 README)。");
|
|
793
|
+
} else if (needRestart) {
|
|
794
|
+
// 需要重启但没做成功:必须给出**可照抄**的命令,而不是只说"请重启"
|
|
795
|
+
L.push("");
|
|
796
|
+
L.push("⚠️ 还需要重启一次 dsh web,插件面板才会出现(插件只在进程启动时装载)。");
|
|
797
|
+
L.push(" 请手动执行(任选其一):");
|
|
798
|
+
L.push(" launchctl kickstart -k gui/$(id -u)/com.dshweb.dev # 用 launchd 托管时");
|
|
799
|
+
L.push(" kill $(lsof -ti tcp:3080 -sTCP:LISTEN) && dsh web --no-open # 手动启动时");
|
|
800
|
+
L.push(" 重启后:打开 http://127.0.0.1:3080 → 设置 → 「远程控制」→ 注册/登录手机号。");
|
|
453
801
|
} else {
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
console.log(` 若你是从 DeepSeek App/插件市场 安装:请【完全退出并重开 App】(或刷新 profile),插件即生效。`);
|
|
457
|
-
console.log(` 登录后 bridge 会自动启动,手机端即可看到本机。`);
|
|
802
|
+
L.push("");
|
|
803
|
+
L.push(" 下一步: 打开 dsh web → 设置 → 「远程控制」→ 注册/登录手机号即可(无需任何命令)。");
|
|
458
804
|
}
|
|
459
|
-
|
|
460
|
-
console.log(` 服务状态: ${st.ok ? "✅ 运行中" + (st.pid ? ` (pid=${st.pid})` : "") : "❌ 未运行(" + (st.detail || st.status) + ")"}`);
|
|
461
|
-
console.log("══════════════════════════════════════");
|
|
805
|
+
console.log("\n" + L.join("\n") + "\n");
|
|
462
806
|
}
|
|
463
807
|
|
|
464
808
|
// ---------- plugin:安装/卸载 dsh web 远程控制插件 ----------
|
|
@@ -527,8 +871,121 @@ function stripPluginEntries(patch) {
|
|
|
527
871
|
}
|
|
528
872
|
|
|
529
873
|
/**
|
|
530
|
-
*
|
|
531
|
-
*
|
|
874
|
+
* 解析 patch 文本里的 insert 行 id(与 dshmarket 同口径的极简解析,不引 YAML 依赖)。
|
|
875
|
+
* 用途有二:① 判断本插件是否已激活(幂等);② **写完校验**——patch 文件形态非法时
|
|
876
|
+
* dsh web 会直接启动失败,所以每次写入后必须重新解析确认结果仍是「一行一条的 insert 列表」。
|
|
877
|
+
* @returns {{ids: string[], badLines: string[]}} badLines 非空 = 文件形态不合法
|
|
878
|
+
*/
|
|
879
|
+
function parsePatchInsertIds(text) {
|
|
880
|
+
const ids = [];
|
|
881
|
+
const badLines = [];
|
|
882
|
+
for (const raw of String(text ?? "").split(/\r?\n/)) {
|
|
883
|
+
// 先剥注释与尾部空白;注意 id 行在块内是缩进的(` - id: xxx`),不能要求顶格
|
|
884
|
+
const line = raw.replace(/#.*$/, "").replace(/\s+$/, "");
|
|
885
|
+
const trimmed = line.trim();
|
|
886
|
+
if (trimmed === "") continue;
|
|
887
|
+
if (trimmed === "[]") continue; // 新 profile 的空文档占位
|
|
888
|
+
if (/^-\s*insert:\s*$/.test(trimmed)) continue; // insert 块头(可缩进)
|
|
889
|
+
const m = /^-\s*id:\s*(\S+)\s*$/.exec(trimmed); // 条目行(可缩进)
|
|
890
|
+
if (m) { ids.push(m[1]); continue; }
|
|
891
|
+
// 其余缩进行属于某个条目的字段(name/config/...),合法;顶层非列表行才是异常形态
|
|
892
|
+
if (/^\s+\S/.test(line)) continue;
|
|
893
|
+
badLines.push(trimmed);
|
|
894
|
+
}
|
|
895
|
+
return { ids, badLines };
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* 写入 patch 前校验基座形态。
|
|
900
|
+
* 只接受「空/仅占位/现有条目」三种基座;发现异常内容就**拒写**(绝不把一个本来还能用的
|
|
901
|
+
* profile 弄坏 —— 改动前先确认目标文件是可安全追加的形态,而不是先写再看结果)。
|
|
902
|
+
* @returns {{ok: boolean, reason?: string, cleaned: string, insertedIds: string[]}}
|
|
903
|
+
*/
|
|
904
|
+
function validatePatchBase(text) {
|
|
905
|
+
const { ids, badLines } = parsePatchInsertIds(text);
|
|
906
|
+
if (badLines.length) {
|
|
907
|
+
return { ok: false, reason: `patch 文件含无法识别的行(未做改动): ${badLines[0].slice(0, 60)}`, cleaned: "", insertedIds: ids };
|
|
908
|
+
}
|
|
909
|
+
// 去掉空文档占位 `[]`(它在条目之前会让整份文档变成「数组套列表」而解析失败)
|
|
910
|
+
const cleaned = String(text ?? "").split(/\r?\n/).filter((l) => l.trim() !== "[]").join("\n").trim();
|
|
911
|
+
return { ok: true, cleaned, insertedIds: ids };
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/** 安全写回 patch(原子:先写同目录临时文件再 rename,避免中途被打断留下半截文件)。 */
|
|
915
|
+
function writePatchAtomic(patchFile, text) {
|
|
916
|
+
const tmp = `${patchFile}.dsh-remote.tmp`;
|
|
917
|
+
fs.writeFileSync(tmp, text);
|
|
918
|
+
fs.renameSync(tmp, patchFile);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* 原子地写入插件 patch 激活行;写后**重新解析校验**,不合法立即回滚。
|
|
923
|
+
* 返回 {ok, changed, reason?, basePatch}
|
|
924
|
+
*/
|
|
925
|
+
function ensurePatchActivation(patchFile, pluginLocalDir, pkgFile) {
|
|
926
|
+
const original = fs.readFileSync(patchFile, "utf8");
|
|
927
|
+
// 幂等:已经是唯一激活点就什么都不做(但要确保 bundles 里没有重复激活)
|
|
928
|
+
const base = validatePatchBase(original);
|
|
929
|
+
if (!base.ok) return { ok: false, reason: base.reason, changed: false, basePatch: original };
|
|
930
|
+
const hasOurRow = base.insertedIds.some((id) => PLUGIN_ALL_IDS.includes(id));
|
|
931
|
+
if (hasOurRow) {
|
|
932
|
+
const bundleRemoved = removeBundleEntry(pkgFile);
|
|
933
|
+
if (bundleRemoved) console.log("✅ 已从 dsh.profile.bundles 移除重复激活点(激活统一走 patch 行)");
|
|
934
|
+
// 已有条目也要**自我修正**:配置目录变了(换安装方式 / 之前写错)时 relayDir 必须跟着更新,
|
|
935
|
+
// 否则面板会一直去旧目录找 .dsh-config.json(表现就是"未登录 / 空配置")。
|
|
936
|
+
const want = pluginBlock(CONFIG_DIR);
|
|
937
|
+
const wantDir = (/relayDir: '([^']*)'/.exec(want) || [])[1] || "";
|
|
938
|
+
if (wantDir && !original.includes(`relayDir: '${wantDir}'`)) {
|
|
939
|
+
const base2 = validatePatchBase(stripPluginEntries(original));
|
|
940
|
+
if (base2.ok) {
|
|
941
|
+
const merged2 = `${base2.cleaned ? base2.cleaned + "\n" : ""}\n${want}\n`;
|
|
942
|
+
const chk = parsePatchInsertIds(merged2);
|
|
943
|
+
if (!chk.badLines.length && chk.ids.filter((id) => id === PLUGIN_ID).length === 1) {
|
|
944
|
+
writePatchAtomic(patchFile, merged2);
|
|
945
|
+
console.log(`✅ 已同步激活行的 relayDir → ${wantDir}`);
|
|
946
|
+
return { ok: true, changed: true, basePatch: original };
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
console.warn("⚠️ 激活行的 relayDir 与当前配置目录不一致,但未能安全改写(保持原样)");
|
|
950
|
+
}
|
|
951
|
+
return { ok: true, changed: bundleRemoved, basePatch: original };
|
|
952
|
+
}
|
|
953
|
+
// 写前再次校验我们的块本身可解析(id 行必须能被 parsePatchInsertIds 认出来)
|
|
954
|
+
// ⚠️ relayDir 必须传**配置目录**(CONFIG_DIR, 即 ~/.dsh-remote),不是插件安装目录:
|
|
955
|
+
// 面板要读的是该目录下的 .dsh-config.json(账号/中继配置);传成 pluginLocalDir 会让面板
|
|
956
|
+
// 对着插件源码目录找配置 → 界面显示成"未登录 / 空配置"(实测踩到)。
|
|
957
|
+
const block = pluginBlock(CONFIG_DIR);
|
|
958
|
+
if (!parsePatchInsertIds(block).ids.includes(PLUGIN_ID)) {
|
|
959
|
+
return { ok: false, reason: "生成的激活块自身不合法", changed: false, basePatch: original };
|
|
960
|
+
}
|
|
961
|
+
const merged = `${base.cleaned ? base.cleaned + "\n" : ""}\n${block}\n`;
|
|
962
|
+
// 写后校验:整份文件必须仍是干净的 insert 列表,且我们的 id 恰好出现一次
|
|
963
|
+
const check = parsePatchInsertIds(merged);
|
|
964
|
+
if (check.badLines.length || check.ids.filter((id) => id === PLUGIN_ID).length !== 1) {
|
|
965
|
+
return { ok: false, reason: "合并后的 patch 校验未通过(未做改动)", changed: false, basePatch: original };
|
|
966
|
+
}
|
|
967
|
+
writePatchAtomic(patchFile, merged);
|
|
968
|
+
const after = fs.readFileSync(patchFile, "utf8");
|
|
969
|
+
const verify = parsePatchInsertIds(after);
|
|
970
|
+
if (verify.badLines.length || verify.ids.filter((id) => id === PLUGIN_ID).length !== 1) {
|
|
971
|
+
writePatchAtomic(patchFile, original); // 回滚
|
|
972
|
+
return { ok: false, reason: "写入后校验失败,已回滚", changed: false, basePatch: original };
|
|
973
|
+
}
|
|
974
|
+
removeBundleEntry(pkgFile); // 单一激活点:bundles 里不能再有本插件
|
|
975
|
+
return { ok: true, changed: true, basePatch: original };
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/** 清理 patch 中引用本插件的条目块(含前置注释/收尾标记)。返回是否发生变更。 */
|
|
979
|
+
function stripIncludeEntries(patchFile, patch, reason) {
|
|
980
|
+
const next = stripPluginEntries(patch);
|
|
981
|
+
if (next === patch) return false;
|
|
982
|
+
writePatchAtomic(patchFile, next);
|
|
983
|
+
console.log(`✅ 已移除 ${patchFile} 中的冗余 include(${reason};激活点必须唯一,避免重复 ID 崩溃)`);
|
|
984
|
+
return true;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* 归一化 patch 基座:去掉 dsh 新 profile 默认的空文档占位行 `[]`。 * 默认 cordis.patch.yml 是「顶部注释 + []」,若保留 [] 再往下拼 `- insert:`,
|
|
532
989
|
* YAML 会报 “end of the stream or a document separator is expected”(dsh web 启动即崩)。
|
|
533
990
|
* 返回内容不含结尾换行;[] 行只在独立成行时视为占位,不影响真正的条目。
|
|
534
991
|
*/
|
|
@@ -636,13 +1093,17 @@ function removeBundleEntry(pkgFile) {
|
|
|
636
1093
|
|
|
637
1094
|
/**
|
|
638
1095
|
* 插件安装(pluginCmd 非卸载分支)收敛策略 —— 2026-09-06「重复 ID 崩溃」根治;
|
|
639
|
-
* 2026-09 插件由 dsh-remote-ui 更名 dsh-remote-web
|
|
640
|
-
*
|
|
641
|
-
*
|
|
642
|
-
*
|
|
643
|
-
*
|
|
644
|
-
*
|
|
645
|
-
*
|
|
1096
|
+
* 2026-09 插件由 dsh-remote-ui 更名 dsh-remote-web,本函数同时兼容清理旧名残留。
|
|
1097
|
+
*
|
|
1098
|
+
* ⚠️ 2026-09-13 调整:「恰好一处激活」仍然成立,但**激活点二选一**:
|
|
1099
|
+
* · dsh-setup 安装(file: 依赖 + 自建 node_modules 链接)→ 激活点 = 用户级 cordis.patch.yml
|
|
1100
|
+
* 的一行 insert。理由:harness 的 web profile 是 `patchReload: "live"`,会加载
|
|
1101
|
+
* `@deepseek-ai/cordis-plugin-hmr` 监听该文件,**存盘后约 1 秒重新 compose 并动态装载**,
|
|
1102
|
+
* 用户不需要重启 dsh web(只需刷新页面拿到浏览器半)。插件市场自己也是这套机制。
|
|
1103
|
+
* · 插件市场安装(github:/npm: 依赖,源码归市场管)→ 激活点 = dsh.profile.bundles
|
|
1104
|
+
* (插件自带 bundle patch)。bundles 只在启动时读、没有 watcher,所以这一路仍需重启。
|
|
1105
|
+
* 两条路都**只保留一个激活点**:写 patch 就同时从 bundles 移除,反之清掉 include,
|
|
1106
|
+
* 绝不两处并存(那正是历史上 dsh web 启动即报「重复 ID」崩溃的原因)。
|
|
646
1107
|
*/
|
|
647
1108
|
function convergePluginActivation(profileDir, pkgFile, patchFile, pluginDir, patch) {
|
|
648
1109
|
const pkg = JSON.parse(fs.readFileSync(pkgFile, "utf8"));
|
|
@@ -653,18 +1114,11 @@ function convergePluginActivation(profileDir, pkgFile, patchFile, pluginDir, pat
|
|
|
653
1114
|
const marketManaged = dep && !String(dep).startsWith("file:"); // github:/npm: 等由市场/包管理器管源码
|
|
654
1115
|
const managedByUs = !dep || String(dep).startsWith("file:"); // 无依赖或 file: 拷贝 → 我们管
|
|
655
1116
|
|
|
656
|
-
const stripInclude = (reason) => {
|
|
657
|
-
const newPatch = stripPluginEntries(patch);
|
|
658
|
-
if (newPatch !== patch) {
|
|
659
|
-
fs.writeFileSync(patchFile, newPatch);
|
|
660
|
-
console.log(`✅ 已移除 ${patchFile} 中的冗余 include(${reason};激活统一走插件自带 bundle patch,避免重复 ID 崩溃)`);
|
|
661
|
-
}
|
|
662
|
-
};
|
|
663
|
-
|
|
664
1117
|
if (marketManaged && inBundles) {
|
|
665
1118
|
// 插件市场安装形态:依赖与源码归市场管,我们只清历史 include(若旧版曾写过)
|
|
666
|
-
|
|
1119
|
+
stripIncludeEntries(patchFile, patch, "插件市场安装形态无需用户 include");
|
|
667
1120
|
console.log("ℹ 插件市场安装形态(bundles+dependency):已保持市场管理的源码不变。");
|
|
1121
|
+
console.log(" (该形态经 dsh.profile.bundles 激活,只在启动时读取 —— 装完需重启一次 dsh web)");
|
|
668
1122
|
return;
|
|
669
1123
|
}
|
|
670
1124
|
|
|
@@ -675,19 +1129,19 @@ function convergePluginActivation(profileDir, pkgFile, patchFile, pluginDir, pat
|
|
|
675
1129
|
console.error(`❌ 插件入口缺失:${entryFile}(本包不完整?请用官方源重装:npx --registry=https://registry.npmjs.org @mrrisega/dsh-remote@latest)`);
|
|
676
1130
|
process.exit(1);
|
|
677
1131
|
}
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
const
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
console.log(`✅
|
|
690
|
-
return;
|
|
1132
|
+
declarePluginDep(pkgFile); // file: 依赖(包管理器 install 不误删)
|
|
1133
|
+
ensurePluginLinked(profileDir, pluginLocalDir); // node_modules 链接(name 才能被解析)
|
|
1134
|
+
// 唯一激活点:patch 行(+ 顺带把 bundles 里的本插件条目移除,避免两处并存)
|
|
1135
|
+
const r = ensurePatchActivation(patchFile, pluginLocalDir, pkgFile);
|
|
1136
|
+
if (r.ok) {
|
|
1137
|
+
console.log(`✅ 插件已就绪: ${pluginLocalDir}`);
|
|
1138
|
+
return { hotPatch: true, changed: r.changed, basePatch: r.basePatch };
|
|
1139
|
+
}
|
|
1140
|
+
// patch 行写不进去(文件形态异常)→ 回退 bundles(需重启),保证功能可用
|
|
1141
|
+
console.warn(`⚠️ 无法写入 patch 激活行(${r.reason}),改为 bundles 形态(需重启 dsh web 生效)`);
|
|
1142
|
+
ensureBundleEntry(pkgFile);
|
|
1143
|
+
console.log(`✅ 插件已就绪: ${pluginLocalDir}(bundles 形态)`);
|
|
1144
|
+
return { hotPatch: false, changed: true, basePatch: patch };
|
|
691
1145
|
}
|
|
692
1146
|
|
|
693
1147
|
// 异常形态:有非 file: 依赖但不在 bundles(无法靠 bundle patch 激活)
|
|
@@ -697,10 +1151,7 @@ function convergePluginActivation(profileDir, pkgFile, patchFile, pluginDir, pat
|
|
|
697
1151
|
|
|
698
1152
|
async function pluginCmd(argv) {
|
|
699
1153
|
const uninstall = hasFlag(argv, "--uninstall");
|
|
700
|
-
const
|
|
701
|
-
const profileDir = profileIdx > -1 && argv[profileIdx + 1]
|
|
702
|
-
? argv[profileIdx + 1]
|
|
703
|
-
: process.env.DSH_PROFILE_DIR || path.join(os.homedir(), ".dsh", "profiles", "web");
|
|
1154
|
+
const profileDir = resolveProfileDir(argv);
|
|
704
1155
|
const pkgFile = path.join(profileDir, "package.json");
|
|
705
1156
|
const patchFile = path.join(profileDir, "cordis.patch.yml");
|
|
706
1157
|
const pluginDir = path.join(THIS_DIR, "packages", PLUGIN_ID);
|
|
@@ -719,11 +1170,8 @@ async function pluginCmd(argv) {
|
|
|
719
1170
|
|
|
720
1171
|
if (uninstall) {
|
|
721
1172
|
// 移除 patch 中的插件条目(兼容旧版无标记条目;旧名 dsh-remote-ui 一并清理)
|
|
722
|
-
|
|
723
|
-
if (
|
|
724
|
-
fs.writeFileSync(patchFile, newPatch);
|
|
725
|
-
console.log(`✅ 已从 ${patchFile} 移除插件条目`);
|
|
726
|
-
} else {
|
|
1173
|
+
// 用原子写回:卸载同样不能在 profile 配置上留下半截文件
|
|
1174
|
+
if (!stripIncludeEntries(patchFile, patch, "卸载清理")) {
|
|
727
1175
|
console.log(`ℹ patch 中未发现 ${PLUGIN_ID}(或历史名)条目。`);
|
|
728
1176
|
}
|
|
729
1177
|
// 清理本地目录与链接:当前名 + 历史名(dsh-remote-ui-plugin / node_modules/dsh-remote-ui)
|
|
@@ -747,11 +1195,13 @@ async function pluginCmd(argv) {
|
|
|
747
1195
|
return;
|
|
748
1196
|
}
|
|
749
1197
|
|
|
750
|
-
//
|
|
751
|
-
|
|
1198
|
+
// 安装:收敛到"恰好一处激活"(热挂载 patch 行 或 bundles,二选一),绝不与市场/历史 include 并存
|
|
1199
|
+
//
|
|
1200
|
+
// ⚠️ 必须 **return 结果**:setup() 依赖它判断激活形态(false/undefined = 走 bundles,需重启),
|
|
1201
|
+
// 少了这个 return,安装后就不会去验证热挂载,白白多报一次"需要重启"(实测踩到)。
|
|
1202
|
+
return convergePluginActivation(profileDir, pkgFile, patchFile, pluginDir, patch);
|
|
752
1203
|
|
|
753
|
-
|
|
754
|
-
console.log(" 若从 DeepSeek App/插件市场 安装:请完全退出并重开 App 让插件生效。");
|
|
1204
|
+
// 引导语只在 setup 汇总里打印一次(这里不再重复;单独跑 `dsh-remote plugin` 也无需引导)
|
|
755
1205
|
}
|
|
756
1206
|
|
|
757
1207
|
// ---------- main ----------
|