@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/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
- // 配置目录:npm 安装时放在用户目录(node_modules 内不可写);仓库开发时放在仓库根。
36
- const CONFIG_DIR = process.env.DSH_RELAY_DIR || (IS_NPM_INSTALL ? path.join(os.homedir(), ".dsh-remote") : THIS_DIR);
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(`✅ 运行时已固化到 ${CONFIG_DIR}(自启动指向稳定路径,不再依赖 npx 缓存)`);
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(), "Library/LaunchAgents/com.dshremote.bridge.plist");
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>com.dshremote.bridge</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>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string></dict>
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
- fs.writeFileSync(plistPath, plist);
212
- console.log(`✅ 已创建自启动服务: ${plistPath}`);
213
- return plistPath;
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=/usr/local/bin:/usr/bin:/bin\n\n[Install]\nWantedBy=default.target\n`;
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
- console.log(`✅ 已创建自启动服务: ${unitPath}`);
222
- return unitPath;
267
+ return unitPath; // 路径在最终汇总里统一展示
223
268
  }
224
269
  console.log("⚠️ 当前平台暂不支持自启动,请手动运行 `dsh-remote run`");
225
270
  return null;
226
271
  }
227
272
 
228
- function restartBridgeService() {
229
- const svcFile = autostartFilePath();
230
- if (svcFile && !fs.existsSync(svcFile)) {
231
- console.log("[dsh-remote] 未检测到自启动服务,自动生成...");
232
- writeAutostartFile();
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
- if (process.platform === "darwin") {
235
- const plistPath = autostartFilePath();
236
- if (!fs.existsSync(plistPath)) return { ok: false, status: "not-installed", detail: "plist 不存在" };
237
- const uid = process.getuid();
238
- const domain = `gui/${uid}`;
239
- const target = `${domain}/com.dshremote.bridge`;
240
- const q = (s) => "'" + String(s).replace(/'/g, `'\\''`) + "'";
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) return { ok: false, status: "failed", detail: (boot.stderr || boot.stdout).trim() || "launchctl 启动失败" };
248
- const pr = sh(`launchctl print ${target}`);
249
- if (pr.ok && /state\s*=\s*running/.test(pr.stdout)) {
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
- const ls = sh(`launchctl list | grep com.dshremote.bridge`);
254
- if (ls.ok) {
255
- const pidStr = ls.stdout.trim().split(/\s+/)[0];
256
- if (pidStr && pidStr !== "-" && /^\d+$/.test(pidStr))
257
- return { ok: true, status: "running", pid: Number(pidStr) };
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
- return { ok: false, status: "failed", detail: (pr.stderr || ls.stderr || "服务未在运行").trim() };
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
- if (r.ok) {
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
- console.log("\n══════════════════════════════════════");
449
- console.log("✅ 安装完成!");
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
- console.log(` 服务器地址: ${cfg.tunnel_url}`);
452
- console.log(` 手机端: 打开 ${cfg.tunnel_url.replace(/^ws/, "https")}/app/ ,用访问密钥登录即可。`);
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
- console.log(` 远程控制地址: ${pub.app_url || DEFAULT_APP_URL}`);
455
- console.log(` 下一步(小白/无需任何命令): 打开 dsh web → 设置 → 「远程控制」→ 注册/登录手机号即可。`);
456
- console.log(` 若你是从 DeepSeek App/插件市场 安装:请【完全退出并重开 App】(或刷新 profile),插件即生效。`);
457
- console.log(` 登录后 bridge 会自动启动,手机端即可看到本机。`);
802
+ L.push("");
803
+ L.push(" 下一步: 打开 dsh web → 设置 → 「远程控制」→ 注册/登录手机号即可(无需任何命令)。");
458
804
  }
459
- if (svc.path) console.log(` 自启动服务: ${svc.path}`);
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
- * 归一化 patch 基座:去掉 dsh 新 profile 默认的空文档占位行 `[]`。
531
- * 默认 cordis.patch.yml 是「顶部注释 + []」,若保留 [] 再往下拼 `- insert:`,
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
- * dsh-remote-web 是带 dsh.bundle.patch 的 bundle:package.json 的 dsh.profile.bundles
641
- * 声明它后,加载器会自动应用插件自带的 cordis.patch.yml(节点半+浏览器半的唯一激活点)。
642
- * 若用户级 cordis.patch.yml 再手工 insert 同一个 id,dsh web 启动即报“重复 ID”崩溃。
643
- * 因此本命令【绝不写用户 include】,只负责:让插件以 bundle 形态可解析,
644
- * 并清理历史遗留的 include 块与旧名(≤0.4.9 dsh-remote-ui)痕迹。
645
- * 市场形态(github:/npm 依赖)则完全交由市场管理,只清理 include。
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
- stripInclude("插件市场安装形态无需用户 include");
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
- console.log(`✅ 插件已拷贝到 ${pluginLocalDir}`);
679
- declarePluginDep(pkgFile); // file: 依赖(包管理器 install 不误删)
680
- const addedBundle = ensureBundleEntry(pkgFile); // bundles 声明 → 插件自带 patch 自动激活
681
- const linked = ensurePluginLinked(profileDir, pluginLocalDir); // 自建 node_modules 链接
682
- stripInclude("bundle patch 已是唯一激活点"); // 清历史 include
683
- console.log(addedBundle
684
- ? `✅ 已加入 dsh.profile.bundles(${PLUGIN_ID} 自带 patch 自动生效)`
685
- : `ℹ dsh.profile.bundles 已含 ${PLUGIN_ID}`);
686
- console.log(linked
687
- ? `✅ 已建立 node_modules/${PLUGIN_ID} → ${pluginLocalDir}(免包管理器即可解析)`
688
- : `✅ node_modules/${PLUGIN_ID} 已就绪`);
689
- console.log(`✅ 插件安装完成(bundle 形态,无用户 include)。配置目录: ${CONFIG_DIR}`);
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 profileIdx = argv.indexOf("--profile");
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
- const newPatch = stripPluginEntries(patch);
723
- if (newPatch !== patch) {
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
- // 安装:收敛到“恰好一处激活”(bundle patch 唯一激活点),绝不与市场/历史 include 并存
751
- convergePluginActivation(profileDir, pkgFile, patchFile, pluginDir, patch);
1198
+ // 安装:收敛到"恰好一处激活"(热挂载 patch 行 或 bundles,二选一),绝不与市场/历史 include 并存
1199
+ //
1200
+ // ⚠️ 必须 **return 结果**:setup() 依赖它判断激活形态(false/undefined = 走 bundles,需重启),
1201
+ // 少了这个 return,安装后就不会去验证热挂载,白白多报一次"需要重启"(实测踩到)。
1202
+ return convergePluginActivation(profileDir, pkgFile, patchFile, pluginDir, patch);
752
1203
 
753
- console.log(" 打开 dsh web → 设置 → 「远程控制」,注册/登录手机号即可(无需任何命令)。");
754
- console.log(" 若从 DeepSeek App/插件市场 安装:请完全退出并重开 App 让插件生效。");
1204
+ // 引导语只在 setup 汇总里打印一次(这里不再重复;单独跑 `dsh-remote plugin` 也无需引导)
755
1205
  }
756
1206
 
757
1207
  // ---------- main ----------