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.
@@ -0,0 +1,5 @@
1
+ # dsh-selfupdater 的 bundle 补丁:把本插件插入 profile 的 layer 栈。
2
+ # 格式与 dshmarket 完全一致(Loader entries 追加),id/name 必须与 package.json 一致。
3
+ - insert:
4
+ - id: dsh-selfupdater
5
+ name: 'dsh-selfupdater'
package/lib/index.js ADDED
@@ -0,0 +1,494 @@
1
+ /**
2
+ * dsh-selfupdater 宿主入口:注册 HTTP 路由,桥接浏览器设置卡片与升级脚本。
3
+ *
4
+ * 职责边界:本文件只做"读状态 / 查版本 / 触发升级",真正的
5
+ * 下载-换目录-重启-回滚全部由分离进程 lib/updater.mjs 完成(原因:
6
+ * 要替换的是 DSH 自己脚下的 node_modules,必须先让 DSH 退出)。
7
+ *
8
+ * 安全模型(与 dshmarket lib/http.js 完全一致):
9
+ * - POST 接口仅接受 same-origin 请求(Origin 与 Host 头一致);
10
+ * - 升级动作通过锁文件防并发,双端校验。
11
+ */
12
+ import { spawn } from 'node:child_process';
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
14
+ import { basename, dirname, join, resolve } from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ export const name = 'dsh-selfupdater';
18
+
19
+ const PLUGIN_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
20
+ /** 要升级的主程序包名。 */
21
+ const PKG = '@deepseek-ai/dsh';
22
+ /** npm registry 查询超时。 */
23
+ const FETCH_TIMEOUT_MS = 15000;
24
+ /** 国内 npm 镜像(腾讯云),registry.npmjs.org 直连超时时的第一回退。 */
25
+ const NPM_CHINA_MIRROR = 'https://mirrors.cloud.tencent.com/npm';
26
+ /** 插件所在的 profile 名(与 runner.js 的 DSH_PLUGIN_PROFILE 约定一致)。 */
27
+ const PLUGIN_PROFILE = process.env.DSH_PLUGIN_PROFILE ?? 'web';
28
+
29
+ /* ------------------------------------------------------------------ *
30
+ * semver 比较(零依赖,与 updater.mjs 内实现保持一致)
31
+ * ------------------------------------------------------------------ */
32
+
33
+ const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
34
+
35
+ function parseSemver(v) {
36
+ const m = SEMVER_RE.exec(String(v ?? '').trim());
37
+ if (m === null) return null;
38
+ return {
39
+ core: [Number(m[1]), Number(m[2]), Number(m[3])],
40
+ pre: m[4] === undefined ? [] : m[4].split('.'),
41
+ };
42
+ }
43
+
44
+ /** 返回负数/0/正数;任一侧非法返回 null。 */
45
+ function compareVersions(a, b) {
46
+ const pa = parseSemver(a);
47
+ const pb = parseSemver(b);
48
+ if (pa === null || pb === null) return null;
49
+ for (let i = 0; i < 3; i++) {
50
+ if (pa.core[i] !== pb.core[i]) return pa.core[i] - pb.core[i];
51
+ }
52
+ if (pa.pre.length === 0 || pb.pre.length === 0) return pb.pre.length - pa.pre.length;
53
+ for (let i = 0; i < Math.max(pa.pre.length, pb.pre.length); i++) {
54
+ const x = pa.pre[i];
55
+ const y = pb.pre[i];
56
+ if (x === undefined) return -1;
57
+ if (y === undefined) return 1;
58
+ if (x === y) continue;
59
+ const nx = /^\d+$/.test(x);
60
+ const ny = /^\d+$/.test(y);
61
+ if (nx && ny) return Number(x) - Number(y);
62
+ if (nx !== ny) return nx ? -1 : 1;
63
+ return x < y ? -1 : 1;
64
+ }
65
+ return 0;
66
+ }
67
+
68
+ function isNewer(latest, installed) {
69
+ const cmp = compareVersions(latest, installed);
70
+ return cmp !== null && cmp > 0;
71
+ }
72
+
73
+ /* ------------------------------------------------------------------ *
74
+ * HTTP 工具(照搬 dshmarket lib/http.js)
75
+ * ------------------------------------------------------------------ */
76
+
77
+ /** 写 JSON 响应并禁用缓存。 */
78
+ function sendJson(response, code, payload) {
79
+ response.writeHead(code, {
80
+ 'cache-control': 'no-store',
81
+ 'content-type': 'application/json; charset=utf-8',
82
+ });
83
+ response.end(JSON.stringify(payload));
84
+ }
85
+
86
+ /**
87
+ * 同源校验(与 dshmarket lib/http.js 完全一致):Origin 的 host 部分必须
88
+ * 与请求头 Host 一致。经 runner.js 透明反代访问时,反代会把 Origin 与 Host
89
+ * 统一改写为 http://127.0.0.1:<DSH_PORT>,两者天然相等,校验照常通过;
90
+ * 直连场景下两者本来就相同。跨站伪造请求的 Origin 是外部地址,会被拒绝。
91
+ */
92
+ function sameOrigin(request) {
93
+ const origin = request.headers.origin;
94
+ const host = request.headers.host;
95
+ if (origin === undefined || host === undefined) return false;
96
+ try {
97
+ return new URL(origin).host === host;
98
+ } catch {
99
+ return false;
100
+ }
101
+ }
102
+
103
+ /* ------------------------------------------------------------------ *
104
+ * 状态与路径解析
105
+ * ------------------------------------------------------------------ */
106
+
107
+ /** 从 --profile 参数读取宿主实际启动的 profile(与 dshmarket 相同的兜底逻辑)。 */
108
+ function argvProfile() {
109
+ const argv = process.argv;
110
+ const flag = argv.indexOf('--profile');
111
+ if (flag !== -1 && flag + 1 < argv.length && !argv[flag + 1].startsWith('-')) {
112
+ return argv[flag + 1];
113
+ }
114
+ return undefined;
115
+ }
116
+
117
+ /**
118
+ * 定位 DSH 应用目录(node_modules 所在处)。
119
+ * 优先 TRIM_APPDEST 环境变量;否则从本插件安装位置向上找 node_modules 边界。
120
+ */
121
+ function resolveAppDir() {
122
+ const envDir = process.env.TRIM_APPDEST;
123
+ if (envDir && existsSync(join(envDir, 'node_modules', PKG))) return resolve(envDir);
124
+ // 插件位于 …/APP_DIR/node_modules/dsh-selfupdater/lib/index.js → 上三级即 APP_DIR。
125
+ const candidate = resolve(PLUGIN_ROOT, '..', '..');
126
+ if (existsSync(join(candidate, 'node_modules', PKG))) return candidate;
127
+ throw new Error(`无法定位 ${PKG} 的应用目录`);
128
+ }
129
+
130
+ /** 解析工作区目录(与 runner.js 的优先级一致)。 */
131
+ function resolveWorkspace(appDir) {
132
+ return resolve(process.env.TRIM_VAR ?? appDir);
133
+ }
134
+
135
+ /** 服务端口:与 runner.js 保持一致的环境变量与默认值。 */
136
+ function servicePort() {
137
+ return parseInt(process.env.DSH_PORT ?? '3081', 10);
138
+ }
139
+
140
+ /** 读当前安装的主程序版本。 */
141
+ function currentDshVersion(appDir) {
142
+ try {
143
+ return JSON.parse(readFileSync(join(appDir, 'node_modules', PKG, 'package.json'), 'utf8')).version ?? 'unknown';
144
+ } catch {
145
+ return 'unknown';
146
+ }
147
+ }
148
+
149
+ /**
150
+ * 查询 npm registry 最新版本号(带镜像回退,参照 dshmarket regions.ts 的做法):
151
+ * 1. 优先走环境变量 DSHSU_REGISTRY_URL 指定的镜像(部署方可自行指定国内源);
152
+ * 2. 默认先试腾讯云国内镜像(飞牛OS 部署多在国内网络,直连 registry.npmjs.org
153
+ * 经常超时——这正是"检查更新没反应"的常见原因);
154
+ * 3. 镜像失败后回退官方源,保证海外网络也能用。
155
+ */
156
+ async function fetchLatestVersion(pkg) {
157
+ const errors = [];
158
+ for (const base of registryCandidates()) {
159
+ try {
160
+ return await fetchFromRegistry(base, pkg);
161
+ } catch (err) {
162
+ errors.push(`${base}: ${err.message}`);
163
+ }
164
+ }
165
+ throw new Error(errors.join(';'));
166
+ }
167
+
168
+ /** 本次要依次尝试的 registry 地址列表(去重)。 */
169
+ function registryCandidates() {
170
+ const custom = process.env.DSHSU_REGISTRY_URL?.replace(/\/+$/, '');
171
+ const list = [custom, NPM_CHINA_MIRROR, 'https://registry.npmjs.org'];
172
+ return [...new Set(list.filter((v) => typeof v === 'string' && v !== ''))];
173
+ }
174
+
175
+ /** 从单个 registry 取 latest 版本号。 */
176
+ async function fetchFromRegistry(base, pkg) {
177
+ const res = await fetch(`${base}/${encodeURIComponent(pkg)}`, {
178
+ headers: { accept: 'application/json', 'user-agent': 'dsh-selfupdater' },
179
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
180
+ });
181
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
182
+ const doc = await res.json();
183
+ const latest = doc?.['dist-tags']?.latest;
184
+ if (typeof latest !== 'string' || latest === '') throw new Error('未返回 latest');
185
+ return latest;
186
+ }
187
+ /** 读升级状态文件(不存在时返回空对象)。 */
188
+ function readStatus(dshStateDir) {
189
+ try {
190
+ return JSON.parse(readFileSync(join(dshStateDir, 'selfupdate-status.json'), 'utf8'));
191
+ } catch {
192
+ return {};
193
+ }
194
+ }
195
+
196
+ /**
197
+ * 持久化升级状态文件。
198
+ * 关键修复:首次使用时 .dsh 目录可能尚不存在(只有跑过一次升级脚本才会建它),
199
+ * writeFileSync 会直接抛 ENOENT,导致检查结果从未落盘 —— 这正是 UI 上
200
+ * "最新版本一直显示 —、上次检查一直是从未"的根因;这里先确保目录存在。
201
+ * 写失败仅记录日志不抛出,不能让状态落盘问题阻断接口应答。
202
+ */
203
+ function writeStatus(dshStateDir, payload) {
204
+ try {
205
+ mkdirSync(dshStateDir, { recursive: true });
206
+ writeFileSync(join(dshStateDir, 'selfupdate-status.json'), JSON.stringify(payload, null, 2));
207
+ } catch (err) {
208
+ console.warn(`[dsh-selfupdater] 状态文件写入失败: ${err.message}`);
209
+ }
210
+ }
211
+
212
+ /* ------------------------------------------------------------------ *
213
+ * 插件更新(与主程序升级共用同一入口,但走独立的状态/锁/升级脚本)
214
+ * ------------------------------------------------------------------ */
215
+
216
+ /** 读插件状态文件(不存在时返回空对象)。 */
217
+ function readPluginStatus(dshStateDir) {
218
+ try {
219
+ return JSON.parse(readFileSync(join(dshStateDir, 'pluginupdate-status.json'), 'utf8'));
220
+ } catch {
221
+ return {};
222
+ }
223
+ }
224
+
225
+ /** 持久化插件状态文件(自动建目录;失败仅告警不阻断应答)。 */
226
+ function writePluginStatus(dshStateDir, payload) {
227
+ try {
228
+ mkdirSync(dshStateDir, { recursive: true });
229
+ writeFileSync(join(dshStateDir, 'pluginupdate-status.json'), JSON.stringify(payload, null, 2));
230
+ } catch (err) {
231
+ console.warn(`[dsh-selfupdater] 插件状态文件写入失败: ${err.message}`);
232
+ }
233
+ }
234
+
235
+ /**
236
+ * 列出 profile 中已安装的插件及其版本。
237
+ * 数据源是 profile/package.json 的 dependencies 字段 —— dsh plugin add
238
+ * 安装时会把它登记进去,这是最权威的"已装清单"。
239
+ */
240
+ function listInstalledPlugins(workspace) {
241
+ const profilePkg = join(workspace, '.dsh', 'profiles', PLUGIN_PROFILE, 'package.json');
242
+ try {
243
+ const deps = JSON.parse(readFileSync(profilePkg, 'utf8')).dependencies ?? {};
244
+ return Object.entries(deps).map(([pkgName, range]) => ({
245
+ name: pkgName,
246
+ installedVersion: String(range).replace(/^[~^]\s*/, '') || String(range),
247
+ }));
248
+ } catch {
249
+ return [];
250
+ }
251
+ }
252
+
253
+ /** 查询 npm 上目标插件的 latest 版本号(复用带镜像回退的实现)。 */
254
+ async function fetchPluginLatest(pkgName) {
255
+ return fetchLatestVersion(pkgName);
256
+ }
257
+
258
+ /* ------------------------------------------------------------------ *
259
+ * apply 入口
260
+ * ------------------------------------------------------------------ */
261
+
262
+ /**
263
+ * 与 dshmarket 一致的挂载方式:等待 webServer + loader 服务就绪后注册路由。
264
+ * @param ctx - cordis 宿主上下文
265
+ */
266
+ export function apply(ctx) {
267
+ ctx.inject(['webServer', 'loader'], async (hostCtx) => {
268
+ const host = hostCtx;
269
+ const appDir = resolveAppDir();
270
+ const workspace = resolveWorkspace(appDir);
271
+ const dshStateDir = join(workspace, '.dsh');
272
+ const lockFile = join(dshStateDir, 'selfupdate.lock');
273
+ const pluginLockFile = join(dshStateDir, 'pluginupdate.lock');
274
+ const port = servicePort();
275
+
276
+ /** 升级是否正在进行(锁文件存在)。 */
277
+ const isBusy = () => existsSync(lockFile);
278
+
279
+ /**
280
+ * 注册一个精确匹配的路由(照搬 dshmarket 的挂载方式)。
281
+ * webServer.register({ kind:'exact', path, handler }) 返回反注册函数;
282
+ * method 分发由 handler 自行完成,与 dshmarket 各路由的做法一致。
283
+ */
284
+ function registerRoute(method, path, handler) {
285
+ return host.webServer.register({
286
+ kind: 'exact',
287
+ path,
288
+ handler: async (request, response) => {
289
+ if (request.method !== method) {
290
+ response.writeHead(405, { allow: method });
291
+ response.end();
292
+ return;
293
+ }
294
+ await handler(request, response);
295
+ },
296
+ });
297
+ }
298
+
299
+ /* ---------- GET /dsh-selfupdater/status ---------- */
300
+ registerRoute('GET', '/dsh-selfupdater/status', (_req, res) => {
301
+ const status = readStatus(dshStateDir);
302
+ const current = currentDshVersion(appDir);
303
+ sendJson(res, 200, {
304
+ currentVersion: current,
305
+ latestVersion: status.latestVersion ?? null,
306
+ state: isBusy() ? (status.state ?? 'running') : (status.state ?? 'idle'),
307
+ message: status.message ?? null,
308
+ lastCheck: status.updatedAt ?? null,
309
+ });
310
+ });
311
+
312
+ /* ---------- POST /dsh-selfupdater/check ---------- */
313
+ registerRoute('POST', '/dsh-selfupdater/check', async (req, res) => {
314
+ if (!sameOrigin(req)) {
315
+ sendJson(res, 403, { error: 'untrusted request' });
316
+ return;
317
+ }
318
+ try {
319
+ const latest = await fetchLatestVersion(PKG);
320
+ const current = currentDshVersion(appDir);
321
+ const updateAvailable = isNewer(latest, current);
322
+ // 检查结果落盘(内部自动建目录),UI 刷新后仍能看到上次检查时间。
323
+ writeStatus(dshStateDir, {
324
+ ...(await Promise.resolve(readStatus(dshStateDir))),
325
+ currentVersion: current,
326
+ latestVersion: latest,
327
+ state: isBusy() ? 'running' : 'idle',
328
+ message: updateAvailable ? `发现新版本 ${latest}` : '当前已是最新',
329
+ updatedAt: new Date().toISOString(),
330
+ });
331
+ sendJson(res, 200, { currentVersion: current, latestVersion: latest, updateAvailable });
332
+ } catch (err) {
333
+ host.logger?.warn?.(`[dsh-selfupdater] 检查更新失败: ${err.message}`);
334
+ // 失败信息也写入状态文件:UI 轮询能看到具体原因,不再"没反应"。
335
+ writeStatus(dshStateDir, {
336
+ ...readStatus(dshStateDir),
337
+ state: isBusy() ? 'running' : 'idle',
338
+ message: `检查更新失败:${err.message}`,
339
+ updatedAt: new Date().toISOString(),
340
+ });
341
+ sendJson(res, 502, { error: `检查更新失败:${err.message}` });
342
+ }
343
+ });
344
+
345
+ /* ---------- POST /dsh-selfupdater/perform ---------- */
346
+ registerRoute('POST', '/dsh-selfupdater/perform', (req, res) => {
347
+ if (!sameOrigin(req)) {
348
+ sendJson(res, 403, { error: 'untrusted request' });
349
+ return;
350
+ }
351
+ // 并发防护:锁文件已存在说明有升级在跑(或上次异常残留未清理)。
352
+ if (isBusy()) {
353
+ sendJson(res, 409, { error: '已有一次升级在进行中' });
354
+ return;
355
+ }
356
+ // 预置锁文件:updater.mjs 启动后会校验它存在才继续。
357
+ try {
358
+ writeFileSync(lockFile, JSON.stringify({ pid: process.pid, startedAt: Date.now(), by: 'plugin' }));
359
+ } catch (err) {
360
+ sendJson(res, 500, { error: `写入锁文件失败:${err.message}` });
361
+ return;
362
+ }
363
+ const child = spawn(process.execPath, [
364
+ join(PLUGIN_ROOT, 'lib', 'updater.mjs'),
365
+ '--pid', String(process.pid),
366
+ '--app-dir', appDir,
367
+ '--workspace', workspace,
368
+ '--port', String(port),
369
+ '--pkg', PKG,
370
+ ], { detached: true, stdio: 'ignore', env: process.env });
371
+ child.unref();
372
+
373
+ host.logger?.info?.(`[dsh-selfupdater] 升级进程已启动 pid=${child.pid},主程序即将退出`);
374
+ // 给 spawn 一点时间落稳再退出,避免父进程先死导致子进程被会话回收。
375
+ setTimeout(() => process.exit(0), 800);
376
+ // 先应答,让前端拿到"已受理"再等断线。
377
+ sendJson(res, 202, { accepted: true, note: '服务将自动退出并由升级脚本接管' });
378
+ });
379
+
380
+ /* ---------- GET /dsh-selfupdater/plugins ---------- */
381
+ /** 插件更新是否正在进行(锁文件存在)。 */
382
+ const pluginBusy = () => existsSync(pluginLockFile);
383
+ /** 读插件状态并合并"服务端视角"的忙闲标记。 */
384
+ const pluginStatusView = () => ({
385
+ ...readPluginStatus(dshStateDir),
386
+ state: pluginBusy() ? (readPluginStatus(dshStateDir).state ?? 'running') : (readPluginStatus(dshStateDir).state ?? 'idle'),
387
+ });
388
+
389
+ registerRoute('GET', '/dsh-selfupdater/plugins', (_req, res) => {
390
+ const installed = listInstalledPlugins(workspace);
391
+ const status = pluginStatusView();
392
+ // 把上次检查得到的 latest 缓存合并进列表,UI 无需二次请求。
393
+ const cache = status.updates ?? {};
394
+ const items = installed.map((p) => ({
395
+ ...p,
396
+ latestVersion: cache[p.name]?.latestVersion ?? null,
397
+ updateAvailable: cache[p.name]?.updateAvailable === true,
398
+ checkedAt: cache[p.name]?.checkedAt ?? null,
399
+ }));
400
+ sendJson(res, 200, {
401
+ profile: PLUGIN_PROFILE,
402
+ busy: pluginBusy(),
403
+ state: status.state ?? 'idle',
404
+ message: status.message ?? null,
405
+ updatedAt: status.updatedAt ?? null,
406
+ plugins: items,
407
+ });
408
+ });
409
+
410
+ /* ---------- POST /dsh-selfupdater/plugins/check ---------- */
411
+ registerRoute('POST', '/dsh-selfupdater/plugins/check', async (req, res) => {
412
+ if (!sameOrigin(req)) {
413
+ sendJson(res, 403, { error: 'untrusted request' });
414
+ return;
415
+ }
416
+ const installed = listInstalledPlugins(workspace);
417
+ if (installed.length === 0) {
418
+ sendJson(res, 200, { plugins: [], note: '未发现已安装插件' });
419
+ return;
420
+ }
421
+ // 并发查询所有插件的 npm 最新版;单个失败不拖垮整体。
422
+ const results = await Promise.allSettled(installed.map(async (p) => {
423
+ const latestVersion = await fetchPluginLatest(p.name);
424
+ return {
425
+ name: p.name,
426
+ installedVersion: p.installedVersion,
427
+ latestVersion,
428
+ updateAvailable: isNewer(latestVersion, p.installedVersion),
429
+ checkedAt: new Date().toISOString(),
430
+ };
431
+ }));
432
+ const updates = {};
433
+ for (const r of results) {
434
+ if (r.status !== 'fulfilled') continue;
435
+ updates[r.value.name] = {
436
+ latestVersion: r.value.latestVersion,
437
+ updateAvailable: r.value.updateAvailable,
438
+ checkedAt: r.value.checkedAt,
439
+ };
440
+ }
441
+ const failed = results.filter((r) => r.status === 'rejected').length;
442
+ writePluginStatus(dshStateDir, {
443
+ ...readPluginStatus(dshStateDir),
444
+ state: 'idle',
445
+ message: failed > 0 ? `检查完成,${failed} 个插件查询失败(网络原因可重试)` : '检查完成',
446
+ updates,
447
+ updatedAt: new Date().toISOString(),
448
+ });
449
+ sendJson(res, 200, {
450
+ updatedCount: Object.values(updates).filter((u) => u.updateAvailable).length,
451
+ failedCount: failed,
452
+ });
453
+ });
454
+
455
+ /* ---------- POST /dsh-selfupdater/plugins/update ---------- */
456
+ registerRoute('POST', '/dsh-selfupdater/plugins/update', (req, res) => {
457
+ if (!sameOrigin(req)) {
458
+ sendJson(res, 403, { error: 'untrusted request' });
459
+ return;
460
+ }
461
+ if (pluginBusy()) {
462
+ sendJson(res, 409, { error: '已有一次插件更新在进行中' });
463
+ return;
464
+ }
465
+ try {
466
+ writePluginStatus(dshStateDir, {
467
+ ...readPluginStatus(dshStateDir),
468
+ state: 'running',
469
+ message: '插件更新已受理,正在启动升级脚本…',
470
+ startedAt: new Date().toISOString(),
471
+ });
472
+ writeFileSync(pluginLockFile, JSON.stringify({ pid: process.pid, startedAt: Date.now(), by: 'plugin-update' }));
473
+ } catch (err) {
474
+ sendJson(res, 500, { error: `写入锁文件失败:${err.message}` });
475
+ return;
476
+ }
477
+ const child = spawn(process.execPath, [
478
+ join(PLUGIN_ROOT, 'lib', 'plugin-updater.mjs'),
479
+ '--pid', String(process.pid),
480
+ '--app-dir', appDir,
481
+ '--workspace', workspace,
482
+ '--port', String(port),
483
+ ], { detached: true, stdio: 'ignore', env: process.env });
484
+ child.unref();
485
+
486
+ host.logger?.info?.(`[dsh-selfupdater] 插件更新进程已启动 pid=${child.pid},DSH 即将退出`);
487
+ // 与主程序升级一致:给 spawn 落稳时间后主动退出,让脚本接管重启链。
488
+ setTimeout(() => process.exit(0), 800);
489
+ sendJson(res, 202, { accepted: true, note: '插件更新将由分离脚本执行并自动重启服务' });
490
+ });
491
+
492
+ host.logger?.info?.('[dsh-selfupdater] 路由已挂载');
493
+ });
494
+ }