@mrrisega/dsh-remote 0.3.0

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 ADDED
@@ -0,0 +1,616 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dsh-remote — 一键安装 + 配置 + 自启动(电脑端)
4
+ *
5
+ * 用法:
6
+ * dsh-remote [setup] [选项] 一键安装(默认命令,无需任何参数)
7
+ * dsh-remote settings 打开本地设置页(登录账号 / 自建配置)
8
+ * dsh-remote run 前台运行 bridge(调试/守护)
9
+ * dsh-remote status 查看配置与服务状态
10
+ * dsh-remote plugin [--uninstall] 手动安装/卸载 dsh web 远程控制插件
11
+ *
12
+ * setup 选项(全部可选):
13
+ * --server <wss://host:port> --key <访问密钥> 自建模式(不填则连默认云端服务)
14
+ * --api <URL> 覆盖云端服务地址(高级)
15
+ * --no-autostart 不安装开机自启服务
16
+ * --no-plugin 不安装 dsh web 插件
17
+ *
18
+ * 行为:
19
+ * 1. 写入配置 <CONFIG_DIR>/.dsh-config.json(0600;npm 安装时为 ~/.dsh-remote/)
20
+ * 2. 生成自启动服务(macOS launchd / Linux systemd),随 dsh web(3080) 存活自动保活
21
+ * 3. 自动把远程控制插件装进 dsh web 设置页(若检测到 profile)
22
+ * 4. 登录在设置页完成: dsh-remote settings(手机号+密码,或自建密钥)
23
+ */
24
+
25
+ import http from "node:http";
26
+ import fs from "node:fs";
27
+ import os from "node:os";
28
+ import path from "node:path";
29
+ import { spawn, execSync } from "node:child_process";
30
+ import { fileURLToPath } from "node:url";
31
+ import { childStopped } from "./clients/dsh-remote/src/lifecycle.mjs";
32
+
33
+ const THIS_DIR = path.dirname(fileURLToPath(import.meta.url)); // 本包目录(仓库或 node_modules)
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);
37
+ const CONFIG_PATH = path.join(CONFIG_DIR, ".dsh-config.json");
38
+ const SETTINGS_PORT = 3499;
39
+ // 默认云端服务地址(服务商 SaaS 入口;自建用户用 --server/--key 指向自己的 router)
40
+ const DEFAULT_API = "https://n.risegao.cn:13443/relay-api";
41
+ const DEFAULT_APP_URL = "https://n.risegao.cn:13443/app/";
42
+ const REPO_URL = "https://github.com/mrRisega/dsh-remote";
43
+
44
+ // ---------- 工具 ----------
45
+
46
+ function sh(cmd, timeoutMs = 15000, cwd = undefined) {
47
+ try {
48
+ const stdout = execSync(cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: timeoutMs, ...(cwd ? { cwd } : {}) });
49
+ return { ok: true, stdout: String(stdout ?? ""), stderr: "", code: 0 };
50
+ } catch (e) {
51
+ return { ok: false, stdout: String(e.stdout ?? ""), stderr: String(e.stderr ?? ""), code: e.status ?? -1 };
52
+ }
53
+ }
54
+
55
+ /** 解析真实可执行路径(launchd/systemd 需要真实文件 + 可执行位)。 */
56
+ function resolveExecutable(p) {
57
+ try {
58
+ const real = fs.realpathSync(p);
59
+ fs.accessSync(real, fs.constants.X_OK);
60
+ return real;
61
+ } catch { return null; }
62
+ }
63
+
64
+ function preferredNode() {
65
+ const candidates = [
66
+ process.env.DSH_SETUP_NODE20 || "",
67
+ process.env.DSH_SETUP_NODE || "",
68
+ process.execPath
69
+ ].filter(Boolean);
70
+ for (const p of candidates) {
71
+ const real = resolveExecutable(p);
72
+ if (real) return real;
73
+ }
74
+ return process.execPath;
75
+ }
76
+ const NODE_BIN = preferredNode();
77
+
78
+ // ---------- 配置读写 ----------
79
+ function loadConfig() {
80
+ try { return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")); }
81
+ catch { return {}; }
82
+ }
83
+ function saveConfig(cfg) {
84
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
85
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), { mode: 0o600 });
86
+ }
87
+
88
+ // ---------- 公共配置(从服务端取域名,服务商可随时更换) ----------
89
+ async function fetchPublicConfig(apiBase) {
90
+ try {
91
+ const r = await fetch(apiBase + "/api/public-config", { signal: AbortSignal.timeout(6000) });
92
+ if (r.ok) return await r.json();
93
+ } catch {}
94
+ return {};
95
+ }
96
+
97
+ /** 从账号 API 地址推导隧道 WebSocket 地址:https://host/relay-api → wss://host */
98
+ function deriveTunnelUrl(apiUrl) {
99
+ return apiUrl.replace(/\/relay-api\/?$/, "").replace(/^https/, "wss");
100
+ }
101
+
102
+ /** 归一化自建服务器地址:缺省补 wss://,去掉末尾 / */
103
+ function normalizeTunnelUrl(raw) {
104
+ let u = String(raw || "").trim().replace(/\/+$/, "");
105
+ if (!/^wss?:/i.test(u)) u = "wss://" + u;
106
+ return u;
107
+ }
108
+
109
+ function argValue(argv, name) {
110
+ const i = argv.indexOf(name);
111
+ return i > -1 ? argv[i + 1] : null;
112
+ }
113
+ function hasFlag(argv, name) {
114
+ return argv.includes(name);
115
+ }
116
+
117
+ // ---------- 自启动服务生成与热启动 ----------
118
+ function autostartFilePath() {
119
+ if (process.platform === "darwin")
120
+ return path.join(os.homedir(), "Library/LaunchAgents/com.dshremote.bridge.plist");
121
+ if (process.platform === "linux")
122
+ return path.join(os.homedir(), ".config/systemd/user/dsh-bridge.service");
123
+ return null;
124
+ }
125
+
126
+ function writeAutostartFile() {
127
+ const runCmd = `"${NODE_BIN}" "${fileURLToPath(import.meta.url)}" run`;
128
+ if (process.platform === "darwin") {
129
+ const plistPath = autostartFilePath();
130
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
131
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
132
+ <plist version="1.0"><dict>
133
+ <key>Label</key><string>com.dshremote.bridge</string>
134
+ <key>ProgramArguments</key>
135
+ <array><string>${NODE_BIN}</string><string>${fileURLToPath(import.meta.url)}</string><string>run</string></array>
136
+ <key>RunAtLoad</key><true/>
137
+ <key>KeepAlive</key><true/>
138
+ <key>StandardOutPath</key><string>${path.join(CONFIG_DIR, ".dsh-bridge.log")}</string>
139
+ <key>StandardErrorPath</key><string>${path.join(CONFIG_DIR, ".dsh-bridge.log")}</string>
140
+ <key>EnvironmentVariables</key><dict><key>PATH</key><string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string></dict>
141
+ </dict></plist>`;
142
+ fs.writeFileSync(plistPath, plist);
143
+ console.log(`✅ 已创建自启动服务: ${plistPath}`);
144
+ return plistPath;
145
+ }
146
+ if (process.platform === "linux") {
147
+ const dir = path.join(os.homedir(), ".config/systemd/user");
148
+ fs.mkdirSync(dir, { recursive: true });
149
+ 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`;
150
+ const unitPath = autostartFilePath();
151
+ fs.writeFileSync(unitPath, unit);
152
+ console.log(`✅ 已创建自启动服务: ${unitPath}`);
153
+ return unitPath;
154
+ }
155
+ console.log("⚠️ 当前平台暂不支持自启动,请手动运行 `dsh-remote run`");
156
+ return null;
157
+ }
158
+
159
+ function restartBridgeService() {
160
+ const svcFile = autostartFilePath();
161
+ if (svcFile && !fs.existsSync(svcFile)) {
162
+ console.log("[dsh-remote] 未检测到自启动服务,自动生成...");
163
+ writeAutostartFile();
164
+ }
165
+ if (process.platform === "darwin") {
166
+ const plistPath = autostartFilePath();
167
+ if (!fs.existsSync(plistPath)) return { ok: false, status: "not-installed", detail: "plist 不存在" };
168
+ const uid = process.getuid();
169
+ const domain = `gui/${uid}`;
170
+ const target = `${domain}/com.dshremote.bridge`;
171
+ const q = (s) => "'" + String(s).replace(/'/g, `'\\''`) + "'";
172
+ sh(`launchctl bootout ${target}`);
173
+ let boot = sh(`launchctl bootstrap ${domain} ${q(plistPath)}`);
174
+ if (!boot.ok) {
175
+ sh(`launchctl unload ${q(plistPath)}`);
176
+ boot = sh(`launchctl load -w ${q(plistPath)}`);
177
+ }
178
+ if (!boot.ok) return { ok: false, status: "failed", detail: (boot.stderr || boot.stdout).trim() || "launchctl 启动失败" };
179
+ const pr = sh(`launchctl print ${target}`);
180
+ if (pr.ok && /state\s*=\s*running/.test(pr.stdout)) {
181
+ const m = pr.stdout.match(/pid\s*=\s*(\d+)/);
182
+ return { ok: true, status: "running", pid: m ? Number(m[1]) : null };
183
+ }
184
+ const ls = sh(`launchctl list | grep com.dshremote.bridge`);
185
+ if (ls.ok) {
186
+ const pidStr = ls.stdout.trim().split(/\s+/)[0];
187
+ if (pidStr && pidStr !== "-" && /^\d+$/.test(pidStr))
188
+ return { ok: true, status: "running", pid: Number(pidStr) };
189
+ }
190
+ return { ok: false, status: "failed", detail: (pr.stderr || ls.stderr || "服务未在运行").trim() };
191
+ }
192
+ if (process.platform === "linux") {
193
+ const r = sh(`systemctl --user restart dsh-bridge`);
194
+ if (!r.ok) return { ok: false, status: "failed", detail: (r.stderr || r.stdout).trim() || "systemctl restart 失败" };
195
+ const a = sh(`systemctl --user is-active dsh-bridge`);
196
+ return a.ok && a.stdout.trim() === "active"
197
+ ? { ok: true, status: "running", pid: null }
198
+ : { ok: false, status: "failed", detail: (a.stdout || a.stderr).trim() };
199
+ }
200
+ return { ok: false, status: "unsupported", detail: `平台 ${process.platform} 不支持自启动` };
201
+ }
202
+
203
+ function installAutostart() {
204
+ const svcPath = writeAutostartFile();
205
+ const r = restartBridgeService();
206
+ if (r.ok) {
207
+ console.log(`✅ 自启动服务已加载并运行${r.pid ? ` (pid=${r.pid})` : ""}`);
208
+ } else {
209
+ console.log(`⚠️ 自启动服务启动失败: ${r.detail || r.status}`);
210
+ }
211
+ return { path: svcPath, status: r };
212
+ }
213
+
214
+ // ---------- 设置页(本地 HTTP:远程地址 + 账号/自建配置) ----------
215
+ function serveSettings() {
216
+ const cfg = loadConfig();
217
+ const server = http.createServer(async (req, res) => {
218
+ const url = new URL(req.url, `http://127.0.0.1:${SETTINGS_PORT}`);
219
+ const send = (code, body, type = "text/html") => {
220
+ res.writeHead(code, { "Content-Type": type + "; charset=utf-8" });
221
+ res.end(body);
222
+ };
223
+
224
+ if (req.method === "POST" && url.pathname === "/api/config") {
225
+ let raw = "";
226
+ for await (const c of req) raw += c;
227
+ try {
228
+ const body = JSON.parse(raw);
229
+ const local = Boolean(body.local_key || body.server);
230
+ if (local) {
231
+ if (!body.server || !body.local_key) {
232
+ return send(400, JSON.stringify({ ok: false, message: "自建模式需要服务器地址与访问密钥" }), "application/json");
233
+ }
234
+ cfg.tunnel_url = normalizeTunnelUrl(body.server);
235
+ cfg.local_key = String(body.local_key).trim();
236
+ delete cfg.phone; delete cfg.password;
237
+ } else {
238
+ if (!body.phone || !body.password) {
239
+ return send(400, JSON.stringify({ ok: false, message: "SaaS 模式需要手机号与密码" }), "application/json");
240
+ }
241
+ cfg.phone = body.phone; cfg.password = body.password;
242
+ delete cfg.local_key;
243
+ cfg.api_url = body.api_url || cfg.api_url || DEFAULT_API;
244
+ if (!cfg.tunnel_url) cfg.tunnel_url = deriveTunnelUrl(cfg.api_url);
245
+ }
246
+ // 自动获取服务端下发的 bridge_secret(device-login 共享密钥,一键安装开箱即用)
247
+ if (!cfg.bridge_secret && !cfg.local_key) {
248
+ const pub = await fetchPublicConfig(cfg.api_url || DEFAULT_API);
249
+ if (pub.bridge_secret) cfg.bridge_secret = String(pub.bridge_secret);
250
+ }
251
+ saveConfig(cfg);
252
+ const r = restartBridgeService();
253
+ return send(200, JSON.stringify({ ok: true, service: r.ok ? "running" : (r.status || "failed") }), "application/json");
254
+ } catch { return send(400, JSON.stringify({ ok: false, message: "JSON 解析失败" }), "application/json"); }
255
+ }
256
+
257
+ const mode = cfg.local_key ? "自建服务" : "SaaS 云端服务";
258
+ const pub = await fetchPublicConfig(cfg.api_url || DEFAULT_API);
259
+ const remoteUrl = pub.app_url || (cfg.local_key ? (cfg.tunnel_url || "") : DEFAULT_APP_URL);
260
+
261
+ const html = `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"/>
262
+ <meta name="viewport" content="width=device-width,initial-scale=1"/>
263
+ <title>dsh-remote 设置</title>
264
+ <style>
265
+ body{font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;background:#0d1117;color:#e6edf3;max-width:520px;margin:0 auto;padding:24px}
266
+ h1{font-size:18px}label{display:block;font-size:13px;color:#8b949e;margin:14px 0 6px}
267
+ input{width:100%;padding:11px 13px;border-radius:8px;border:1px solid #30363d;background:#161b22;color:#e6edf3;font-size:15px;box-sizing:border-box}
268
+ button{width:100%;padding:13px;border-radius:8px;border:none;background:#2f81f7;color:#fff;font-size:15px;font-weight:600;cursor:pointer;margin-top:16px}
269
+ .card{background:#161b22;border:1px solid #30363d;border-radius:12px;padding:18px;margin-bottom:16px}
270
+ .url{background:#010409;border:1px solid #30363d;border-radius:8px;padding:12px;font-family:monospace;font-size:14px;word-break:break-all}
271
+ .msg{font-size:13px;margin-top:10px;min-height:18px}.ok{color:#3fb950}.err{color:#f85149}
272
+ </style></head><body>
273
+ <h1>dsh-remote 设置</h1>
274
+ <div class="card"><h3 style="margin:0 0 8px">📱 远程控制地址(当前模式:${mode})</h3>
275
+ <div class="url">${remoteUrl || "(未配置)"}</div>
276
+ <div style="font-size:12px;color:#8b949e;margin-top:8px">手机浏览器打开此地址,即可远程控制本机 dsh web。</div></div>
277
+ <div class="card"><h3 style="margin:0 0 4px">🔑 连接配置</h3>
278
+ <div style="font-size:12px;color:#8b949e;margin-bottom:8px">二选一:填手机号密码(SaaS),或填服务器地址+访问密钥(自建)。</div>
279
+ <label>SaaS 手机号</label><input id="phone" value="${cfg.phone || ""}" autocomplete="tel"/>
280
+ <label>SaaS 密码</label><input id="pass" type="password" placeholder="••••••••" autocomplete="current-password"/>
281
+ <div style="height:1px;background:#30363d;margin:16px 0"></div>
282
+ <label>自建服务器地址(wss://host:port)</label><input id="server" value="${cfg.tunnel_url || ""}" placeholder="wss://relay.example.com"/>
283
+ <label>自建访问密钥</label><input id="lkey" type="password" value="${cfg.local_key || ""}" placeholder="访问密钥"/>
284
+ <button onclick="save()">保存并生效</button>
285
+ <div class="msg" id="msg"></div></div>
286
+ <script>
287
+ async function save(){
288
+ const m=document.getElementById("msg");m.className="msg";m.textContent="保存中...";
289
+ const local = document.getElementById("lkey").value.trim() !== "" || document.getElementById("server").value.trim() !== "";
290
+ try{
291
+ const r=await fetch("/api/config",{method:"POST",headers:{"content-type":"application/json"},
292
+ body:JSON.stringify(local
293
+ ? {server:document.getElementById("server").value.trim(),local_key:document.getElementById("lkey").value.trim()}
294
+ : {phone:document.getElementById("phone").value.trim(),password:document.getElementById("pass").value})});
295
+ const d=await r.json();
296
+ if(d.ok){m.className="msg ok";m.textContent = d.service==="running" ? "✅ 已保存并生效,无需手动重启" : "✅ 已保存(服务状态: "+(d.service||"未知")+",可运行 dsh-remote setup 修复)";}
297
+ else{m.className="msg err";m.textContent=d.message||"保存失败";}
298
+ }catch(e){m.className="msg err";m.textContent="保存失败: "+e.message;}
299
+ }
300
+ </script></body></html>`;
301
+ return send(200, html);
302
+ });
303
+ server.listen(SETTINGS_PORT, "127.0.0.1", () => {
304
+ console.log(`\n✅ 设置页已打开: http://127.0.0.1:${SETTINGS_PORT}`);
305
+ console.log(" 在浏览器中配置账号或自建连接,查看远程控制地址。Ctrl-C 关闭。\n");
306
+ });
307
+ }
308
+
309
+ // ---------- run:前台跑 bridge(带配置 + 自启动 watcher) ----------
310
+ async function runBridge() {
311
+ let cfg = loadConfig();
312
+ let warnedNoLogin = false;
313
+
314
+ // watcher:检测 dsh web(127.0.0.1:3080)存活,存活才启动 bridge
315
+ const checkUpstream = () => new Promise((resolve) => {
316
+ const t = setTimeout(() => resolve(false), 2000);
317
+ fetch("http://127.0.0.1:3080/")
318
+ .then(() => { clearTimeout(t); resolve(true); })
319
+ .catch(() => { clearTimeout(t); resolve(false); });
320
+ });
321
+
322
+ console.log("[dsh-remote] 等待 dsh web(127.0.0.1:3080)启动...");
323
+ let bridgeProc = null;
324
+ let starting = false;
325
+
326
+ const ensureBridge = async () => {
327
+ if (starting) return;
328
+ // 每次循环重读配置:设置页登录/切换模式后无需重启守护即可生效
329
+ cfg = loadConfig();
330
+ const saas = Boolean((cfg.phone || cfg.email) && cfg.password);
331
+ const local = Boolean(cfg.local_key);
332
+ if (!saas && !local) {
333
+ if (!warnedNoLogin) {
334
+ warnedNoLogin = true;
335
+ console.log("⚠️ 尚未登录:打开设置页 `dsh-remote settings` 登录账号(或配置自建密钥)后自动启动。");
336
+ }
337
+ return;
338
+ }
339
+ warnedNoLogin = false;
340
+ if (local && !cfg.tunnel_url) {
341
+ console.log("⚠️ 自建模式缺少服务器地址:请用 `dsh-remote setup --server wss://host:port --key <密钥>` 重新配置。");
342
+ return;
343
+ }
344
+ if (saas && !cfg.tunnel_url) {
345
+ cfg.tunnel_url = deriveTunnelUrl(cfg.api_url || DEFAULT_API);
346
+ saveConfig(cfg);
347
+ }
348
+ const apiUrl = saas ? (cfg.api_url || DEFAULT_API) : "";
349
+
350
+ const alive = await checkUpstream();
351
+ if (alive && childStopped(bridgeProc)) {
352
+ starting = true;
353
+ console.log("[dsh-remote] dsh web 在线,启动 bridge...");
354
+ // 清除代理环境变量(bridge 需直连 relay,不受本机代理影响)
355
+ const childEnv = {
356
+ ...process.env,
357
+ DSH_BRIDGE_CONFIG: CONFIG_PATH,
358
+ DSH_BRIDGE_TUNNEL_URL: cfg.tunnel_url,
359
+ ...(saas ? { DSH_BRIDGE_PHONE: (cfg.phone || cfg.email || ""), DSH_BRIDGE_PASSWORD: cfg.password, DSH_BRIDGE_API: apiUrl, DSH_BRIDGE_SECRET: cfg.bridge_secret || "" } : {}),
360
+ ...(local ? { DSH_BRIDGE_LOCAL_KEY: cfg.local_key } : {})
361
+ };
362
+ for (const k of ["http_proxy", "https_proxy", "all_proxy", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "no_proxy", "NO_PROXY"]) {
363
+ delete childEnv[k];
364
+ }
365
+ bridgeProc = spawn(NODE_BIN,
366
+ [path.join(THIS_DIR, "clients/dsh-remote/dsh-bridge.mjs")],
367
+ { env: childEnv, stdio: "inherit" });
368
+ bridgeProc.on("exit", () => { console.log("[dsh-remote] bridge 退出,等待重启..."); });
369
+ setTimeout(() => { starting = false; }, 5000);
370
+ } else if (!alive && bridgeProc && bridgeProc.exitCode === null) {
371
+ console.log("[dsh-remote] dsh web 离线,停止 bridge...");
372
+ bridgeProc.kill();
373
+ }
374
+ };
375
+
376
+ await ensureBridge();
377
+ setInterval(ensureBridge, 10000); // 每 10s 检查
378
+ console.log("[dsh-remote] 守护运行中(Ctrl-C 退出)");
379
+ }
380
+
381
+ // ---------- setup:一键安装(默认云端服务;--server/--key 走自建) ----------
382
+ async function setup(argv) {
383
+ const api = argValue(argv, "--api");
384
+ const server = argValue(argv, "--server");
385
+ const key = argValue(argv, "--key");
386
+ const noAutostart = hasFlag(argv, "--no-autostart");
387
+ const noPlugin = hasFlag(argv, "--no-plugin");
388
+ const selfHosted = Boolean(server || key);
389
+
390
+ let cfg = loadConfig();
391
+
392
+ if (selfHosted) {
393
+ if (!server || !key) {
394
+ console.error("❌ 自建模式需要 --server(wss://host:port)与 --key(访问密钥)两个参数。");
395
+ process.exit(1);
396
+ }
397
+ cfg.tunnel_url = normalizeTunnelUrl(server);
398
+ cfg.local_key = String(key).trim();
399
+ delete cfg.phone; delete cfg.password;
400
+ saveConfig(cfg);
401
+
402
+ // 校验:用访问密钥向 router 换本地 JWT(连不通立即报错)
403
+ const u = new URL(cfg.tunnel_url);
404
+ u.protocol = u.protocol === "wss:" ? "https:" : "http:";
405
+ u.pathname = "/_login";
406
+ try {
407
+ const r = await fetch(u.toString(), {
408
+ method: "POST",
409
+ headers: { "content-type": "application/json" },
410
+ body: JSON.stringify({ key: cfg.local_key }),
411
+ signal: AbortSignal.timeout(8000)
412
+ });
413
+ const d = await r.json().catch(() => ({}));
414
+ if (r.status !== 200 || !d.token) {
415
+ console.error(`❌ 访问密钥校验失败(${r.status}): ${d.error?.message || "未知错误"}`);
416
+ process.exit(1);
417
+ }
418
+ console.log("✅ 已连接你的 relay-router,访问密钥有效。");
419
+ } catch (e) {
420
+ console.error(`❌ 无法连接 ${u.toString()}: ${e.message}`);
421
+ console.error(" 请确认服务器地址、端口与 TLS 配置(自建需 https/wss 入口)。");
422
+ process.exit(1);
423
+ }
424
+ } else {
425
+ // 默认云端服务:无需任何参数;登录在设置页完成
426
+ cfg.api_url = (api || cfg.api_url || DEFAULT_API).replace(/\/+$/, "");
427
+ if (!cfg.tunnel_url) cfg.tunnel_url = deriveTunnelUrl(cfg.api_url);
428
+ // 自动获取服务端下发的 bridge_secret(device-login 共享密钥,一键安装开箱即用)
429
+ if (!cfg.bridge_secret) {
430
+ const pub = await fetchPublicConfig(cfg.api_url);
431
+ if (pub.bridge_secret) cfg.bridge_secret = String(pub.bridge_secret);
432
+ }
433
+ saveConfig(cfg);
434
+ }
435
+
436
+ let svc = { path: null, status: { ok: true, status: "skipped", detail: "--no-autostart" } };
437
+ if (!noAutostart) {
438
+ svc = installAutostart();
439
+ } else {
440
+ console.log("ℹ --no-autostart:跳过自启动服务安装(可用 `dsh-remote run` 手动运行 bridge)。");
441
+ }
442
+ const st = svc.status;
443
+
444
+ // 自动安装 dsh web 插件(非致命:失败只提示,不阻断安装)
445
+ if (!noPlugin) {
446
+ try {
447
+ await pluginCmd([]);
448
+ } catch (e) {
449
+ console.warn(`⚠️ 插件安装未完成:${e.message}`);
450
+ }
451
+ }
452
+
453
+ const pub = await fetchPublicConfig(cfg.api_url || DEFAULT_API);
454
+ console.log("\n══════════════════════════════════════");
455
+ console.log("✅ 安装完成!");
456
+ if (selfHosted) {
457
+ console.log(` 服务器地址: ${cfg.tunnel_url}`);
458
+ console.log(` 手机端: 打开 ${cfg.tunnel_url.replace(/^ws/, "https")}/app/ ,用访问密钥登录即可。`);
459
+ } else {
460
+ console.log(` 远程控制地址: ${pub.app_url || DEFAULT_APP_URL}`);
461
+ console.log(` 下一步: 运行 \`dsh-remote settings\` 打开设置页,用手机号+密码登录(或注册)。`);
462
+ console.log(` 登录后 bridge 会自动启动,手机端即可看到本机。`);
463
+ }
464
+ if (svc.path) console.log(` 自启动服务: ${svc.path}`);
465
+ console.log(` 服务状态: ${st.ok ? "✅ 运行中" + (st.pid ? ` (pid=${st.pid})` : "") : "❌ 未运行(" + (st.detail || st.status) + ")"}`);
466
+ console.log("══════════════════════════════════════");
467
+ }
468
+
469
+ // ---------- plugin:安装/卸载 dsh web 远程控制插件 ----------
470
+ const PLUGIN_MARKER_START = "# >>> dsh-remote-ui (managed by dsh-remote plugin; do not edit)";
471
+ const PLUGIN_MARKER_END = "# <<< dsh-remote-ui";
472
+
473
+ function pluginBlock(relayDir) {
474
+ return `${PLUGIN_MARKER_START}
475
+ - insert:
476
+ - id: dsh-remote-ui
477
+ name: 'dsh-remote-ui'
478
+ config:
479
+ relayDir: '${relayDir}'
480
+ ${PLUGIN_MARKER_END}`;
481
+ }
482
+
483
+ /** 移除 patch 中所有引用 dsh-remote-ui 的条目块(含其前置注释),返回剩余内容。 */
484
+ function stripPluginEntries(patch) {
485
+ const lines = patch.split("\n");
486
+ const out = [];
487
+ let i = 0;
488
+ while (i < lines.length) {
489
+ const line = lines[i];
490
+ if (/^- insert:\s*$/.test(line)) {
491
+ // insert 块 = 该行 + 后续「缩进」行;空行/注释行属于下一个条目,不并入
492
+ const block = [line];
493
+ let j = i + 1;
494
+ while (j < lines.length && (lines[j].startsWith(" ") || lines[j].startsWith("\t"))) {
495
+ block.push(lines[j]);
496
+ j++;
497
+ }
498
+ if (block.join("\n").includes("dsh-remote-ui")) {
499
+ // 连带删除块前的连续注释(旧版条目说明 / 管理标记),以及块后的收尾标记行
500
+ while (out.length && /^\s*#/.test(out[out.length - 1])) out.pop();
501
+ if (j < lines.length && /^\s*#/.test(lines[j]) && lines[j].includes("dsh-remote-ui")) j++;
502
+ i = j;
503
+ continue;
504
+ }
505
+ out.push(...block);
506
+ i = j;
507
+ continue;
508
+ }
509
+ out.push(line);
510
+ i++;
511
+ }
512
+ return out.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
513
+ }
514
+
515
+ /** 在 profile 目录执行依赖安装(pnpm 优先)。 */
516
+ function installProfileDeps(profileDir) {
517
+ const pm = sh("command -v pnpm >/dev/null 2>&1 && echo pnpm || echo npm").stdout.trim() || "npm";
518
+ const cmd = pm === "pnpm" ? "pnpm install" : "npm install";
519
+ return sh(cmd, 120000, profileDir);
520
+ }
521
+
522
+ async function pluginCmd(argv) {
523
+ const uninstall = hasFlag(argv, "--uninstall");
524
+ const profileIdx = argv.indexOf("--profile");
525
+ const profileDir = profileIdx > -1 && argv[profileIdx + 1]
526
+ ? argv[profileIdx + 1]
527
+ : process.env.DSH_PROFILE_DIR || path.join(os.homedir(), ".dsh", "profiles", "web");
528
+ const pkgFile = path.join(profileDir, "package.json");
529
+ const patchFile = path.join(profileDir, "cordis.patch.yml");
530
+ const pluginDir = path.join(THIS_DIR, "packages/dsh-remote-ui");
531
+
532
+ if (!fs.existsSync(pkgFile) || !fs.existsSync(patchFile)) {
533
+ console.error(`❌ 未找到 dsh web profile(${profileDir})。`);
534
+ console.error(" 请先安装 DeepSeek Harness(npx @deepseek-ai/dsh web)并初始化默认 profile。");
535
+ process.exit(1);
536
+ }
537
+ if (!fs.existsSync(pluginDir)) {
538
+ console.error(`❌ 本包缺少 packages/dsh-remote-ui(${pluginDir})。`);
539
+ process.exit(1);
540
+ }
541
+
542
+ const patch = fs.readFileSync(patchFile, "utf8");
543
+ const pkg = JSON.parse(fs.readFileSync(pkgFile, "utf8"));
544
+
545
+ if (uninstall) {
546
+ // 移除 patch 中的插件条目(兼容旧版无标记条目)
547
+ const newPatch = stripPluginEntries(patch);
548
+ if (newPatch !== patch) {
549
+ fs.writeFileSync(patchFile, newPatch);
550
+ console.log(`✅ 已从 ${patchFile} 移除插件条目`);
551
+ } else {
552
+ console.log("ℹ patch 中未发现 dsh-remote-ui 条目。");
553
+ }
554
+ if (pkg.dependencies && pkg.dependencies["dsh-remote-ui"]) {
555
+ delete pkg.dependencies["dsh-remote-ui"];
556
+ fs.writeFileSync(pkgFile, JSON.stringify(pkg, null, 2) + "\n");
557
+ console.log(`✅ 已从 ${pkgFile} 移除 dsh-remote-ui 依赖`);
558
+ }
559
+ console.log(" 执行依赖更新...");
560
+ const r = installProfileDeps(profileDir);
561
+ if (!r.ok) console.warn(`⚠️ 依赖更新失败:${r.stderr.trim() || r.stdout.trim()}(请手动在 ${profileDir} 执行 pnpm install)`);
562
+ console.log("✅ 卸载完成。重启 dsh web 生效。");
563
+ return;
564
+ }
565
+
566
+ // 安装:写依赖 + patch 块
567
+ pkg.dependencies = pkg.dependencies || {};
568
+ pkg.dependencies["dsh-remote-ui"] = `link:${pluginDir}`;
569
+ fs.writeFileSync(pkgFile, JSON.stringify(pkg, null, 2) + "\n");
570
+
571
+ // 先清掉旧条目(含旧版无标记条目),再写入带标记的新块,保证不重复
572
+ const stripped = stripPluginEntries(patch);
573
+ const block = pluginBlock(CONFIG_DIR);
574
+ fs.writeFileSync(patchFile, stripped + block + "\n");
575
+ console.log(`✅ 已写入 ${patchFile}`);
576
+ console.log(" 执行依赖更新(pnpm install)...");
577
+ const r = installProfileDeps(profileDir);
578
+ if (!r.ok) console.warn(`⚠️ 依赖更新失败:${r.stderr.trim() || r.stdout.trim()}(请手动在 ${profileDir} 执行 pnpm install)`);
579
+ console.log(`✅ 插件安装完成。配置目录: ${CONFIG_DIR}`);
580
+ console.log(" 重启 dsh web(或重开 profile)后,在「设置 → 远程控制」查看面板。");
581
+ }
582
+
583
+ // ---------- main ----------
584
+ // 无命令名(或首个参数以 - 开头)时默认执行 setup
585
+ const raw = process.argv[2];
586
+ const cmd = raw && !raw.startsWith("-") ? raw : "setup";
587
+ const args = raw && !raw.startsWith("-") ? process.argv.slice(3) : process.argv.slice(2);
588
+ if (cmd === "setup" || cmd === "install") await setup(args);
589
+ else if (cmd === "settings") serveSettings();
590
+ else if (cmd === "run") await runBridge();
591
+ else if (cmd === "plugin") await pluginCmd(process.argv.slice(3));
592
+ else if (cmd === "status") {
593
+ const cfg = loadConfig();
594
+ const local = Boolean(cfg.local_key);
595
+ console.log("配置文件:", CONFIG_PATH);
596
+ console.log("连接模式:", local ? `自建服务(${cfg.tunnel_url || "未设置服务器地址"})` : `SaaS 云端服务(${cfg.phone || "未配置账号"})`);
597
+ console.log("API:", cfg.api_url || (local ? "(自建模式无需账号 API)" : DEFAULT_API));
598
+ console.log("远程地址: 运行 settings 查看最新");
599
+ } else {
600
+ console.log(`dsh-remote — 手机远程控制 dsh web(隧道模式)
601
+
602
+ 用法:
603
+ dsh-remote 一键安装(默认命令,无需任何参数;含插件与自启动)
604
+ dsh-remote settings 打开本地设置页(登录账号 / 自建配置)
605
+ dsh-remote run 前台运行 bridge(调试)
606
+ dsh-remote status 查看配置与服务状态
607
+ dsh-remote plugin 手动安装 dsh web 远程控制插件(--uninstall 卸载)
608
+
609
+ 自建模式(可选):
610
+ dsh-remote setup --server wss://你的域名:端口 --key 访问密钥
611
+
612
+ 登录: 安装后运行 \`dsh-remote settings\`,用手机号+密码登录(或配置自建密钥)。
613
+ 文档: ${REPO_URL}
614
+ `);
615
+ process.exit(cmd === "help" ? 0 : 1);
616
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@mrrisega/dsh-remote",
3
+ "version": "0.3.0",
4
+ "description": "Remote control for DeepSeek Harness (dsh web) from any phone browser: tunnel-mode relay client with one-command install, self-hosting support, and a dsh web plugin panel",
5
+ "type": "module",
6
+ "license": "PolyForm-Noncommercial-1.0.0",
7
+ "private": false,
8
+ "bin": {
9
+ "dsh-remote": "dsh-setup.mjs"
10
+ },
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "files": [
15
+ "dsh-setup.mjs",
16
+ "clients/dsh-remote",
17
+ "packages/dsh-remote-ui",
18
+ "docs/self-hosting.md"
19
+ ],
20
+ "workspaces": [
21
+ "packages/*",
22
+ "clients/*"
23
+ ],
24
+ "engines": {
25
+ "node": ">=20"
26
+ },
27
+ "scripts": {
28
+ "test": "npm run test:router && npm run test:plugin && npm run test:bridge",
29
+ "test:router": "node --test packages/relay-router/test/native-device-selection.test.mjs packages/relay-router/test/quotas.test.mjs",
30
+ "test:plugin": "node --test --test-concurrency=1 packages/dsh-remote-ui/test/*.test.mjs",
31
+ "test:bridge": "node --test clients/dsh-remote/test/*.test.mjs",
32
+ "check": "node --check clients/dsh-remote/dsh-bridge.mjs && node --check dsh-setup.mjs && node --check packages/dsh-remote-ui/lib/index.js && node --check packages/dsh-remote-ui/lib/client.js && bash -n deploy/install-open.sh"
33
+ },
34
+ "dependencies": {
35
+ "ws": "^8.18.0"
36
+ }
37
+ }