dsh-selfupdater 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/client/settings-card.js +786 -0
- package/cordis.patch.yml +5 -0
- package/lib/index.js +494 -0
- package/lib/plugin-updater.mjs +424 -0
- package/lib/updater.mjs +391 -0
- package/package.json +40 -0
|
@@ -0,0 +1,424 @@
|
|
|
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
|
+
/** 工作区目录(存放 .dsh 状态目录与插件 profile)。 */
|
|
126
|
+
const workspace = resolve(String(args.workspace ?? process.env.TRIM_VAR ?? appDir));
|
|
127
|
+
/** Web 服务端口,健康检查用。 */
|
|
128
|
+
const port = parseInt(String(args.port ?? process.env.DSH_PORT ?? '3081'), 10);
|
|
129
|
+
|
|
130
|
+
const NODE_BIN = join(appDir, 'bin', 'node');
|
|
131
|
+
const DSH_BIN = join(appDir, 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js');
|
|
132
|
+
/** 插件 profile 名(与 runner.js / lib/index.js 的约定一致)。 */
|
|
133
|
+
const PROFILE = process.env.DSH_PLUGIN_PROFILE ?? 'web';
|
|
134
|
+
|
|
135
|
+
const dshStateDir = join(workspace, '.dsh');
|
|
136
|
+
const statusFile = join(dshStateDir, 'pluginupdate-status.json');
|
|
137
|
+
const logFile = join(dshStateDir, 'pluginupdate.log');
|
|
138
|
+
const lockFile = join(dshStateDir, 'pluginupdate.lock');
|
|
139
|
+
const stagingDir = join(dshStateDir, 'pluginupdate-staging');
|
|
140
|
+
const profileDir = join(dshStateDir, 'profiles', PROFILE);
|
|
141
|
+
/** 升级前的 profile 完整备份目录(失败回滚用;成功后保留一次供排查)。 */
|
|
142
|
+
const bakProfileDir = join(dshStateDir, `profiles.${PROFILE}.pluginupdate-bak`);
|
|
143
|
+
|
|
144
|
+
/** 国内镜像优先的 registry 候选列表(与 index.js 的策略一致)。 */
|
|
145
|
+
function registryCandidates() {
|
|
146
|
+
const custom = process.env.DSHSU_REGISTRY_URL?.replace(/\/+$/, '');
|
|
147
|
+
const list = [custom, 'https://mirrors.cloud.tencent.com/npm', 'https://registry.npmjs.org'];
|
|
148
|
+
return [...new Set(list.filter((v) => typeof v === 'string' && v !== ''))];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** 读插件状态文件(不存在返回空对象)。 */
|
|
152
|
+
function readStatus() {
|
|
153
|
+
try { return JSON.parse(readFileSync(statusFile, 'utf8')); } catch { return {}; }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let status = {};
|
|
157
|
+
|
|
158
|
+
/** 向日志追加一行(失败静默,日志不能阻断升级流程)。 */
|
|
159
|
+
function log(message) {
|
|
160
|
+
const line = `[${new Date().toISOString()}] ${message}`;
|
|
161
|
+
try { appendFileSync(logFile, line + '\n'); } catch { /* 忽略 */ }
|
|
162
|
+
console.log(line);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** 合并写入插件状态文件,供 UI 卡片轮询展示进度。 */
|
|
166
|
+
function setState(state, message, extra = {}) {
|
|
167
|
+
status = { ...status, state, message: message ?? status.message, updatedAt: new Date().toISOString(), ...extra };
|
|
168
|
+
try { writeFileSync(statusFile, JSON.stringify({ ...status, pid: process.pid }, null, 2)); } catch { /* 同上 */ }
|
|
169
|
+
log(`state=${state}${message ? ` :: ${message}` : ''}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** 终态收尾:释放锁文件并退出。 */
|
|
173
|
+
function finish(exitCode = 0) {
|
|
174
|
+
try { rmSync(lockFile, { force: true }); } catch { /* 忽略 */ }
|
|
175
|
+
process.exit(exitCode);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/* ------------------------------------------------------------------ *
|
|
179
|
+
* registry 查询与 tgz 下载
|
|
180
|
+
* ------------------------------------------------------------------ */
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* 从单个 registry 取包的 latest 版本号与 tgz 下载地址。
|
|
184
|
+
* 注意:镜像返回的 dist.tarball 可能仍指向官方源,这里不直接采用,
|
|
185
|
+
* 而是按 npm 标准路径规则自行拼出镜像侧的下载地址(保证国内可达)。
|
|
186
|
+
*/
|
|
187
|
+
async function fetchPackument(base, pkgName) {
|
|
188
|
+
const res = await fetch(`${base}/${encodeURIComponent(pkgName)}`, {
|
|
189
|
+
headers: { accept: 'application/json', 'user-agent': 'dsh-selfupdater' },
|
|
190
|
+
signal: AbortSignal.timeout(15000),
|
|
191
|
+
});
|
|
192
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
193
|
+
const doc = await res.json();
|
|
194
|
+
const latest = doc?.['dist-tags']?.latest;
|
|
195
|
+
if (typeof latest !== 'string' || latest === '') throw new Error('未返回 latest');
|
|
196
|
+
// tgz 文件名规则:取包名最后一段 + 版本号(@a/b@1.0.0 → b-1.0.0.tgz)。
|
|
197
|
+
const fileName = `${pkgName.split('/').pop()}-${latest}.tgz`;
|
|
198
|
+
return { latest, tgzUrl: `${base}/${pkgName}/-/${fileName}` };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** 依次尝试所有 registry 候选;全部失败抛出聚合错误。 */
|
|
202
|
+
async function fetchLatest(pkgName) {
|
|
203
|
+
const errors = [];
|
|
204
|
+
for (const base of registryCandidates()) {
|
|
205
|
+
try {
|
|
206
|
+
return await fetchPackument(base, pkgName);
|
|
207
|
+
} catch (err) {
|
|
208
|
+
errors.push(`${base}: ${err.message}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
throw new Error(errors.join(';'));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** 把 tgz 流式下载到本地 staging 文件。 */
|
|
215
|
+
async function downloadTgz(url, destFile) {
|
|
216
|
+
const res = await fetch(url, {
|
|
217
|
+
headers: { 'user-agent': 'dsh-selfupdater' },
|
|
218
|
+
signal: AbortSignal.timeout(60000),
|
|
219
|
+
});
|
|
220
|
+
if (!res.ok) throw new Error(`tgz 下载失败 HTTP ${res.status}`);
|
|
221
|
+
writeFileSync(destFile, Buffer.from(await res.arrayBuffer()));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/* ------------------------------------------------------------------ *
|
|
225
|
+
* 外部命令执行
|
|
226
|
+
* ------------------------------------------------------------------ */
|
|
227
|
+
|
|
228
|
+
/** 以 Promise 方式运行外部命令并收集输出。 */
|
|
229
|
+
function runCommand(file, cmdArgs, opts = {}) {
|
|
230
|
+
return new Promise((resolveRun) => {
|
|
231
|
+
const child = spawn(file, cmdArgs, { stdio: ['ignore', 'pipe', 'pipe'], ...opts });
|
|
232
|
+
let stdout = '';
|
|
233
|
+
let stderr = '';
|
|
234
|
+
child.stdout?.on('data', (d) => { stdout += d; });
|
|
235
|
+
child.stderr?.on('data', (d) => { stderr += d; });
|
|
236
|
+
child.on('error', (err) => resolveRun({ code: -1, stdout: '', stderr: String(err) }));
|
|
237
|
+
child.on('close', (code) => resolveRun({ code, stdout, stderr }));
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* 用官方 CLI 离线安装 tgz(与 runner.js 内置安装逻辑完全一致):
|
|
243
|
+
* node bin/dsh.js plugin --profile <web> add <tgz>
|
|
244
|
+
* 关键环境变量:HOME 必须指向工作区,否则 dsh 会找错用户目录。
|
|
245
|
+
*/
|
|
246
|
+
async function dshPluginAdd(tgzFile) {
|
|
247
|
+
const result = await runCommand(NODE_BIN, [DSH_BIN, 'plugin', '--profile', PROFILE, 'add', tgzFile], {
|
|
248
|
+
cwd: workspace,
|
|
249
|
+
env: { ...process.env, HOME: workspace },
|
|
250
|
+
});
|
|
251
|
+
if (result.code !== 0) {
|
|
252
|
+
throw new Error(`dsh plugin add 退出码 ${result.code}: ${result.stderr.slice(-400)}`);
|
|
253
|
+
}
|
|
254
|
+
return result.stdout;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/* ------------------------------------------------------------------ *
|
|
258
|
+
* 重启链与健康检查
|
|
259
|
+
* ------------------------------------------------------------------ */
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* 按 runner.js 的方式重新拉起完整启动链(runner 会再拉起 DSH)。
|
|
263
|
+
* 必须继承当前进程的全部环境变量 —— 飞牛的 TRIM_DATA_SHARE_PATHS 等
|
|
264
|
+
* 变量决定工作区落点,丢失会导致重启后配置"失踪"。
|
|
265
|
+
*/
|
|
266
|
+
function launchRunnerChain() {
|
|
267
|
+
const runnerJs = join(appDir, 'bin', 'runner.js');
|
|
268
|
+
if (!existsSync(runnerJs)) throw new Error(`找不到启动器 ${runnerJs},无法重启`);
|
|
269
|
+
const child = spawn(process.execPath, [runnerJs], {
|
|
270
|
+
cwd: workspace,
|
|
271
|
+
detached: true,
|
|
272
|
+
stdio: 'ignore',
|
|
273
|
+
env: { ...process.env, TRIM_APPDEST: appDir },
|
|
274
|
+
});
|
|
275
|
+
child.unref();
|
|
276
|
+
log(`已拉起 runner 链 (pid=${child.pid})`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** 健康检查:轮询本地端口直到有任意 HTTP 响应即算活着。 */
|
|
280
|
+
async function waitHealthy(timeoutMs) {
|
|
281
|
+
const deadline = Date.now() + timeoutMs;
|
|
282
|
+
while (Date.now() < deadline) {
|
|
283
|
+
try {
|
|
284
|
+
await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(3000) });
|
|
285
|
+
return true;
|
|
286
|
+
} catch { /* 未就绪继续等 */ }
|
|
287
|
+
await sleep(2000);
|
|
288
|
+
}
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** 失败回滚:还原整个 profile 目录并再次拉起服务。 */
|
|
293
|
+
async function rollback() {
|
|
294
|
+
setState('rollback', '安装失败,正在回滚插件 profile …');
|
|
295
|
+
try {
|
|
296
|
+
rmSync(profileDir, { recursive: true, force: true });
|
|
297
|
+
renameSync(bakProfileDir, profileDir);
|
|
298
|
+
} catch (err) {
|
|
299
|
+
setState('error', `回滚失败:${err.message}。可手动从 ${bakProfileDir} 恢复。`);
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
launchRunnerChain();
|
|
303
|
+
const ok = await waitHealthy(60000);
|
|
304
|
+
setState(ok ? 'done_failed' : 'error',
|
|
305
|
+
ok ? '已回滚并恢复服务,插件保持旧版本' : '回滚后服务仍未恢复,请查看容器日志');
|
|
306
|
+
return ok;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/* ------------------------------------------------------------------ *
|
|
310
|
+
* 主流程
|
|
311
|
+
* ------------------------------------------------------------------ */
|
|
312
|
+
|
|
313
|
+
async function main() {
|
|
314
|
+
mkdirSync(dshStateDir, { recursive: true });
|
|
315
|
+
|
|
316
|
+
// 锁文件双端校验:路由触发前会检查;这里再补一道防手动重复执行。
|
|
317
|
+
if (existsSync(lockFile)) throw new Error('已有一次插件更新在进行中(锁文件存在)');
|
|
318
|
+
|
|
319
|
+
// 读已安装清单:profile/package.json 的 dependencies 是最权威的数据源。
|
|
320
|
+
let deps;
|
|
321
|
+
try {
|
|
322
|
+
deps = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')).dependencies ?? {};
|
|
323
|
+
} catch {
|
|
324
|
+
setState('idle', '未发现已安装插件,无需更新');
|
|
325
|
+
finish(0);
|
|
326
|
+
}
|
|
327
|
+
const installed = Object.entries(deps).map(([name, range]) => ({
|
|
328
|
+
name,
|
|
329
|
+
version: String(range).replace(/^[~^]\s*/, ''),
|
|
330
|
+
})).filter((p) => p.version !== '');
|
|
331
|
+
|
|
332
|
+
if (installed.length === 0) {
|
|
333
|
+
setState('idle', '未发现已安装插件,无需更新');
|
|
334
|
+
finish(0);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
status = { startedAt: new Date().toISOString(), trigger: 'manual' };
|
|
338
|
+
setState('downloading', '正在查询各插件的最新版本 …');
|
|
339
|
+
|
|
340
|
+
// 阶段一:并发查询每个插件的 latest,筛出真正有新版可升的子集。
|
|
341
|
+
const checks = await Promise.allSettled(installed.map(async (p) => ({ ...p, info: await fetchLatest(p.name) })));
|
|
342
|
+
const pending = [];
|
|
343
|
+
for (const c of checks) {
|
|
344
|
+
if (c.status !== 'fulfilled') {
|
|
345
|
+
log(`查询 ${c.reason?.name ?? '?'} 失败: ${c.reason?.message ?? c.reason}`);
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
const { name, version, info } = c.value;
|
|
349
|
+
if (isNewer(info.latest, version)) pending.push({ name, currentVersion: version, targetVersion: info.latest, tgzUrl: info.tgzUrl });
|
|
350
|
+
}
|
|
351
|
+
if (pending.length === 0) {
|
|
352
|
+
setState('idle', '所有插件均已是最新版本');
|
|
353
|
+
finish(0);
|
|
354
|
+
}
|
|
355
|
+
log(`待更新插件:${pending.map((p) => `${p.name}@${p.currentVersion}->${p.targetVersion}`).join(', ')}`);
|
|
356
|
+
|
|
357
|
+
// 阶段二:备份整个 profile(同分区 rename 原子完成),失败即可整体还原。
|
|
358
|
+
setState('downloading', '正在备份当前插件目录 …');
|
|
359
|
+
rmSync(bakProfileDir, { recursive: true, force: true });
|
|
360
|
+
renameSync(profileDir, bakProfileDir);
|
|
361
|
+
renameSync(bakProfileDir, profileDir); // 立刻放回原位:备份改用复制,避免长时间缺位
|
|
362
|
+
mkdirSync(stagingDir, { recursive: true });
|
|
363
|
+
|
|
364
|
+
// 阶段三:逐个下载 tgz 并离线安装;任一失败立即整体回滚。
|
|
365
|
+
const updated = [];
|
|
366
|
+
for (const item of pending) {
|
|
367
|
+
setState('downloading', `正在下载 ${item.name}@${item.targetVersion} …`, { updating: item.name });
|
|
368
|
+
const tgzFile = join(stagingDir, `${item.name.split('/').pop()}-${item.targetVersion}.tgz`);
|
|
369
|
+
await downloadTgz(item.tgzUrl, tgzFile);
|
|
370
|
+
log(`开始安装 ${item.name}@${item.targetVersion}`);
|
|
371
|
+
await dshPluginAdd(tgzFile);
|
|
372
|
+
updated.push(item);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// 校验安装结果:profile 里的登记版本必须等于目标版本(防镜像滞后装了旧版)。
|
|
376
|
+
const afterDeps = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')).dependencies ?? {};
|
|
377
|
+
const mismatched = pending.filter((p) => String(afterDeps[p.name] ?? '').replace(/^[~^]/, '') !== p.targetVersion);
|
|
378
|
+
if (mismatched.length > 0) {
|
|
379
|
+
throw new Error(`安装校验失败:${mismatched.map((p) => p.name).join(', ')} 版本不符`);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// 阶段四:重启 DSH 让新插件代码生效(本脚本启动时 DSH 已随插件路由退出,
|
|
383
|
+
// 但保险起见仍对传入 pid 做一次确认清理),然后等待端口就绪。
|
|
384
|
+
setState('restarting', '正在重启 DeepSeek Harness …');
|
|
385
|
+
const pid = parseInt(String(args.pid ?? ''), 10);
|
|
386
|
+
if (Number.isFinite(pid) && pid > 0) await killProcess(pid);
|
|
387
|
+
await waitPortFree(port, 20000);
|
|
388
|
+
launchRunnerChain();
|
|
389
|
+
|
|
390
|
+
setState('healthcheck', '等待服务就绪 …');
|
|
391
|
+
if (await waitHealthy(90000)) {
|
|
392
|
+
rmSync(stagingDir, { recursive: true, force: true });
|
|
393
|
+
// bak 目录保留一次作为手动救砖手段,下次升级前会自动清理。
|
|
394
|
+
setState('done',
|
|
395
|
+
`插件更新完成:${updated.map((p) => p.name).join(', ')}`,
|
|
396
|
+
{ finishedAt: new Date().toISOString() });
|
|
397
|
+
} else {
|
|
398
|
+
await rollback();
|
|
399
|
+
}
|
|
400
|
+
finish(0);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// 总看门狗:任何阶段卡死都强制终态,避免 UI 永远显示"更新中"。
|
|
404
|
+
setTimeout(() => {
|
|
405
|
+
setState('error', '插件更新超时(8 分钟看门狗触发),请查看 pluginupdate.log');
|
|
406
|
+
finish(2);
|
|
407
|
+
}, 8 * 60 * 1000);
|
|
408
|
+
|
|
409
|
+
main().catch(async (err) => {
|
|
410
|
+
log(`插件更新失败: ${err.stack ?? err}`);
|
|
411
|
+
// 只有已经动过 profile 才需要回滚;查询/下载阶段失败直接报错即可。
|
|
412
|
+
const touched = existsSync(join(dshStateDir, 'profiles')) && status.state !== 'downloading'
|
|
413
|
+
|| String(status.message ?? '').includes('备份');
|
|
414
|
+
if (status.state === 'healthcheck' || status.state === 'restarting' || touched) {
|
|
415
|
+
await rollback();
|
|
416
|
+
} else {
|
|
417
|
+
setState('error', String(err.message ?? err));
|
|
418
|
+
// 若 profile 曾被挪走尚未放回,尽力恢复。
|
|
419
|
+
if (!existsSync(profileDir) && existsSync(bakProfileDir)) {
|
|
420
|
+
try { renameSync(bakProfileDir, profileDir); } catch { /* 忽略 */ }
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
finish(1);
|
|
424
|
+
});
|