@mrrisega/dsh-remote 0.6.6 → 0.6.7-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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.6.6",
3
+ "version": "0.6.7-beta.2",
4
4
  "description": "公网远程控制 DeepSeek Harness(dsh web):安装即得专属加密地址,人在外面也能用手机访问电脑上的 dsh——无需同一局域网/WiFi、无需公网 IP 与内网穿透,全程加密;手机端 100% 还原电脑体验(对话/工具/审批/设置)。技术用户可选自建服务,流量走自己的服务器。Remote control DeepSeek Harness (dsh web) from anywhere over the public internet — install, get an encrypted URL, use it from your phone on any network (dual-half cordis plugin: Settings panel + same-origin /dsh-remote routes). (2026-09 由 dsh-remote-ui 更名 / renamed from dsh-remote-ui; dsh-remote 的 dsh web 插件半,不是纯 UI/皮肤插件)",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -1,4 +1,4 @@
1
- // 交流群二维码(运营配置)在插件侧的两处展示回归:
1
+ // 交流群二维码(后台配置)在插件侧的两处展示回归:
2
2
  // ① 设置面板:「💬 加入交流群」按钮 → 点击弹出二维码大图(未配置后台码 → 按钮不存在);
3
3
  // ② 用户反馈页:底部直接展示企微交流群二维码 + 文案。
4
4
  // 数据来源:node 半 GET /dsh-remote/community(读公开配置 community.qrcode 并拼绝对地址)。
@@ -1,6 +1,6 @@
1
1
  // 「登录后自动闭环」回归(node 半,0.6.4)。
2
2
  //
3
- // 现场(2026-09 生产数据,id 42~52 共 11 个真实注册用户):只有 3 人最终绑定了设备,其余 8 人的
3
+ // 现场观察:新注册用户里只有少数最终绑定了设备,其余的
4
4
  // 电脑端 bridge 从未连上过中继;多人反复点「生成访问链接」(auth.key_create 10~23 次)而
5
5
  // device.bind 始终为 0。典型路径:注册 → 手机端 /api/devices 拿到空列表 → 反复轮询 → 放弃。
6
6
  //
@@ -1,8 +1,8 @@
1
1
  // 「登录后自动闭环」UI 回归(浏览器半,0.6.4;真实 useEffect + 可控假定时器 + 可控 document.hidden)。
2
2
  //
3
- // 现场(2026-09 生产数据):注册后手机端一直是空设备列表,用户在设置面板反复点「生成访问链接」
4
- // (auth.key_create 10~23 次)却始终没有 device.bind——他们看不到「到底卡在哪一步」,也不知道
5
- // 要不要刷新页面。本用例锁死面板侧的自动闭环:
3
+ // 现场:注册后手机端一直是空设备列表,用户在设置面板反复点「生成访问链接」
4
+ // 却始终没有 device.bind——他们看不到「到底卡在哪一步」,也不知道要不要刷新页面。
5
+ // 本用例锁死面板侧的自动闭环:
6
6
  // ① 登录后立刻进入启动/连接流程(2.5s 短轮询自动推进,不需要点任何按钮);
7
7
  // ② 阶段推进到 online → UI 自动变成「已连接 ✅ 现在可以用手机扫码访问」,并自动重取二维码与
8
8
  // 已授权设备列表(全程不刷新页面);
