@mrrisega/dsh-remote 0.6.18 → 0.7.0-beta.2

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.
@@ -340,7 +340,7 @@ export class WeChatRuntime {
340
340
  /**
341
341
  * @param {{
342
342
  * relayDir: string,
343
- * upstream?: string, // dsh web 地址,默认 http://127.0.0.1:3080
343
+ * upstream?: string|(() => string), // dsh web 地址,默认 http://127.0.0.1:3080;函数=每次重连重新求值
344
344
  * cookieOf?: () => string, // 读 .harness-cookie.json 的回调(bridge 已有该逻辑)
345
345
  * secret?: string, // 控制面密钥(bridge_secret);空则控制面拒绝所有请求
346
346
  * logger?: any,
@@ -356,6 +356,16 @@ export class WeChatRuntime {
356
356
  if (!opts.relayDir) throw new Error("WeChatRuntime: 缺少 relayDir");
357
357
  this.relayDir = opts.relayDir;
358
358
  this.upstream = String(opts.upstream || "http://127.0.0.1:3080").replace(/\/+$/, "");
359
+ /**
360
+ * 上游地址的**取值器**:恰好是字符串时是常量函数。
361
+ *
362
+ * 为什么不让它是个固化字符串:宿主端口会变(`dsh web --port 0`、DSH Desktop 每次启动随机端口),
363
+ * 而事件订阅器是**长连接** —— 固化字符串的后果是端口一变就永久重连到旧端口。
364
+ * 【2026-10-09 真机实证】bridge 启动时宿主还是 3080,随后换成 19387;隧道转发侧
365
+ * (每次都读最新 UPSTREAM)一切正常,唯独微信事件通道死循环刷
366
+ * `connect ECONNREFUSED 127.0.0.1:3080` —— 手机能开页面,但**任务完成没有任何推送**。
367
+ */
368
+ this.upstreamOf = typeof opts.upstream === "function" ? opts.upstream : () => this.upstream;
359
369
  this.cookieOf = typeof opts.cookieOf === "function" ? opts.cookieOf : () => "";
360
370
  this.secret = String(opts.secret || "");
361
371
  this.clock = opts.clock || Date.now;
@@ -1597,7 +1607,8 @@ export class WeChatRuntime {
1597
1607
  // 而且完全静默。现在无论订阅器是新建还是注入,都保证挂上处理器(WeakSet 去重,可重复调用)。
1598
1608
  if (!this.subscriber) {
1599
1609
  this.subscriber = createEventSubscriber({
1600
- upstream: this.upstream,
1610
+ // 传**取值器**而不是当次的值:见构造函数里 upstreamOf 的说明(端口变了要能跟上)。
1611
+ upstream: () => this.upstreamOf(),
1601
1612
  cookie: () => this.cookieOf(),
1602
1613
  follow: true,
1603
1614
  log: (level, message, meta) => {
@@ -173,6 +173,12 @@ function printHelp() {
173
173
  dsh-remote doctor 一条命令自检「为什么连不上」(只读;含上游/进程/微信/日志尾部)
174
174
  dsh-remote --help 显示本用法(等同 dsh-remote help)
175
175
 
176
+ 目标 profile(默认 Web 版):
177
+ dsh-remote --profile desktop 装进 DeepSeek Harness **桌面端**的插件目录
178
+ (~/.dsh/profiles/desktop;装前请先完全退出桌面应用)
179
+ 桌面端也可以在应用内「插件」页直接安装 dsh-remote-web —— 那是官方路径,无需退出。
180
+ ⚠️ 桌面端与 Web 版共享 ~/.dsh(会话/设置/凭据),但**插件互相隔离**,两边要各自安装。
181
+
176
182
  自建模式(可选):
177
183
  dsh-remote setup --server wss://你的域名:端口 --key 访问密钥
178
184
 
@@ -239,19 +245,109 @@ function resolveExecutable(p) {
239
245
  } catch { return null; }
240
246
  }
241
247
 
248
+ /**
249
+ * 宿主是否跑在 **Electron 的 Node 模式**下(DeepSeek Harness 桌面端的 Host 就是)。
250
+ *
251
+ * 桌面端 `process.execPath` 是 **Electron 应用二进制**而不是 node:不带
252
+ * `ELECTRON_RUN_AS_NODE=1` 直接拿它跑脚本,得到的不是「跑脚本的 node」,而是
253
+ * **又启动一个桌面端应用实例**。自启动服务是系统在应用退出后拉起的,踩到这里
254
+ * 的表现是「服务登记成功、进程秒退、bridge 永远起不来」,与 0.6.15 那次端口事故
255
+ * 一样难排查 —— 所以必须在**写服务定义时**就把变量带上。
256
+ */
257
+ const IS_ELECTRON_HOST = Boolean(process.versions && process.versions.electron);
258
+
259
+ /**
260
+ * DeepSeek Harness **桌面端自带 Node** 的稳定落点候选。
261
+ *
262
+ * 为什么需要:桌面端主打的正是**机器上没有系统 node** 的用户;而自启动服务要活过
263
+ * 桌面端的退出、甚至升级/重装(`.app` 路径会变、可能被换掉)。桌面端把自带运行时装在
264
+ * **用户目录** `~/.dsh/dsh-runtimes/…`,它不随应用包更新而移动 —— 所以只要它存在,
265
+ * 就该优先于任何指向 `.app` 内部的路径。
266
+ *
267
+ * 目录布局随桌面端版本演进(可能多套一层 runtime/node),故按多个候选探测;
268
+ * `resolveExecutable()` 会过滤掉不存在的路径,多列无害。
269
+ * @returns {string[]} 候选可执行文件绝对路径(可能都不存在)。
270
+ */
271
+ function desktopRuntimeNodeCandidates() {
272
+ const isWin = process.platform === "win32";
273
+ const exe = isWin ? "node.exe" : "node";
274
+ const out = [];
275
+ // ① 稳定落点:装在**用户目录**,桌面端升级/重装都不动它。
276
+ // ⚠️ 真实布局是 `dependencies/node/bin/<exe>`(2026-10-07 拆官方 0.2.0-rc.2 DMG 实测:
277
+ // `…/Resources/runtime/primary-runtime/dependencies/node/bin/node` → Node v24.21.0)。
278
+ // 其余几个是兼容不同版本的兜底;resolveExecutable() 会过滤掉不存在的路径,多列无害。
279
+ const roots = [
280
+ process.env.DSH_DESKTOP_RUNTIME_DIR || "",
281
+ path.join(os.homedir(), ".dsh", "dsh-runtimes", "dsh-primary-runtime"),
282
+ path.join(os.homedir(), ".dsh", "dsh-runtimes", "primary-runtime"),
283
+ ].filter(Boolean);
284
+ for (const root of roots) {
285
+ out.push(
286
+ path.join(root, "dependencies", "node", "bin", exe),
287
+ path.join(root, "bin", exe),
288
+ path.join(root, "node", "bin", exe),
289
+ path.join(root, "runtime", "bin", exe),
290
+ );
291
+ }
292
+ // ② 应用自己把自带 node 的路径交给子进程(其 `runtime/bin/node` shim 就是靠这个变量)——
293
+ // 平台无关,且是"应用自己说的",比我们猜路径可靠。
294
+ if (process.env.DSH_DESKTOP_NODE_EXECUTABLE) out.push(process.env.DSH_DESKTOP_NODE_EXECUTABLE);
295
+ // ③ 最后才是指向应用包内部的绝对路径:它是**真实存在的 node**,但会随桌面端更新换路径,
296
+ // 所以排在稳定落点之后(自启动服务登记它属于"聊胜于无",好过登记 Electron 本体)。
297
+ if (isWin) {
298
+ const local = process.env.LOCALAPPDATA || "";
299
+ if (local) out.push(path.join(local, "Programs", "DeepSeek Harness", "resources", "runtime", "primary-runtime", "dependencies", "node", exe));
300
+ } else {
301
+ out.push(
302
+ "/Applications/DeepSeek Harness.app/Contents/Resources/runtime/primary-runtime/dependencies/node/bin/node",
303
+ "/Applications/DSH Desktop.app/Contents/Resources/runtime/primary-runtime/dependencies/node/bin/node",
304
+ );
305
+ }
306
+ return out;
307
+ }
308
+
242
309
  function preferredNode() {
310
+ // 先归一化"自己的可执行文件",用来判断最终选中的解释器是否就是 Electron 本体。
311
+ const selfReal = resolveExecutable(process.execPath) || process.execPath;
243
312
  const candidates = [
244
313
  process.env.DSH_SETUP_NODE20 || "",
245
314
  process.env.DSH_SETUP_NODE || "",
315
+ // 桌面端(Electron 宿主):**真实 node 优先**。服务要活过桌面应用的退出与更新
316
+ //(.app 路径会变、可能被换掉),登记一个真实 node 才稳;找不到才回落 Electron 自身,
317
+ // 那时由 SERVICE_NODE_ENV 补上 ELECTRON_RUN_AS_NODE=1 把它变成 node。
318
+ ...(IS_ELECTRON_HOST
319
+ ? [
320
+ // 桌面端自带 Node 的**稳定**落点:装在用户目录(~/.dsh/dsh-runtimes/…),
321
+ // 桌面端升级/重装都不动它 —— 比 .app 内部路径更该登记进自启动服务。
322
+ ...desktopRuntimeNodeCandidates(),
323
+ "/opt/homebrew/opt/node@20/bin/node", "/usr/local/opt/node@20/bin/node", "/opt/homebrew/bin/node", "/usr/local/bin/node",
324
+ ]
325
+ : []),
246
326
  process.execPath
247
327
  ].filter(Boolean);
248
328
  for (const p of candidates) {
249
329
  const real = resolveExecutable(p);
250
- if (real) return real;
251
- }
252
- return process.execPath;
330
+ if (real) return { bin: real, electron: IS_ELECTRON_HOST && real === selfReal };
331
+ }
332
+ return { bin: process.execPath, electron: IS_ELECTRON_HOST };
333
+ }
334
+ const INTERPRETER = preferredNode();
335
+ const NODE_BIN = INTERPRETER.bin;
336
+ /** 解释器是 Electron 二进制时必须注入的变量(真实 node 时为空对象,行为与本改动前一致)。 */
337
+ const SERVICE_NODE_ENV = INTERPRETER.electron ? { ELECTRON_RUN_AS_NODE: "1" } : {};
338
+ /** plist `EnvironmentVariables` 里追加的条目(真实 node 时为空串 → 模板逐字节不变)。 */
339
+ const SERVICE_NODE_ENV_PLIST = Object.entries(SERVICE_NODE_ENV)
340
+ .map(([k, v]) => `<key>${k}</key><string>${v}</string>`).join("");
341
+ /** systemd unit 里追加的 `Environment=` 行(真实 node 时为空串 → 模板逐字节不变)。 */
342
+ const SERVICE_NODE_ENV_UNIT = Object.entries(SERVICE_NODE_ENV)
343
+ .map(([k, v]) => `Environment=${k}=${v}\n`).join("");
344
+ /**
345
+ * 供**子进程 spawn** 用的环境(继承当前环境 + 解释器变量)。
346
+ * 本文件里凡是用 NODE_BIN 拉起子进程的地方都要带上它。
347
+ */
348
+ function nodeEnv(extra) {
349
+ return { ...process.env, ...SERVICE_NODE_ENV, ...(extra || {}) };
253
350
  }
254
- const NODE_BIN = preferredNode();
255
351
 
256
352
  /**
257
353
  * 自启动服务里注入的 PATH。
@@ -516,10 +612,17 @@ function writeWinHiddenLauncher(node, setup) {
516
612
  const vbsPath = winHiddenLauncherPath();
517
613
  const commandLine = `"${node}" "${setup}" run`; // 真正要执行的命令行
518
614
  const literal = commandLine.replace(/"/g, '""'); // 嵌进 VBS 字符串字面量后
615
+ // 解释器是 Electron(桌面端宿主且机器上没有真实 node)时,必须先设 ELECTRON_RUN_AS_NODE=1:
616
+ // 否则 wscript 拉起的不是 bridge 守护,而是**又开一个桌面端应用**。
617
+ // VBScript 侧的写法是往进程环境里塞,再 Run(不能像 shell 那样内联前缀)。
618
+ const envLines = Object.entries(SERVICE_NODE_ENV).map(([k, v]) => `sh.Environment("PROCESS")("${k}") = "${v}"`);
619
+ const runLines = envLines.length > 0
620
+ ? [`Set sh = CreateObject("WScript.Shell")`, ...envLines, `sh.Run "${literal}", 0, False`]
621
+ : [`CreateObject("WScript.Shell").Run "${literal}", 0, False`];
519
622
  const src = [
520
623
  "' dsh-remote 自动生成(勿手改,重装会覆盖)",
521
624
  "' 隐藏启动 bridge 守护:窗口样式 0 = 不显示窗口;False = 不等待,node 成为独立进程。",
522
- `CreateObject("WScript.Shell").Run "${literal}", 0, False`,
625
+ ...runLines,
523
626
  "",
524
627
  ].join("\r\n");
525
628
  fs.mkdirSync(CONFIG_DIR, { recursive: true });
@@ -532,6 +635,12 @@ function writeWindowsTask() {
532
635
  const setup = runtimeSetupPath();
533
636
  // 首选 wscript 包装(无黑窗);写不了 .vbs 就退回直接启动(有窗,但保底能自启)
534
637
  let direct = `"${node}" "${setup}" run`;
638
+ // 解释器是 Electron 时,直接启动这条路没有 .vbs 可以设环境变量,只能借 cmd 的 `set`
639
+ // (schtasks 的 /TR 只接受一条命令行,不能内联 env)。
640
+ if (Object.keys(SERVICE_NODE_ENV).length > 0) {
641
+ const sets = Object.entries(SERVICE_NODE_ENV).map(([k, v]) => `set ${k}=${v}`).join(" && ");
642
+ direct = `cmd.exe /c ${sets} && "${node}" "${setup}" run`;
643
+ }
535
644
  let tr = direct;
536
645
  let hidden = false;
537
646
  try {
@@ -599,7 +708,7 @@ function writeAutostartFile() {
599
708
  <key>LimitLoadToSessionType</key><array><string>Aqua</string><string>Background</string></array>
600
709
  <key>StandardOutPath</key><string>${path.join(CONFIG_DIR, ".dsh-bridge.log")}</string>
601
710
  <key>StandardErrorPath</key><string>${path.join(CONFIG_DIR, ".dsh-bridge.log")}</string>
602
- <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>
711
+ <key>EnvironmentVariables</key><dict><key>PATH</key><string>${SERVICE_PATH}</string>${SERVICE_NODE_ENV_PLIST}<key>DSH_BRIDGE_INSTALL_SOURCE</key><string>${INSTALL_SOURCE}</string><key>DSH_BRIDGE_INSTALL_VERSION</key><string>${INSTALL_VERSION}</string></dict>
603
712
  </dict></plist>`;
604
713
  // ~/Library/LaunchAgents 在全新账户/精简系统上可能不存在,必须先建目录,
605
714
  // 否则 writeFileSync 抛 ENOENT 直接把安装流程打断(用户反馈里也踩过类似的路径问题)。
@@ -615,7 +724,7 @@ function writeAutostartFile() {
615
724
  if (process.platform === "linux") {
616
725
  const dir = path.join(os.homedir(), ".config/systemd/user");
617
726
  fs.mkdirSync(dir, { recursive: true });
618
- 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`;
727
+ 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}\n${SERVICE_NODE_ENV_UNIT}Environment=DSH_BRIDGE_INSTALL_SOURCE=${INSTALL_SOURCE}\nEnvironment=DSH_BRIDGE_INSTALL_VERSION=${INSTALL_VERSION}\n\n[Install]\nWantedBy=default.target\n`;
619
728
  const unitPath = autostartFilePath();
620
729
  fs.writeFileSync(unitPath, unit);
621
730
  return unitPath; // 路径在最终汇总里统一展示
@@ -673,7 +782,7 @@ function spawnDetachedBridge() {
673
782
  const child = spawn(NODE_BIN, [runtimeSetupPath(), "run"], {
674
783
  detached: true,
675
784
  stdio: ["ignore", out, out],
676
- env: process.env,
785
+ env: nodeEnv(),
677
786
  // Windows 上 detached 即 DETACHED_PROCESS:子进程**不继承父控制台**,
678
787
  // 控制台程序(node.exe)会因此拿到一个**全新的可见窗口**。
679
788
  // 必须显式 windowsHide 才会带上 CREATE_NO_WINDOW(POSIX 上该选项被忽略)。
@@ -894,14 +1003,63 @@ async function isDshWebUp(timeoutMs = 1200) {
894
1003
  return upstreamReachable(after, timeoutMs);
895
1004
  }
896
1005
 
1006
+ /** 已知的 DSH profile 名(`--profile web` / `--profile desktop` 这种写法要能用)。 */
1007
+ const KNOWN_PROFILE_NAMES = ["web", "desktop"];
1008
+ /** profile 名 → 目录(与 dsh 自己的 `$DSH_HOME/profiles/<name>` 口径一致)。 */
1009
+ function profileDirOfName(name) {
1010
+ return path.join(os.homedir(), ".dsh", "profiles", name);
1011
+ }
1012
+ /** 目标目录是否就是**桌面端**保留的 profile(由 Electron 应用独占管理)。 */
1013
+ function isDesktopProfileDir(dir) {
1014
+ return path.resolve(dir) === path.resolve(profileDirOfName("desktop"));
1015
+ }
1016
+
897
1017
  /**
898
- * 解析 dsh web profile 目录(安装与插件管理共用同一口径)。
1018
+ * 解析目标 profile 目录(安装与插件管理共用同一口径)。
1019
+ *
1020
+ * 三种写法都支持:
1021
+ * · `--profile web` / `--profile desktop` → `~/.dsh/profiles/<name>`
1022
+ * (新增:DeepSeek Harness 桌面端的 profile 就叫 `desktop`)
1023
+ * · `--profile <任意路径>` → 原样当目录用(老行为,保持不变)
1024
+ * · `DSH_PROFILE_DIR` 环境变量 → 与 `--profile` 同口径
1025
+ * 缺省仍是 Web 版 profile,行为与改动前完全一致。
899
1026
  */
900
1027
  function resolveProfileDir(argv = []) {
901
1028
  const i = argv.indexOf("--profile");
902
- return i > -1 && argv[i + 1]
903
- ? argv[i + 1]
904
- : process.env.DSH_PROFILE_DIR || path.join(os.homedir(), ".dsh", "profiles", "web");
1029
+ const explicit = i > -1 && argv[i + 1] ? argv[i + 1] : process.env.DSH_PROFILE_DIR;
1030
+ if (!explicit) return profileDirOfName("web");
1031
+ if (KNOWN_PROFILE_NAMES.includes(explicit)) return profileDirOfName(explicit);
1032
+ return explicit;
1033
+ }
1034
+
1035
+ /**
1036
+ * 从任意 argv 里挑出 profile 选择参数。
1037
+ *
1038
+ * 为什么需要:安装主流程 `setup()` 的 argv 里可能带 `--profile desktop`,但它内部调用
1039
+ * `pluginCmd()` 时原本传的是空数组 —— 于是"指定 desktop profile"会被静默忽略、
1040
+ * 插件仍然装进 Web 版 profile(桌面端用户装了却看不到面板,且极难自查)。
1041
+ */
1042
+ function profileArgvOf(argv = []) {
1043
+ const i = argv.indexOf("--profile");
1044
+ return i > -1 && argv[i + 1] ? ["--profile", argv[i + 1]] : [];
1045
+ }
1046
+
1047
+ /**
1048
+ * 目标 profile 是桌面端时给出的前置提醒(安装前调用)。
1049
+ *
1050
+ * 桌面端的 `profiles/desktop` 由 Electron 应用**独占**:应用运行时它会持有 profile 写锁,
1051
+ * 且运行中的进程不会重新装载我们刚写进去的插件。所以要么完全退出应用再装,要么
1052
+ * 直接在应用内的「插件」页安装(那是官方路径,无需退出)。
1053
+ * 这里只做提醒,不阻断 —— 用户可能正是在"应用已退出"的状态下跑安装器。
1054
+ */
1055
+ function warnIfDesktopProfile(dir) {
1056
+ if (!isDesktopProfileDir(dir)) return;
1057
+ console.log("");
1058
+ console.log("ℹ️ 目标 profile 是 DeepSeek Harness **桌面端**的保留 profile(~/.dsh/profiles/desktop)。");
1059
+ console.log(" · 请先**完全退出** DeepSeek Harness 应用再安装(应用独占该 profile,运行中写入不会被装载);");
1060
+ console.log(" · 装完后重新打开应用即可。更省事的做法是在应用内「插件」页直接安装 dsh-remote-web;");
1061
+ console.log(" · 桌面端与 Web 版共用 ~/.dsh 下的会话/设置/凭据,但**插件互相隔离**,两边需要各自安装。");
1062
+ console.log("");
905
1063
  }
906
1064
 
907
1065
  /**
@@ -1032,7 +1190,7 @@ logLine("已重新拉起 dsh web:pid=" + String(child.pid));
1032
1190
  try {
1033
1191
  fs.mkdirSync(CONFIG_DIR, { recursive: true });
1034
1192
  fs.writeFileSync(helper, src);
1035
- const child = spawn(NODE_BIN, [helper], { detached: true, stdio: "ignore", cwd: os.homedir(), windowsHide: true });
1193
+ const child = spawn(NODE_BIN, [helper], { detached: true, stdio: "ignore", cwd: os.homedir(), windowsHide: true, env: nodeEnv() });
1036
1194
  child.on("error", () => { /* 有 error 监听:绝不能把宿主/安装器打挂 */ });
1037
1195
  child.unref();
1038
1196
  return { ok: true, how: "Windows 重启助手(原命令行自拉起)" };
@@ -1357,7 +1515,7 @@ async function runBridge() {
1357
1515
  // 只有它能让我们在停 bridge 之前请它跑完退出钩子(notifystop)—— 见 stopChildGracefully。
1358
1516
  bridgeProc = spawn(NODE_BIN,
1359
1517
  [path.join(THIS_DIR, "clients/dsh-remote/dsh-bridge.mjs")],
1360
- { env: childEnv, stdio: ["inherit", "inherit", "inherit", "ipc"] });
1518
+ { env: nodeEnv(childEnv), stdio: ["inherit", "inherit", "inherit", "ipc"] });
1361
1519
  bridgeProc.on("exit", () => {
1362
1520
  removePidFile(BRIDGE_PID_FILE);
1363
1521
  console.log("[dsh-remote] bridge 退出,等待重启...");
@@ -1480,7 +1638,10 @@ async function setup(argv) {
1480
1638
  let pluginResult = null;
1481
1639
  if (!noPlugin) {
1482
1640
  try {
1483
- pluginResult = await pluginCmd([]);
1641
+ // ⚠️ 必须把 `--profile` **透传下去**:否则 `--profile desktop`(桌面端)会被静默忽略,
1642
+ // 插件装进 Web 版 profile,而用户面对的是桌面端 —— 面板永远不出现。
1643
+ warnIfDesktopProfile(resolveProfileDir(argv));
1644
+ pluginResult = await pluginCmd(profileArgvOf(argv));
1484
1645
  } catch (e) {
1485
1646
  console.warn(`⚠️ 插件安装未完成:${e.message}`);
1486
1647
  }
@@ -2178,7 +2339,7 @@ function probePluginResolve(profileDir) {
2178
2339
  "}));",
2179
2340
  ].join("\n");
2180
2341
  try {
2181
- const r = spawnSync(process.execPath, ["-e", script], { encoding: "utf8", timeout: 30_000, windowsHide: true });
2342
+ const r = spawnSync(process.execPath, ["-e", script], { encoding: "utf8", timeout: 30_000, windowsHide: true, env: nodeEnv() });
2182
2343
  if (r.error) return { ok: false, reason: `无法启动解析探测进程:${r.error.message}` };
2183
2344
  if (r.status !== 0) {
2184
2345
  const line = String(r.stderr || "").split("\n").find((l) => /MODULE_NOT_FOUND|ERR_|Error/.test(l)) || "解析失败";
@@ -2452,8 +2613,19 @@ async function pluginCmd(argv) {
2452
2613
  const pluginDir = path.join(THIS_DIR, "packages", PLUGIN_ID);
2453
2614
 
2454
2615
  if (!fs.existsSync(pkgFile) || !fs.existsSync(patchFile)) {
2455
- console.error(`❌ 未找到 dsh web profile(${profileDir})。`);
2456
- console.error(" 请先安装 DeepSeek Harness(npx @deepseek-ai/dsh web)并初始化默认 profile。");
2616
+ console.error(`❌ 未找到 profile(${profileDir})。`);
2617
+ if (isDesktopProfileDir(profileDir)) {
2618
+ // 桌面端 profile 由 Electron 应用**自己初始化**:npm 版 `dsh` 明确拒绝管理它
2619
+ // (apps/cli 的 rejectElectronProfile),所以 Web 版那句 "npx @deepseek-ai/dsh web"
2620
+ // 在桌面端**不成立**,照抄只会让用户白折腾。这里必须给桌面端专属的三步。
2621
+ console.error(" 桌面端 profile 由 DeepSeek Harness 应用自己创建,不能像 Web 版那样用 npx 造出来:");
2622
+ console.error(" ① 先启动一次 DeepSeek Harness 应用,让它初始化该 profile;");
2623
+ console.error(" ② 完全退出应用(应用独占该 profile,运行中安装不会被装载);");
2624
+ console.error(" ③ 再执行本次安装:dsh-remote --profile desktop");
2625
+ console.error(" 更省事的官方路径:直接在应用内「插件」页安装 dsh-remote-web。");
2626
+ } else {
2627
+ console.error(" 请先安装 DeepSeek Harness(npx @deepseek-ai/dsh web)并初始化默认 profile。");
2628
+ }
2457
2629
  process.exit(1);
2458
2630
  }
2459
2631
  if (!fs.existsSync(pluginDir)) {
@@ -0,0 +1,269 @@
1
+ /**
2
+ * 桌面端(DeepSeek Harness Desktop / Electron)适配回归。
3
+ *
4
+ * 背景:DeepSeek Harness 于 2026-09-29 发布桌面端 v0.2 预览(Electron 薄壳 + 完整 dsh Web Host,
5
+ * Host 以 `--no-open --port 0` 启动,端口随机、每进程令牌鉴权)。dsh-remote 的两大难点
6
+ * (随机端口、会话 Cookie)此前已因真实事故适配过,因此这次新增的适配**集中在三处**:
7
+ *
8
+ * ① **Electron 宿主下 process.execPath 不是 node**:它是 Electron 应用二进制。
9
+ * 直接 `spawn(process.execPath, [script])` 得到的不是"跑脚本的 node",而是**又开一个桌面端应用**
10
+ * (还会撞桌面端单实例锁)。必须补 `ELECTRON_RUN_AS_NODE=1` —— 官方桌面端自己也正是这么做的
11
+ * (其内置 CLI 就是 `ELECTRON_RUN_AS_NODE=1 exec <Electron> --expose-internals <cli.js>`)。
12
+ * ② **自启动服务里的解释器必须活过桌面应用的退出与更新**:`.app` 路径会变、可能被换掉,
13
+ * 所以"真实 node 优先,找不到才回落 Electron 自身(并带上 ① 的变量)"。
14
+ * ③ **重启语义不同**:桌面端 Host 由 Electron 托管,插件杀掉它会触发桌面端**原生致命恢复弹窗**,
15
+ * 用户观感是"应用崩了"。故 restartHarness 在 Electron 宿主下**有意拒绝**并如实说明,
16
+ * 面板据此不摆一个点了必然失败的按钮。
17
+ *
18
+ * ⚠️ 这些分支无法在开发机上"真装一个桌面端"来跑,所以本用例用两种手段:
19
+ * · 对纯函数(spawnEnv / resolveProfileDir)用**行为断言**(切片 eval,注入宿主相关常量);
20
+ * · 对散落在多文件的模板与分支用**锚点不变量**(跨文件必须同构,防漂移)。
21
+ */
22
+ import assert from "node:assert/strict";
23
+ import { readFileSync } from "node:fs";
24
+ import path from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+ import test from "node:test";
27
+
28
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
29
+ const INDEX_SRC = readFileSync(path.join(HERE, "..", "lib", "index.js"), "utf8").replace(/\r\n/g, "\n");
30
+ const CLIENT_SRC = readFileSync(path.join(HERE, "..", "lib", "client.js"), "utf8").replace(/\r\n/g, "\n");
31
+ const SETUP_SRC = readFileSync(path.join(HERE, "..", "..", "..", "dsh-setup.mjs"), "utf8").replace(/\r\n/g, "\n");
32
+
33
+ // ─────────────── ① 子进程环境:Electron 宿主必须补 ELECTRON_RUN_AS_NODE ───────────────
34
+
35
+ /**
36
+ * 以给定的宿主形态构造一个真实的 spawnEnv 并调用它。
37
+ * 切片只含 spawnEnv 函数体,故它引用的模块级常量/工具必须显式注入。
38
+ *
39
+ * ⚠️ 必须注入 **process 桩**(2026-10-09):`spawnEnv` 的第一件事是 `...process.env`,
40
+ * 而这个套件**是在 DSH 宿主里跑的** —— 从桌面端应用内跑 `npm run test:plugin` 时,
41
+ * 环境里天然带着 `ELECTRON_RUN_AS_NODE=1`(那正是"宿主是 Electron"的标志)。
42
+ * 于是"Web 版绝不得注入这个变量"那条断言会读到**宿主自己的**变量而必红 ——
43
+ * 可被测代码其实一个字节都没往 env 里加。注入干净环境后,这条用例检验的才是插件的行为
44
+ * (而不是"运行测试的这台机器是不是桌面端")。
45
+ *
46
+ * @param {object} selfNodeEnv 该宿主形态下 SELF_NODE_ENV 的值
47
+ * @param {object} [extra] 调用方业务变量
48
+ * @param {object} [sourceEnv] 宿主进程环境(默认干净:只含 PATH)
49
+ */
50
+ function makeSpawnEnv(selfNodeEnv, extra, sourceEnv) {
51
+ const start = INDEX_SRC.indexOf("function spawnEnv(extra)");
52
+ const end = INDEX_SRC.indexOf("function safeSpawn(");
53
+ assert.ok(start > 0 && end > start, "切片锚点失效:spawnEnv / safeSpawn 结构变了");
54
+ const src = INDEX_SRC.slice(start, end);
55
+ const make = new Function("SELF_NODE_ENV", "isWindows", "delimiter", "dirname", "process", "path",
56
+ `${src}\nreturn spawnEnv;`);
57
+ const processStub = { env: sourceEnv ?? { PATH: "/usr/bin:/bin" }, execPath: "/usr/bin/node" };
58
+ return make(selfNodeEnv, () => false, path.posix.delimiter, path.posix.dirname, processStub, path.posix)(extra);
59
+ }
60
+
61
+ test("桌面端:Electron 宿主下 spawnEnv 必须注入 ELECTRON_RUN_AS_NODE=1", () => {
62
+ const env = makeSpawnEnv({ ELECTRON_RUN_AS_NODE: "1" });
63
+ assert.equal(env.ELECTRON_RUN_AS_NODE, "1",
64
+ "缺这个变量时 spawn(process.execPath, [script]) 会变成'又开一个桌面端应用',bridge/watcher 永远起不来");
65
+ });
66
+
67
+ test("Web 版(真实 node):绝不得注入 ELECTRON_RUN_AS_NODE(不能污染既有环境)", () => {
68
+ const env = makeSpawnEnv({});
69
+ assert.ok(!("ELECTRON_RUN_AS_NODE" in env),
70
+ "非 Electron 宿主下注入该变量会污染子进程环境、并让 node 版本探测类工具困惑");
71
+ });
72
+
73
+ test("Web 版:宿主环境**已经带着** ELECTRON_RUN_AS_NODE 时,最终 env 与宿主逐键一致(插件没加料)", () => {
74
+ // 这条是上面那条的补充(2026-10-09):上面那条断言"结果里没有这个键",在 Electron 宿主里
75
+ // 跑测试时**天然不成立**(键来自宿主 process.env)。真正的判据是"插件有没有往 env 里加料",
76
+ // 故这里显式把宿主环境造成带这个变量,验证:最终 env **逐键等于宿主环境 + PATH 调整**,
77
+ // 一个多余的键都没有(`{}` 展开是恒等)。这样这条用例在任何宿主里都恒真且有意义。
78
+ const hostEnv = { PATH: "/usr/bin:/bin", ELECTRON_RUN_AS_NODE: "1", HOME: "/Users/tester" };
79
+ const env = makeSpawnEnv({}, undefined, hostEnv);
80
+ const expectedPath = ["/usr/bin", "/usr/bin:/bin", "/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"].join(":");
81
+ assert.deepEqual(env, { ...hostEnv, PATH: expectedPath },
82
+ "除 PATH 外不得新增/删除/改动任何键(否则就是插件在污染子进程环境)");
83
+ assert.equal(env.ELECTRON_RUN_AS_NODE, "1", "宿主自己的值原样透传,不算插件注入");
84
+ });
85
+
86
+ test("spawnEnv:NODE_INTERPRETER 变量不得挤掉调用方的业务变量,PATH 仍按平台分隔符拼接", () => {
87
+ const env = makeSpawnEnv({ ELECTRON_RUN_AS_NODE: "1" }, { DSH_BRIDGE_INSTALL_SOURCE: "npx" });
88
+ assert.equal(env.DSH_BRIDGE_INSTALL_SOURCE, "npx", "业务变量必须仍能覆盖");
89
+ assert.ok(env.PATH.includes(path.delimiter), "PATH 必须用平台分隔符(写死 ':' 会让 Windows 上 node/npx 全找不到)");
90
+ });
91
+
92
+ // ─────────────── ② 服务定义:plist / systemd 必须带上解释器变量(跨文件同构)───────────────
93
+
94
+ test("跨文件不变量:插件与安装器的 plist / systemd 模板都必须带解释器变量占位", () => {
95
+ // 为什么是"两份都要查":历史上前述两份模板漂移过、并因此各造成一个真机事故
96
+ // (插件每次自愈都会重写 plist,谁写谁说了算 → 漂移等于把另一份正确的覆盖掉)。
97
+ for (const [name, src] of [["lib/index.js", INDEX_SRC], ["dsh-setup.mjs", SETUP_SRC]]) {
98
+ assert.match(src, /const SERVICE_NODE_ENV\b/, `${name}: 必须有 SERVICE_NODE_ENV`);
99
+ assert.match(src, /<key>PATH<\/key><string>\$\{SERVICE_PATH\}<\/string>\$\{SERVICE_NODE_ENV_PLIST\}/,
100
+ `${name}: plist 的 EnvironmentVariables 必须紧跟 PATH 注入 SERVICE_NODE_ENV_PLIST`);
101
+ // ⚠️ systemd 模板两种写法都要认:lib/index.js 是多行模板(真换行),
102
+ // dsh-setup.mjs 是单行模板(字面量 `\n`)。只认其中一种就会漏掉另一份 —— 那正是"漂移"本身。
103
+ assert.match(src, /Environment=PATH=\$\{SERVICE_PATH\}(?:\n|\\n)\$\{SERVICE_NODE_ENV_UNIT\}Environment=DSH_BRIDGE/,
104
+ `${name}: systemd unit 必须在 PATH 之后注入 SERVICE_NODE_ENV_UNIT`);
105
+ }
106
+ });
107
+
108
+ test("桌面端:真实 node 存在时服务定义必须优先登记它(.app 路径会随更新失效)", () => {
109
+ for (const [name, src] of [["lib/index.js", INDEX_SRC], ["dsh-setup.mjs", SETUP_SRC]]) {
110
+ assert.match(src, /electron: IS_ELECTRON_HOST/,
111
+ `${name}: 解释器解析结果必须显式标注"是不是 Electron 二进制"(服务定义据此决定要不要补变量)`);
112
+ }
113
+ // 安装器额外要求:把"当前可执行文件"归一化后再比对,避免 symlink/node@20 造成误判
114
+ assert.match(SETUP_SRC, /const selfReal = resolveExecutable\(process\.execPath\)/,
115
+ "dsh-setup.mjs: 必须归一化自身可执行文件再判断是否为 Electron 本体");
116
+ });
117
+
118
+ // ─────────────── ③ 重启语义:桌面端必须拒绝,且如实说明 ───────────────
119
+
120
+ test("桌面端:restartHarness 在 Electron 宿主下**行为级**拒绝(不靠源码字符串)", async () => {
121
+ // 为什么需要行为级:源码锚点(下面那条)只能证明"代码里写了",证明不了"跑起来真的是这个结果"。
122
+ // 注入 IS_ELECTRON_HOST=true、skipsSystemOps=false(模拟**生产**:
123
+ // 桌面端下 DSH_RELAY_SKIP_SERVICE 不会设置,故必须走到 Electron 守卫而不是被测试隔离短路)。
124
+ const start = INDEX_SRC.indexOf("async function restartHarness(");
125
+ const end = INDEX_SRC.indexOf("async function relayFetch(");
126
+ assert.ok(start > 0 && end > start, "切片锚点失效:restartHarness 结构变了");
127
+ const src = INDEX_SRC.slice(start, end);
128
+ // 只注入守卫之前会被求值的两个依赖;函数体其余部分不会被走到(守卫提前 return),
129
+ // 故其引用的模块级标识符无需注入(new Function 只在**执行**时才解析标识符)。
130
+ const make = new Function("IS_ELECTRON_HOST", "skipsSystemOps", `${src}\nreturn restartHarness;`);
131
+ const restartHarness = make(true, () => false);
132
+ const r = await restartHarness("/tmp/does-not-matter");
133
+ assert.equal(r.ok, false, "桌面端重启必须如实返回失败,不得假装成功");
134
+ assert.equal(r.status, "desktop-managed", "面板据该状态改文案,不能是通用错误");
135
+ assert.equal(r.mode, "desktop");
136
+ assert.match(r.detail, /退出并重新打开/, "必须告诉用户正确做法");
137
+ // 对照组:非 Electron 宿主**不得**走这条分支(否则 Web 版重启会被误禁)
138
+ const restartWeb = make(false, () => true); // skipsSystemOps=true 模拟测试隔离,避免真的重启
139
+ const rw = await restartWeb("/tmp/does-not-matter");
140
+ assert.notEqual(rw.status, "desktop-managed", "Web 版绝不能返回桌面端状态");
141
+ });
142
+
143
+ test("桌面端:restartHarness 必须拒绝重启 Host,并返回可识别的 desktop-managed 状态", () => {
144
+ // 切片终点用 restartHarness **之后**的下一个顶层函数(buildRestartHelperSource 在它之前,不能用)
145
+ const fn = INDEX_SRC.slice(INDEX_SRC.indexOf("async function restartHarness("),
146
+ INDEX_SRC.indexOf("async function relayFetch("));
147
+ assert.ok(fn.length > 0, "切片锚点失效:restartHarness 找不到了");
148
+ assert.match(fn, /if \(IS_ELECTRON_HOST\)/, "必须先有 Electron 宿主守卫");
149
+ assert.match(fn, /status: "desktop-managed"/, "必须返回可识别状态(面板据此换文案、不摆失败按钮)");
150
+ assert.match(fn, /退出并重新打开 DeepSeek Harness/, "必须如实告诉用户正确做法");
151
+ // 守卫必须在真正生成重启脚本**之前**:否则仍会写脚本、动 launchd
152
+ assert.ok(fn.indexOf("if (IS_ELECTRON_HOST)") < fn.indexOf("const cmd = selfCommand();"),
153
+ "守卫必须早于 selfCommand()/脚本生成");
154
+ });
155
+
156
+ test("桌面端:状态接口必须下发宿主形态,面板才能自适应", () => {
157
+ assert.match(INDEX_SRC, /desktopHost: IS_ELECTRON_HOST,/, "composeStatus 必须下发 desktopHost");
158
+ assert.match(INDEX_SRC, /restartSupported: !IS_ELECTRON_HOST,/, "必须下发 restartSupported");
159
+ });
160
+
161
+ test("桌面端:面板不得自动倒计时重启,且不摆点了必然失败的重启按钮", () => {
162
+ assert.match(CLIENT_SRC, /var desktopHost = !!\(st && st\.desktopHost\)/, "面板必须读 desktopHost");
163
+ assert.match(CLIENT_SRC, /isRestartHard \|\| desktopHost \|\| busy !== ""/,
164
+ "自动倒计时重启必须在桌面端短路(Host 由 Electron 托管,不能替用户重启)");
165
+ assert.match(CLIENT_SRC, /\(desktopHost && !isRefreshOnly\) \? null : h\("button"/,
166
+ "桌面端且非'刷新即可'时不得渲染重启按钮");
167
+ // 旧版 host 不下发该字段 → undefined → 行为必须与改动前一致
168
+ assert.match(CLIENT_SRC, /\? "🔄 插件已更新:重启 dsh web 后生效"[\s\S]{0,40}: "🔄 插件已更新:刷新页面即可生效"\);/,
169
+ "非桌面端文案必须保留原有判据 isRestartHard(不得顺手改发布版文案)");
170
+ });
171
+
172
+ test("桌面端:面板文案改为引导'退出并重开应用',且不影响 Web 版", () => {
173
+ assert.match(CLIENT_SRC, /退出并重新打开 DeepSeek Harness 后生效/, "桌面端要有明确引导");
174
+ assert.match(CLIENT_SRC, /桌面端由应用本身托管运行环境/, "底部常驻按钮位置要有桌面端说明");
175
+ });
176
+
177
+ // ─────────────── ④ 安装:--profile desktop 必须真的生效 ───────────────
178
+
179
+ /**
180
+ * 用安装器切片构造真实的 resolveProfileDir(注入 os / path / process,伪造 HOME 与空环境)。
181
+ *
182
+ * ⚠️ 必须注入 **process 桩**(2026-10-09):`resolveProfileDir` 的第二来源是
183
+ * `process.env.DSH_PROFILE_DIR`,而这个测试套件**是在 DSH 宿主里跑的** ——
184
+ * 从桌面端应用内跑 `npm run test:plugin` 时,环境里天然带着
185
+ * `DSH_PROFILE_DIR=/Users/<me>/.dsh/profiles/desktop`,于是"缺省仍是 Web 版"那条断言
186
+ * 会读到真实 HOME 下的 desktop,**在任何装了桌面端的机器上必红**(假红,且指向错误结论)。
187
+ * 注空环境 = 这条用例从此只验证被测逻辑,不受宿主环境影响。
188
+ */
189
+ function makeResolveProfileDir(home) {
190
+ const start = SETUP_SRC.indexOf("const KNOWN_PROFILE_NAMES");
191
+ const end = SETUP_SRC.indexOf("function warnIfDesktopProfile");
192
+ assert.ok(start > 0 && end > start, "切片锚点失效:profile 解析函数结构变了");
193
+ const src = SETUP_SRC.slice(start, end);
194
+ const osStub = { homedir: () => home };
195
+ const processStub = { env: {} };
196
+ const make = new Function("os", "path", "process", `${src}\nreturn { resolveProfileDir, profileArgvOf, isDesktopProfileDir, profileDirOfName };`);
197
+ return make(osStub, path, processStub);
198
+ }
199
+
200
+ test("安装器:--profile desktop 解析到桌面端 profile 目录,不再是相对路径/被忽略", () => {
201
+ const { resolveProfileDir, isDesktopProfileDir } = makeResolveProfileDir("/Users/tester");
202
+ assert.equal(resolveProfileDir(["--profile", "desktop"]), path.join("/Users/tester", ".dsh", "profiles", "desktop"));
203
+ assert.ok(isDesktopProfileDir(resolveProfileDir(["--profile", "desktop"])), "必须能识别出这是桌面端 profile");
204
+ // 老行为必须原样保留
205
+ assert.equal(resolveProfileDir(["--profile", "web"]), path.join("/Users/tester", ".dsh", "profiles", "web"));
206
+ assert.equal(resolveProfileDir(["--profile", "/custom/dir"]), "/custom/dir", "任意路径仍原样当目录用");
207
+ assert.equal(resolveProfileDir([]), path.join("/Users/tester", ".dsh", "profiles", "web"), "缺省仍是 Web 版");
208
+ });
209
+
210
+ test("安装器:setup() 必须把 --profile 透传给插件挂载步骤", () => {
211
+ const { profileArgvOf } = makeResolveProfileDir("/Users/tester");
212
+ assert.deepEqual(profileArgvOf(["--profile", "desktop"]), ["--profile", "desktop"]);
213
+ assert.deepEqual(profileArgvOf([]), [], "未指定时不得凭空塞参数");
214
+ // 事故面:setup() 原本调 pluginCmd([]) → --profile desktop 被静默忽略、插件装进 web profile
215
+ assert.match(SETUP_SRC, /pluginCmd\(profileArgvOf\(argv\)\)/, "setup 必须透传 profile 选择");
216
+ assert.ok(!/pluginCmd\(\[\]\)/.test(SETUP_SRC), "不得再有硬编码空参数(会把 --profile 吞掉)");
217
+ });
218
+
219
+ test("安装器:desktop profile 未初始化时给桌面端专属引导(不得照抄 Web 版的 npx 说法)", () => {
220
+ // 用定长切片而不是尾部锚点:这段逻辑就在 pluginCmd 开头,之后的结构变化不该让用例变脆。
221
+ const start = SETUP_SRC.indexOf("async function pluginCmd(");
222
+ assert.ok(start > 0, "切片锚点失效:pluginCmd 找不到了");
223
+ const fn = SETUP_SRC.slice(start, start + 2500);
224
+ assert.match(fn, /isDesktopProfileDir\(profileDir\)/, "必须区分桌面端 profile");
225
+ assert.match(fn, /启动一次 DeepSeek Harness 应用/, "必须给桌面端专属引导:profile 由应用自己创建");
226
+ assert.match(fn, /完全退出应用/, "必须提醒完全退出(应用独占 profile,运行中安装不会被装载)");
227
+ // 事故面:桌面端 profile 由 Electron 应用初始化,npm 版 dsh 明确拒绝管理它
228
+ //(apps/cli 的 rejectElectronProfile)—— 照抄 Web 版那句会让用户白折腾。
229
+ assert.ok(!/未找到 dsh web profile/.test(fn), "不得再把桌面端统一报成 'dsh web profile'");
230
+ });
231
+
232
+ // ─────────────── ⑤ 桌面端自带 Node:跨平台候选(Windows 用 node.exe)───────────────
233
+
234
+ test("桌面端自带 Node 的候选:跨平台 + 命中官方真实布局 + 稳定落点优先", () => {
235
+ for (const [name, src] of [["lib/index.js", INDEX_SRC], ["dsh-setup.mjs", SETUP_SRC]]) {
236
+ assert.match(src, /function desktopRuntimeNodeCandidates\(\)/, `${name}: 必须有候选构造器`);
237
+ // 真实布局(2026-10-07 拆官方 0.2.0-rc.2 DMG 实测):
238
+ // …/Resources/runtime/primary-runtime/dependencies/node/bin/node (Node v24.21.0)
239
+ assert.match(src, /"dependencies", "node", "bin"/,
240
+ `${name}: 必须命中官方真实布局 dependencies/node/bin(猜错路径等于没这个候选)`);
241
+ assert.match(src, /DSH_DESKTOP_NODE_EXECUTABLE/,
242
+ `${name}: 必须采信应用自报的 node 路径(平台无关,比猜路径可靠)`);
243
+ assert.match(src, /\.\.\.desktopRuntimeNodeCandidates\(\)/,
244
+ `${name}: 必须真的并入解释器候选(不是死代码)`);
245
+ }
246
+
247
+ // 行为断言:注入平台与 HOME,验证真实产出(而不是只查字符串)
248
+ const start = SETUP_SRC.indexOf("function desktopRuntimeNodeCandidates()");
249
+ const end = SETUP_SRC.indexOf("function preferredNode()");
250
+ assert.ok(start > 0 && end > start, "切片锚点失效:候选构造器结构变了");
251
+ const src = SETUP_SRC.slice(start, end);
252
+ const make = new Function("process", "os", "path", `${src}\nreturn desktopRuntimeNodeCandidates;`);
253
+ const home = path.join(path.sep, "Users", "tester");
254
+ const win = make({ platform: "win32", env: {} }, { homedir: () => home }, path)();
255
+ const mac = make({ platform: "darwin", env: {} }, { homedir: () => home }, path)();
256
+
257
+ assert.ok(win.length > 0 && win.every((p) => p.endsWith("node.exe")),
258
+ `Windows 候选必须都是 node.exe(否则永远解析不到解释器),实际 ${JSON.stringify(win)}`);
259
+ assert.ok(mac.length > 0 && mac.every((p) => p.endsWith("node")),
260
+ `POSIX 候选必须都是 node,实际 ${JSON.stringify(mac)}`);
261
+ // 稳定落点优先:首条必须落在用户目录,且命中官方真实布局
262
+ assert.ok(mac[0].startsWith(home),
263
+ `首条候选必须在用户目录(要活过桌面端更新),实际 ${mac[0]}`);
264
+ assert.ok(mac[0].endsWith(path.join("dependencies", "node", "bin", "node")),
265
+ `首条候选必须是官方真实布局 dependencies/node/bin/node,实际 ${mac[0]}`);
266
+ // 会随桌面端更新失效的 .app 路径只能当后手
267
+ assert.ok(!mac[0].startsWith("/Applications"),
268
+ "不得把 .app 内部路径排在稳定落点之前(桌面端一升级自启动就失效)");
269
+ });