@wenbin_wb/dsh-bridge 2.10.6 → 2.10.8
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/CHANGELOG.md +27 -0
- package/README.md +1 -1
- package/client/client.js +395 -5
- package/client/index.js +334 -3
- package/client/mobile-styles.js +68 -0
- package/docs/cloudflare-fixed-domain.md +158 -0
- package/lib/auth/manager.js +54 -4
- package/lib/bridge-rpc-constants.js +1 -0
- package/lib/bridge-rpc.js +8 -0
- package/lib/cloudflared-manager.mjs +31 -4
- package/lib/index.js +158 -2
- package/package.json +3 -3
package/lib/auth/manager.js
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
|
|
4
4
|
import { randomBytes, pbkdf2 as pbkdf2Callback, timingSafeEqual } from 'node:crypto'
|
|
5
5
|
import { promisify } from 'node:util'
|
|
6
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
7
|
+
import { homedir } from 'node:os'
|
|
8
|
+
import { join, dirname } from 'node:path'
|
|
6
9
|
|
|
7
10
|
const pbkdf2Async = promisify(pbkdf2Callback)
|
|
8
11
|
|
|
@@ -18,6 +21,50 @@ const LEGACY_PBKDF2_ITERATIONS = 10000
|
|
|
18
21
|
const PBKDF2_KEYLEN = 32
|
|
19
22
|
const PBKDF2_PREFIX = 'pbkdf2-sha256$'
|
|
20
23
|
|
|
24
|
+
// 登录 Session 持久化:宿主(dsh web 进程)重启后,已登录设备免重输访问密码。
|
|
25
|
+
// 落盘文件与 config.json 同目录(~/.dsh/dsh-bridge/sessions.json,权限 600);
|
|
26
|
+
// sessionToken 与 secretToken 同级机密(48 位随机 hex),同目录存储不扩大暴露面。
|
|
27
|
+
// 改密码 / 切模式 / 重新生成 token 时的 sessions.clear() 会同步清空文件(吊销语义保持);
|
|
28
|
+
// 文件缺失或损坏时安全降级为空 Map(等价于旧行为:重新登录一次)。
|
|
29
|
+
// 路径可通过构造参数 sessionsFile 注入(测试传临时目录,避免读写真实环境)。
|
|
30
|
+
const DEFAULT_SESSIONS_FILE = join(
|
|
31
|
+
process.env.DSH_HOME ?? join(homedir(), '.dsh'),
|
|
32
|
+
'dsh-bridge', 'sessions.json'
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
class PersistentSessionMap extends Map {
|
|
36
|
+
/** @param {string} file 会话落盘文件路径 */
|
|
37
|
+
constructor(file) {
|
|
38
|
+
super()
|
|
39
|
+
this._file = file
|
|
40
|
+
}
|
|
41
|
+
_flush() {
|
|
42
|
+
try {
|
|
43
|
+
mkdirSync(dirname(this._file), { recursive: true })
|
|
44
|
+
writeFileSync(this._file, JSON.stringify([...this.entries()]), { mode: 0o600 })
|
|
45
|
+
} catch { /* 落盘失败降级为纯内存语义,不阻断认证流程 */ }
|
|
46
|
+
}
|
|
47
|
+
static load(file) {
|
|
48
|
+
const m = new PersistentSessionMap(file)
|
|
49
|
+
try {
|
|
50
|
+
const arr = JSON.parse(readFileSync(file, 'utf8'))
|
|
51
|
+
if (Array.isArray(arr)) {
|
|
52
|
+
const now = Date.now()
|
|
53
|
+
for (const [k, v] of arr) {
|
|
54
|
+
// 只恢复结构合法且未过期的会话
|
|
55
|
+
if (typeof k === 'string' && v && Number.isFinite(v.expiresAt) && v.expiresAt > now) {
|
|
56
|
+
Map.prototype.set.call(m, k, { createdAt: Number(v.createdAt) || 0, expiresAt: Number(v.expiresAt) })
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} catch { /* 文件缺失/损坏:降级为空 */ }
|
|
61
|
+
return m
|
|
62
|
+
}
|
|
63
|
+
set(k, v) { super.set(k, v); this._flush(); return this }
|
|
64
|
+
delete(k) { const r = super.delete(k); if (r) this._flush(); return r }
|
|
65
|
+
clear() { super.clear(); this._flush() }
|
|
66
|
+
}
|
|
67
|
+
|
|
21
68
|
export class AuthManager {
|
|
22
69
|
/**
|
|
23
70
|
* @param {object} opts
|
|
@@ -30,9 +77,10 @@ export class AuthManager {
|
|
|
30
77
|
* @param {boolean} [opts.config.allowLoopback=true]
|
|
31
78
|
* @param {boolean} [opts.config.adminProtection=true] 管理保护独立开关:关闭后管理操作免 adminToken
|
|
32
79
|
* @param {(patch: object) => Promise<void>|void} [opts.onPersist]
|
|
80
|
+
* @param {string} [opts.sessionsFile] 登录会话持久化文件路径(默认 ~/.dsh/dsh-bridge/sessions.json;测试注入临时目录)
|
|
33
81
|
* @param {object} [opts.logger]
|
|
34
82
|
*/
|
|
35
|
-
constructor({ config = {}, onPersist, logger = console } = {}) {
|
|
83
|
+
constructor({ config = {}, onPersist, sessionsFile, logger = console } = {}) {
|
|
36
84
|
this.logger = logger
|
|
37
85
|
this.onPersist = onPersist
|
|
38
86
|
|
|
@@ -51,8 +99,9 @@ export class AuthManager {
|
|
|
51
99
|
// 内部隧道专用鉴权密钥(内存生成,用于辨别本地自建隧道转发流量与真实本机访问)
|
|
52
100
|
this.internalTunnelSecret = randomBytes(24).toString('hex')
|
|
53
101
|
|
|
54
|
-
// Session
|
|
55
|
-
|
|
102
|
+
// Session 存储与重启恢复:sessionToken -> { createdAt, expiresAt }
|
|
103
|
+
// 持久化到 sessions.json(路径可经 sessionsFile 注入),宿主(dsh web)重启后已登录设备免重输访问密码
|
|
104
|
+
this.sessions = PersistentSessionMap.load(sessionsFile ?? DEFAULT_SESSIONS_FILE)
|
|
56
105
|
// 管理员解锁 Session 内存存储:adminToken -> { createdAt, expiresAt }
|
|
57
106
|
this.adminSessions = new Map()
|
|
58
107
|
// 防暴力破解:ip -> { failedCount, lockUntil }
|
|
@@ -540,7 +589,8 @@ export class AuthManager {
|
|
|
540
589
|
|
|
541
590
|
dispose() {
|
|
542
591
|
clearInterval(this._cleanupTimer)
|
|
543
|
-
|
|
592
|
+
// 不清空 sessions:进程退出时内存随进程自然消失;
|
|
593
|
+
// 若此处 clear() 会把持久化文件一并清空,宿主重启后已登录设备仍会全部丢失
|
|
544
594
|
this.rateLimits.clear()
|
|
545
595
|
}
|
|
546
596
|
}
|
package/lib/bridge-rpc.js
CHANGED
|
@@ -289,6 +289,14 @@ export function installBridgeRpc(ctx, { service, authManager, platformManager, l
|
|
|
289
289
|
return ok(result);
|
|
290
290
|
}
|
|
291
291
|
|
|
292
|
+
if (endpoint === BRIDGE_ENDPOINTS.upgradeDsh) {
|
|
293
|
+
const adminErr = checkAdminAuth(authManager, payload, { requireConfigured: true });
|
|
294
|
+
if (adminErr) return adminErr;
|
|
295
|
+
|
|
296
|
+
const result = await service.upgradeDsh(payload);
|
|
297
|
+
return ok(result);
|
|
298
|
+
}
|
|
299
|
+
|
|
292
300
|
if (endpoint === BRIDGE_ENDPOINTS.restartDsh) {
|
|
293
301
|
const adminErr = checkAdminAuth(authManager, payload, { requireConfigured: true });
|
|
294
302
|
if (adminErr) return adminErr;
|
|
@@ -15,6 +15,14 @@ const RETRY_BASE_MS = 5 * 1000; // 自愈退避起点 5s
|
|
|
15
15
|
const RETRY_MAX_MS = 5 * 60 * 1000; // 自愈退避封顶 5min
|
|
16
16
|
const DEFAULT_MAX_RETRIES = 12; // 连续失败超过该次数转为 error,不再无限重试
|
|
17
17
|
|
|
18
|
+
// ── 确定性失败特征:cloudflared 因配置/用法错误退出(非网络瞬态)─────────
|
|
19
|
+
// 这类失败重试无意义,识别后直接置 error 让用户看到明确原因,而不是
|
|
20
|
+
// 误判成"意外退出"退避重连 N 次(issue #35 作者建议)。
|
|
21
|
+
function isFatalCloudflaredError(stderrTail) {
|
|
22
|
+
if (!stderrTail) return false;
|
|
23
|
+
return /Incorrect Usage|flag provided but not defined|invalid.*token|unauthorized/i.test(stderrTail);
|
|
24
|
+
}
|
|
25
|
+
|
|
18
26
|
// 上游 release 不提供任何官方校验和文件(已核实 2024.10.0 资产清单),
|
|
19
27
|
// 因此无法做下载校验和比对;退而求其次:记录产物 SHA-256 指纹供事后审计比对。
|
|
20
28
|
async function sha256File(filePath) {
|
|
@@ -181,7 +189,12 @@ export class CloudflaredManager {
|
|
|
181
189
|
this._setState('connecting', '正在初始化...');
|
|
182
190
|
this._run().catch((err) => {
|
|
183
191
|
this.logger?.error('cloudflared 启动失败: %s', err.message);
|
|
184
|
-
//
|
|
192
|
+
// 确定性失败(用法错误/配置错误,如 Incorrect Usage)重试无意义 → 直接 error,
|
|
193
|
+
// 让用户看到明确原因;瞬态失败(网络/握手/秒退)才退避自愈
|
|
194
|
+
if (err && err.fatal) {
|
|
195
|
+
this._setState('error', err.message);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
185
198
|
this._scheduleRestart(`cloudflared 启动失败: ${err.message}`);
|
|
186
199
|
});
|
|
187
200
|
}
|
|
@@ -223,6 +236,11 @@ export class CloudflaredManager {
|
|
|
223
236
|
this._setState('connecting', '正在自动重连...');
|
|
224
237
|
this._run().catch((err) => {
|
|
225
238
|
this.logger?.error('cloudflared 自动重连失败: %s', err.message);
|
|
239
|
+
// 自愈期间遇到确定性失败同样终止重试(如用户改坏配置后重启)
|
|
240
|
+
if (err && err.fatal) {
|
|
241
|
+
this._setState('error', err.message);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
226
244
|
this._scheduleRestart(`cloudflared 启动失败: ${err.message}`);
|
|
227
245
|
});
|
|
228
246
|
}
|
|
@@ -364,7 +382,8 @@ export class CloudflaredManager {
|
|
|
364
382
|
this._setState('connecting', this._restartCount > 0 ? '正在自动重连...' : '正在连接 Cloudflare...');
|
|
365
383
|
|
|
366
384
|
const args = this.token
|
|
367
|
-
|
|
385
|
+
// --no-autoupdate 是 tunnel 子命令的全局 flag,须在 run 之前(cloudflared 2024.10.0,issue #35)
|
|
386
|
+
? ['tunnel', ...(this.noAutoupdate ? ['--no-autoupdate'] : []), 'run', '--token', this.token]
|
|
368
387
|
: ['tunnel', ...(this.noAutoupdate ? ['--no-autoupdate'] : []), '--url', `http://127.0.0.1:${this.port}`];
|
|
369
388
|
|
|
370
389
|
// 隐藏日志中的 token 敏感字段
|
|
@@ -385,6 +404,8 @@ export class CloudflaredManager {
|
|
|
385
404
|
|
|
386
405
|
let resolved = false;
|
|
387
406
|
let timeoutTimer = null;
|
|
407
|
+
// 累积 stderr 尾部(供 exit 时判断确定性失败:CLI 用法错误 / 配置错误)
|
|
408
|
+
let stderrTail = '';
|
|
388
409
|
|
|
389
410
|
// 仅当当前引用的仍是本次 spawn 的进程时才清空——自愈重启后旧进程的
|
|
390
411
|
// exit 事件可能晚于新进程 spawn 触发;exit handler 内用 stillCurrent
|
|
@@ -443,6 +464,7 @@ export class CloudflaredManager {
|
|
|
443
464
|
proc.stderr.on('data', (d) => {
|
|
444
465
|
const text = d.toString();
|
|
445
466
|
this.logger?.debug('cloudflared: %s', text.trim());
|
|
467
|
+
stderrTail = (stderrTail + text).slice(-2000); // 只保留尾部 2KB
|
|
446
468
|
parseUrl(text);
|
|
447
469
|
if (text.includes('Registered tunnel') && !resolved) {
|
|
448
470
|
this._setState('connecting', '隧道已注册,等待就绪...');
|
|
@@ -461,8 +483,13 @@ export class CloudflaredManager {
|
|
|
461
483
|
if (stillCurrent) this.process = null;
|
|
462
484
|
this.url = null;
|
|
463
485
|
if (!resolved) {
|
|
464
|
-
// 就绪前退出 = 启动失败,交给 reject → start()/restart 的 catch
|
|
465
|
-
|
|
486
|
+
// 就绪前退出 = 启动失败,交给 reject → start()/restart 的 catch 进入退避自愈。
|
|
487
|
+
// 若 stderr 显示确定性配置/用法错误(Incorrect Usage 等)→ 标 fatal,
|
|
488
|
+
// catch 收到后直接置 error,不再无意义退避重连。
|
|
489
|
+
const msg = `cloudflared 启动失败: ${isFatalCloudflaredError(stderrTail) ? '配置错误(' + (stderrTail.trim().split('\n').pop() || '请检查 Token 与参数') + ')' : `cloudflared 退出,code=${code ?? ''} signal=${signal ?? ''}`}`;
|
|
490
|
+
const err = new Error(msg);
|
|
491
|
+
if (isFatalCloudflaredError(stderrTail)) err.fatal = true;
|
|
492
|
+
reject(err);
|
|
466
493
|
} else if (!this._stopped && stillCurrent) {
|
|
467
494
|
// 就绪后的意外退出(崩溃 / OOM / 误杀 / autoupdate 残留自替换)→ 退避自愈
|
|
468
495
|
this._scheduleRestart(`cloudflared 进程意外退出 (code=${code ?? ''}${signal ? `, ${signal}` : ''})`);
|
package/lib/index.js
CHANGED
|
@@ -10,7 +10,7 @@ import { get as httpsGet } from 'node:https';
|
|
|
10
10
|
import { networkInterfaces, homedir, totalmem, freemem, cpus, loadavg, platform, arch, release, hostname, uptime } from 'node:os';
|
|
11
11
|
import { join, dirname, basename, resolve, normalize } from 'node:path';
|
|
12
12
|
import { fileURLToPath } from 'node:url';
|
|
13
|
-
import { readFileSync, existsSync } from 'node:fs';
|
|
13
|
+
import { readFileSync, existsSync, realpathSync } from 'node:fs';
|
|
14
14
|
import { readFile, writeFile, mkdir, unlink, readdir, stat, access } from 'node:fs/promises';
|
|
15
15
|
import { spawn } from 'node:child_process';
|
|
16
16
|
import { createHash, createHmac } from 'node:crypto';
|
|
@@ -1033,6 +1033,63 @@ class BridgeService {
|
|
|
1033
1033
|
}
|
|
1034
1034
|
|
|
1035
1035
|
// 检查 npm 上是否有新版本(优先国内高速镜像 npmmirror,降级 npmjs 官方源)
|
|
1036
|
+
// 探测 DSH 宿主 CLI 的安装形态,判断能否通过"npm 全局升级"自动更新。
|
|
1037
|
+
// 仅当 dsh 命令 resolve 到当前 node 同 prefix 的 node_modules/@deepseek-ai/dsh(标准 npm 全局安装)
|
|
1038
|
+
// 时才允许一键升级;pnpm / 源码 / Electron 加壳等形态无法用 npm -g 升级,仅返回提示。
|
|
1039
|
+
// @returns {Promise<{upgradable:boolean, rootGlobal?:string, dshRealPath?:string, reason?:string}>}
|
|
1040
|
+
async _probeDshUpgrade() {
|
|
1041
|
+
const isWin = process.platform === 'win32';
|
|
1042
|
+
const nodeDir = dirname(process.execPath);
|
|
1043
|
+
const existingPath = process.env.PATH || process.env.Path || '';
|
|
1044
|
+
const extraPaths = isWin ? [nodeDir] : [nodeDir, '/opt/homebrew/bin', '/opt/homebrew/sbin', '/usr/local/bin', '/usr/bin', '/bin', join(homedir(), '.local/bin'), join(homedir(), '.npm-global/bin')];
|
|
1045
|
+
const separator = isWin ? ';' : ':';
|
|
1046
|
+
const augmentedEnv = { ...process.env, PATH: [...extraPaths, existingPath].filter(Boolean).join(separator) };
|
|
1047
|
+
if (isWin) augmentedEnv.Path = augmentedEnv.PATH;
|
|
1048
|
+
|
|
1049
|
+
const runCmd = (cmd, args) => new Promise((resolve) => {
|
|
1050
|
+
let cp;
|
|
1051
|
+
try {
|
|
1052
|
+
cp = spawn(cmd, args, { windowsHide: true, shell: true, env: augmentedEnv, timeout: 8000 });
|
|
1053
|
+
} catch {
|
|
1054
|
+
return resolve(null);
|
|
1055
|
+
}
|
|
1056
|
+
let stdout = ''; let stderr = '';
|
|
1057
|
+
cp.stdout?.on('data', (d) => { stdout += d.toString(); });
|
|
1058
|
+
cp.stderr?.on('data', (d) => { stderr += d.toString(); });
|
|
1059
|
+
cp.on('error', () => resolve(null));
|
|
1060
|
+
cp.on('close', (code) => {
|
|
1061
|
+
if (code === 0 && stdout.trim()) resolve(stdout.trim().split(/\r?\n/)[0]);
|
|
1062
|
+
else resolve(null);
|
|
1063
|
+
});
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
try {
|
|
1067
|
+
// 1. 找 dsh bin 的真实路径(Windows which 不存在,用 where)
|
|
1068
|
+
const dshBin = await runCmd(isWin ? 'where' : 'which', [isWin ? 'dsh.cmd' : 'dsh']);
|
|
1069
|
+
if (!dshBin) {
|
|
1070
|
+
return { upgradable: false, reason: '未找到 dsh 命令(可能通过源码/打包方式运行,非 npm 全局安装)' };
|
|
1071
|
+
}
|
|
1072
|
+
// realpath 在主进程内解析(子进程 node -e + shell 引号嵌套易出错)
|
|
1073
|
+
let dshRealPath = dshBin;
|
|
1074
|
+
try {
|
|
1075
|
+
dshRealPath = realpathSync(dshBin) || dshBin;
|
|
1076
|
+
} catch { /* 保留原始路径继续判断 */ }
|
|
1077
|
+
// 2. 当前 node 对应的全局 node_modules 根
|
|
1078
|
+
const globalNodeModules = await runCmd('npm', ['root', '-g']);
|
|
1079
|
+
const rootGlobal = globalNodeModules ? dirname(globalNodeModules) : dirname(nodeDir);
|
|
1080
|
+
const marker = join('node_modules', '@deepseek-ai', 'dsh');
|
|
1081
|
+
if (dshRealPath.includes(marker) && (dshRealPath.startsWith(rootGlobal) || dshRealPath.includes('node-v') && dshRealPath.includes('lib'))) {
|
|
1082
|
+
return { upgradable: true, rootGlobal, dshRealPath };
|
|
1083
|
+
}
|
|
1084
|
+
return {
|
|
1085
|
+
upgradable: false,
|
|
1086
|
+
reason: 'dsh 非标准 npm 全局安装(Electron/打包/源码/pnpm 等),无法自动升级;请按官方渠道手动更新',
|
|
1087
|
+
};
|
|
1088
|
+
} catch {
|
|
1089
|
+
return { upgradable: false, reason: '探测 dsh 安装形态失败,请按官方渠道手动更新' };
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1036
1093
|
async checkVersion() {
|
|
1037
1094
|
// TTL 缓存:窗口内直接返回上次结果,避免重复请求外网 registry
|
|
1038
1095
|
if (this._versionCheckCache && Date.now() - this._versionCheckCachedAt < this._versionCheckTtlMs) {
|
|
@@ -1062,18 +1119,27 @@ class BridgeService {
|
|
|
1062
1119
|
try {
|
|
1063
1120
|
const latestData = await fetchRegistry('https://registry.npmmirror.com/@wenbin_wb/dsh-bridge/latest', 3500)
|
|
1064
1121
|
.catch(() => fetchRegistry('https://registry.npmjs.org/@wenbin_wb/dsh-bridge/latest', 5000));
|
|
1122
|
+
// DSH 宿主 CLI 最新版本(npm 全局包 @deepseek-ai/dsh;与插件同源镜像,失败不阻断面板)
|
|
1123
|
+
const dshLatestData = await fetchRegistry('https://registry.npmmirror.com/@deepseek-ai/dsh/latest', 3500)
|
|
1124
|
+
.catch(() => fetchRegistry('https://registry.npmjs.org/@deepseek-ai/dsh/latest', 5000))
|
|
1125
|
+
.catch(() => null);
|
|
1126
|
+
// 安装形态探测(决定是否给"一键升级 DSH"按钮):npm 全局可升级,加壳/源码仅提醒
|
|
1127
|
+
const dshProbe = await this._probeDshUpgrade();
|
|
1065
1128
|
const result = {
|
|
1066
1129
|
current: VERSION,
|
|
1067
1130
|
latest: latestData?.version ?? null,
|
|
1068
1131
|
releaseNotes: latestData?.releaseNotes ?? null,
|
|
1069
1132
|
dshVersion: await this.getDshVersion(),
|
|
1133
|
+
dshLatest: dshLatestData?.version ?? null,
|
|
1134
|
+
dshUpgradable: dshProbe.upgradable,
|
|
1135
|
+
dshUpgradeReason: dshProbe.reason || '',
|
|
1070
1136
|
};
|
|
1071
1137
|
this._versionCheckCache = result;
|
|
1072
1138
|
this._versionCheckCachedAt = Date.now();
|
|
1073
1139
|
return result;
|
|
1074
1140
|
} catch (e) {
|
|
1075
1141
|
// 失败不缓存(让下次调用可重试),但记录便于诊断
|
|
1076
|
-
return { current: VERSION, latest: null, error: e.message ?? '检查失败', dshVersion: await this.getDshVersion() };
|
|
1142
|
+
return { current: VERSION, latest: null, error: e.message ?? '检查失败', dshVersion: await this.getDshVersion(), dshLatest: null, dshUpgradable: false, dshUpgradeReason: '' };
|
|
1077
1143
|
}
|
|
1078
1144
|
}
|
|
1079
1145
|
|
|
@@ -1165,6 +1231,96 @@ class BridgeService {
|
|
|
1165
1231
|
return { ok: false, error: lastError?.message ?? '升级命令执行失败', version: targetVersion };
|
|
1166
1232
|
}
|
|
1167
1233
|
|
|
1234
|
+
// 一键升级 DSH 宿主 CLI(npm 全局包 @deepseek-ai/dsh)。
|
|
1235
|
+
// 升级的是"与当前 node 配对"的全局 prefix(dsh 命令所在目录),完成后需重启 DSH 生效。
|
|
1236
|
+
async upgradeDsh({ version } = {}) {
|
|
1237
|
+
const targetVersion = version ? String(version).trim() : 'latest';
|
|
1238
|
+
// 严格 SemVer 白名单校验(dsh 用 rc 版本号,如 0.1.2-rc.1)
|
|
1239
|
+
if (!/^(latest|\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?)$/.test(targetVersion)) {
|
|
1240
|
+
return { ok: false, error: `非法的版本号格式: ${targetVersion}`, version: targetVersion };
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
// 安装形态守卫:非标准 npm 全局安装(Electron/源码/pnpm 等)拒绝自动升级,
|
|
1244
|
+
// 避免"装一份不生效的新副本、误导用户以为升级成功"
|
|
1245
|
+
const probe = await this._probeDshUpgrade();
|
|
1246
|
+
if (!probe.upgradable) {
|
|
1247
|
+
return {
|
|
1248
|
+
ok: false,
|
|
1249
|
+
error: probe.reason || '当前 DSH 为非 npm 全局安装,无法自动升级;请按官方渠道手动更新',
|
|
1250
|
+
version: targetVersion,
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
const pkgSpec = `@deepseek-ai/dsh@${targetVersion}`;
|
|
1255
|
+
const isWin = process.platform === 'win32';
|
|
1256
|
+
const nodeDir = dirname(process.execPath);
|
|
1257
|
+
const home = homedir();
|
|
1258
|
+
const extraPaths = isWin ? [nodeDir] : [
|
|
1259
|
+
nodeDir,
|
|
1260
|
+
'/opt/homebrew/bin',
|
|
1261
|
+
'/opt/homebrew/sbin',
|
|
1262
|
+
'/usr/local/bin',
|
|
1263
|
+
'/usr/bin',
|
|
1264
|
+
'/bin',
|
|
1265
|
+
join(home, '.nvm/current/bin'),
|
|
1266
|
+
join(home, '.fnm/current/bin'),
|
|
1267
|
+
join(home, '.local/bin'),
|
|
1268
|
+
join(home, '.cargo/bin'),
|
|
1269
|
+
];
|
|
1270
|
+
const separator = isWin ? ';' : ':';
|
|
1271
|
+
const existingPath = process.env.PATH || process.env.Path || '';
|
|
1272
|
+
const augmentedEnv = {
|
|
1273
|
+
...process.env,
|
|
1274
|
+
PATH: [...extraPaths, existingPath].filter(Boolean).join(separator),
|
|
1275
|
+
};
|
|
1276
|
+
if (isWin) augmentedEnv.Path = augmentedEnv.PATH;
|
|
1277
|
+
|
|
1278
|
+
// 与当前 node 配对的 npm 绝对路径(保证装进 dsh 所在 prefix,而非 PATH 里其它 npm)
|
|
1279
|
+
const siblingNpm = join(nodeDir, isWin ? 'npm.cmd' : 'npm');
|
|
1280
|
+
const npmBin = existsSync(siblingNpm) ? siblingNpm : 'npm';
|
|
1281
|
+
// 优先用探测到的全局 root(dsh 实际所在 prefix),其次 nodeDir 父目录
|
|
1282
|
+
const globalRoot = probe.rootGlobal || dirname(nodeDir);
|
|
1283
|
+
|
|
1284
|
+
const tasks = [
|
|
1285
|
+
{ cmd: npmBin, args: ['install', '-g', '--prefix', globalRoot, pkgSpec] },
|
|
1286
|
+
{ cmd: npmBin, args: ['install', '-g', pkgSpec] },
|
|
1287
|
+
];
|
|
1288
|
+
|
|
1289
|
+
let lastError = null;
|
|
1290
|
+
for (const task of tasks) {
|
|
1291
|
+
try {
|
|
1292
|
+
const res = await new Promise((resolve, reject) => {
|
|
1293
|
+
let cp;
|
|
1294
|
+
try {
|
|
1295
|
+
cp = spawn(task.cmd, task.args, {
|
|
1296
|
+
windowsHide: true,
|
|
1297
|
+
shell: true,
|
|
1298
|
+
env: augmentedEnv,
|
|
1299
|
+
timeout: 180000,
|
|
1300
|
+
});
|
|
1301
|
+
} catch (spawnErr) {
|
|
1302
|
+
return reject(spawnErr);
|
|
1303
|
+
}
|
|
1304
|
+
let stdout = '';
|
|
1305
|
+
let stderr = '';
|
|
1306
|
+
cp.stdout?.on('data', (d) => { stdout += d.toString(); });
|
|
1307
|
+
cp.stderr?.on('data', (d) => { stderr += d.toString(); });
|
|
1308
|
+
cp.on('error', reject);
|
|
1309
|
+
cp.on('close', (code) => {
|
|
1310
|
+
if (code === 0) resolve({ stdout, stderr });
|
|
1311
|
+
else reject(new Error(stderr || stdout || `进程退出码 ${code}`));
|
|
1312
|
+
});
|
|
1313
|
+
});
|
|
1314
|
+
const output = (res.stdout || res.stderr || '升级成功').trim().slice(-500);
|
|
1315
|
+
this.logger?.info('dsh-bridge: DSH 升级命令成功: %s %s', task.cmd, task.args.join(' '));
|
|
1316
|
+
return { ok: true, command: `${task.cmd} ${task.args.join(' ')}`, output, version: targetVersion };
|
|
1317
|
+
} catch (err) {
|
|
1318
|
+
lastError = err;
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
return { ok: false, error: lastError?.message ?? '升级命令执行失败', version: targetVersion };
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1168
1324
|
// 优雅重启 DSH 服务(支持守护进程自动拉起或独立派生子进程重启)
|
|
1169
1325
|
async restartDsh() {
|
|
1170
1326
|
this.logger?.info('收到 DSH 重启请求,正在调度重启...');
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wenbin_wb/dsh-bridge",
|
|
3
|
-
"version": "2.10.
|
|
3
|
+
"version": "2.10.8",
|
|
4
4
|
"description": "手机扫码即可在移动端/公网继续用 DeepSeek Harness,人不在电脑前也能接着干。一键局域网二维码、Cloudflare 公网隧道、自建隧道与微信 / QQ / 飞书 / Telegram Bot(多工作区/会话持久化/媒体/卡片审批/流式输出),无需自己搭公网服务器。",
|
|
5
|
-
"releaseNotes": "【v2.10.
|
|
5
|
+
"releaseNotes": "【v2.10.8】\n• 新增:登录 Session 持久化(#36)——宿主 dsh web 重启后已登录设备免重输访问密码,手机远程访问不再每天重登;改密码/切换模式仍同步吊销全部旧会话(安全语义保持)\n• 新增:设置页检测 DSH 宿主更新并支持一键升级(npm 全局安装形态)——发现新版黄色提示 + 一键升级按钮;非 npm 安装(源码/打包)仅提醒并给出官方升级方式\n• 修复:iOS Safari 键盘弹起时聊天输入框上下跳动——键盘弹起时固定输入框滚动容器高度,避免随键盘反复重排抖动\n• 新增:移动端输入框可折叠——聊天头部工具栏新增折叠按钮,收起输入框后消息阅读区最大化;底部保留「点击输入消息」细条一键唤回;折叠偏好跨会话记忆",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "lib/index.js",
|
|
8
8
|
"exports": {
|
|
@@ -103,4 +103,4 @@
|
|
|
103
103
|
"access": "public",
|
|
104
104
|
"registry": "https://registry.npmjs.org/"
|
|
105
105
|
}
|
|
106
|
-
}
|
|
106
|
+
}
|