@@ -0,0 +1,82 @@
1
+ /**
2
+ * 安装器命令行分派契约测试(行为级,真起进程)。
3
+ *
4
+ * 背景(2026-09-17 实测发现):`dsh-remote --help` 过去会**直接开始安装** ——
5
+ * 分派逻辑是「首个参数以 `-` 开头就当成 setup 的参数」,而 `--help` 不在任何分支里,
6
+ * 于是落到默认的 setup 路径:往 ~/.dsh-remote 同步运行时脚本、装插件、登记自启动。
7
+ * 用户想先看一眼用法,结果被装了一整套东西。
8
+ *
9
+ * 本用例锁死两条:
10
+ * ① `--help` / `-h` / `help` → 打印用法、退出码 0、**不碰磁盘**;
11
+ * ② 参数拼错(不认识的 flag)→ 明确报错、退出码 1、**也不碰磁盘**。
12
+ *
13
+ * ⚠️ 隔离:所有用例都伪造 HOME + DSH_PROFILE_DIR,并设 DSH_RELAY_SKIP_SERVICE=1,
14
+ * 否则会污染真实的自启动服务与 ~/.dsh-remote(见 setup-output.test.mjs 的事故复盘)。
15
+ */
16
+ import assert from "node:assert/strict";
17
+ import { execFile } from "node:child_process";
18
+ import fs from "node:fs";
19
+ import os from "node:os";
20
+ import path from "node:path";
21
+ import test from "node:test";
22
+ import { fileURLToPath } from "node:url";
23
+
24
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
25
+ const SETUP = path.join(HERE, "..", "..", "..", "dsh-setup.mjs");
26
+
27
+ /** 在隔离 HOME 下运行安装器,返回 { code, stdout, stderr, touched }。 */
28
+ function runInstaller(args) {
29
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "dsh-cli-"));
30
+ return new Promise((resolve) => {
31
+ execFile(
32
+ process.execPath,
33
+ [SETUP, ...args],
34
+ {
35
+ env: {
36
+ ...process.env,
37
+ HOME: home,
38
+ DSH_RELAY_SKIP_SERVICE: "1",
39
+ DSH_PROFILE_DIR: path.join(home, "prof"),
40
+ DSH_RELAY_DIR: path.join(home, ".dsh-remote")
41
+ },
42
+ timeout: 30_000
43
+ },
44
+ (err, stdout, stderr) => {
45
+ const touched = fs.existsSync(path.join(home, ".dsh-remote"));
46
+ fs.rmSync(home, { recursive: true, force: true });
47
+ resolve({ code: err ? err.code ?? 1 : 0, stdout, stderr, touched });
48
+ }
49
+ );
50
+ });
51
+ }
52
+
53
+ test("--help / -h / help:打印用法、退出码 0、且不落盘", async () => {
54
+ for (const flag of ["--help", "-h", "help"]) {
55
+ const r = await runInstaller([flag]);
56
+ assert.equal(r.code, 0, `${flag} 应以 0 退出`);
57
+ assert.match(r.stdout, /用法:/, `${flag} 应打印用法`);
58
+ assert.match(r.stdout, /dsh-remote status/, `${flag} 的用法应列出子命令`);
59
+ assert.equal(r.touched, false, `${flag} 不得在用户主目录创建任何东西`);
60
+ }
61
+ });
62
+
63
+ test("参数拼错:明确报错、退出码 1、也不落盘", async () => {
64
+ for (const flag of ["--bogus", "--dry-run", "--hepl"]) {
65
+ const r = await runInstaller([flag]);
66
+ assert.equal(r.code, 1, `${flag} 应以 1 退出`);
67
+ assert.match(r.stderr, /未知参数/, `${flag} 应明确说是未知参数`);
68
+ assert.match(r.stderr, /--help/, `${flag} 的报错应告诉用户怎么看用法`);
69
+ assert.equal(r.touched, false, `${flag} 不得开始安装`);
70
+ }
71
+ });
72
+
73
+ test("源码级:用法文本只有一处实现(避免提前退出与末尾分派两处漂移)", () => {
74
+ const src = fs.readFileSync(SETUP, "utf8");
75
+ const hits = src.match(/dsh-remote — 手机远程控制 dsh web/g) || [];
76
+ assert.equal(hits.length, 1, "用法文案应只出现一次(共用 printHelp())");
77
+ assert.match(src, /function printHelp\(\)/, "应有 printHelp()");
78
+ // 关键:提前退出必须挡在 ensureRuntimeCopy() 之前
79
+ const iHelp = src.indexOf("printHelp();\n process.exit(0);");
80
+ const iSync = src.indexOf("try { ensureRuntimeCopy(); }");
81
+ assert.ok(iHelp > -1 && iSync > -1 && iHelp < iSync, "--help 的提前退出必须在运行时同步之前");
82
+ });
@@ -0,0 +1,400 @@
1
+ /**
2
+ * 插件挂载(node_modules/<id> 链接)回归 —— 0.6.7-beta.2 新增。
3
+ *
4
+ * 背景(Windows 用户实测,2026-09-18):0.6.7-beta.1 的 `ensurePluginLinked` 用
5
+ * `fs.symlinkSync(target, path, "dir")` 建链接,并把 junction / 整目录拷贝两级兜底写在它的 `catch` 里。
6
+ * 在 **非特权进程 + 开发者模式关闭**(= 普通用户默认状态)下,`'dir'` 在 Windows 上
7
+ * **既不抛错也产不出可用链接**(实测:用户目录下留一个空目录,%TEMP% 下什么都不留;
8
+ * 同一位置两次运行一次报成功一次抛 UNKNOWN)。于是:
9
+ * · 报成功 → catch 不进入 → 两级兜底永不执行 → 留下空壳/什么都没有;
10
+ * · 报错 → catch 进入 → 但上一步已留空目录 → junction 撞 EEXIST → 只剩 cpSync 一支。
11
+ * 而调用方把返回值丢掉、patch 照写、还打印「✅ 插件已就绪」,最终
12
+ * dsh web 按裸包名解析到空目录 → `plugin tree failed to load` → **整个 dsh web 起不来**。
13
+ *
14
+ * ⚠️ 本文件存在的意义就是"上一版为什么漏测":老用例只断言"函数没抛错 / 返回 true",
15
+ * 而这里的断言对象是**通过 node_modules/<id> 这条路径到底能不能读到东西**,
16
+ * 并且用注入式 fs 代理**复现**了 Windows 那种"不抛错但落盘是空壳"的形态。
17
+ */
18
+ import assert from "node:assert/strict";
19
+ import { execFile } from "node:child_process";
20
+ import fs from "node:fs";
21
+ import os from "node:os";
22
+ import path from "node:path";
23
+ import test from "node:test";
24
+ import { createRequire } from "node:module";
25
+ import { fileURLToPath } from "node:url";
26
+
27
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
28
+ const SETUP = path.join(HERE, "..", "..", "..", "dsh-setup.mjs");
29
+ const PLUGIN_SRC = path.join(HERE, "..");
30
+ const PLUGIN_ID = "dsh-remote-web";
31
+ const SETUP_SRC = fs.readFileSync(SETUP, "utf8");
32
+
33
+ // ─────────────── 源码内取函数(安装器是 CLI 脚本,import 会直接执行 main) ───────────────
34
+
35
+ /** 取出 dsh-setup.mjs 里某个函数的源码文本。 */
36
+ function fnSrc(name, nextMarker) {
37
+ const start = SETUP_SRC.indexOf(`function ${name}(`);
38
+ const end = SETUP_SRC.indexOf(nextMarker, start);
39
+ assert.ok(start > -1 && end > start, `应能从安装器源码里取出 ${name}`);
40
+ return SETUP_SRC.slice(start, end);
41
+ }
42
+
43
+ /** 用真实 fs/path 组装出被测函数(纯函数,可安全求值)。 */
44
+ function loadLinkFns() {
45
+ const src = fnSrc("pluginLinkUsable", "/**\n * 把插件挂到")
46
+ + fnSrc("ensurePluginLinked", "/**\n * 独立验收:复刻 dsh 客户端模块系统的真实动作");
47
+ return new Function("fs", "path", "PLUGIN_ID", "PLUGIN_LEGACY_IDS",
48
+ `${src}; return { pluginLinkUsable, ensurePluginLinked };`)(fs, path, PLUGIN_ID, ["dsh-remote-ui"]);
49
+ }
50
+
51
+ /** 造一份"像真的"插件目录(package.json + lib/index.js 已是可用性判据的最低要求)。 */
52
+ function makePluginDir(root, { name = PLUGIN_ID, withEntry = true } = {}) {
53
+ fs.mkdirSync(path.join(root, "lib"), { recursive: true });
54
+ fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ name, version: "0.0.0-test" }));
55
+ if (withEntry) fs.writeFileSync(path.join(root, "lib", "index.js"), "export const x = 1;\n");
56
+ return root;
57
+ }
58
+
59
+ function tmpDir(prefix = "dsh-link-") {
60
+ return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
61
+ }
62
+
63
+ // ─────────────── ① 可用性判据必须否定所有畸形形态 ───────────────
64
+
65
+ test("pluginLinkUsable:空目录/畸形链接一律判为不可用(不能只看'没抛错')", () => {
66
+ const { pluginLinkUsable } = loadLinkFns();
67
+ const root = tmpDir();
68
+ try {
69
+ const local = makePluginDir(path.join(root, "local"));
70
+ const nm = path.join(root, "node_modules", PLUGIN_ID);
71
+ fs.mkdirSync(path.dirname(nm), { recursive: true });
72
+
73
+ assert.equal(pluginLinkUsable(nm, local), false, "不存在 → 不可用");
74
+
75
+ // 这就是本 bug 的落盘形态:一个同名空目录
76
+ fs.mkdirSync(nm, { recursive: true });
77
+ assert.equal(pluginLinkUsable(nm, local), false, "★空目录必须判为不可用(老实现正是在这里当作成功)");
78
+
79
+ // 有 package.json 但 name 不对(旧的别名包残留 / 别的包)
80
+ fs.writeFileSync(path.join(nm, "package.json"), JSON.stringify({ name: "dsh-remote-ui" }));
81
+ fs.mkdirSync(path.join(nm, "lib"), { recursive: true });
82
+ fs.writeFileSync(path.join(nm, "lib", "index.js"), "x");
83
+ assert.equal(pluginLinkUsable(nm, local), false, "name 不匹配 → 不可用(面板注入靠裸包名)");
84
+
85
+ // name 对了但没有节点半入口
86
+ fs.writeFileSync(path.join(nm, "package.json"), JSON.stringify({ name: PLUGIN_ID }));
87
+ fs.rmSync(path.join(nm, "lib", "index.js"));
88
+ assert.equal(pluginLinkUsable(nm, local), false, "缺 lib/index.js → 不可用");
89
+
90
+ // 内容完整 + realpath 指回本地目录 → 可用
91
+ fs.writeFileSync(path.join(nm, "lib", "index.js"), fs.readFileSync(path.join(local, "lib", "index.js")));
92
+ fs.rmSync(nm, { recursive: true, force: true });
93
+ // 正向对照必须按平台取链接类型:Windows 非特权进程下 'dir' 本来就建不出来(正是本 bug),
94
+ // 拿它当"正确答案"会让用例在真 Windows 上自己失败。
95
+ if (process.platform === "win32") fs.symlinkSync(local, nm, "junction");
96
+ else fs.symlinkSync(local, nm, "dir");
97
+ assert.equal(pluginLinkUsable(nm, local), true, "真链接 → 可用");
98
+
99
+ // 整目录拷贝(内容一致)→ 也算可用
100
+ fs.rmSync(nm, { recursive: true, force: true });
101
+ fs.cpSync(local, nm, { recursive: true });
102
+ assert.equal(pluginLinkUsable(nm, local), true, "整目录拷贝(内容一致)→ 可用");
103
+ } finally {
104
+ fs.rmSync(root, { recursive: true, force: true });
105
+ }
106
+ });
107
+
108
+ // ─────────────── ② 注入 Windows 的失败形态:不抛错但落盘是空壳 ───────────────
109
+
110
+ /** 造一个 fs 代理:可按需让某个策略"静默失败"(不抛错、也不产出任何东西)。 */
111
+ function fsProxy({ silentFail = [], emptyDirInstead = [] } = {}) {
112
+ return {
113
+ ...fs,
114
+ symlinkSync(target, p, type) {
115
+ if (emptyDirInstead.includes(type)) { fs.mkdirSync(p, { recursive: true }); return; } // ← 本 bug 的落盘形态
116
+ if (silentFail.includes(type)) return; // ← 本 bug 的"报成功"
117
+ return fs.symlinkSync(target, p, type);
118
+ },
119
+ cpSync(...args) {
120
+ if (silentFail.includes("copy")) return;
121
+ return fs.cpSync(...args);
122
+ },
123
+ };
124
+ }
125
+
126
+ function loadLinkFnsWithFs(fakeFs) {
127
+ const src = fnSrc("pluginLinkUsable", "/**\n * 把插件挂到")
128
+ + fnSrc("ensurePluginLinked", "/**\n * 独立验收:复刻 dsh 客户端模块系统的真实动作");
129
+ return new Function("fs", "path", "PLUGIN_ID", "PLUGIN_LEGACY_IDS",
130
+ `${src}; return { pluginLinkUsable, ensurePluginLinked };`)(fakeFs, path, PLUGIN_ID, ["dsh-remote-ui"]);
131
+ }
132
+
133
+ test("ensurePluginLinked:'不抛错但什么都没建' 时,兜底链必须继续生效(本 bug 的核心)", () => {
134
+ const root = tmpDir();
135
+ try {
136
+ const local = makePluginDir(path.join(root, "local"));
137
+ const profile = path.join(root, "profile");
138
+ fs.mkdirSync(profile, { recursive: true });
139
+ // 模拟 Windows:junction 报告成功但落盘什么都没有(老实现据此当作成功、直接 return)
140
+ const { ensurePluginLinked } = loadLinkFnsWithFs(fsProxy({ silentFail: ["junction"] }));
141
+ const changed = ensurePluginLinked(profile, local);
142
+ assert.equal(changed, true, "应当重建并报告发生了变更");
143
+ const nm = path.join(profile, "node_modules", PLUGIN_ID);
144
+ assert.ok(fs.existsSync(path.join(nm, "package.json")), "★兜底必须真的把内容落盘(老实现在这里留下空壳)");
145
+ assert.ok(fs.existsSync(path.join(nm, "lib", "index.js")), "★节点半入口必须可读");
146
+ assert.ok(fs.readdirSync(nm).length > 0, "node_modules/<id> 是空目录 = 本 bug 复现");
147
+ } finally {
148
+ fs.rmSync(root, { recursive: true, force: true });
149
+ }
150
+ });
151
+
152
+ test("ensurePluginLinked:上一支留下空目录残留时,下一支不得被 EEXIST 顶掉", () => {
153
+ const root = tmpDir();
154
+ try {
155
+ const local = makePluginDir(path.join(root, "local"));
156
+ const profile = path.join(root, "profile");
157
+ fs.mkdirSync(profile, { recursive: true });
158
+ const { ensurePluginLinked } = loadLinkFnsWithFs(fsProxy({ emptyDirInstead: ["junction"] }));
159
+ ensurePluginLinked(profile, local);
160
+ const nm = path.join(profile, "node_modules", PLUGIN_ID);
161
+ assert.ok(fs.existsSync(path.join(nm, "lib", "index.js")),
162
+ "★先留空目录再走兜底也必须成功(老实现这里撞 EEXIST,三级兜底退化成一级)");
163
+ } finally {
164
+ fs.rmSync(root, { recursive: true, force: true });
165
+ }
166
+ });
167
+
168
+ test("ensurePluginLinked:所有策略都失败时必须抛错,绝不返回成功", () => {
169
+ const root = tmpDir();
170
+ try {
171
+ const local = makePluginDir(path.join(root, "local"));
172
+ const profile = path.join(root, "profile");
173
+ fs.mkdirSync(profile, { recursive: true });
174
+ const { ensurePluginLinked } = loadLinkFnsWithFs(fsProxy({ silentFail: ["junction", "dir", "copy"] }));
175
+ assert.throws(() => ensurePluginLinked(profile, local), /插件挂载失败/,
176
+ "全失败必须抛错 —— 否则调用方会写出一个起不来的 profile");
177
+ } finally {
178
+ fs.rmSync(root, { recursive: true, force: true });
179
+ }
180
+ });
181
+
182
+ test("ensurePluginLinked:已经挂好时幂等返回 false(不重复重建)", () => {
183
+ const root = tmpDir();
184
+ try {
185
+ const local = makePluginDir(path.join(root, "local"));
186
+ const profile = path.join(root, "profile");
187
+ fs.mkdirSync(profile, { recursive: true });
188
+ const { ensurePluginLinked } = loadLinkFns();
189
+ assert.equal(ensurePluginLinked(profile, local), true, "首次应重建");
190
+ assert.equal(ensurePluginLinked(profile, local), false, "已就绪应幂等返回 false");
191
+ } finally {
192
+ fs.rmSync(root, { recursive: true, force: true });
193
+ }
194
+ });
195
+
196
+ // ─────────────── ③ 真起进程:装完必须真的能被裸包名解析到 ───────────────
197
+
198
+ /** 准备一个最小可用 profile(package.json + 空的 cordis.patch.yml)。 */
199
+ function makeProfile(root) {
200
+ const profile = path.join(root, "prof");
201
+ fs.mkdirSync(profile, { recursive: true });
202
+ fs.writeFileSync(path.join(profile, "package.json"), JSON.stringify({ name: "dsh-profile-web", version: "0.0.0" }, null, 2));
203
+ fs.writeFileSync(path.join(profile, "cordis.patch.yml"), "[]\n");
204
+ return profile;
205
+ }
206
+
207
+ /** 在隔离 HOME 下跑安装器命令。 */
208
+ function runInstaller(args, env = {}) {
209
+ const home = env.HOME || fs.mkdtempSync(path.join(os.tmpdir(), "dsh-link-home-"));
210
+ return new Promise((resolve) => {
211
+ execFile(process.execPath, [SETUP, ...args], {
212
+ env: { ...process.env, HOME: home, DSH_RELAY_SKIP_SERVICE: "1", DSH_RELAY_DIR: path.join(home, ".dsh-remote"), ...env },
213
+ timeout: 60_000,
214
+ }, (err, stdout, stderr) => resolve({ code: err ? (err.code ?? 1) : 0, stdout, stderr }));
215
+ });
216
+ }
217
+
218
+ /** 复刻 dsh 的真实解析动作(浏览器半注入设置面板靠它)。 */
219
+ function resolveFromProfile(profile, spec) {
220
+ const req = createRequire(path.join(profile, "__probe.cjs"));
221
+ return req.resolve(spec);
222
+ }
223
+
224
+ test("端到端:安装器装完插件后,node_modules/<id> 必须真的可读、可解析(不能只看退出码)", async () => {
225
+ const root = tmpDir();
226
+ try {
227
+ const profile = makeProfile(root);
228
+ const r = await runInstaller(["plugin"], { DSH_PROFILE_DIR: profile });
229
+ assert.equal(r.code, 0, `安装应成功:${r.stdout}\n${r.stderr}`);
230
+
231
+ const nm = path.join(profile, "node_modules", PLUGIN_ID);
232
+ // ① 落盘可读 —— Windows 非特权下老代码在这里就挂了
233
+ const pkg = JSON.parse(fs.readFileSync(path.join(nm, "package.json"), "utf8"));
234
+ assert.equal(pkg.name, PLUGIN_ID);
235
+ assert.ok(fs.existsSync(path.join(nm, "lib", "index.js")), "节点半入口必须存在");
236
+ assert.ok(fs.readdirSync(nm).length > 0, "★空目录 = 本 bug 复现");
237
+
238
+ // ② 复刻 dsh 的解析动作
239
+ assert.ok(resolveFromProfile(profile, `${PLUGIN_ID}/package.json`).endsWith("package.json"));
240
+ assert.ok(resolveFromProfile(profile, PLUGIN_ID), "裸包名必须能解析到入口");
241
+ assert.equal(pkg.dsh.client.platform, "web", "浏览器半声明必须在(面板注入的前提)");
242
+
243
+ // ③ 激活行必须写进 patch
244
+ const patch = fs.readFileSync(path.join(profile, "cordis.patch.yml"), "utf8");
245
+ assert.match(patch, /dsh-remote-web/, "patch 里应有插件激活行");
246
+ } finally {
247
+ fs.rmSync(root, { recursive: true, force: true });
248
+ }
249
+ });
250
+
251
+ test("端到端:node_modules/<id> 已是空壳时,重新安装必须自愈", async () => {
252
+ const root = tmpDir();
253
+ try {
254
+ const profile = makeProfile(root);
255
+ // 复现中招现场:同名空目录
256
+ fs.mkdirSync(path.join(profile, "node_modules", PLUGIN_ID), { recursive: true });
257
+ const r = await runInstaller(["plugin"], { DSH_PROFILE_DIR: profile });
258
+ assert.equal(r.code, 0, `重装应自愈:${r.stdout}\n${r.stderr}`);
259
+ const nm = path.join(profile, "node_modules", PLUGIN_ID);
260
+ assert.ok(fs.existsSync(path.join(nm, "lib", "index.js")), "★空壳必须被重建(空目录不许当作已挂载)");
261
+ assert.ok(resolveFromProfile(profile, `${PLUGIN_ID}/package.json`));
262
+ } finally {
263
+ fs.rmSync(root, { recursive: true, force: true });
264
+ }
265
+ });
266
+
267
+ test("端到端:挂载失败必须中止安装,且**不写** patch(宁可不装,也不能写出起不来的 profile)", async (t) => {
268
+ if (typeof process.getuid === "function" && process.getuid() === 0) return; // root 不受权限位约束
269
+ // Windows 的 chmod 只改只读属性、**不限制目录写入**,制造不出"必然失败"的落盘环境。
270
+ // 该场景由上面三条注入式 fs 代理用例覆盖(含"全失败必须抛错")。
271
+ if (process.platform === "win32") { t.skip("Windows 无 POSIX 权限位语义"); return; }
272
+ const root = tmpDir();
273
+ try {
274
+ const profile = makeProfile(root);
275
+ const nmDir = path.join(profile, "node_modules");
276
+ fs.mkdirSync(nmDir, { recursive: true });
277
+ fs.chmodSync(nmDir, 0o500); // 只读:symlink 与 cpSync 都必然失败
278
+ try {
279
+ const r = await runInstaller(["plugin"], { DSH_PROFILE_DIR: profile });
280
+ assert.equal(r.code, 1, `应中止安装(非 0 退出):${r.stdout}\n${r.stderr}`);
281
+ assert.match(r.stderr, /已中止安装/, "必须明确告知已中止");
282
+ const patch = fs.readFileSync(path.join(profile, "cordis.patch.yml"), "utf8");
283
+ assert.ok(!/dsh-remote-web/.test(patch), "★绝不能写激活行:那会让 dsh web 起不来");
284
+ assert.ok(!/插件已就绪/.test(r.stdout), "不得谎报成功");
285
+ } finally {
286
+ fs.chmodSync(nmDir, 0o700);
287
+ }
288
+ } finally {
289
+ fs.rmSync(root, { recursive: true, force: true });
290
+ }
291
+ });
292
+
293
+ // ─────────────── ④ 自救入口:repair ───────────────
294
+
295
+ test("repair:空壳现场能被修好,并打印挂载诊断三行", async () => {
296
+ const root = tmpDir();
297
+ try {
298
+ const profile = makeProfile(root);
299
+ await runInstaller(["plugin"], { DSH_PROFILE_DIR: profile });
300
+ // 破坏:把链接换成空壳(= 用户中招后的现场)
301
+ const nm = path.join(profile, "node_modules", PLUGIN_ID);
302
+ fs.rmSync(nm, { recursive: true, force: true });
303
+ fs.mkdirSync(nm, { recursive: true });
304
+
305
+ const r = await runInstaller(["repair"], { DSH_PROFILE_DIR: profile });
306
+ assert.equal(r.code, 0, `repair 应成功:${r.stdout}\n${r.stderr}`);
307
+ assert.match(r.stdout, /\[link\]/, "应打印链接诊断");
308
+ assert.match(r.stdout, /\[resolve\]/, "应打印解析诊断");
309
+ assert.match(r.stdout, /\[client\]/, "应打印客户端半声明诊断");
310
+ assert.match(r.stdout, /🛟/, "挂载不可用时应先摘掉激活行保命");
311
+ assert.ok(fs.existsSync(path.join(nm, "lib", "index.js")), "★应修好挂载");
312
+ assert.ok(resolveFromProfile(profile, `${PLUGIN_ID}/package.json`), "★应可解析");
313
+ assert.match(fs.readFileSync(path.join(profile, "cordis.patch.yml"), "utf8"), /dsh-remote-web/, "应恢复激活行");
314
+ } finally {
315
+ fs.rmSync(root, { recursive: true, force: true });
316
+ }
317
+ });
318
+
319
+ test("repair:本地插件目录缺失时明确报错并给重装命令(不静默成功)", async () => {
320
+ const root = tmpDir();
321
+ try {
322
+ const profile = makeProfile(root);
323
+ const r = await runInstaller(["repair"], { DSH_PROFILE_DIR: profile });
324
+ assert.equal(r.code, 1, `无本地拷贝应失败:${r.stdout}\n${r.stderr}`);
325
+ assert.match(r.stderr, /请重新安装/, "必须给出可照抄的重装命令");
326
+ } finally {
327
+ fs.rmSync(root, { recursive: true, force: true });
328
+ }
329
+ });
330
+
331
+ // ─────────────── ⑤ 包管理器识别(Windows 上 `command -v` 恒失效) ───────────────
332
+
333
+ test("detectPackageManager:按声明文件识别,且代码里不得再用 command -v pnpm", () => {
334
+ const src = fnSrc("detectPackageManager", "/**\n * 市场装法下把插件包升到最新");
335
+ const detect = new Function("fs", "path", "spawnSync", "process", `${src}; return detectPackageManager;`)(
336
+ fs, path, () => ({ status: 1 }), process);
337
+
338
+ const root = tmpDir();
339
+ try {
340
+ const a = path.join(root, "a"); fs.mkdirSync(a);
341
+ fs.writeFileSync(path.join(a, "pnpm-workspace.yaml"), "packages: []\n");
342
+ assert.equal(detect(a), "pnpm", "有 pnpm-workspace.yaml → pnpm");
343
+
344
+ const b = path.join(root, "b"); fs.mkdirSync(b);
345
+ fs.writeFileSync(path.join(b, "package-lock.json"), "{}");
346
+ assert.equal(detect(b), "npm", "有 package-lock.json → npm");
347
+
348
+ const c = path.join(root, "c"); fs.mkdirSync(c);
349
+ assert.equal(detect(c), "pnpm", "都探测不到时兜底 pnpm(dsh profile 默认就是 pnpm)");
350
+ } finally {
351
+ fs.rmSync(root, { recursive: true, force: true });
352
+ }
353
+
354
+ // 源码契约:不得再用 POSIX 内建 `command -v`(Windows 走 cmd.exe,永远命中 || 分支)
355
+ const codeLines = SETUP_SRC.split("\n").filter((l) => !/^\s*(\/\/|\*|\/\*)/.test(l));
356
+ assert.ok(!codeLines.some((l) => /command -v pnpm/.test(l)), "不得再用 command -v pnpm 探测");
357
+ assert.ok(/const pm = detectPackageManager\(profileDir\);/.test(SETUP_SRC), "升级路径必须用 detectPackageManager");
358
+ });
359
+
360
+ // ─────────────── ⑥ 源码契约:策略顺序与失败即中止 ───────────────
361
+
362
+ test("源码契约:Windows 首选 junction(不用 'dir'),且每支之间清理残留", () => {
363
+ const fn = fnSrc("ensurePluginLinked", "/**\n * 独立验收:复刻 dsh 客户端模块系统的真实动作");
364
+ const winBranch = fn.slice(fn.indexOf('process.platform === "win32"'), fn.indexOf('["copy"'));
365
+ assert.ok(winBranch.includes('"junction"'), "Windows 首选必须是 junction");
366
+ assert.ok(!/"dir"/.test(winBranch), "★Windows 分支不得再尝试 symlinkSync(...,'dir')");
367
+ assert.ok(/fs\.rmSync\(nmPlugin, \{ recursive: true, force: true \}\);\s*\}\s*catch/.test(fn) || /rmSync\(nmPlugin[^\n]*\n/.test(fn),
368
+ "每次尝试前必须清残留(否则下一支撞 EEXIST)");
369
+ assert.ok(/for \(const \[how, run\] of attempts\)/.test(fn), "必须是「逐支验证」的循环,而不是 try 嵌套 catch");
370
+ assert.ok(/throw new Error\(`插件挂载失败/.test(fn), "全部失败必须抛错");
371
+ // POSIX 行为保持不变(原生 symlink 无需特权)
372
+ const posixBranch = fn.slice(fn.indexOf(':\n [') >= 0 ? fn.indexOf(':\n [') : fn.indexOf('['), fn.indexOf("const failures"));
373
+ assert.ok(/"dir"/.test(posixBranch), "POSIX 仍用原生 symlink");
374
+ });
375
+
376
+ test("源码契约:调用方必须检查结果并在失败时中止(老代码丢返回值还报成功)", () => {
377
+ // 调用必须被 try/catch 包住(老代码是裸调用 + 丢返回值)
378
+ assert.ok(/try \{\s*\n\s*ensurePluginLinked\(profileDir, pluginLocalDir\);/.test(SETUP_SRC),
379
+ "ensurePluginLinked 必须在 try 里调用(否则失败会变成未捕获异常)");
380
+ const call = SETUP_SRC.slice(SETUP_SRC.indexOf("try {\n ensurePluginLinked(profileDir, pluginLocalDir);"), SETUP_SRC.indexOf("const r = ensurePatchActivation"));
381
+ assert.ok(/catch \(e\) \{[\s\S]*?process\.exit\(1\);/.test(call), "挂载失败必须 process.exit(1)");
382
+ // 顺序(只在 activateLocalCopy 函数体内看):挂载 → 独立验收 → 才 declarePluginDep / 写 patch
383
+ const body = SETUP_SRC.slice(
384
+ SETUP_SRC.indexOf("function activateLocalCopy("),
385
+ SETUP_SRC.indexOf("function convergePluginActivation("));
386
+ assert.ok(body.length > 0, "应能定位 activateLocalCopy");
387
+ const order = [
388
+ "ensurePluginLinked(profileDir, pluginLocalDir)",
389
+ "const probe = probePluginResolve(profileDir);",
390
+ "declarePluginDep(pkgFile);",
391
+ "ensurePatchActivation(patchFile",
392
+ ];
393
+ let last = -1;
394
+ for (const marker of order) {
395
+ const i = body.indexOf(marker);
396
+ assert.ok(i > last, `activateLocalCopy 内的顺序必须是:挂载 → 验收 → 依赖 → 激活(${marker})`);
397
+ last = i;
398
+ }
399
+ assert.ok(/function probePluginResolve\(/.test(SETUP_SRC), "应有独立验收(复刻 dsh 的裸包名解析)");
400
+ });
@@ -122,7 +122,9 @@ test("失败归因:客户端白名单必须与服务端逐字一致,且事
122
122
  for (const c of codes) assert.ok(out.queued.includes(c), `客户端 failCodes 应含 ${c}`);
123
123
 
124
124
  // ② 与服务端白名单**集合级**一致(少一个码 = 上报被丢弃、多一个 = 服务端拒收)
125
- const serverPath = join(REPO, "..", "dsh-relay-enterprise", "relay-enterprise", "src", "api.js");
125
+ // 可选:把服务端 api.js 的路径通过环境变量给出,即可做「客户端↔服务端白名单」集合级对拍。
126
+ // 未设置时跳过(跨仓库对拍属于本地/CI 场景,不应把另一仓库的位置硬编码进本仓库)。
127
+ const serverPath = process.env.DSH_TELEMETRY_SERVER_API || "";
126
128
  if (existsSync(serverPath)) {
127
129
  const apiSrc = readFileSync(serverPath, "utf8");
128
130
  const block = /const TELEMETRY_FAIL_CODES = new Set\(\[([\s\S]*?)\]\);/.exec(apiSrc);
@@ -0,0 +1,94 @@
1
+ // 「中继不提供额度接口」时的降级回归。
2
+ //
3
+ // 背景:插件是**开源侧产物,自建与 SaaS 两种模式共用**。自建中继不限制带宽与流量,
4
+ // 也不提供 `/_quota`;此时面板必须:
5
+ // ① 代理不报错(返回 {ok:true, quota:null} 而不是 5xx);
6
+ // ② 不显示任何**服务端并未下发的**限制文案(例如硬编码的“本月流量限额 1GB”)。
7
+ //
8
+ // 曾在 2026-09 修过一次:面板在拿不到额度数据时硬编码回退成
9
+ // 「免费额度: 带宽 ≈1Mbps · 本月流量限额 1GB」——对自建用户就是凭空捏造限制。
10
+ import assert from "node:assert/strict";
11
+ import http from "node:http";
12
+ import { readFileSync } from "node:fs";
13
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
14
+ import os from "node:os";
15
+ import path from "node:path";
16
+ import test from "node:test";
17
+ import { fileURLToPath } from "node:url";
18
+ import { apply } from "../lib/index.js";
19
+
20
+ const CLIENT_SRC = readFileSync(
21
+ path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "lib", "client.js"),
22
+ "utf8"
23
+ );
24
+
25
+ /** 假 relay:只提供 device-login;其余 404(模拟极简自建账号服务)。 */
26
+ function startFakeRelay() {
27
+ const srv = http.createServer(async (req, res) => {
28
+ const url = new URL(req.url, "http://x");
29
+ const send = (code, obj) => { res.writeHead(code, { "content-type": "application/json" }); res.end(JSON.stringify(obj)); };
30
+ if (req.method === "POST" && url.pathname === "/api/device-login") return send(200, { token: "jwt-self" });
31
+ send(404, { error: { code: "not_found" } });
32
+ });
33
+ return new Promise((r) => srv.listen(0, "127.0.0.1", () => r({ srv, port: srv.address().port })));
34
+ }
35
+
36
+ /** 假中继:**没有 `/_quota`**(干净自建中继就是这样)。 */
37
+ function startFakeRelayRouter() {
38
+ const srv = http.createServer((_req, res) => { res.writeHead(404); res.end(); });
39
+ return new Promise((r) => srv.listen(0, "127.0.0.1", () => r({ srv, port: srv.address().port })));
40
+ }
41
+
42
+ test("中继无 /_quota:代理返回 {ok:true,quota:null} 且不报错", async () => {
43
+ const relay = await startFakeRelay();
44
+ const router = await startFakeRelayRouter();
45
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), "dsh-ui-noquota-"));
46
+ await writeFile(path.join(tempDir, ".dsh-config.json"), JSON.stringify({
47
+ phone: "13800000000",
48
+ password: "pw",
49
+ device_id: "dev-selfhost0001",
50
+ api_url: `http://127.0.0.1:${relay.port}`,
51
+ tunnel_url: `ws://127.0.0.1:${router.port}`
52
+ }));
53
+
54
+ const routes = new Map();
55
+ apply({
56
+ webServer: { register(route) { routes.set(route.path, route.handler); return () => {}; } },
57
+ effect(register) { return register(); },
58
+ logger: { info() {}, warn() {} }
59
+ }, { relayDir: tempDir });
60
+
61
+ const host = http.createServer((req, res) => {
62
+ const url = new URL(req.url, "http://x");
63
+ const handler = routes.get(url.pathname);
64
+ (handler || ((_r, rs) => { rs.writeHead(404); rs.end(); }))(req, res);
65
+ });
66
+ await new Promise((r) => host.listen(0, "127.0.0.1", r));
67
+
68
+ try {
69
+ const r = await fetch(`http://127.0.0.1:${host.address().port}/dsh-remote/quota`);
70
+ assert.equal(r.status, 200, "拿不到额度不应变成 5xx");
71
+ const body = await r.json();
72
+ assert.equal(body.ok, true);
73
+ assert.equal(body.quota, null, "中继没有额度接口 → quota 必须是 null(而不是编造一份)");
74
+ } finally {
75
+ host.close(); relay.srv.close(); router.srv.close();
76
+ await rm(tempDir, { recursive: true, force: true });
77
+ }
78
+ });
79
+
80
+ test("面板文案:额度数据缺失时不得断言任何限制", () => {
81
+ // 有额度数据 + 限量 → 才允许出现带宽/用量文案,且带宽值必须来自服务端
82
+ assert.match(CLIENT_SRC, /if \(quota && quota\.limit_enabled\) \{/, "有额度且限量时才展示用量");
83
+ assert.doesNotMatch(CLIENT_SRC, /带宽 ≈1Mbps/, "不得硬编码带宽数值,应从服务端下发");
84
+ assert.match(CLIENT_SRC, /quota\.max_mbps \? "带宽 ≈" \+ quota\.max_mbps/, "带宽值服务端有才写");
85
+ // 无额度数据 → 只说明是免费用户,不得出现硬编码的限额
86
+ assert.doesNotMatch(
87
+ CLIENT_SRC,
88
+ /quotaPct !== null \?[^:]*:[^"]*本月流量限额 1GB/,
89
+ "不得在拿不到额度时硬编码“本月流量限额 1GB”"
90
+ );
91
+ assert.doesNotMatch(CLIENT_SRC, /:\s*" · 本月流量限额 1GB"/, "硬编码限额回退已移除");
92
+ // 中继报告「不限量」时也不该说限额
93
+ assert.match(CLIENT_SRC, /免费用户 · 当前不限速、不限流量/, "不限量应如实说明");
94
+ });
@@ -207,7 +207,7 @@ test("登录态账号区:无「切换账号」,有「退出登录」,头
207
207
  const points = [
208
208
  "📱 远程访问:用手机或另一台电脑的浏览器,随时随地使用同一份 dsh web——人在哪都能用(免公网 IP、免内网穿透);官方托管中继,4G/5G 即用,也可自建服务。",
209
209
  "🛠 电脑端一键安装:bridge 与「远程访问」面板一次到位——云端/自建切换、账号登录、bridge 启停、一次性扫码访问、已授权设备管理、意见反馈都在这里。",
210
- "🔒 安全与通道:HTTP / WebSocket 全量透传,一次性访问密钥认证,面板实时显示设备与已授权设备列表;服务端可配置流量配额。",
210
+ "🔒 安全与通道:HTTP / WebSocket 全量透传,一次性访问密钥认证,面板实时显示设备与已授权设备列表。",
211
211
  "🛡 端到端加密:手机↔电脑之间的消息内容用「你的账号密码派生密钥」端到端加密——密钥与密码不落服务端(仅存校验值),中继只可见路径/大小/时间(详见 README「安全与隐私」)。",
212
212
  ];
213
213
  for (const p of points) {
@@ -1,7 +1,7 @@
1
1
  // 匿名装机/连接遥测(客户端半)回归 —— 契约 + 隐私边界(2026-09)。
2
2
  //
3
- // 现场(2026-09 生产数据):11 个新注册用户里只有 3 人把设备连上——6 人电脑端从未安装、2 人装了但
4
- // bridge 没连上;而「装不上」的人没有任何账号、也就没有任何数据,「本地 OK、新机器失败」无法归因。
3
+ // 现场:新注册用户里只有少数把设备连上,多数卡在电脑端安装这一步;而「装不上」的人
4
+ // 往往没有账号、也就没有任何数据,「本地 OK、新机器失败」无法归因。
5
5
  // 本用例锁死这条补上的通道:
6
6
  // ① 触发点正确:plugin_loaded / install_started / install_failed(fail_code) / runtime_ready /
7
7
  // bridge_started / bridge_registered / tunnel_disconnected / first_remote_ok(只一次) / panel_opened;