dsh-version-check 0.0.0-stage → 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/lib/index.js ADDED
@@ -0,0 +1,833 @@
1
+ import { detectDeployment, findNpmExecutable, readInstalledVersion } from "./deployment.js";
2
+ import { gateRequest } from "./gate.js";
3
+ import { runNpmInstall, scheduleServiceRestart } from "./install.js";
4
+ import { normalizePreferencesPatch, preferencesFilePath, readPreferencesFile, resolvePreferences, writePreferencesFile } from "./preferences.js";
5
+ import { fetchDistTags, fetchPublishedVersions, pickTarget, selectRollbackCandidates } from "./registry.js";
6
+ import { compareVersions, isValidVersion } from "./versions.js";
7
+ import { readFileSync } from "node:fs";
8
+ // src/index.ts
9
+ /**
10
+ * dsh-version-check Host 半。
11
+ * 注册七个 loopback 路由:读取状态、检查更新、执行更新、列出可回滚版本、执行回滚、
12
+ * 重启服务、保存设置;并把 styles/client.css 注入页面 head,供浏览器半使用。
13
+ */
14
+ const STATE_PATH = "/__dsh-version-check/state";
15
+ const CHECK_PATH = "/__dsh-version-check/check";
16
+ const UPDATE_PATH = "/__dsh-version-check/update";
17
+ const VERSIONS_PATH = "/__dsh-version-check/versions";
18
+ const ROLLBACK_PATH = "/__dsh-version-check/rollback";
19
+ const RESTART_PATH = "/__dsh-version-check/restart";
20
+ const PREFERENCES_PATH = "/__dsh-version-check/preferences";
21
+ const STYLESHEET_PATH = new URL("./styles/client.css", import.meta.url);
22
+ const INITIAL_CHECK_DELAY_MS = 5e3;
23
+ const RESTART_DELAY_AFTER_UPDATE_MS = 1200;
24
+ const RESTART_DELAY_MANUAL_MS = 300;
25
+ const RESTART_WATCHDOG_MS = 6e4;
26
+ const BODY_LIMIT = 16384;
27
+ const VERSIONS_CACHE_MS = 3e5;
28
+ const DEFAULTS = Object.freeze({
29
+ registry: "https://registry.npmjs.org",
30
+ checkEnabled: true,
31
+ checkIntervalMinutes: 360,
32
+ channel: "auto",
33
+ allowedOrigins: [],
34
+ restartTimeoutSeconds: 900
35
+ });
36
+ let cachedDeployment = null;
37
+ /**
38
+ * 首次 apply 时执行部署探测,结果在进程内复用。
39
+ */
40
+ function getDeployment() {
41
+ if (cachedDeployment === null) cachedDeployment = detectDeployment();
42
+ return cachedDeployment;
43
+ }
44
+ /**
45
+ * 生成可读的错误原因,附带 Node fetch 错误中常见的底层错误码。
46
+ */
47
+ function describeError(error) {
48
+ if (error instanceof Error) {
49
+ const cause = error.cause;
50
+ if (cause !== null && cause !== void 0 && typeof cause === "object" && "code" in cause && typeof cause.code === "string") return `${error.message} (${cause.code})`;
51
+ return error.message;
52
+ }
53
+ return String(error);
54
+ }
55
+ /**
56
+ * 校验正整数配置。
57
+ */
58
+ function positiveInteger(value, name, fallback) {
59
+ if (value === void 0) return fallback;
60
+ if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) throw new TypeError(`${name} 必须是正整数`);
61
+ return value;
62
+ }
63
+ /**
64
+ * 校验 registry 配置:必须是 http/https URL,去掉末尾斜杠。
65
+ */
66
+ function resolveRegistry(value) {
67
+ const text = value === void 0 ? DEFAULTS.registry : value;
68
+ if (typeof text !== "string") throw new TypeError("registry 必须是字符串");
69
+ if (!text.startsWith("http://") && !text.startsWith("https://")) throw new Error(`registry 必须以 http:// 或 https:// 开头: ${text}`);
70
+ let parsed;
71
+ try {
72
+ parsed = new URL(text);
73
+ } catch {
74
+ throw new Error(`registry 不是合法的 URL: ${text}`);
75
+ }
76
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`registry 只支持 http/https: ${text}`);
77
+ return text.replace(/\/+$/, "");
78
+ }
79
+ /**
80
+ * 校验 channel 配置。
81
+ */
82
+ function resolveChannel(value) {
83
+ const text = value === void 0 ? DEFAULTS.channel : value;
84
+ if (typeof text !== "string" || text.trim().length === 0) throw new TypeError("channel 必须是非空字符串");
85
+ return text.trim();
86
+ }
87
+ /**
88
+ * 校验 allowedOrigins 配置:每一项都是不带路径的 http/https 源地址。
89
+ */
90
+ function resolveAllowedOrigins(value) {
91
+ const list = value === void 0 ? DEFAULTS.allowedOrigins : value;
92
+ if (!Array.isArray(list)) throw new TypeError("allowedOrigins 必须是字符串数组");
93
+ const origins = [];
94
+ for (const entry of list) {
95
+ if (typeof entry !== "string" || entry.length === 0) throw new TypeError("allowedOrigins 的每一项必须是非空字符串");
96
+ let parsed;
97
+ try {
98
+ parsed = new URL(entry);
99
+ } catch {
100
+ throw new Error(`allowedOrigins 不是合法的 URL: ${entry}`);
101
+ }
102
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`allowedOrigins 只支持 http/https: ${entry}`);
103
+ if (parsed.pathname !== "/" || parsed.search.length > 0 || parsed.hash.length > 0) throw new Error(`allowedOrigins 只接受源地址,不含路径: ${entry}`);
104
+ origins.push(entry.replace(/\/+$/, ""));
105
+ }
106
+ return origins;
107
+ }
108
+ /**
109
+ * 校验布尔配置。
110
+ */
111
+ function booleanOption(value, name, fallback) {
112
+ if (value === void 0) return fallback;
113
+ if (typeof value !== "boolean") throw new TypeError(`${name} 必须是布尔值`);
114
+ return value;
115
+ }
116
+ /**
117
+ * 校验 patch 层传入的配置。
118
+ */
119
+ function resolveConfig(input) {
120
+ const raw = input === void 0 || input === null ? {} : input;
121
+ if (typeof raw !== "object" || Array.isArray(raw)) throw new TypeError("dsh-version-check 的 config 必须是对象");
122
+ const record = raw;
123
+ return {
124
+ registry: resolveRegistry(record.registry),
125
+ checkEnabled: booleanOption(record.checkEnabled, "checkEnabled", DEFAULTS.checkEnabled),
126
+ checkIntervalMinutes: positiveInteger(record.checkIntervalMinutes, "checkIntervalMinutes", DEFAULTS.checkIntervalMinutes),
127
+ channel: resolveChannel(record.channel),
128
+ allowedOrigins: resolveAllowedOrigins(record.allowedOrigins),
129
+ restartTimeoutSeconds: positiveInteger(record.restartTimeoutSeconds, "restartTimeoutSeconds", DEFAULTS.restartTimeoutSeconds)
130
+ };
131
+ }
132
+ /**
133
+ * 写 JSON 响应。
134
+ */
135
+ function sendJson(res, status, body) {
136
+ const payload = JSON.stringify(body);
137
+ res.writeHead(status, {
138
+ "content-type": "application/json; charset=utf-8",
139
+ "cache-control": "no-store",
140
+ "content-length": Buffer.byteLength(payload)
141
+ });
142
+ res.end(payload);
143
+ }
144
+ /**
145
+ * 写错误 JSON 响应。
146
+ */
147
+ function sendError(res, status, code, message) {
148
+ sendJson(res, status, {
149
+ ok: false,
150
+ error: {
151
+ code,
152
+ message
153
+ }
154
+ });
155
+ }
156
+ /**
157
+ * 判断请求的 Content-Type 是否为 application/json。
158
+ */
159
+ function isJsonContentType(req) {
160
+ const value = req.headers["content-type"];
161
+ if (typeof value !== "string") return false;
162
+ return value.split(";")[0].trim().toLowerCase() === "application/json";
163
+ }
164
+ /**
165
+ * POST 路由的来源校验:来源通过 gateRequest,且必须携带 Origin 头。
166
+ * @returns 拒绝原因,通过时返回 null。
167
+ */
168
+ function postRefusal(req, allowedOrigins) {
169
+ const refusal = gateRequest(req, allowedOrigins);
170
+ if (refusal !== null) return `请求来源校验失败: ${refusal}`;
171
+ const origin = req.headers.origin;
172
+ if (typeof origin !== "string" || origin.length === 0) return "POST 请求必须携带 Origin 头";
173
+ return null;
174
+ }
175
+ /**
176
+ * 读取并解析 JSON 请求体。
177
+ * @param limit 最大字节数。
178
+ * @returns 解析失败或超限时返回 null。
179
+ */
180
+ function readJsonBody(req, limit) {
181
+ return new Promise((resolve) => {
182
+ const chunks = [];
183
+ let size = 0;
184
+ let overflow = false;
185
+ let settled = false;
186
+ const settle = (value) => {
187
+ if (settled) return;
188
+ settled = true;
189
+ resolve(value);
190
+ };
191
+ req.on("data", (chunk) => {
192
+ size += chunk.length;
193
+ if (size > limit) {
194
+ overflow = true;
195
+ chunks.length = 0;
196
+ return;
197
+ }
198
+ chunks.push(chunk);
199
+ });
200
+ req.on("end", () => {
201
+ if (overflow) {
202
+ settle(null);
203
+ return;
204
+ }
205
+ try {
206
+ settle(JSON.parse(Buffer.concat(chunks).toString("utf8")));
207
+ } catch {
208
+ settle(null);
209
+ }
210
+ });
211
+ req.on("error", () => settle(null));
212
+ req.on("close", () => settle(null));
213
+ });
214
+ }
215
+ /**
216
+ * 拼装安装失败的可读摘要。
217
+ */
218
+ function installFailureMessage(result, timeoutSeconds) {
219
+ const tail = result.outputTail.trim();
220
+ if (tail.length > 0) return tail.slice(-300);
221
+ if (result.timedOut === true) return `npm 安装超时(${timeoutSeconds} 秒)`;
222
+ if (result.error !== void 0) return `npm 安装进程启动失败: ${describeError(result.error)}`;
223
+ return `npm 安装失败(退出码 ${result.code === null ? "未知" : String(result.code)})`;
224
+ }
225
+ /**
226
+ * Host 入口。
227
+ * @param ctx cordis 上下文。
228
+ * @param config patch 层配置。
229
+ */
230
+ function apply(ctx, config) {
231
+ const patchDefaults = resolveConfig(config);
232
+ const deployment = getDeployment();
233
+ const preferencesPath = preferencesFilePath();
234
+ const restartTimers = /* @__PURE__ */ new Set();
235
+ const stylesheet = readFileSync(STYLESHEET_PATH, "utf8");
236
+ const defaultPreferences = {
237
+ checkEnabled: patchDefaults.checkEnabled,
238
+ checkIntervalMinutes: patchDefaults.checkIntervalMinutes,
239
+ channel: patchDefaults.channel
240
+ };
241
+ let preferences;
242
+ let preferencesError = null;
243
+ try {
244
+ preferences = resolvePreferences(defaultPreferences, readPreferencesFile(preferencesPath));
245
+ } catch (error) {
246
+ preferencesError = {
247
+ code: "E_PREFERENCES",
248
+ message: `读取已保存的设置失败,暂时使用 patch 默认值: ${describeError(error)}`
249
+ };
250
+ preferences = resolvePreferences(defaultPreferences, {});
251
+ }
252
+ const state = {
253
+ bootVersion: deployment.version,
254
+ installedVersion: deployment.version,
255
+ latest: null,
256
+ channel: preferences.channel === "auto" ? "latest" : preferences.channel,
257
+ phase: "idle",
258
+ lastCheckedAt: null,
259
+ lastError: preferencesError,
260
+ installOutput: null,
261
+ target: null
262
+ };
263
+ let inflightCheck = null;
264
+ let activeInstall = null;
265
+ let autoCheckTimer = null;
266
+ let autoCheckInterval = null;
267
+ let publishedCache = null;
268
+ const isBusy = () => state.phase === "installing" || state.phase === "restarting";
269
+ /**
270
+ * 对外暴露的 phase:磁盘版本与启动版本不同时覆盖为 restart-required。
271
+ */
272
+ const effectivePhase = () => {
273
+ if (isBusy()) return state.phase;
274
+ if (state.bootVersion !== state.installedVersion) return "restart-required";
275
+ return state.phase;
276
+ };
277
+ /** @returns 路由返回的状态快照。 */
278
+ const snapshot = () => {
279
+ const latest = state.latest;
280
+ return {
281
+ ok: true,
282
+ bootVersion: state.bootVersion,
283
+ installedVersion: state.installedVersion,
284
+ latest: latest === null ? null : {
285
+ version: latest.version,
286
+ tag: latest.tag
287
+ },
288
+ channel: state.channel,
289
+ updateAvailable: latest !== null && compareVersions(state.installedVersion, latest.version) < 0,
290
+ phase: effectivePhase(),
291
+ restartMode: deployment.restartMode,
292
+ restartUnit: deployment.unitName,
293
+ lastCheckedAt: state.lastCheckedAt,
294
+ lastError: state.lastError,
295
+ installOutput: state.installOutput,
296
+ target: state.target,
297
+ settings: {
298
+ checkEnabled: preferences.checkEnabled,
299
+ checkIntervalMinutes: preferences.checkIntervalMinutes,
300
+ channel: preferences.channel
301
+ }
302
+ };
303
+ };
304
+ /** 重新读取磁盘上的 DSH 版本。 */
305
+ const refreshInstalledVersion = () => {
306
+ state.installedVersion = readInstalledVersion(deployment.packageRoot);
307
+ };
308
+ /**
309
+ * 查询 registry 并更新 latest;手动检查与定时检查共用。
310
+ * registry 失败转换为 E_REGISTRY 状态,其他错误就地抛出。
311
+ */
312
+ const performCheck = async () => {
313
+ if (isBusy()) return snapshot();
314
+ state.phase = "checking";
315
+ let distTags;
316
+ try {
317
+ distTags = await fetchDistTags(patchDefaults.registry);
318
+ } catch (error) {
319
+ state.lastCheckedAt = Date.now();
320
+ if (state.phase !== "checking") return snapshot();
321
+ state.phase = "idle";
322
+ state.lastError = {
323
+ code: "E_REGISTRY",
324
+ message: describeError(error)
325
+ };
326
+ return snapshot();
327
+ }
328
+ state.lastCheckedAt = Date.now();
329
+ if (state.phase !== "checking") return snapshot();
330
+ const picked = pickTarget(distTags, state.installedVersion, preferences.channel);
331
+ if (picked === null) {
332
+ state.latest = null;
333
+ state.phase = "idle";
334
+ state.lastError = {
335
+ code: "E_REGISTRY",
336
+ message: "registry 没有返回可用的 dist-tag"
337
+ };
338
+ return snapshot();
339
+ }
340
+ state.latest = {
341
+ version: picked.version,
342
+ tag: picked.tag
343
+ };
344
+ state.channel = picked.tag;
345
+ state.phase = "idle";
346
+ state.lastError = preferencesError;
347
+ return snapshot();
348
+ };
349
+ /**
350
+ * 查询 registry;已有查询在飞行中时复用同一个 Promise。
351
+ */
352
+ const checkForUpdate = () => {
353
+ if (inflightCheck !== null) return inflightCheck;
354
+ inflightCheck = performCheck().finally(() => {
355
+ inflightCheck = null;
356
+ });
357
+ return inflightCheck;
358
+ };
359
+ /**
360
+ * 读取 registry 的已发布版本列表,结果在进程内缓存 VERSIONS_CACHE_MS。
361
+ */
362
+ const loadPublishedVersions = async () => {
363
+ if (publishedCache !== null && Date.now() - publishedCache.fetchedAt < VERSIONS_CACHE_MS) return publishedCache.versions;
364
+ const versions = await fetchPublishedVersions(patchDefaults.registry);
365
+ publishedCache = {
366
+ fetchedAt: Date.now(),
367
+ versions
368
+ };
369
+ return versions;
370
+ };
371
+ /** 停止自动检查定时器。 */
372
+ const clearAutoCheck = () => {
373
+ if (autoCheckTimer !== null) {
374
+ clearTimeout(autoCheckTimer);
375
+ autoCheckTimer = null;
376
+ }
377
+ if (autoCheckInterval !== null) {
378
+ clearInterval(autoCheckInterval);
379
+ autoCheckInterval = null;
380
+ }
381
+ };
382
+ /**
383
+ * 按当前设置重新安排自动检查:启用时先给一次延迟检查,再按间隔轮询。
384
+ * @param immediate 是否立即触发一次检查。
385
+ */
386
+ const armAutoCheck = (immediate) => {
387
+ clearAutoCheck();
388
+ if (!preferences.checkEnabled) return;
389
+ if (immediate) checkForUpdate();
390
+ else autoCheckTimer = setTimeout(() => {
391
+ autoCheckTimer = null;
392
+ checkForUpdate();
393
+ }, INITIAL_CHECK_DELAY_MS);
394
+ autoCheckInterval = setInterval(() => {
395
+ checkForUpdate();
396
+ }, preferences.checkIntervalMinutes * 60 * 1e3);
397
+ };
398
+ /**
399
+ * 延时执行 systemd 重启,定时器纳入统一清理。
400
+ * 重启成功后本进程会被 systemd 终止;如果进程仍然存活到看门狗超时,
401
+ * 说明重启没有生效,把状态改为可读的错误。
402
+ */
403
+ const scheduleRestartAfter = (delayMs) => {
404
+ const timer = setTimeout(() => {
405
+ restartTimers.delete(timer);
406
+ const fail = (error) => {
407
+ state.phase = "error";
408
+ state.lastError = {
409
+ code: "E_RESTART",
410
+ message: `服务重启失败: ${describeError(error)}`
411
+ };
412
+ };
413
+ scheduleServiceRestart(deployment.unitName, fail);
414
+ const watchdog = setTimeout(() => {
415
+ restartTimers.delete(watchdog);
416
+ if (state.phase === "restarting") {
417
+ state.phase = "error";
418
+ state.lastError = {
419
+ code: "E_RESTART",
420
+ message: "自动重启未生效,请手动重启 dsh 服务"
421
+ };
422
+ }
423
+ }, RESTART_WATCHDOG_MS);
424
+ restartTimers.add(watchdog);
425
+ }, delayMs);
426
+ restartTimers.add(timer);
427
+ };
428
+ /**
429
+ * npm 安装结束后的状态流转:校验版本,再决定重启方式。
430
+ * @param target 目标版本。
431
+ */
432
+ const finishInstall = (target, result) => {
433
+ state.installOutput = result.outputTail.length > 0 ? result.outputTail.slice(-400) : null;
434
+ if (result.error !== void 0 || result.timedOut === true || result.code !== 0) {
435
+ state.phase = "error";
436
+ state.lastError = {
437
+ code: "E_NPM",
438
+ message: installFailureMessage(result, patchDefaults.restartTimeoutSeconds)
439
+ };
440
+ state.target = null;
441
+ return;
442
+ }
443
+ let installedVersion;
444
+ try {
445
+ installedVersion = readInstalledVersion(deployment.packageRoot);
446
+ } catch (error) {
447
+ state.phase = "error";
448
+ state.lastError = {
449
+ code: "E_VERIFY",
450
+ message: `安装后无法读取 DSH 版本: ${describeError(error)}`
451
+ };
452
+ state.target = null;
453
+ return;
454
+ }
455
+ if (installedVersion !== target) {
456
+ state.phase = "error";
457
+ state.lastError = {
458
+ code: "E_VERIFY",
459
+ message: `安装后版本校验失败: 磁盘版本为 ${installedVersion},期望 ${target}`
460
+ };
461
+ state.target = null;
462
+ return;
463
+ }
464
+ state.installedVersion = installedVersion;
465
+ state.target = null;
466
+ if (deployment.restartMode === "systemd" && deployment.unitName !== null) {
467
+ state.phase = "restarting";
468
+ scheduleRestartAfter(RESTART_DELAY_AFTER_UPDATE_MS);
469
+ return;
470
+ }
471
+ state.phase = "restart-required";
472
+ };
473
+ /**
474
+ * 启动 npm 全局安装。
475
+ */
476
+ const startInstall = (version) => {
477
+ const npmExecutable = findNpmExecutable();
478
+ if (npmExecutable === null) {
479
+ state.phase = "error";
480
+ state.lastError = {
481
+ code: "E_NPM",
482
+ message: "在 PATH 中找不到 npm 可执行文件"
483
+ };
484
+ state.target = null;
485
+ return;
486
+ }
487
+ if (deployment.npmPrefix === null) {
488
+ state.phase = "error";
489
+ state.lastError = {
490
+ code: "E_NPM",
491
+ message: "无法从 DSH 安装目录推断 npm 全局前缀"
492
+ };
493
+ state.target = null;
494
+ return;
495
+ }
496
+ activeInstall = runNpmInstall({
497
+ npmExecutable,
498
+ npmPrefix: deployment.npmPrefix,
499
+ version,
500
+ timeoutMs: patchDefaults.restartTimeoutSeconds * 1e3,
501
+ onExit: (result) => {
502
+ activeInstall = null;
503
+ finishInstall(version, result);
504
+ }
505
+ });
506
+ };
507
+ /**
508
+ * 磁盘上的版本号必须是可解析的 semver,否则 state 计算无法进行。
509
+ * @returns 合法时返回 true;不合法时已写 500 响应并返回 false。
510
+ */
511
+ const ensureInstalledVersionParsable = (res) => {
512
+ if (isValidVersion(state.installedVersion)) return true;
513
+ sendError(res, 500, "E_STATE", `磁盘上的 DSH 版本号无法解析: ${state.installedVersion}`);
514
+ return false;
515
+ };
516
+ /** GET /__dsh-version-check/state */
517
+ const handleStateRoute = (req, res) => {
518
+ if (req.method !== "GET") {
519
+ sendError(res, 405, "E_METHOD", "该路由只接受 GET");
520
+ return;
521
+ }
522
+ const refusal = gateRequest(req, patchDefaults.allowedOrigins);
523
+ if (refusal !== null) {
524
+ sendError(res, 403, "E_FORBIDDEN", `请求来源校验失败: ${refusal}`);
525
+ return;
526
+ }
527
+ try {
528
+ refreshInstalledVersion();
529
+ } catch (error) {
530
+ sendError(res, 500, "E_STATE", `无法读取 DSH 安装版本: ${describeError(error)}`);
531
+ return;
532
+ }
533
+ if (!ensureInstalledVersionParsable(res)) return;
534
+ sendJson(res, 200, snapshot());
535
+ };
536
+ /** POST /__dsh-version-check/check */
537
+ const handleCheckRoute = async (req, res) => {
538
+ if (req.method !== "POST") {
539
+ sendError(res, 405, "E_METHOD", "该路由只接受 POST");
540
+ return;
541
+ }
542
+ const refusal = postRefusal(req, patchDefaults.allowedOrigins);
543
+ if (refusal !== null) {
544
+ sendError(res, 403, "E_FORBIDDEN", refusal);
545
+ return;
546
+ }
547
+ if (!isJsonContentType(req)) {
548
+ sendError(res, 415, "E_CONTENT_TYPE", "Content-Type 必须是 application/json");
549
+ return;
550
+ }
551
+ req.resume();
552
+ try {
553
+ refreshInstalledVersion();
554
+ } catch (error) {
555
+ sendError(res, 500, "E_STATE", `无法读取 DSH 安装版本: ${describeError(error)}`);
556
+ return;
557
+ }
558
+ if (!ensureInstalledVersionParsable(res)) return;
559
+ if (!isBusy()) await checkForUpdate();
560
+ sendJson(res, 200, snapshot());
561
+ };
562
+ /** POST /__dsh-version-check/update */
563
+ const handleUpdateRoute = async (req, res) => {
564
+ if (req.method !== "POST") {
565
+ sendError(res, 405, "E_METHOD", "该路由只接受 POST");
566
+ return;
567
+ }
568
+ const refusal = postRefusal(req, patchDefaults.allowedOrigins);
569
+ if (refusal !== null) {
570
+ sendError(res, 403, "E_FORBIDDEN", refusal);
571
+ return;
572
+ }
573
+ if (!isJsonContentType(req)) {
574
+ sendError(res, 415, "E_CONTENT_TYPE", "Content-Type 必须是 application/json");
575
+ return;
576
+ }
577
+ const body = await readJsonBody(req, BODY_LIMIT);
578
+ if (body === null || typeof body !== "object" || Array.isArray(body)) {
579
+ sendError(res, 400, "E_CONFIRM", "请求体必须是合法的 JSON 对象");
580
+ return;
581
+ }
582
+ const requestBody = body;
583
+ if (requestBody.confirm !== true) {
584
+ sendError(res, 400, "E_CONFIRM", "更新操作需要显式确认: {\"confirm\": true}");
585
+ return;
586
+ }
587
+ if (isBusy()) {
588
+ sendError(res, 409, "E_BUSY", "正在安装或重启,请稍后再试");
589
+ return;
590
+ }
591
+ if (!preferences.checkEnabled) {
592
+ sendError(res, 409, "E_UPDATE_DISABLED", "自动检查已关闭,更新功能不可用;请先在设置页开启检查更新");
593
+ return;
594
+ }
595
+ try {
596
+ refreshInstalledVersion();
597
+ } catch (error) {
598
+ sendError(res, 500, "E_STATE", `无法读取 DSH 安装版本: ${describeError(error)}`);
599
+ return;
600
+ }
601
+ if (!isValidVersion(state.installedVersion)) {
602
+ sendError(res, 500, "E_STATE", `磁盘上的 DSH 版本号无法解析: ${state.installedVersion}`);
603
+ return;
604
+ }
605
+ const version = requestBody.version;
606
+ const latest = state.latest;
607
+ if (!(latest !== null && typeof version === "string" && isValidVersion(version) && version === latest.version && compareVersions(state.installedVersion, version) < 0)) {
608
+ sendError(res, 409, "E_NOT_AVAILABLE", "目标版本不是当前可更新的最新版本");
609
+ return;
610
+ }
611
+ state.phase = "installing";
612
+ state.target = version;
613
+ state.lastError = null;
614
+ state.installOutput = null;
615
+ sendJson(res, 202, snapshot());
616
+ startInstall(version);
617
+ };
618
+ /** GET /__dsh-version-check/versions */
619
+ const handleVersionsRoute = async (req, res) => {
620
+ if (req.method !== "GET") {
621
+ sendError(res, 405, "E_METHOD", "该路由只接受 GET");
622
+ return;
623
+ }
624
+ const refusal = gateRequest(req, patchDefaults.allowedOrigins);
625
+ if (refusal !== null) {
626
+ sendError(res, 403, "E_FORBIDDEN", `请求来源校验失败: ${refusal}`);
627
+ return;
628
+ }
629
+ if (!isBusy()) try {
630
+ refreshInstalledVersion();
631
+ } catch (error) {
632
+ sendError(res, 500, "E_STATE", `无法读取 DSH 安装版本: ${describeError(error)}`);
633
+ return;
634
+ }
635
+ if (!ensureInstalledVersionParsable(res)) return;
636
+ let published;
637
+ try {
638
+ published = await loadPublishedVersions();
639
+ } catch (error) {
640
+ sendError(res, 502, "E_REGISTRY", describeError(error));
641
+ return;
642
+ }
643
+ sendJson(res, 200, {
644
+ ok: true,
645
+ installedVersion: state.installedVersion,
646
+ versions: selectRollbackCandidates(published, state.installedVersion)
647
+ });
648
+ };
649
+ /** POST /__dsh-version-check/rollback */
650
+ const handleRollbackRoute = async (req, res) => {
651
+ if (req.method !== "POST") {
652
+ sendError(res, 405, "E_METHOD", "该路由只接受 POST");
653
+ return;
654
+ }
655
+ const refusal = postRefusal(req, patchDefaults.allowedOrigins);
656
+ if (refusal !== null) {
657
+ sendError(res, 403, "E_FORBIDDEN", refusal);
658
+ return;
659
+ }
660
+ if (!isJsonContentType(req)) {
661
+ sendError(res, 415, "E_CONTENT_TYPE", "Content-Type 必须是 application/json");
662
+ return;
663
+ }
664
+ const body = await readJsonBody(req, BODY_LIMIT);
665
+ if (body === null || typeof body !== "object" || Array.isArray(body)) {
666
+ sendError(res, 400, "E_CONFIRM", "请求体必须是合法的 JSON 对象");
667
+ return;
668
+ }
669
+ const requestBody = body;
670
+ if (requestBody.confirm !== true) {
671
+ sendError(res, 400, "E_CONFIRM", "回滚操作需要显式确认: {\"confirm\": true}");
672
+ return;
673
+ }
674
+ if (isBusy()) {
675
+ sendError(res, 409, "E_BUSY", "正在安装或重启,请稍后再试");
676
+ return;
677
+ }
678
+ try {
679
+ refreshInstalledVersion();
680
+ } catch (error) {
681
+ sendError(res, 500, "E_STATE", `无法读取 DSH 安装版本: ${describeError(error)}`);
682
+ return;
683
+ }
684
+ if (!isValidVersion(state.installedVersion)) {
685
+ sendError(res, 500, "E_STATE", `磁盘上的 DSH 版本号无法解析: ${state.installedVersion}`);
686
+ return;
687
+ }
688
+ const version = requestBody.version;
689
+ if (typeof version !== "string" || !isValidVersion(version)) {
690
+ sendError(res, 409, "E_NOT_AVAILABLE", "目标版本不是比当前版本更旧的已发布版本");
691
+ return;
692
+ }
693
+ let published;
694
+ try {
695
+ published = await loadPublishedVersions();
696
+ } catch (error) {
697
+ sendError(res, 502, "E_REGISTRY", describeError(error));
698
+ return;
699
+ }
700
+ if (!selectRollbackCandidates(published, state.installedVersion).some((entry) => entry.version === version)) {
701
+ sendError(res, 409, "E_NOT_AVAILABLE", "目标版本不是比当前版本更旧的已发布版本");
702
+ return;
703
+ }
704
+ state.phase = "installing";
705
+ state.target = version;
706
+ state.lastError = null;
707
+ state.installOutput = null;
708
+ sendJson(res, 202, snapshot());
709
+ startInstall(version);
710
+ };
711
+ /** POST /__dsh-version-check/restart */
712
+ const handleRestartRoute = async (req, res) => {
713
+ if (req.method !== "POST") {
714
+ sendError(res, 405, "E_METHOD", "该路由只接受 POST");
715
+ return;
716
+ }
717
+ const refusal = postRefusal(req, patchDefaults.allowedOrigins);
718
+ if (refusal !== null) {
719
+ sendError(res, 403, "E_FORBIDDEN", refusal);
720
+ return;
721
+ }
722
+ if (!isJsonContentType(req)) {
723
+ sendError(res, 415, "E_CONTENT_TYPE", "Content-Type 必须是 application/json");
724
+ return;
725
+ }
726
+ const body = await readJsonBody(req, BODY_LIMIT);
727
+ if (body === null || typeof body !== "object" || Array.isArray(body)) {
728
+ sendError(res, 400, "E_CONFIRM", "请求体必须是合法的 JSON 对象");
729
+ return;
730
+ }
731
+ if (body.confirm !== true) {
732
+ sendError(res, 400, "E_CONFIRM", "重启操作需要显式确认: {\"confirm\": true}");
733
+ return;
734
+ }
735
+ if (state.phase === "installing") {
736
+ sendError(res, 409, "E_BUSY", "正在安装新版本,安装结束后会自动重启");
737
+ return;
738
+ }
739
+ if (deployment.restartMode !== "systemd" || deployment.unitName === null) {
740
+ sendError(res, 409, "E_RESTART_MANUAL", "当前部署无法自动重启 DSH,请手动重启 dsh 服务");
741
+ return;
742
+ }
743
+ state.phase = "restarting";
744
+ state.lastError = null;
745
+ sendJson(res, 202, snapshot());
746
+ scheduleRestartAfter(RESTART_DELAY_MANUAL_MS);
747
+ };
748
+ /** POST /__dsh-version-check/preferences */
749
+ const handlePreferencesRoute = async (req, res) => {
750
+ if (req.method !== "POST") {
751
+ sendError(res, 405, "E_METHOD", "该路由只接受 POST");
752
+ return;
753
+ }
754
+ const refusal = postRefusal(req, patchDefaults.allowedOrigins);
755
+ if (refusal !== null) {
756
+ sendError(res, 403, "E_FORBIDDEN", refusal);
757
+ return;
758
+ }
759
+ if (!isJsonContentType(req)) {
760
+ sendError(res, 415, "E_CONTENT_TYPE", "Content-Type 必须是 application/json");
761
+ return;
762
+ }
763
+ const body = await readJsonBody(req, BODY_LIMIT);
764
+ if (body === null || typeof body !== "object" || Array.isArray(body)) {
765
+ sendError(res, 400, "E_PREFERENCES", "请求体必须是合法的 JSON 对象");
766
+ return;
767
+ }
768
+ if (isBusy()) {
769
+ sendError(res, 409, "E_BUSY", "正在安装或重启,请稍后再试");
770
+ return;
771
+ }
772
+ let patch;
773
+ try {
774
+ patch = normalizePreferencesPatch(body);
775
+ } catch (error) {
776
+ sendError(res, 400, "E_PREFERENCES", describeError(error));
777
+ return;
778
+ }
779
+ const next = {
780
+ ...preferences,
781
+ ...patch
782
+ };
783
+ try {
784
+ writePreferencesFile(preferencesPath, next);
785
+ } catch (error) {
786
+ sendError(res, 500, "E_PREFERENCES", `无法保存设置: ${describeError(error)}`);
787
+ return;
788
+ }
789
+ preferences = next;
790
+ preferencesError = null;
791
+ state.lastError = null;
792
+ if (preferences.checkEnabled) armAutoCheck(false);
793
+ else {
794
+ clearAutoCheck();
795
+ if (state.phase === "checking") state.phase = "idle";
796
+ }
797
+ sendJson(res, 200, snapshot());
798
+ };
799
+ ctx.effect(() => {
800
+ armAutoCheck(false);
801
+ return clearAutoCheck;
802
+ }, "dsh-version-check: 定时检查 registry");
803
+ ctx.effect(() => () => {
804
+ for (const timer of restartTimers) clearTimeout(timer);
805
+ restartTimers.clear();
806
+ }, "dsh-version-check: 清理待执行的重启定时器");
807
+ ctx.effect(() => () => {
808
+ if (activeInstall !== null) activeInstall.kill("SIGKILL");
809
+ }, "dsh-version-check: 清理安装子进程");
810
+ ctx.inject(["webServer"], (webCtx) => {
811
+ webCtx.on("webserver/index-inject", (table) => {
812
+ table.push({
813
+ kind: "style",
814
+ text: stylesheet
815
+ });
816
+ });
817
+ const routes = [
818
+ [STATE_PATH, handleStateRoute],
819
+ [CHECK_PATH, handleCheckRoute],
820
+ [UPDATE_PATH, handleUpdateRoute],
821
+ [VERSIONS_PATH, handleVersionsRoute],
822
+ [ROLLBACK_PATH, handleRollbackRoute],
823
+ [RESTART_PATH, handleRestartRoute],
824
+ [PREFERENCES_PATH, handlePreferencesRoute]
825
+ ];
826
+ for (const [path, handler] of routes) webCtx.effect(() => webCtx.webServer.register({
827
+ kind: "exact",
828
+ path,
829
+ handler
830
+ }), `dsh-version-check: ${path}`);
831
+ });
832
+ }
833
+ export { apply };