dsh-selfupdater 0.3.4 → 0.4.6
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/client/settings-card.js +18 -6
- package/lib/index.js +38 -141
- package/lib/plugin-update-task.mjs +333 -0
- package/lib/updater.mjs +1 -1
- package/package.json +2 -2
- package/lib/plugin-updater.mjs +0 -437
package/client/settings-card.js
CHANGED
|
@@ -224,6 +224,8 @@ const CARD_CSS = `
|
|
|
224
224
|
.dshsu-pill{font-size:12px;padding:2px 9px;border-radius:999px;white-space:nowrap}
|
|
225
225
|
.dshsu-pill-ok{color:var(--dshsu-success);background:var(--dshsu-success-soft)}
|
|
226
226
|
.dshsu-pill-bad{color:var(--dshsu-danger);background:var(--dshsu-danger-soft)}
|
|
227
|
+
/* 待重启徽章:琥珀色提醒"新版已装好,重启后生效" */
|
|
228
|
+
.dshsu-pill-new{color:var(--dshsu-warn);background:var(--dshsu-warn-soft);font-weight:600}
|
|
227
229
|
/* ---- 单卡片内两个小节之间的分隔线(随主题换肤) ---- */
|
|
228
230
|
.dshsu-divider{height:1px;background:var(--dshsu-border);margin:4px 0}
|
|
229
231
|
/* 单卡片内的插件小节容器:只负责纵向排布,不画边框(边框属于整张卡片)。 */
|
|
@@ -280,6 +282,7 @@ const STATE_LABELS = {
|
|
|
280
282
|
rollback: '升级失败,正在回滚…',
|
|
281
283
|
done: '升级完成',
|
|
282
284
|
done_failed: '已回滚到旧版本',
|
|
285
|
+
done_pending_restart: '已更新,等待重启生效',
|
|
283
286
|
error: '升级出错',
|
|
284
287
|
};
|
|
285
288
|
|
|
@@ -459,8 +462,10 @@ function formatTime(iso) {
|
|
|
459
462
|
* 展示模板与 DSH 小节同款三行:当前版本 / 最新版本 / 上次检查。
|
|
460
463
|
* ------------------------------------------------------------------ */
|
|
461
464
|
|
|
462
|
-
/** 插件更新进行中的状态集合(与后端锁文件/state 约定一致)。
|
|
463
|
-
|
|
465
|
+
/** 插件更新进行中的状态集合(与后端锁文件/state 约定一致)。
|
|
466
|
+
* 注意:done_pending_restart 是"已装好、等重启"的终态,不算进行中;
|
|
467
|
+
* swapping 是 v0.4.6 进程内安装阶段的 state,需要视为忙碌。 */
|
|
468
|
+
const PLUGIN_BUSY_STATES = ['running', 'downloading', 'swapping', 'healthcheck', 'rollback'];
|
|
464
469
|
|
|
465
470
|
/**
|
|
466
471
|
* 插件更新小节:数据契约与 DSH 小节一致
|
|
@@ -471,10 +476,12 @@ const PLUGIN_BUSY_STATES = ['running', 'downloading', 'restarting', 'healthcheck
|
|
|
471
476
|
*/
|
|
472
477
|
function PluginSection({ t, plugin, busy, checking, msg, onCheck, onUpgrade }) {
|
|
473
478
|
const updateAvailable = plugin?.updateAvailable === true;
|
|
474
|
-
//
|
|
475
|
-
const msgClass =
|
|
479
|
+
// 结果消息的语义着色:成功绿(含"待重启生效"类提示)/ 失败红 / 其余灰。
|
|
480
|
+
const msgClass = /已是最新|完成|成功|无需更新|生效/.test(msg) ? ' dshsu-msg-ok'
|
|
476
481
|
: /失败|出错|错误|超时/.test(msg) ? ' dshsu-msg-bad'
|
|
477
482
|
: '';
|
|
483
|
+
// 已装好新版但宿主未重启:右侧给出醒目的"待重启"徽章提示。
|
|
484
|
+
const pendingRestart = plugin?.state === 'done_pending_restart';
|
|
478
485
|
|
|
479
486
|
return h('div', { className: 'dshsu-sub' },
|
|
480
487
|
// 小节标题:拼图图标 + 右侧版本号徽章(有新版时变琥珀色提示)
|
|
@@ -527,6 +534,8 @@ function PluginSection({ t, plugin, busy, checking, msg, onCheck, onUpgrade }) {
|
|
|
527
534
|
onClick: onUpgrade,
|
|
528
535
|
}, t.upgradeNow),
|
|
529
536
|
h('span', { className: 'dshsu-spacer' }),
|
|
537
|
+
// 待重启徽章:提醒用户重启 DeepSeek Harness 后新版本才会生效
|
|
538
|
+
pendingRestart ? h('span', { className: 'dshsu-pill dshsu-pill-new' }, t.pendingRestart) : null,
|
|
530
539
|
),
|
|
531
540
|
);
|
|
532
541
|
}
|
|
@@ -545,6 +554,7 @@ const FALLBACK_DICT = {
|
|
|
545
554
|
pluginNav: '插件更新',
|
|
546
555
|
pluginUpdating: '插件更新进行中…', checkingLabel: '正在检查更新…',
|
|
547
556
|
checkFailed: '检查更新失败',
|
|
557
|
+
pendingRestart: '重启后生效',
|
|
548
558
|
// 侧边导航文案:现在只有一张"版本更新"卡片。
|
|
549
559
|
cardNav: '版本更新',
|
|
550
560
|
},
|
|
@@ -556,6 +566,7 @@ const FALLBACK_DICT = {
|
|
|
556
566
|
pluginNav: 'Plugin Updates',
|
|
557
567
|
pluginUpdating: 'Plugin update in progress…', checkingLabel: 'Checking…',
|
|
558
568
|
checkFailed: 'Check failed',
|
|
569
|
+
pendingRestart: 'Restart to apply',
|
|
559
570
|
// Side navigation label: there is now only one "Updates" card.
|
|
560
571
|
cardNav: 'Updates',
|
|
561
572
|
},
|
|
@@ -659,12 +670,12 @@ function apply(ctx) {
|
|
|
659
670
|
}
|
|
660
671
|
}
|
|
661
672
|
|
|
662
|
-
/**
|
|
673
|
+
/** 一键升级自身:v0.4.6 起服务保持运行,更新完成后提示重启生效。 */
|
|
663
674
|
async function handlePluginUpgrade() {
|
|
664
675
|
try {
|
|
665
676
|
await api('/plugins/update', { method: 'POST', body: '{}' });
|
|
666
677
|
pluginMsg = '';
|
|
667
|
-
//
|
|
678
|
+
// 后台任务运行期间高频轮询进度,直至出现 done_pending_restart / error 终态。
|
|
668
679
|
setTimeout(pluginPollLoop, POLL_ACTIVE_MS);
|
|
669
680
|
} catch (err) {
|
|
670
681
|
console.warn(`[${NS}] 触发插件更新失败:`, err);
|
|
@@ -716,6 +727,7 @@ function apply(ctx) {
|
|
|
716
727
|
updateAvailable: dict('updateAvailable'), upgraded: dict('upgraded'), failed: dict('failed'),
|
|
717
728
|
pluginNav: dict('pluginNav'),
|
|
718
729
|
pluginUpdating: dict('pluginUpdating'), checkingLabel: dict('checkingLabel'),
|
|
730
|
+
pendingRestart: dict('pendingRestart'),
|
|
719
731
|
},
|
|
720
732
|
status: latestStatus,
|
|
721
733
|
busy: isBusyState(latestStatus),
|
package/lib/index.js
CHANGED
|
@@ -1,75 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-selfupdater 宿主入口:注册 HTTP 路由,桥接浏览器设置卡片与升级脚本。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* 职责边界:
|
|
5
|
+
* - 主程序(@deepseek-ai/dsh)升级:要替换的是 DSH 自己脚下的 node_modules,
|
|
6
|
+
* 必须先让 DSH 退出,因此仍走分离进程 lib/updater.mjs(自杀→接管→复活链路);
|
|
7
|
+
* - 插件(dsh-selfupdater 自己)更新:v0.4.6 起改为进程内原地安装
|
|
8
|
+
* (lib/plugin-update-task.mjs),全程不杀宿主、不 rename profile,
|
|
9
|
+
* 完成后提示用户重启生效 —— 参照 dshmarket 的成熟做法,
|
|
10
|
+
* 规避与飞牛OS 守护进程拉起实例的端口竞态。
|
|
7
11
|
*
|
|
8
12
|
* 安全模型(与 dshmarket lib/http.js 完全一致):
|
|
9
13
|
* - POST 接口仅接受 same-origin 请求(Origin 与 Host 头一致);
|
|
10
14
|
* - 升级动作通过锁文件防并发,双端校验。
|
|
11
15
|
*/
|
|
12
16
|
import { spawn } from 'node:child_process';
|
|
13
|
-
import { createRequire } from 'node:module';
|
|
14
17
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
15
|
-
import {
|
|
18
|
+
import { dirname, join, resolve } from 'node:path';
|
|
16
19
|
import { fileURLToPath } from 'node:url';
|
|
20
|
+
import { fetchLatestVersion, installedSelfVersion, isNewer, runPluginUpdateTask } from './plugin-update-task.mjs';
|
|
17
21
|
|
|
18
22
|
export const name = 'dsh-selfupdater';
|
|
19
23
|
|
|
20
24
|
const PLUGIN_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
21
25
|
/** 要升级的主程序包名。 */
|
|
22
26
|
const PKG = '@deepseek-ai/dsh';
|
|
23
|
-
/** npm registry 查询超时。 */
|
|
24
|
-
const FETCH_TIMEOUT_MS = 15000;
|
|
25
|
-
/** 国内 npm 镜像(腾讯云),registry.npmjs.org 直连超时时的第一回退。 */
|
|
26
|
-
const NPM_CHINA_MIRROR = 'https://mirrors.cloud.tencent.com/npm';
|
|
27
|
-
/** 插件所在的 profile 名(与 runner.js 的 DSH_PLUGIN_PROFILE 约定一致)。 */
|
|
28
|
-
const PLUGIN_PROFILE = process.env.DSH_PLUGIN_PROFILE ?? 'web';
|
|
29
|
-
|
|
30
|
-
/* ------------------------------------------------------------------ *
|
|
31
|
-
* semver 比较(零依赖,与 updater.mjs 内实现保持一致)
|
|
32
|
-
* ------------------------------------------------------------------ */
|
|
33
|
-
|
|
34
|
-
const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
35
|
-
|
|
36
|
-
function parseSemver(v) {
|
|
37
|
-
const m = SEMVER_RE.exec(String(v ?? '').trim());
|
|
38
|
-
if (m === null) return null;
|
|
39
|
-
return {
|
|
40
|
-
core: [Number(m[1]), Number(m[2]), Number(m[3])],
|
|
41
|
-
pre: m[4] === undefined ? [] : m[4].split('.'),
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** 返回负数/0/正数;任一侧非法返回 null。 */
|
|
46
|
-
function compareVersions(a, b) {
|
|
47
|
-
const pa = parseSemver(a);
|
|
48
|
-
const pb = parseSemver(b);
|
|
49
|
-
if (pa === null || pb === null) return null;
|
|
50
|
-
for (let i = 0; i < 3; i++) {
|
|
51
|
-
if (pa.core[i] !== pb.core[i]) return pa.core[i] - pb.core[i];
|
|
52
|
-
}
|
|
53
|
-
if (pa.pre.length === 0 || pb.pre.length === 0) return pb.pre.length - pa.pre.length;
|
|
54
|
-
for (let i = 0; i < Math.max(pa.pre.length, pb.pre.length); i++) {
|
|
55
|
-
const x = pa.pre[i];
|
|
56
|
-
const y = pb.pre[i];
|
|
57
|
-
if (x === undefined) return -1;
|
|
58
|
-
if (y === undefined) return 1;
|
|
59
|
-
if (x === y) continue;
|
|
60
|
-
const nx = /^\d+$/.test(x);
|
|
61
|
-
const ny = /^\d+$/.test(y);
|
|
62
|
-
if (nx && ny) return Number(x) - Number(y);
|
|
63
|
-
if (nx !== ny) return nx ? -1 : 1;
|
|
64
|
-
return x < y ? -1 : 1;
|
|
65
|
-
}
|
|
66
|
-
return 0;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function isNewer(latest, installed) {
|
|
70
|
-
const cmp = compareVersions(latest, installed);
|
|
71
|
-
return cmp !== null && cmp > 0;
|
|
72
|
-
}
|
|
73
27
|
|
|
74
28
|
/* ------------------------------------------------------------------ *
|
|
75
29
|
* HTTP 工具(照搬 dshmarket lib/http.js)
|
|
@@ -105,16 +59,6 @@ function sameOrigin(request) {
|
|
|
105
59
|
* 状态与路径解析
|
|
106
60
|
* ------------------------------------------------------------------ */
|
|
107
61
|
|
|
108
|
-
/** 从 --profile 参数读取宿主实际启动的 profile(与 dshmarket 相同的兜底逻辑)。 */
|
|
109
|
-
function argvProfile() {
|
|
110
|
-
const argv = process.argv;
|
|
111
|
-
const flag = argv.indexOf('--profile');
|
|
112
|
-
if (flag !== -1 && flag + 1 < argv.length && !argv[flag + 1].startsWith('-')) {
|
|
113
|
-
return argv[flag + 1];
|
|
114
|
-
}
|
|
115
|
-
return undefined;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
62
|
/**
|
|
119
63
|
* 定位 DSH 应用目录(node_modules 所在处)。
|
|
120
64
|
* 优先 TRIM_APPDEST 环境变量;否则从本插件安装位置向上找 node_modules 边界。
|
|
@@ -168,44 +112,6 @@ function currentDshVersion(appDir) {
|
|
|
168
112
|
}
|
|
169
113
|
}
|
|
170
114
|
|
|
171
|
-
/**
|
|
172
|
-
* 查询 npm registry 最新版本号(带镜像回退,参照 dshmarket regions.ts 的做法):
|
|
173
|
-
* 1. 优先走环境变量 DSHSU_REGISTRY_URL 指定的镜像(部署方可自行指定国内源);
|
|
174
|
-
* 2. 默认先试腾讯云国内镜像(飞牛OS 部署多在国内网络,直连 registry.npmjs.org
|
|
175
|
-
* 经常超时——这正是"检查更新没反应"的常见原因);
|
|
176
|
-
* 3. 镜像失败后回退官方源,保证海外网络也能用。
|
|
177
|
-
*/
|
|
178
|
-
async function fetchLatestVersion(pkg) {
|
|
179
|
-
const errors = [];
|
|
180
|
-
for (const base of registryCandidates()) {
|
|
181
|
-
try {
|
|
182
|
-
return await fetchFromRegistry(base, pkg);
|
|
183
|
-
} catch (err) {
|
|
184
|
-
errors.push(`${base}: ${err.message}`);
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
throw new Error(errors.join(';'));
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/** 本次要依次尝试的 registry 地址列表(去重)。 */
|
|
191
|
-
function registryCandidates() {
|
|
192
|
-
const custom = process.env.DSHSU_REGISTRY_URL?.replace(/\/+$/, '');
|
|
193
|
-
const list = [custom, NPM_CHINA_MIRROR, 'https://registry.npmjs.org'];
|
|
194
|
-
return [...new Set(list.filter((v) => typeof v === 'string' && v !== ''))];
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/** 从单个 registry 取 latest 版本号。 */
|
|
198
|
-
async function fetchFromRegistry(base, pkg) {
|
|
199
|
-
const res = await fetch(`${base}/${encodeURIComponent(pkg)}`, {
|
|
200
|
-
headers: { accept: 'application/json', 'user-agent': 'dsh-selfupdater' },
|
|
201
|
-
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
202
|
-
});
|
|
203
|
-
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
204
|
-
const doc = await res.json();
|
|
205
|
-
const latest = doc?.['dist-tags']?.latest;
|
|
206
|
-
if (typeof latest !== 'string' || latest === '') throw new Error('未返回 latest');
|
|
207
|
-
return latest;
|
|
208
|
-
}
|
|
209
115
|
/** 读升级状态文件(不存在时返回空对象)。 */
|
|
210
116
|
function readStatus(dshStateDir) {
|
|
211
117
|
try {
|
|
@@ -232,7 +138,7 @@ function writeStatus(dshStateDir, payload) {
|
|
|
232
138
|
}
|
|
233
139
|
|
|
234
140
|
/* ------------------------------------------------------------------ *
|
|
235
|
-
*
|
|
141
|
+
* 插件更新(自身一键更新;v0.4.6 起由进程内任务完成,见 plugin-update-task.mjs)
|
|
236
142
|
* ------------------------------------------------------------------ */
|
|
237
143
|
|
|
238
144
|
/** 读插件状态文件(不存在时返回空对象)。 */
|
|
@@ -254,21 +160,6 @@ function writePluginStatus(dshStateDir, payload) {
|
|
|
254
160
|
}
|
|
255
161
|
}
|
|
256
162
|
|
|
257
|
-
/**
|
|
258
|
-
* 读本插件自身的已装版本:优先 import 自己的 package.json(ESM 顶层 await
|
|
259
|
-
* 不适合这里,改用 createRequire 同步读取),失败时回退硬编码兜底值。
|
|
260
|
-
* 用途:让"检测更新"能覆盖 dsh-selfupdater 自己 —— 此前清单来自 profile
|
|
261
|
-
* 的 dependencies,但"检测自己"语义上不依赖那份清单。
|
|
262
|
-
*/
|
|
263
|
-
function readOwnVersion() {
|
|
264
|
-
try {
|
|
265
|
-
const require = createRequire(import.meta.url);
|
|
266
|
-
return require('../package.json').version;
|
|
267
|
-
} catch {
|
|
268
|
-
return '0.0.0';
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
|
|
272
163
|
/* ------------------------------------------------------------------ *
|
|
273
164
|
* apply 入口
|
|
274
165
|
* ------------------------------------------------------------------ */
|
|
@@ -286,9 +177,13 @@ export function apply(ctx) {
|
|
|
286
177
|
const lockFile = join(dshStateDir, 'selfupdate.lock');
|
|
287
178
|
const pluginLockFile = join(dshStateDir, 'pluginupdate.lock');
|
|
288
179
|
const port = servicePort();
|
|
289
|
-
/**
|
|
180
|
+
/** 本插件的包名(用于"检测自己"的更新能力)。 */
|
|
290
181
|
const SELF_NAME = name;
|
|
291
|
-
|
|
182
|
+
/**
|
|
183
|
+
* 已安装的自身版本:每次请求实时读 profile 清单(而非模块加载时的快照)。
|
|
184
|
+
* 这样更新装好但宿主未重启时,UI 立即显示新版号,"检查更新"也不会误报。
|
|
185
|
+
*/
|
|
186
|
+
const selfInstalled = () => installedSelfVersion(workspace);
|
|
292
187
|
|
|
293
188
|
/** 升级是否正在进行(锁文件存在)。 */
|
|
294
189
|
const isBusy = () => existsSync(lockFile);
|
|
@@ -411,11 +306,14 @@ export function apply(ctx) {
|
|
|
411
306
|
const status = pluginStatusView();
|
|
412
307
|
// 上次检查的缓存结果(latest / updateAvailable / checkedAt)存在 updates[SELF_NAME]。
|
|
413
308
|
const cache = status.updates?.[SELF_NAME] ?? {};
|
|
309
|
+
const current = selfInstalled();
|
|
414
310
|
sendJson(res, 200, {
|
|
415
311
|
name: SELF_NAME,
|
|
416
|
-
currentVersion:
|
|
312
|
+
currentVersion: current,
|
|
417
313
|
latestVersion: cache.latestVersion ?? null,
|
|
418
|
-
|
|
314
|
+
// 检查缓存只对"已安装版本"有意义;若装好的新版还没重启
|
|
315
|
+
// (current 已是最新),就不能再按缓存说"有更新"。
|
|
316
|
+
updateAvailable: cache.updateAvailable === true && isNewer(cache.latestVersion, current),
|
|
419
317
|
lastCheck: cache.checkedAt ?? null,
|
|
420
318
|
busy: pluginBusy(),
|
|
421
319
|
state: status.state ?? 'idle',
|
|
@@ -433,21 +331,21 @@ export function apply(ctx) {
|
|
|
433
331
|
// 只查自己这一个包,成功后把结果写入状态文件缓存。
|
|
434
332
|
try {
|
|
435
333
|
const latestVersion = await fetchLatestVersion(SELF_NAME);
|
|
334
|
+
const hasUpdate = isNewer(latestVersion, selfInstalled());
|
|
436
335
|
writePluginStatus(dshStateDir, {
|
|
437
336
|
...readPluginStatus(dshStateDir),
|
|
438
337
|
state: 'idle',
|
|
439
|
-
message:
|
|
440
|
-
? `发现新版本 ${latestVersion}` : '当前已是最新',
|
|
338
|
+
message: hasUpdate ? `发现新版本 ${latestVersion}` : '当前已是最新',
|
|
441
339
|
updates: {
|
|
442
340
|
[SELF_NAME]: {
|
|
443
341
|
latestVersion,
|
|
444
|
-
updateAvailable:
|
|
342
|
+
updateAvailable: hasUpdate,
|
|
445
343
|
checkedAt: new Date().toISOString(),
|
|
446
344
|
},
|
|
447
345
|
},
|
|
448
346
|
updatedAt: new Date().toISOString(),
|
|
449
347
|
});
|
|
450
|
-
sendJson(res, 200, { updatedCount:
|
|
348
|
+
sendJson(res, 200, { updatedCount: hasUpdate ? 1 : 0 });
|
|
451
349
|
} catch (err) {
|
|
452
350
|
host.logger?.warn?.(`[dsh-selfupdater] 插件检查更新失败: ${err.message}`);
|
|
453
351
|
writePluginStatus(dshStateDir, {
|
|
@@ -461,6 +359,13 @@ export function apply(ctx) {
|
|
|
461
359
|
});
|
|
462
360
|
|
|
463
361
|
/* ---------- POST /dsh-selfupdater/plugins/update ---------- */
|
|
362
|
+
/**
|
|
363
|
+
* v0.4.6 重构:不再 spawn 分离脚本后 process.exit(0) 自杀。
|
|
364
|
+
* 旧链路(自杀→脚本接管→拉起 runner→健康检查→回滚)与飞牛OS
|
|
365
|
+
* 守护进程竞态,导致"更新失败+服务停摆+版本没变"。
|
|
366
|
+
* 新链路:202 应答后在本进程内原地安装新版(服务保持运行),
|
|
367
|
+
* 完成后状态置 done_pending_restart,由前端提示用户重启生效。
|
|
368
|
+
*/
|
|
464
369
|
registerRoute('POST', '/dsh-selfupdater/plugins/update', (req, res) => {
|
|
465
370
|
if (!sameOrigin(req)) {
|
|
466
371
|
sendJson(res, 403, { error: 'untrusted request' });
|
|
@@ -474,7 +379,7 @@ export function apply(ctx) {
|
|
|
474
379
|
writePluginStatus(dshStateDir, {
|
|
475
380
|
...readPluginStatus(dshStateDir),
|
|
476
381
|
state: 'running',
|
|
477
|
-
message: '
|
|
382
|
+
message: '插件更新已受理…',
|
|
478
383
|
startedAt: new Date().toISOString(),
|
|
479
384
|
});
|
|
480
385
|
writeFileSync(pluginLockFile, JSON.stringify({ pid: process.pid, startedAt: Date.now(), by: 'plugin-update' }));
|
|
@@ -482,19 +387,11 @@ export function apply(ctx) {
|
|
|
482
387
|
sendJson(res, 500, { error: `写入锁文件失败:${err.message}` });
|
|
483
388
|
return;
|
|
484
389
|
}
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
'--port', String(port),
|
|
491
|
-
], { detached: true, stdio: 'ignore', env: process.env });
|
|
492
|
-
child.unref();
|
|
493
|
-
|
|
494
|
-
host.logger?.info?.(`[dsh-selfupdater] 插件更新进程已启动 pid=${child.pid},DSH 即将退出`);
|
|
495
|
-
// 与主程序升级一致:给 spawn 落稳时间后主动退出,让脚本接管重启链。
|
|
496
|
-
setTimeout(() => process.exit(0), 800);
|
|
497
|
-
sendJson(res, 202, { accepted: true, note: '插件更新将由分离脚本执行并自动重启服务' });
|
|
390
|
+
// fire-and-forget:任务自行写状态文件并释放锁,失败不影响应答。
|
|
391
|
+
void runPluginUpdateTask({ appDir, workspace, logger: host.logger })
|
|
392
|
+
.catch((err) => host.logger?.warn?.(`[dsh-selfupdater] 插件更新任务异常: ${err.message}`));
|
|
393
|
+
host.logger?.info?.('[dsh-selfupdater] 插件更新任务已在进程内启动(服务保持运行)');
|
|
394
|
+
sendJson(res, 202, { accepted: true, note: '更新在后台进行,完成后需重启 DeepSeek Harness 生效' });
|
|
498
395
|
});
|
|
499
396
|
|
|
500
397
|
host.logger?.info?.('[dsh-selfupdater] 路由已挂载');
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 进程内插件更新任务(v0.4.6 起取代旧的分离进程 plugin-updater.mjs)。
|
|
3
|
+
*
|
|
4
|
+
* 【为什么改架构】旧流程:spawn 分离脚本 → DSH 自杀式 process.exit(0) →
|
|
5
|
+
* 脚本等端口释放 → 拉起 runner → 健康检查 → 失败回滚。在飞牛OS 上与系统
|
|
6
|
+
* 守护进程形成竞态:守护检测到 DSH 死亡会立刻拉起新实例占住端口,脚本随后
|
|
7
|
+
* 误判失败并触发回滚,最终表现为"更新失败 + 服务停摆 + 版本没变"。
|
|
8
|
+
*
|
|
9
|
+
* 【新流程】参照插件市场 dshmarket 的成熟做法:
|
|
10
|
+
* 全程不杀宿主进程、不 rename 现有 profile,只在原地把新版 tgz 装入
|
|
11
|
+
* profile(Node 已加载的旧代码继续运行不受影响),装完校验后置状态
|
|
12
|
+
* done_pending_restart,由前端提示用户重启 DeepSeek Harness 使新版本生效。
|
|
13
|
+
*
|
|
14
|
+
* 同时提供"检查更新"用的版本查询能力:
|
|
15
|
+
* 一律走 /latest 版本级端点 —— 整包 packument 会被 Fastly 等 CDN 长时间
|
|
16
|
+
* 缓存,导致"新版本已发布却查不到"(0.4.5 发布后被误判未发布的根因)。
|
|
17
|
+
*/
|
|
18
|
+
import { spawn } from 'node:child_process';
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
20
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import { dirname, join } from 'node:path';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
|
|
24
|
+
/** 本插件包名(更新的目标就是自己)。 */
|
|
25
|
+
export const SELF_NAME = 'dsh-selfupdater';
|
|
26
|
+
/** npm registry 查询超时。 */
|
|
27
|
+
const FETCH_TIMEOUT_MS = 15000;
|
|
28
|
+
/** tgz 下载超时。 */
|
|
29
|
+
const DOWNLOAD_TIMEOUT_MS = 180000;
|
|
30
|
+
/** dsh plugin add 安装命令超时。 */
|
|
31
|
+
const INSTALL_TIMEOUT_MS = 300000;
|
|
32
|
+
/** 国内 npm 镜像(腾讯云),直连官方源超时时的第一回退。 */
|
|
33
|
+
const NPM_CHINA_MIRROR = 'https://mirrors.cloud.tencent.com/npm';
|
|
34
|
+
|
|
35
|
+
/* ------------------------------------------------------------------ *
|
|
36
|
+
* semver 比较(零依赖;从 index.js 迁移过来,供两处共用)
|
|
37
|
+
* ------------------------------------------------------------------ */
|
|
38
|
+
|
|
39
|
+
const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
40
|
+
|
|
41
|
+
function parseSemver(v) {
|
|
42
|
+
const m = SEMVER_RE.exec(String(v ?? '').trim());
|
|
43
|
+
if (m === null) return null;
|
|
44
|
+
return {
|
|
45
|
+
core: [Number(m[1]), Number(m[2]), Number(m[3])],
|
|
46
|
+
pre: m[4] === undefined ? [] : m[4].split('.'),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 返回负数/0/正数;任一侧非法返回 null。 */
|
|
51
|
+
function compareVersions(a, b) {
|
|
52
|
+
const pa = parseSemver(a);
|
|
53
|
+
const pb = parseSemver(b);
|
|
54
|
+
if (pa === null || pb === null) return null;
|
|
55
|
+
for (let i = 0; i < 3; i++) {
|
|
56
|
+
if (pa.core[i] !== pb.core[i]) return pa.core[i] - pb.core[i];
|
|
57
|
+
}
|
|
58
|
+
if (pa.pre.length === 0 || pb.pre.length === 0) return pb.pre.length - pa.pre.length;
|
|
59
|
+
for (let i = 0; i < Math.max(pa.pre.length, pb.pre.length); i++) {
|
|
60
|
+
const x = pa.pre[i];
|
|
61
|
+
const y = pb.pre[i];
|
|
62
|
+
if (x === undefined) return -1;
|
|
63
|
+
if (y === undefined) return 1;
|
|
64
|
+
if (x === y) continue;
|
|
65
|
+
const nx = /^\d+$/.test(x);
|
|
66
|
+
const ny = /^\d+$/.test(y);
|
|
67
|
+
if (nx && ny) return Number(x) - Number(y);
|
|
68
|
+
if (nx !== ny) return nx ? -1 : 1;
|
|
69
|
+
return x < y ? -1 : 1;
|
|
70
|
+
}
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** latest 是否严格新于 installed;任一侧非法返回 false。 */
|
|
75
|
+
export function isNewer(latest, installed) {
|
|
76
|
+
const cmp = compareVersions(latest, installed);
|
|
77
|
+
return cmp !== null && cmp > 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* ------------------------------------------------------------------ *
|
|
81
|
+
* 版本查询(多 registry 回退 + /latest 版本级端点)
|
|
82
|
+
* ------------------------------------------------------------------ */
|
|
83
|
+
|
|
84
|
+
/** 本次要依次尝试的 registry 地址列表(去重)。 */
|
|
85
|
+
function registryCandidates() {
|
|
86
|
+
const custom = process.env.DSHSU_REGISTRY_URL?.replace(/\/+$/, '');
|
|
87
|
+
const list = [custom, NPM_CHINA_MIRROR, 'https://registry.npmjs.org'];
|
|
88
|
+
return [...new Set(list.filter((v) => typeof v === 'string' && v !== ''))];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 从单个 registry 的 /latest 版本级端点取文档(含 dist.tarball)。
|
|
93
|
+
* 用版本级端点而非整包 packument:后者会被 Fastly 等 CDN 长时间缓存,
|
|
94
|
+
* 出现"包已发布但查不到新版"的假象。
|
|
95
|
+
*/
|
|
96
|
+
async function fetchVersionDoc(base, pkg) {
|
|
97
|
+
const res = await fetch(`${base}/${encodeURIComponent(pkg)}/latest`, {
|
|
98
|
+
headers: { accept: 'application/json', 'user-agent': 'dsh-selfupdater' },
|
|
99
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
100
|
+
});
|
|
101
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
102
|
+
const doc = await res.json();
|
|
103
|
+
if (typeof doc?.version !== 'string' || doc.version === '') throw new Error('响应缺少 version 字段');
|
|
104
|
+
return { version: doc.version, tarball: doc?.dist?.tarball ?? null };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 依次尝试各 registry,返回首个成功的 { version, tarball }。 */
|
|
108
|
+
async function fetchLatestRelease(pkg) {
|
|
109
|
+
const errors = [];
|
|
110
|
+
for (const base of registryCandidates()) {
|
|
111
|
+
try {
|
|
112
|
+
return await fetchVersionDoc(base, pkg);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
errors.push(`${base}: ${err.message}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
throw new Error(errors.join(';'));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** 只取最新版本号(检查更新路由使用)。 */
|
|
121
|
+
export async function fetchLatestVersion(pkg) {
|
|
122
|
+
return (await fetchLatestRelease(pkg)).version;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* ------------------------------------------------------------------ *
|
|
126
|
+
* 小工具
|
|
127
|
+
* ------------------------------------------------------------------ */
|
|
128
|
+
|
|
129
|
+
/** 读 JSON 文件,不存在/损坏返回空对象。 */
|
|
130
|
+
function readJson(file) {
|
|
131
|
+
try {
|
|
132
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
133
|
+
} catch {
|
|
134
|
+
return {};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 读 profile 清单里记录的本插件版本声明(可能带 ^/~ 前缀);无记录返回 null。 */
|
|
139
|
+
function profileDeclaredVersion(profileDir) {
|
|
140
|
+
const dep = readJson(join(profileDir, 'package.json'))?.dependencies?.[SELF_NAME];
|
|
141
|
+
return typeof dep === 'string' && dep !== '' ? dep : null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 去掉 ^/~ 等范围前缀后比较是否等于目标版本。 */
|
|
145
|
+
function declarationMatches(declared, expected) {
|
|
146
|
+
return declared.replace(/^[\^~>=<\s]+/, '') === expected;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 当前正在运行的自身版本(读本包 package.json;ESM 下用 createRequire 同步读)。 */
|
|
150
|
+
function runningVersion() {
|
|
151
|
+
try {
|
|
152
|
+
const require = createRequire(import.meta.url);
|
|
153
|
+
return require('../package.json').version;
|
|
154
|
+
} catch {
|
|
155
|
+
return '0.0.0';
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 已安装的自身版本(供"检查更新/状态展示"使用)。
|
|
161
|
+
* 优先读 profile 清单 dependencies 里声明的版本:更新装好但宿主未重启时,
|
|
162
|
+
* 它才是"已安装版本",能让 UI 立即显示新版号并停止误报"发现新版本";
|
|
163
|
+
* 清单里没有记录时回退到正在运行的版本(首次安装/手工部署场景)。
|
|
164
|
+
*/
|
|
165
|
+
export function installedSelfVersion(workspace) {
|
|
166
|
+
const profileDir = join(workspace, '.dsh', 'profiles', process.env.DSH_PLUGIN_PROFILE ?? 'web');
|
|
167
|
+
const declared = profileDeclaredVersion(profileDir);
|
|
168
|
+
return declared !== null ? declared.replace(/^[\^~>=<\s]+/, '') : runningVersion();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 定位 dsh CLI 入口:打包布局优先,其次 node_modules 标准布局。 */
|
|
172
|
+
function locateDshBin(appDir) {
|
|
173
|
+
const candidates = [
|
|
174
|
+
join(appDir, 'bin', 'dsh.js'),
|
|
175
|
+
join(appDir, 'node_modules', '@deepseek-ai', 'dsh', 'bin', 'dsh.js'),
|
|
176
|
+
];
|
|
177
|
+
for (const p of candidates) {
|
|
178
|
+
if (existsSync(p)) return p;
|
|
179
|
+
}
|
|
180
|
+
throw new Error('无法定位 dsh 命令行工具(bin/dsh.js)');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** 下载 tgz 到本地暂存路径(体积小,直接缓冲写入)。 */
|
|
184
|
+
async function downloadTgz(url, destFile) {
|
|
185
|
+
mkdirSync(dirname(destFile), { recursive: true });
|
|
186
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
|
|
187
|
+
if (!res.ok) throw new Error(`下载失败 HTTP ${res.status}`);
|
|
188
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
189
|
+
if (buf.length < 1024) throw new Error(`下载内容过小(${buf.length} 字节),疑似损坏`);
|
|
190
|
+
writeFileSync(destFile, buf);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** 阻塞式执行子命令:退出码非 0 或超时都抛错,stderr 附在错误信息里。 */
|
|
194
|
+
function runCommand(file, cmdArgs, timeoutMs) {
|
|
195
|
+
return new Promise((resolveP, rejectP) => {
|
|
196
|
+
const child = spawn(file, cmdArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
197
|
+
let stdout = '';
|
|
198
|
+
let stderr = '';
|
|
199
|
+
let settled = false;
|
|
200
|
+
const timer = setTimeout(() => {
|
|
201
|
+
child.kill('SIGKILL');
|
|
202
|
+
rejectP(new Error(`命令超时(${Math.round(timeoutMs / 1000)}s)`));
|
|
203
|
+
}, timeoutMs);
|
|
204
|
+
const finish = (fn, value) => {
|
|
205
|
+
if (settled) return;
|
|
206
|
+
settled = true;
|
|
207
|
+
clearTimeout(timer);
|
|
208
|
+
fn(value);
|
|
209
|
+
};
|
|
210
|
+
child.stdout.on('data', (d) => { stdout += d; });
|
|
211
|
+
child.stderr.on('data', (d) => { stderr += d; });
|
|
212
|
+
child.on('error', (err) => finish(rejectP, err));
|
|
213
|
+
child.on('close', (code) => {
|
|
214
|
+
if (code === 0) finish(resolveP, stdout.trim());
|
|
215
|
+
else finish(rejectP, new Error(`退出码 ${code}${stderr.trim() ? `:${stderr.trim().slice(-800)}` : ''}`));
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/* ------------------------------------------------------------------ *
|
|
221
|
+
* 任务主体
|
|
222
|
+
* ------------------------------------------------------------------ */
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* 执行一次插件自更新(进程内异步,不退出宿主)。
|
|
226
|
+
* @param opts.appDir - DSH 应用目录(node_modules 所在处)
|
|
227
|
+
* @param opts.workspace - 工作区目录(.dsh 落盘处)
|
|
228
|
+
* @param opts.logger - 宿主 logger(可选)
|
|
229
|
+
*/
|
|
230
|
+
export async function runPluginUpdateTask({ appDir, workspace, logger }) {
|
|
231
|
+
const log = (msg) => logger?.info?.(`[dsh-selfupdater] ${msg}`);
|
|
232
|
+
const warnLog = (msg) => logger?.warn?.(`[dsh-selfupdater] ${msg}`);
|
|
233
|
+
|
|
234
|
+
const dshStateDir = join(workspace, '.dsh');
|
|
235
|
+
const statusFile = join(dshStateDir, 'pluginupdate-status.json');
|
|
236
|
+
const lockFile = join(dshStateDir, 'pluginupdate.lock');
|
|
237
|
+
const profileName = process.env.DSH_PLUGIN_PROFILE ?? 'web';
|
|
238
|
+
const profileDir = join(dshStateDir, 'profiles', profileName);
|
|
239
|
+
const stagingTgz = join(dshStateDir, 'pluginupdate.staging.tgz');
|
|
240
|
+
const manifestPath = join(profileDir, 'package.json');
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* 写状态文件(保留 updates 检查缓存等历史字段;失败仅告警不断流程)。
|
|
244
|
+
* 终态约定:done_pending_restart = 装好了但需要重启生效;error = 失败可重试。
|
|
245
|
+
*/
|
|
246
|
+
const setState = (state, message, extra = {}) => {
|
|
247
|
+
try {
|
|
248
|
+
mkdirSync(dshStateDir, { recursive: true });
|
|
249
|
+
writeFileSync(statusFile, JSON.stringify({
|
|
250
|
+
...readJson(statusFile),
|
|
251
|
+
...extra,
|
|
252
|
+
state,
|
|
253
|
+
message,
|
|
254
|
+
updatedAt: new Date().toISOString(),
|
|
255
|
+
}, null, 2));
|
|
256
|
+
} catch (err) {
|
|
257
|
+
warnLog(`插件状态文件写入失败: ${err.message}`);
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
/** 安装前的清单原文快照(安装失败时尽力还原,避免半成品状态)。 */
|
|
262
|
+
let manifestBackup = null;
|
|
263
|
+
try {
|
|
264
|
+
/* 0. 清理旧架构遗留的孤儿备份目录(此前 NAS 上 ENOENT 回滚报错的源头)。 */
|
|
265
|
+
const orphanBak = `${profileDir}.pluginupdate-bak`;
|
|
266
|
+
if (existsSync(orphanBak)) {
|
|
267
|
+
warnLog(`清理遗留备份目录 ${orphanBak}`);
|
|
268
|
+
rmSync(orphanBak, { recursive: true, force: true });
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/* 1. 确定当前版本:优先 profile 清单记录(更新后未重启时它才是新值),
|
|
272
|
+
无记录时退回正在运行的版本。 */
|
|
273
|
+
const declared = profileDeclaredVersion(profileDir);
|
|
274
|
+
const installed = declared !== null ? declared.replace(/^[\^~>=<\s]+/, '') : runningVersion();
|
|
275
|
+
setState('running', '正在查询最新版本…', { startedAt: new Date().toISOString() });
|
|
276
|
+
|
|
277
|
+
/* 2. 查询最新版本与下载地址。 */
|
|
278
|
+
const release = await fetchLatestRelease(SELF_NAME);
|
|
279
|
+
if (!isNewer(release.version, installed)) {
|
|
280
|
+
log(`已是最新版本 ${installed},无需更新`);
|
|
281
|
+
setState('done_pending_restart', `当前已是最新(${installed}),无需更新`);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/* 3. 下载新版 tgz 到暂存位置。 */
|
|
286
|
+
if (!release.tarball) throw new Error('registry 未返回 tarball 下载地址');
|
|
287
|
+
setState('downloading', `正在下载 ${SELF_NAME}@${release.version}…`);
|
|
288
|
+
await downloadTgz(release.tarball, stagingTgz);
|
|
289
|
+
|
|
290
|
+
/* 4. 记录 profile 清单原文,安装失败时可还原。 */
|
|
291
|
+
manifestBackup = existsSync(manifestPath) ? readFileSync(manifestPath) : null;
|
|
292
|
+
|
|
293
|
+
/* 5. 原地安装:调 dsh CLI 把 tgz 装进现有 profile(不 rename、不停机)。
|
|
294
|
+
子进程同步等待结束,与旧版的 detached 分离脚本有本质区别。 */
|
|
295
|
+
setState('swapping', `正在安装 ${release.version}(服务保持运行)…`);
|
|
296
|
+
const dshBin = locateDshBin(appDir);
|
|
297
|
+
await runCommand(
|
|
298
|
+
process.execPath,
|
|
299
|
+
[dshBin, 'plugin', '--profile', profileName, 'add', stagingTgz],
|
|
300
|
+
INSTALL_TIMEOUT_MS,
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
/* 6. 校验:清单版本号一致 + 入口文件真实落盘。 */
|
|
304
|
+
const after = profileDeclaredVersion(profileDir);
|
|
305
|
+
if (after === null || !declarationMatches(after, release.version)) {
|
|
306
|
+
throw new Error(`安装后清单版本异常:期望 ${release.version},实际 ${after ?? '缺失'}`);
|
|
307
|
+
}
|
|
308
|
+
if (!existsSync(join(profileDir, 'node_modules', SELF_NAME, 'lib', 'index.js'))) {
|
|
309
|
+
throw new Error('入口文件未落盘,安装可能不完整');
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/* 7. 成功:不重启宿主,提示用户重启使新版本生效。 */
|
|
313
|
+
setState('done_pending_restart',
|
|
314
|
+
`插件已更新到 ${release.version},请重启 DeepSeek Harness 使新版本生效`,
|
|
315
|
+
{ finishedAt: new Date().toISOString(), targetVersion: release.version });
|
|
316
|
+
log(`插件更新完成(${installed} → ${release.version}),等待用户重启生效`);
|
|
317
|
+
} catch (err) {
|
|
318
|
+
/* 失败兜底:还原清单 + 落盘明确错误,锁在 finally 里释放以便重试。 */
|
|
319
|
+
warnLog(`插件更新失败: ${err.message}`);
|
|
320
|
+
if (manifestBackup !== null) {
|
|
321
|
+
try {
|
|
322
|
+
mkdirSync(dirname(manifestPath), { recursive: true });
|
|
323
|
+
writeFileSync(manifestPath, manifestBackup);
|
|
324
|
+
} catch { /* 还原是尽力而为,不掩盖原始错误 */ }
|
|
325
|
+
}
|
|
326
|
+
setState('error',
|
|
327
|
+
`插件更新失败:${err.message}。可稍后重试,或通过插件市场重新安装`,
|
|
328
|
+
{ finishedAt: new Date().toISOString() });
|
|
329
|
+
} finally {
|
|
330
|
+
rmSync(stagingTgz, { force: true }); // 暂存 tgz 无论成败都清掉
|
|
331
|
+
rmSync(lockFile, { force: true }); // 释放更新锁,允许下次发起
|
|
332
|
+
}
|
|
333
|
+
}
|
package/lib/updater.mjs
CHANGED
|
@@ -132,7 +132,7 @@ const args = parseArgs(process.argv);
|
|
|
132
132
|
const appDir = resolve(String(args['app-dir'] ?? ''));
|
|
133
133
|
/**
|
|
134
134
|
* 工作区目录(存放 .dsh 状态目录)。
|
|
135
|
-
* 与 index.js
|
|
135
|
+
* 与 index.js 的推导链一致:共享目录 > HOME > TRIM_VAR > appDir。
|
|
136
136
|
* 正常情况下 index.js 已通过 --workspace 传入解析结果,这里仅兜底;
|
|
137
137
|
* 不能只信 TRIM_VAR —— 飞牛部署时 .dsh 实际写在 $HOME(工作区)下。
|
|
138
138
|
*/
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-selfupdater",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Self-update plugin for DeepSeek Harness:
|
|
3
|
+
"version": "0.4.6",
|
|
4
|
+
"description": "Self-update plugin for DeepSeek Harness: DSH core upgrades via detached swap script; plugin self-update installs in-place without killing the host and prompts for restart. DSH 主程序与已装插件的一站式在线更新插件。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "lib/index.js",
|
package/lib/plugin-updater.mjs
DELETED
|
@@ -1,437 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* dsh-selfupdater 分离式插件升级脚本(独立进程执行,不依赖 DSH 存活)。
|
|
4
|
-
*
|
|
5
|
-
* 为什么必须分离进程:插件文件装在 profile 目录里且已被 DSH 进程加载进内存,
|
|
6
|
-
* Windows 上运行中文件被锁、Linux 上热替换也可能出现半新半旧状态;
|
|
7
|
-
* 所以插件路由只负责"触发后退出",由本脚本完成:
|
|
8
|
-
* 备份 profile → 从 npm 下载新版 tgz → dsh plugin add 安装 → 重启 → 健康检查 → 失败回滚。
|
|
9
|
-
*
|
|
10
|
-
* 用法(由 lib/index.js 的 POST /plugins/update 自动调用):
|
|
11
|
-
* node plugin-updater.mjs --pid <DSH主进程PID> --app-dir <APP目录> \
|
|
12
|
-
* --workspace <工作区目录> --port <服务端口>
|
|
13
|
-
*/
|
|
14
|
-
import { spawn } from 'node:child_process';
|
|
15
|
-
import net from 'node:net';
|
|
16
|
-
import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
17
|
-
import { join, resolve } from 'node:path';
|
|
18
|
-
|
|
19
|
-
/* ------------------------------------------------------------------ *
|
|
20
|
-
* 工具函数
|
|
21
|
-
* ------------------------------------------------------------------ */
|
|
22
|
-
|
|
23
|
-
/** 解析 --key value 形式的命令行参数。 */
|
|
24
|
-
function parseArgs(argv) {
|
|
25
|
-
const out = {};
|
|
26
|
-
for (let i = 2; i < argv.length; i++) {
|
|
27
|
-
if (!argv[i].startsWith('--')) continue;
|
|
28
|
-
const next = argv[i + 1];
|
|
29
|
-
out[argv[i].slice(2)] = next !== undefined && !next.startsWith('--') ? next : true;
|
|
30
|
-
}
|
|
31
|
-
return out;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* semver 比较(零依赖实现,与 updater.mjs 保持一致)。
|
|
38
|
-
* 返回负数/0/正数;任一侧不是合法 semver 时返回 null。
|
|
39
|
-
*/
|
|
40
|
-
const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
41
|
-
|
|
42
|
-
function compareVersions(a, b) {
|
|
43
|
-
const pa = SEMVER_RE.exec(String(a ?? '').trim());
|
|
44
|
-
const pb = SEMVER_RE.exec(String(b ?? '').trim());
|
|
45
|
-
if (pa === null || pb === null) return null;
|
|
46
|
-
for (let i = 1; i <= 3; i++) {
|
|
47
|
-
const x = Number(pa[i]);
|
|
48
|
-
const y = Number(pb[i]);
|
|
49
|
-
if (x !== y) return x - y;
|
|
50
|
-
}
|
|
51
|
-
// 有预发布段的一方更小(1.0.0-rc.1 < 1.0.0);双方都有时逐段比较。
|
|
52
|
-
const preA = pa[4] === undefined ? [] : pa[4].split('.');
|
|
53
|
-
const preB = pb[4] === undefined ? [] : pb[4].split('.');
|
|
54
|
-
if (preA.length === 0 || preB.length === 0) return preB.length - preA.length;
|
|
55
|
-
for (let i = 0; i < Math.max(preA.length, preB.length); i++) {
|
|
56
|
-
const x = preA[i];
|
|
57
|
-
const y = preB[i];
|
|
58
|
-
if (x === undefined) return -1;
|
|
59
|
-
if (y === undefined) return 1;
|
|
60
|
-
if (x === y) continue;
|
|
61
|
-
const nx = /^\d+$/.test(x);
|
|
62
|
-
const ny = /^\d+$/.test(y);
|
|
63
|
-
if (nx && ny) return Number(x) - Number(y);
|
|
64
|
-
if (nx !== ny) return nx ? -1 : 1;
|
|
65
|
-
return x < y ? -1 : 1;
|
|
66
|
-
}
|
|
67
|
-
return 0;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** 仅当 latest 严格高于 installed 才算可升级。 */
|
|
71
|
-
function isNewer(latest, installed) {
|
|
72
|
-
const cmp = compareVersions(latest, installed);
|
|
73
|
-
return cmp !== null && cmp > 0;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** 探测端口是否已被监听(占用=true)。 */
|
|
77
|
-
function probePort(port, host = '127.0.0.1') {
|
|
78
|
-
return new Promise((resolveProbe) => {
|
|
79
|
-
const socket = net.connect({ host, port });
|
|
80
|
-
socket.on('connect', () => { socket.destroy(); resolveProbe(true); });
|
|
81
|
-
socket.on('error', () => resolveProbe(false));
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/** 等待端口释放。 */
|
|
86
|
-
async function waitPortFree(port, timeoutMs) {
|
|
87
|
-
const deadline = Date.now() + timeoutMs;
|
|
88
|
-
while (Date.now() < deadline) {
|
|
89
|
-
if (!(await probePort(port))) return true;
|
|
90
|
-
await sleep(500);
|
|
91
|
-
}
|
|
92
|
-
return false;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/** 进程是否仍存活(kill(pid,0) 只做权限探测不真正发信号)。 */
|
|
96
|
-
function isAlive(pid) {
|
|
97
|
-
try {
|
|
98
|
-
process.kill(pid, 0);
|
|
99
|
-
return true;
|
|
100
|
-
} catch {
|
|
101
|
-
return false;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/** 结束旧 DSH 主进程:SIGTERM 给优雅退出机会,超时后 SIGKILL 兜底。 */
|
|
106
|
-
async function killProcess(pid) {
|
|
107
|
-
if (!isAlive(pid)) return;
|
|
108
|
-
process.kill(pid, 'SIGTERM');
|
|
109
|
-
const deadline = Date.now() + 8000;
|
|
110
|
-
while (isAlive(pid) && Date.now() < deadline) await sleep(300);
|
|
111
|
-
if (isAlive(pid)) {
|
|
112
|
-
try { process.kill(pid, 'SIGKILL'); } catch { /* 已退出则忽略 */ }
|
|
113
|
-
await sleep(1000);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/* ------------------------------------------------------------------ *
|
|
118
|
-
* 全局上下文与状态落盘
|
|
119
|
-
* ------------------------------------------------------------------ */
|
|
120
|
-
|
|
121
|
-
const args = parseArgs(process.argv);
|
|
122
|
-
|
|
123
|
-
/** DSH 应用目录(含 bin/node 与 node_modules/@deepseek-ai/dsh)。 */
|
|
124
|
-
const appDir = resolve(String(args['app-dir'] ?? ''));
|
|
125
|
-
/**
|
|
126
|
-
* 工作区目录(存放 .dsh 状态目录与插件 profile)。
|
|
127
|
-
* 与 index.js 的 resolveWorkspace 推导链一致:共享目录 > HOME > TRIM_VAR > appDir。
|
|
128
|
-
* index.js 已解析出正确 workspace 并通过 --workspace 传入,这里仅在参数缺失时兜底;
|
|
129
|
-
* 不能只信 TRIM_VAR —— 飞牛部署时 profiles 实际写在 $HOME/.dsh 下。
|
|
130
|
-
*/
|
|
131
|
-
const workspace = (() => {
|
|
132
|
-
if (args.workspace) return resolve(String(args.workspace));
|
|
133
|
-
const shares = (process.env.TRIM_DATA_SHARE_PATHS ?? '').split(':').map((s) => s.trim()).filter(Boolean);
|
|
134
|
-
for (const dir of [...shares, process.env.HOME ?? '', process.env.TRIM_VAR ?? '', appDir]) {
|
|
135
|
-
if (dir !== '' && existsSync(join(dir, '.dsh'))) return resolve(dir);
|
|
136
|
-
}
|
|
137
|
-
return resolve(shares[0] ?? process.env.HOME ?? process.env.TRIM_VAR ?? appDir);
|
|
138
|
-
})();
|
|
139
|
-
/** Web 服务端口,健康检查用。 */
|
|
140
|
-
const port = parseInt(String(args.port ?? process.env.DSH_PORT ?? '3081'), 10);
|
|
141
|
-
|
|
142
|
-
const NODE_BIN = join(appDir, 'bin', 'node');
|
|
143
|
-
const DSH_BIN = join(appDir, 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js');
|
|
144
|
-
/** 插件 profile 名(与 runner.js / lib/index.js 的约定一致)。 */
|
|
145
|
-
const PROFILE = process.env.DSH_PLUGIN_PROFILE ?? 'web';
|
|
146
|
-
|
|
147
|
-
const dshStateDir = join(workspace, '.dsh');
|
|
148
|
-
const statusFile = join(dshStateDir, 'pluginupdate-status.json');
|
|
149
|
-
const logFile = join(dshStateDir, 'pluginupdate.log');
|
|
150
|
-
const lockFile = join(dshStateDir, 'pluginupdate.lock');
|
|
151
|
-
const stagingDir = join(dshStateDir, 'pluginupdate-staging');
|
|
152
|
-
const profileDir = join(dshStateDir, 'profiles', PROFILE);
|
|
153
|
-
/** 升级前的 profile 完整备份目录(失败回滚用;成功后保留一次供排查)。 */
|
|
154
|
-
const bakProfileDir = profileDir + '.pluginupdate-bak';
|
|
155
|
-
|
|
156
|
-
/** 国内镜像优先的 registry 候选列表(与 index.js 的策略一致)。 */
|
|
157
|
-
function registryCandidates() {
|
|
158
|
-
const custom = process.env.DSHSU_REGISTRY_URL?.replace(/\/+$/, '');
|
|
159
|
-
const list = [custom, 'https://mirrors.cloud.tencent.com/npm', 'https://registry.npmjs.org'];
|
|
160
|
-
return [...new Set(list.filter((v) => typeof v === 'string' && v !== ''))];
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/** 读插件状态文件(不存在返回空对象)。 */
|
|
164
|
-
function readStatus() {
|
|
165
|
-
try { return JSON.parse(readFileSync(statusFile, 'utf8')); } catch { return {}; }
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
let status = {};
|
|
169
|
-
|
|
170
|
-
/** 向日志追加一行(失败静默,日志不能阻断升级流程)。 */
|
|
171
|
-
function log(message) {
|
|
172
|
-
const line = `[${new Date().toISOString()}] ${message}`;
|
|
173
|
-
try { appendFileSync(logFile, line + '\n'); } catch { /* 忽略 */ }
|
|
174
|
-
console.log(line);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/** 合并写入插件状态文件,供 UI 卡片轮询展示进度。 */
|
|
178
|
-
function setState(state, message, extra = {}) {
|
|
179
|
-
status = { ...status, state, message: message ?? status.message, updatedAt: new Date().toISOString(), ...extra };
|
|
180
|
-
try { writeFileSync(statusFile, JSON.stringify({ ...status, pid: process.pid }, null, 2)); } catch { /* 同上 */ }
|
|
181
|
-
log(`state=${state}${message ? ` :: ${message}` : ''}`);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/** 终态收尾:释放锁文件并退出。 */
|
|
185
|
-
function finish(exitCode = 0) {
|
|
186
|
-
try { rmSync(lockFile, { force: true }); } catch { /* 忽略 */ }
|
|
187
|
-
process.exit(exitCode);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/* ------------------------------------------------------------------ *
|
|
191
|
-
* registry 查询与 tgz 下载
|
|
192
|
-
* ------------------------------------------------------------------ */
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* 从单个 registry 取包的 latest 版本号与 tgz 下载地址。
|
|
196
|
-
* 注意:镜像返回的 dist.tarball 可能仍指向官方源,这里不直接采用,
|
|
197
|
-
* 而是按 npm 标准路径规则自行拼出镜像侧的下载地址(保证国内可达)。
|
|
198
|
-
*/
|
|
199
|
-
async function fetchPackument(base, pkgName) {
|
|
200
|
-
const res = await fetch(`${base}/${encodeURIComponent(pkgName)}`, {
|
|
201
|
-
headers: { accept: 'application/json', 'user-agent': 'dsh-selfupdater' },
|
|
202
|
-
signal: AbortSignal.timeout(15000),
|
|
203
|
-
});
|
|
204
|
-
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
205
|
-
const doc = await res.json();
|
|
206
|
-
const latest = doc?.['dist-tags']?.latest;
|
|
207
|
-
if (typeof latest !== 'string' || latest === '') throw new Error('未返回 latest');
|
|
208
|
-
// tgz 文件名规则:取包名最后一段 + 版本号(@a/b@1.0.0 → b-1.0.0.tgz)。
|
|
209
|
-
const fileName = `${pkgName.split('/').pop()}-${latest}.tgz`;
|
|
210
|
-
return { latest, tgzUrl: `${base}/${pkgName}/-/${fileName}` };
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/** 依次尝试所有 registry 候选;全部失败抛出聚合错误。 */
|
|
214
|
-
async function fetchLatest(pkgName) {
|
|
215
|
-
const errors = [];
|
|
216
|
-
for (const base of registryCandidates()) {
|
|
217
|
-
try {
|
|
218
|
-
return await fetchPackument(base, pkgName);
|
|
219
|
-
} catch (err) {
|
|
220
|
-
errors.push(`${base}: ${err.message}`);
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
throw new Error(errors.join(';'));
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/** 把 tgz 流式下载到本地 staging 文件。 */
|
|
227
|
-
async function downloadTgz(url, destFile) {
|
|
228
|
-
const res = await fetch(url, {
|
|
229
|
-
headers: { 'user-agent': 'dsh-selfupdater' },
|
|
230
|
-
signal: AbortSignal.timeout(60000),
|
|
231
|
-
});
|
|
232
|
-
if (!res.ok) throw new Error(`tgz 下载失败 HTTP ${res.status}`);
|
|
233
|
-
writeFileSync(destFile, Buffer.from(await res.arrayBuffer()));
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/* ------------------------------------------------------------------ *
|
|
237
|
-
* 外部命令执行
|
|
238
|
-
* ------------------------------------------------------------------ */
|
|
239
|
-
|
|
240
|
-
/** 以 Promise 方式运行外部命令并收集输出。 */
|
|
241
|
-
function runCommand(file, cmdArgs, opts = {}) {
|
|
242
|
-
return new Promise((resolveRun) => {
|
|
243
|
-
const child = spawn(file, cmdArgs, { stdio: ['ignore', 'pipe', 'pipe'], ...opts });
|
|
244
|
-
let stdout = '';
|
|
245
|
-
let stderr = '';
|
|
246
|
-
child.stdout?.on('data', (d) => { stdout += d; });
|
|
247
|
-
child.stderr?.on('data', (d) => { stderr += d; });
|
|
248
|
-
child.on('error', (err) => resolveRun({ code: -1, stdout: '', stderr: String(err) }));
|
|
249
|
-
child.on('close', (code) => resolveRun({ code, stdout, stderr }));
|
|
250
|
-
});
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/**
|
|
254
|
-
* 用官方 CLI 离线安装 tgz(与 runner.js 内置安装逻辑完全一致):
|
|
255
|
-
* node bin/dsh.js plugin --profile <web> add <tgz>
|
|
256
|
-
* 关键环境变量:HOME 必须指向工作区,否则 dsh 会找错用户目录。
|
|
257
|
-
*/
|
|
258
|
-
async function dshPluginAdd(tgzFile) {
|
|
259
|
-
const result = await runCommand(NODE_BIN, [DSH_BIN, 'plugin', '--profile', PROFILE, 'add', tgzFile], {
|
|
260
|
-
cwd: workspace,
|
|
261
|
-
env: { ...process.env, HOME: workspace },
|
|
262
|
-
});
|
|
263
|
-
if (result.code !== 0) {
|
|
264
|
-
throw new Error(`dsh plugin add 退出码 ${result.code}: ${result.stderr.slice(-400)}`);
|
|
265
|
-
}
|
|
266
|
-
return result.stdout;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
/* ------------------------------------------------------------------ *
|
|
270
|
-
* 重启链与健康检查
|
|
271
|
-
* ------------------------------------------------------------------ */
|
|
272
|
-
|
|
273
|
-
/**
|
|
274
|
-
* 按 runner.js 的方式重新拉起完整启动链(runner 会再拉起 DSH)。
|
|
275
|
-
* 必须继承当前进程的全部环境变量 —— 飞牛的 TRIM_DATA_SHARE_PATHS 等
|
|
276
|
-
* 变量决定工作区落点,丢失会导致重启后配置"失踪"。
|
|
277
|
-
*/
|
|
278
|
-
function launchRunnerChain() {
|
|
279
|
-
const runnerJs = join(appDir, 'bin', 'runner.js');
|
|
280
|
-
if (!existsSync(runnerJs)) throw new Error(`找不到启动器 ${runnerJs},无法重启`);
|
|
281
|
-
const child = spawn(process.execPath, [runnerJs], {
|
|
282
|
-
cwd: workspace,
|
|
283
|
-
detached: true,
|
|
284
|
-
stdio: 'ignore',
|
|
285
|
-
env: { ...process.env, TRIM_APPDEST: appDir },
|
|
286
|
-
});
|
|
287
|
-
child.unref();
|
|
288
|
-
log(`已拉起 runner 链 (pid=${child.pid})`);
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
/** 健康检查:轮询本地端口直到有任意 HTTP 响应即算活着。 */
|
|
292
|
-
async function waitHealthy(timeoutMs) {
|
|
293
|
-
const deadline = Date.now() + timeoutMs;
|
|
294
|
-
while (Date.now() < deadline) {
|
|
295
|
-
try {
|
|
296
|
-
await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(3000) });
|
|
297
|
-
return true;
|
|
298
|
-
} catch { /* 未就绪继续等 */ }
|
|
299
|
-
await sleep(2000);
|
|
300
|
-
}
|
|
301
|
-
return false;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
/** 失败回滚:还原整个 profile 目录并再次拉起服务。 */
|
|
305
|
-
async function rollback() {
|
|
306
|
-
setState('rollback', '安装失败,正在回滚插件 profile …');
|
|
307
|
-
try {
|
|
308
|
-
rmSync(profileDir, { recursive: true, force: true });
|
|
309
|
-
renameSync(bakProfileDir, profileDir);
|
|
310
|
-
} catch (err) {
|
|
311
|
-
setState('error', `回滚失败:${err.message}。可手动从 ${bakProfileDir} 恢复。`);
|
|
312
|
-
return false;
|
|
313
|
-
}
|
|
314
|
-
launchRunnerChain();
|
|
315
|
-
const ok = await waitHealthy(60000);
|
|
316
|
-
setState(ok ? 'done_failed' : 'error',
|
|
317
|
-
ok ? '已回滚并恢复服务,插件保持旧版本' : '回滚后服务仍未恢复,请查看容器日志');
|
|
318
|
-
return ok;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
/* ------------------------------------------------------------------ *
|
|
322
|
-
* 主流程
|
|
323
|
-
* ------------------------------------------------------------------ */
|
|
324
|
-
|
|
325
|
-
async function main() {
|
|
326
|
-
mkdirSync(dshStateDir, { recursive: true });
|
|
327
|
-
|
|
328
|
-
// 锁文件双端校验:路由触发前会检查;这里再补一道防手动重复执行。
|
|
329
|
-
if (existsSync(lockFile)) throw new Error('已有一次插件更新在进行中(锁文件存在)');
|
|
330
|
-
|
|
331
|
-
/**
|
|
332
|
-
* 只更新本插件自身(dsh-selfupdater):其他插件已有各自的更新渠道,
|
|
333
|
-
* 不再扫描 profile 的 dependencies 清单。
|
|
334
|
-
* 已装版本直接读自己脚本身旁的 package.json,最可靠。
|
|
335
|
-
*/
|
|
336
|
-
let selfVersion = '';
|
|
337
|
-
try {
|
|
338
|
-
selfVersion = JSON.parse(
|
|
339
|
-
readFileSync(new URL('./../package.json', import.meta.url), 'utf8'),
|
|
340
|
-
).version ?? '';
|
|
341
|
-
} catch { /* 读不到版本号则无事可做 */ }
|
|
342
|
-
if (selfVersion === '') {
|
|
343
|
-
setState('error', '无法读取 dsh-selfupdater 自身版本号,更新中止');
|
|
344
|
-
finish(1);
|
|
345
|
-
}
|
|
346
|
-
const installed = [{ name: 'dsh-selfupdater', version: selfVersion }];
|
|
347
|
-
|
|
348
|
-
status = { startedAt: new Date().toISOString(), trigger: 'manual' };
|
|
349
|
-
setState('downloading', '正在查询 dsh-selfupdater 的最新版本 …');
|
|
350
|
-
|
|
351
|
-
// 阶段一:并发查询每个插件的 latest,筛出真正有新版可升的子集。
|
|
352
|
-
const checks = await Promise.allSettled(installed.map(async (p) => ({ ...p, info: await fetchLatest(p.name) })));
|
|
353
|
-
const pending = [];
|
|
354
|
-
for (const c of checks) {
|
|
355
|
-
if (c.status !== 'fulfilled') {
|
|
356
|
-
log(`查询 ${c.reason?.name ?? '?'} 失败: ${c.reason?.message ?? c.reason}`);
|
|
357
|
-
continue;
|
|
358
|
-
}
|
|
359
|
-
const { name, version, info } = c.value;
|
|
360
|
-
if (isNewer(info.latest, version)) pending.push({ name, currentVersion: version, targetVersion: info.latest, tgzUrl: info.tgzUrl });
|
|
361
|
-
}
|
|
362
|
-
if (pending.length === 0) {
|
|
363
|
-
setState('idle', 'dsh-selfupdater 已是最新版本');
|
|
364
|
-
finish(0);
|
|
365
|
-
}
|
|
366
|
-
log(`待更新插件:${pending.map((p) => `${p.name}@${p.currentVersion}->${p.targetVersion}`).join(', ')}`);
|
|
367
|
-
|
|
368
|
-
// 阶段二:备份整个 profile(同分区 rename 原子完成),失败即可整体还原。
|
|
369
|
-
setState('downloading', '正在备份当前插件目录 …');
|
|
370
|
-
rmSync(bakProfileDir, { recursive: true, force: true });
|
|
371
|
-
renameSync(profileDir, bakProfileDir);
|
|
372
|
-
// 注意:不再立即还原备份!备份目录需要保留到更新成功完成,
|
|
373
|
-
// 这样如果更新失败,rollback() 函数才能从备份恢复。
|
|
374
|
-
mkdirSync(profileDir, { recursive: true });
|
|
375
|
-
mkdirSync(stagingDir, { recursive: true });
|
|
376
|
-
|
|
377
|
-
// 阶段三:逐个下载 tgz 并离线安装;任一失败立即整体回滚。
|
|
378
|
-
const updated = [];
|
|
379
|
-
for (const item of pending) {
|
|
380
|
-
setState('downloading', `正在下载 ${item.name}@${item.targetVersion} …`, { updating: item.name });
|
|
381
|
-
const tgzFile = join(stagingDir, `${item.name.split('/').pop()}-${item.targetVersion}.tgz`);
|
|
382
|
-
await downloadTgz(item.tgzUrl, tgzFile);
|
|
383
|
-
log(`开始安装 ${item.name}@${item.targetVersion}`);
|
|
384
|
-
await dshPluginAdd(tgzFile);
|
|
385
|
-
updated.push(item);
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
// 校验安装结果:profile 里的登记版本必须等于目标版本(防镜像滞后装了旧版)。
|
|
389
|
-
const afterDeps = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')).dependencies ?? {};
|
|
390
|
-
const mismatched = pending.filter((p) => String(afterDeps[p.name] ?? '').replace(/^[~^]/, '') !== p.targetVersion);
|
|
391
|
-
if (mismatched.length > 0) {
|
|
392
|
-
throw new Error(`安装校验失败:${mismatched.map((p) => p.name).join(', ')} 版本不符`);
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
// 阶段四:重启 DSH 让新插件代码生效(本脚本启动时 DSH 已随插件路由退出,
|
|
396
|
-
// 但保险起见仍对传入 pid 做一次确认清理),然后等待端口就绪。
|
|
397
|
-
setState('restarting', '正在重启 DeepSeek Harness …');
|
|
398
|
-
const pid = parseInt(String(args.pid ?? ''), 10);
|
|
399
|
-
if (Number.isFinite(pid) && pid > 0) await killProcess(pid);
|
|
400
|
-
await waitPortFree(port, 20000);
|
|
401
|
-
launchRunnerChain();
|
|
402
|
-
|
|
403
|
-
setState('healthcheck', '等待服务就绪 …');
|
|
404
|
-
if (await waitHealthy(90000)) {
|
|
405
|
-
rmSync(stagingDir, { recursive: true, force: true });
|
|
406
|
-
// bak 目录保留一次作为手动救砖手段,下次升级前会自动清理。
|
|
407
|
-
setState('done',
|
|
408
|
-
`插件更新完成:dsh-selfupdater@${updated[0]?.targetVersion ?? ''}`,
|
|
409
|
-
{ finishedAt: new Date().toISOString() });
|
|
410
|
-
} else {
|
|
411
|
-
await rollback();
|
|
412
|
-
}
|
|
413
|
-
finish(0);
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
// 总看门狗:任何阶段卡死都强制终态,避免 UI 永远显示"更新中"。
|
|
417
|
-
setTimeout(() => {
|
|
418
|
-
setState('error', '插件更新超时(8 分钟看门狗触发),请查看 pluginupdate.log');
|
|
419
|
-
finish(2);
|
|
420
|
-
}, 8 * 60 * 1000);
|
|
421
|
-
|
|
422
|
-
main().catch(async (err) => {
|
|
423
|
-
log(`插件更新失败: ${err.stack ?? err}`);
|
|
424
|
-
// 只有已经动过 profile 才需要回滚;查询/下载阶段失败直接报错即可。
|
|
425
|
-
const touched = existsSync(join(dshStateDir, 'profiles')) && status.state !== 'downloading'
|
|
426
|
-
|| String(status.message ?? '').includes('备份');
|
|
427
|
-
if (status.state === 'healthcheck' || status.state === 'restarting' || touched) {
|
|
428
|
-
await rollback();
|
|
429
|
-
} else {
|
|
430
|
-
setState('error', String(err.message ?? err));
|
|
431
|
-
// 若 profile 曾被挪走尚未放回,尽力恢复。
|
|
432
|
-
if (!existsSync(profileDir) && existsSync(bakProfileDir)) {
|
|
433
|
-
try { renameSync(bakProfileDir, profileDir); } catch { /* 忽略 */ }
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
finish(1);
|
|
437
|
-
});
|