@noob-stupid/dsh-plugin-console 0.5.107 → 0.5.109
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/client.js
CHANGED
|
@@ -739,6 +739,23 @@ window.__ModuleLoader__.load({
|
|
|
739
739
|
compActionDone: "{action}:{name}",
|
|
740
740
|
noComponents: "暂无已识别的服务器组件(用 AI 赋能部署后自动出现)",
|
|
741
741
|
aiFallbackTitle: "关闭后:常规通道失败将直接取消安装,不再调用模型 API(零费用)",
|
|
742
|
+
// ★ 加法(用户 2026-11-04 第 2 项):功能包里的 [自更新] 开关 —— **只更新控制台自己**。
|
|
743
|
+
selfUpdateLabel: "自更新",
|
|
744
|
+
selfUpdateAria: "自更新:只自动更新控制台自己(不碰任何别的插件);点一下切换开关",
|
|
745
|
+
selfUpdateCheckLabel: "立即检查",
|
|
746
|
+
selfUpdateCheckHint: "立刻查一次有没有新版本(只更新控制台自己;装完要重启服务才生效)",
|
|
747
|
+
selfUpdateLastCheck: "上次检查",
|
|
748
|
+
selfUpdateLastResult: "上次结果",
|
|
749
|
+
selfUpdateNever: "从未检查",
|
|
750
|
+
selfUpdateResultNever: "还没有跑过检查",
|
|
751
|
+
selfUpdateUpToDate: "已是最新",
|
|
752
|
+
selfUpdateUpdated: "已更新到 {version}(重启服务后生效)",
|
|
753
|
+
selfUpdateSkipped: "本轮跳过:{reason}",
|
|
754
|
+
selfUpdateFailed: "失败:{reason}",
|
|
755
|
+
selfUpdateChecking: "正在检查控制台的新版本…",
|
|
756
|
+
selfUpdateSaving: "正在写入开关…",
|
|
757
|
+
selfUpdateOn: "自更新已开启(只自动更新控制台自己)",
|
|
758
|
+
selfUpdateOff: "自更新已关闭(不会再自动更新任何东西)",
|
|
742
759
|
aiFallbackOff: "已按设置关闭 AI 兜底,本次安装已取消(零费用)",
|
|
743
760
|
aiConsentRemember: "以后不再提醒(自动同意 AI 兜底,可能产生费用;可随时在插件市场页面最底部恢复提醒)",
|
|
744
761
|
aiRememberNote: "已按设置自动同意本地 AI 兜底(如需恢复弹窗提醒,请到市场底部修改)",
|
|
@@ -1542,6 +1559,23 @@ window.__ModuleLoader__.load({
|
|
|
1542
1559
|
compActionDone: "{action}: {name}",
|
|
1543
1560
|
noComponents: "No server components yet (they appear after an AI Empower deployment)",
|
|
1544
1561
|
aiFallbackTitle: "When off: if regular channels fail, the install is cancelled directly without calling the model API (zero cost)",
|
|
1562
|
+
// ★ additive: the [Self-update] switch in the feature-pack toolbar — updates the console itself only.
|
|
1563
|
+
selfUpdateLabel: "Self-update",
|
|
1564
|
+
selfUpdateAria: "Self-update: auto-update the console itself only (never other plugins); click to toggle",
|
|
1565
|
+
selfUpdateCheckLabel: "Check now",
|
|
1566
|
+
selfUpdateCheckHint: "Check for a new console version right now (self only; restart the service to apply)",
|
|
1567
|
+
selfUpdateLastCheck: "Last checked",
|
|
1568
|
+
selfUpdateLastResult: "Last result",
|
|
1569
|
+
selfUpdateNever: "Never checked",
|
|
1570
|
+
selfUpdateResultNever: "No check has run yet",
|
|
1571
|
+
selfUpdateUpToDate: "Up to date",
|
|
1572
|
+
selfUpdateUpdated: "Updated to {version} (takes effect after restarting the service)",
|
|
1573
|
+
selfUpdateSkipped: "Skipped this round: {reason}",
|
|
1574
|
+
selfUpdateFailed: "Failed: {reason}",
|
|
1575
|
+
selfUpdateChecking: "Checking for a new console version…",
|
|
1576
|
+
selfUpdateSaving: "Saving the switch…",
|
|
1577
|
+
selfUpdateOn: "Self-update is on (updates the console itself only)",
|
|
1578
|
+
selfUpdateOff: "Self-update is off (nothing is auto-updated anymore)",
|
|
1545
1579
|
aiFallbackOff: "AI fallback is disabled by your setting; this install was cancelled (zero cost)",
|
|
1546
1580
|
aiConsentRemember: "Don't ask again (auto-approve the AI fallback, may incur costs; restore reminders anytime at the very bottom of the market)",
|
|
1547
1581
|
aiRememberNote: "AI fallback auto-approved per your setting (restore reminders at the market bottom)",
|
|
@@ -2412,6 +2446,34 @@ const RESTART_FAILED_NOTE = "重启请求失败";
|
|
|
2412
2446
|
if (/[\\/]$/u.test(text)) return text;
|
|
2413
2447
|
return text + (text.includes("\\") ? "\\" : "/");
|
|
2414
2448
|
}
|
|
2449
|
+
// ── [自更新] 开关的四个**纯函数**(用户 2026-11-04 第 2 项,加法)──────────────────────
|
|
2450
|
+
// 为什么拆成纯函数:面板里那段渲染一旦写错,只有开浏览器才看得见(本仓为此吃过"整块空白"的亏)。
|
|
2451
|
+
// 这四件都只吃 `/state` 下发的 `selfAutoUpdate`(+ 字典),**不写盘、不发请求、不编造**:
|
|
2452
|
+
// · 字段缺失(老服务端 / 还没刷新)⇒ 按**默认开启**显示,但**绝不**因此写回服务端;
|
|
2453
|
+
// · 上次检查 / 上次结果拿不到 ⇒ 一律显示「—」,有结果才照服务端原话念。
|
|
2454
|
+
function selfAutoUpdateEnabledOf(info) {
|
|
2455
|
+
if (info === null || info === undefined) return true; // 默认开启;**不**写回
|
|
2456
|
+
return info.enabled !== false;
|
|
2457
|
+
}
|
|
2458
|
+
function selfAutoUpdateLastCheckOf(info, neverText) {
|
|
2459
|
+
if (info === null || info === undefined) return "—"; // 拿不到就是「—」,不编造一个时间
|
|
2460
|
+
const at = Number(info.lastCheckAt);
|
|
2461
|
+
if (!Number.isFinite(at) || at <= 0) return neverText;
|
|
2462
|
+
try { return new Date(at).toLocaleString(); } catch { return "—"; }
|
|
2463
|
+
}
|
|
2464
|
+
function selfAutoUpdateLastResultOf(info, t) {
|
|
2465
|
+
if (info === null || info === undefined) return "—";
|
|
2466
|
+
return selfUpdateOutcomeText(info.lastResult, t);
|
|
2467
|
+
}
|
|
2468
|
+
/** 「一次检查的结果」→ 一句话(`checkAndUpdate` 的结构与 `lastResult` 同形,两处共用这一份)。 */
|
|
2469
|
+
function selfUpdateOutcomeText(outcome, t) {
|
|
2470
|
+
if (outcome === null || outcome === undefined || typeof outcome !== "object") return t("selfUpdateResultNever");
|
|
2471
|
+
if (outcome.updated === true) return t("selfUpdateUpdated").replace("{version}", String(outcome.to ?? outcome.version ?? "?"));
|
|
2472
|
+
if (outcome.skipped === "up-to-date") return t("selfUpdateUpToDate");
|
|
2473
|
+
if (outcome.ok === false) return t("selfUpdateFailed").replace("{reason}", String(outcome.reason ?? ""));
|
|
2474
|
+
if (typeof outcome.skipped === "string" && outcome.skipped !== "") return t("selfUpdateSkipped").replace("{reason}", String(outcome.reason ?? outcome.skipped));
|
|
2475
|
+
return t("selfUpdateUpToDate");
|
|
2476
|
+
}
|
|
2415
2477
|
function PluginConsoleTab({ t, section = null }) {
|
|
2416
2478
|
const [state, setState] = react.useState({ status: "loading" });
|
|
2417
2479
|
// ★ 0.5.59:健康摘要**可关掉**(用户 2026-10-17「这个提示可以关掉」)。
|
|
@@ -2780,6 +2842,57 @@ const RESTART_FAILED_NOTE = "重启请求失败";
|
|
|
2780
2842
|
const [aiFallback, setAiFallback] = react.useState(() => {
|
|
2781
2843
|
try { return localStorage.getItem("pc-ai-fallback-v2") !== "off"; } catch { return true; }
|
|
2782
2844
|
});
|
|
2845
|
+
// ★ 加法(用户 2026-11-04 第 2 项):功能包工具栏里的 **[自更新]** 开关 —— 只更新控制台自己。
|
|
2846
|
+
// 读的是 `/state.selfAutoUpdate`(服务端判据全在 domain/self-auto-update.js);这里只负责:
|
|
2847
|
+
// · 默认开启(字段缺失也按开启**显示**,但绝不因为"没读到"就往服务端写回一个值);
|
|
2848
|
+
// · 点一下 → POST `{action:"toggle", enabled:<下一值>}`;写请求期间**禁用并显示进行中**;
|
|
2849
|
+
// · 成败都有反馈:失败把服务端错误原文念出来(绝不静默,也绝不假装切过去了)。
|
|
2850
|
+
const [selfUpdateBusy, setSelfUpdateBusy] = react.useState(false);
|
|
2851
|
+
const selfUpdateInfo = state.status === "ready" && state.data !== null && state.data !== undefined
|
|
2852
|
+
&& state.data.selfAutoUpdate !== null && state.data.selfAutoUpdate !== undefined ? state.data.selfAutoUpdate : null;
|
|
2853
|
+
const selfUpdateEnabled = selfAutoUpdateEnabledOf(selfUpdateInfo);
|
|
2854
|
+
const selfUpdateTitleText = () => t("selfUpdateAria") + "\n"
|
|
2855
|
+
+ t("selfUpdateLastCheck") + ":" + selfAutoUpdateLastCheckOf(selfUpdateInfo, t("selfUpdateNever")) + "\n"
|
|
2856
|
+
+ t("selfUpdateLastResult") + ":" + selfAutoUpdateLastResultOf(selfUpdateInfo, t);
|
|
2857
|
+
const selfUpdateToggle = () => {
|
|
2858
|
+
if (selfUpdateBusy) return;
|
|
2859
|
+
const next = !selfUpdateEnabled;
|
|
2860
|
+
setSelfUpdateBusy(true);
|
|
2861
|
+
setMessage(t("selfUpdateSaving"));
|
|
2862
|
+
call("/plugin-console/self-auto-update", { action: "toggle", enabled: next }).then(
|
|
2863
|
+
(data) => {
|
|
2864
|
+
setSelfUpdateBusy(false);
|
|
2865
|
+
const view = data !== null && data !== undefined ? data.selfAutoUpdate : null;
|
|
2866
|
+
const nowOn = view !== null && view !== undefined ? view.enabled === true : next;
|
|
2867
|
+
setMessage((nowOn ? t("selfUpdateOn") : t("selfUpdateOff")) + (data !== null && data !== undefined && typeof data.note === "string" && data.note !== "" ? "(" + data.note + ")" : ""));
|
|
2868
|
+
refresh();
|
|
2869
|
+
},
|
|
2870
|
+
(error) => {
|
|
2871
|
+
setSelfUpdateBusy(false);
|
|
2872
|
+
setMessage(t("safetySwitchFailed") + ":" + ((error !== null && error !== undefined && typeof error.message === "string" && error.message !== "") ? error.message : t("safetySwitchFailed")));
|
|
2873
|
+
refresh();
|
|
2874
|
+
},
|
|
2875
|
+
);
|
|
2876
|
+
};
|
|
2877
|
+
// 「立即检查」:与定时器同一条路(服务端单飞,正在跑时直接回 skipped:'busy')
|
|
2878
|
+
const selfUpdateCheckNow = () => {
|
|
2879
|
+
if (selfUpdateBusy) return;
|
|
2880
|
+
setSelfUpdateBusy(true);
|
|
2881
|
+
setMessage(t("selfUpdateChecking"));
|
|
2882
|
+
call("/plugin-console/self-auto-update", { action: "check" }).then(
|
|
2883
|
+
(data) => {
|
|
2884
|
+
setSelfUpdateBusy(false);
|
|
2885
|
+
const check = data !== null && data !== undefined ? data.check : null;
|
|
2886
|
+
setMessage(selfUpdateOutcomeText(check, t));
|
|
2887
|
+
refresh();
|
|
2888
|
+
},
|
|
2889
|
+
(error) => {
|
|
2890
|
+
setSelfUpdateBusy(false);
|
|
2891
|
+
setMessage(t("safetySwitchFailed") + ":" + ((error !== null && error !== undefined && typeof error.message === "string" && error.message !== "") ? error.message : t("safetySwitchFailed")));
|
|
2892
|
+
refresh();
|
|
2893
|
+
},
|
|
2894
|
+
);
|
|
2895
|
+
};
|
|
2783
2896
|
// AI 赋能:来源输入 / 计划确认 / 执行进度
|
|
2784
2897
|
const [aiOpen, setAiOpen] = react.useState(false);
|
|
2785
2898
|
const [aiSource, setAiSource] = react.useState("");
|
|
@@ -7576,6 +7689,29 @@ const RESTART_FAILED_NOTE = "重启请求失败";
|
|
|
7576
7689
|
return next;
|
|
7577
7690
|
}),
|
|
7578
7691
|
}, t("aiFallbackLabel")),
|
|
7692
|
+
// ★ 加法(用户 2026-11-04 第 2 项):**[自更新]** —— 只更新控制台自己,**默认开启**。
|
|
7693
|
+
// data-active 反映服务端下发的开关值(字段缺失 ⇒ 按开启显示);写请求期间 disabled +
|
|
7694
|
+
// data-busy(那颗按钮不会"点了没反应":先给一句"正在写入",回来再报结果)。
|
|
7695
|
+
el("button", {
|
|
7696
|
+
type: "button",
|
|
7697
|
+
className: styles.toolItem,
|
|
7698
|
+
"data-active": selfUpdateEnabled ? "true" : "false",
|
|
7699
|
+
"data-busy": selfUpdateBusy ? "true" : "false",
|
|
7700
|
+
disabled: selfUpdateBusy,
|
|
7701
|
+
title: selfUpdateTitleText(),
|
|
7702
|
+
"aria-label": t("selfUpdateAria"),
|
|
7703
|
+
onClick: () => selfUpdateToggle(),
|
|
7704
|
+
}, t("selfUpdateLabel") + (selfUpdateEnabled ? " ✓" : "") + (selfUpdateBusy ? " …" : "")),
|
|
7705
|
+
// 「立即检查」:同一组里的第二颗(用户要的那一颗按钮仍然是上面的 [自更新])
|
|
7706
|
+
el("button", {
|
|
7707
|
+
type: "button",
|
|
7708
|
+
className: styles.toolItem,
|
|
7709
|
+
"data-busy": selfUpdateBusy ? "true" : "false",
|
|
7710
|
+
disabled: selfUpdateBusy,
|
|
7711
|
+
title: t("selfUpdateCheckHint"),
|
|
7712
|
+
"aria-label": t("selfUpdateCheckHint"),
|
|
7713
|
+
onClick: () => selfUpdateCheckNow(),
|
|
7714
|
+
}, t("selfUpdateCheckLabel") + (selfUpdateBusy ? " …" : "")),
|
|
7579
7715
|
el("button", {
|
|
7580
7716
|
type: "button",
|
|
7581
7717
|
className: styles.toolItem,
|
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
// L1 · domain —— self-auto-update.js(控制台**只更新它自己**的自动更新:配置 + 检查 + 覆盖式安装 + 退避 + 调度)
|
|
2
|
+
//
|
|
3
|
+
// ── 用户原话(2026-11-04)────────────────────────────────────────────────────────────
|
|
4
|
+
// 「我的意思是,自更新我们的插件本身」「然后弄功能包一个按钮,[自更新],默认打开」。
|
|
5
|
+
// 范围**严格限定**在 `@noob-stupid/dsh-plugin-console` 自己:任何别的插件都绝不自动更新。
|
|
6
|
+
//
|
|
7
|
+
// ── 本模块做什么(一切"装"的动作都复用既有实现,这里只做编排)─────────────────────────────
|
|
8
|
+
// ① 配置持久化 `~/.dsh/plugin-console/auto-update.json`:`{ enabled, intervalHours, lastCheckAt, lastResult }`,
|
|
9
|
+
// `enabled` 默认 **true**;写盘走"临时文件 + rename"(原子);读坏文件**降级为默认值并且绝不覆盖用户文件**。
|
|
10
|
+
// ② `checkAndUpdate()`:纯编排 —— 关掉 ⇒ 不动;有安装作业 / 拿不到锁 ⇒ 不动;框架升级中 ⇒ 不动;
|
|
11
|
+
// 远端**严格高于**本地才装;装走 `install-self-overlay.js`(下载 tarball → 只覆盖不清空 → 读回核对版本);
|
|
12
|
+
// 备份 / 失败自愈 / manualCommand 走 `self-update-guard.js`(唯一实现)。**全程不调用 pnpm**,
|
|
13
|
+
// 也**绝不重启服务**(宿主不能安全地重启自己)—— 结果里写明"重启后生效"。
|
|
14
|
+
// ③ 失败退避:同一目标版本失败过一次,6 小时内不再重试(防无限循环刷日志);成功后清空。
|
|
15
|
+
// ④ 进程内定时器:启动后延迟约 2 分钟首检,之后每 `intervalHours` 一次;**单飞**(上一轮没跑完不叠下一轮);
|
|
16
|
+
// 定时器一律 `unref()`,绝不阻塞进程退出。
|
|
17
|
+
//
|
|
18
|
+
// ── 硬规矩(改代码时别越线)────────────────────────────────────────────────────────
|
|
19
|
+
// ① **只对自己生效**:目标包名恒等于 `selfPackageNameOf()`;调用方就算点名别的包,也只会拿到
|
|
20
|
+
// `skipped:'not-self'` 的如实拒绝(`tests/test-self-auto-update.mjs` ⑨ 钉住:放宽成任意包必须变红)。
|
|
21
|
+
// ② **零 pnpm**:装包只走覆盖式(`runSelfOverlayInstall`);本文件里不出现任何包管理器调用
|
|
22
|
+
// (⑨ 的变异断言:把 pnpm 加回来必须变红 —— 运行期有 exec 记录桩,静态还有源码扫描)。
|
|
23
|
+
// ③ 绝不因为自动更新失败而影响宿主:调度里每一步都 catch 成**一条如实记录**,路由层也只回结果。
|
|
24
|
+
// ④ 不谎报:`updated:true` 只在"读回的版本 === 目标版本"时成立;探测不到、不可比、装失败都如实说。
|
|
25
|
+
// ⑤ domain 层不认识 cordis(分层断言),profileDir / 作业表这类宿主信息一律由调用方注入。
|
|
26
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
27
|
+
import { dirname, join } from 'node:path'
|
|
28
|
+
import { compareSemverText, parseSemverText } from '../infra/semver.js'
|
|
29
|
+
import { dshHome, pluginRoot } from '../infra/paths.js'
|
|
30
|
+
import { probeRegistryPackage } from './dep-source.js'
|
|
31
|
+
import { preferredRegistries } from './sources.js'
|
|
32
|
+
import { installLockHolder, withInstallLockOr } from './install-lock.js'
|
|
33
|
+
import { runSelfOverlayInstall } from './install-self-overlay.js'
|
|
34
|
+
import { beginSelfUpdateGuard, isSelfUpdate, selfPackageNameOf, settleSelfUpdateGuard } from './self-update-guard.js'
|
|
35
|
+
import { readInstalledVersion } from './selfupdate-read.js'
|
|
36
|
+
|
|
37
|
+
/** 配置文件(与其它控制台状态文件同目录:`<DSH_HOME>/plugin-console/`)。 */
|
|
38
|
+
const CONFIG_FILE = 'auto-update.json'
|
|
39
|
+
/** 默认检查间隔(小时)。用户没改过就是这个值。 */
|
|
40
|
+
const DEFAULT_INTERVAL_HOURS = 6
|
|
41
|
+
/** 首检延迟:**启动后约 2 分钟**(先把宿主自己的启动活干完,再谈更新)。 */
|
|
42
|
+
const DEFAULT_FIRST_DELAY_MS = 2 * 60 * 1000
|
|
43
|
+
/** 失败退避窗口:同一目标版本 6 小时内不重试。 */
|
|
44
|
+
const RETRY_BACKOFF_MS = 6 * 60 * 60 * 1000
|
|
45
|
+
const HOUR_MS = 60 * 60 * 1000
|
|
46
|
+
/** 框架升级状态文件的"仍在进行中"窗口 —— 与 `/framework-upgrade-status` 的 15 分钟残留清理同一口径。 */
|
|
47
|
+
const FW_STATE_STALE_MS = 15 * 60 * 1000
|
|
48
|
+
/** 框架升级的终止状态(与 routes/framework.js#routeFrameworkUpgradeStatusGet 同一批;那边贴着 600 行
|
|
49
|
+
* 棘轮、import 不出来,本文件只在**窗口判定**上复述这份名单,取值口径逐字相同)。 */
|
|
50
|
+
const FW_TERMINAL_STATUS = new Set(['idle', 'done', 'failed', 'ready'])
|
|
51
|
+
|
|
52
|
+
const label = (error) => (error instanceof Error ? error.message : String(error))
|
|
53
|
+
const nowOf = (deps = {}) => (typeof deps.now === 'function' ? Number(deps.now()) : Date.now())
|
|
54
|
+
|
|
55
|
+
// ── ① 配置:路径 / 默认值 / 归一化 / 原子读 / 原子写 ────────────────────────────────────
|
|
56
|
+
function autoUpdateConfigPath(home = undefined) {
|
|
57
|
+
return join(home ?? dshHome(), 'plugin-console', CONFIG_FILE)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 默认配置(`enabled: true` —— 用户要的"默认打开")。 */
|
|
61
|
+
function defaultAutoUpdateConfig() {
|
|
62
|
+
return { enabled: true, intervalHours: DEFAULT_INTERVAL_HOURS, lastCheckAt: null, lastResult: null }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 把磁盘上的原始对象归一成合法配置:不合法的项按默认值、并逐项记下"哪里说不过去"。 */
|
|
66
|
+
function normalizeAutoUpdateConfig(raw) {
|
|
67
|
+
const base = defaultAutoUpdateConfig()
|
|
68
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return { config: base, problems: ['顶层不是对象'] }
|
|
69
|
+
const problems = []
|
|
70
|
+
const out = { ...base }
|
|
71
|
+
if (typeof raw.enabled === 'boolean') out.enabled = raw.enabled
|
|
72
|
+
else if (raw.enabled !== undefined) problems.push('enabled 不是布尔值')
|
|
73
|
+
if (Number.isFinite(Number(raw.intervalHours)) && Number(raw.intervalHours) > 0) {
|
|
74
|
+
out.intervalHours = Math.min(168, Math.max(1, Number(raw.intervalHours)))
|
|
75
|
+
} else if (raw.intervalHours !== undefined) problems.push('intervalHours 不是正数')
|
|
76
|
+
if (Number.isFinite(Number(raw.lastCheckAt)) && Number(raw.lastCheckAt) > 0) out.lastCheckAt = Number(raw.lastCheckAt)
|
|
77
|
+
else if (raw.lastCheckAt !== undefined && raw.lastCheckAt !== null) problems.push('lastCheckAt 不是时间戳')
|
|
78
|
+
if (raw.lastResult !== null && raw.lastResult !== undefined) {
|
|
79
|
+
if (typeof raw.lastResult === 'object' && raw.lastResult !== null && !Array.isArray(raw.lastResult)) out.lastResult = raw.lastResult
|
|
80
|
+
else problems.push('lastResult 不是对象')
|
|
81
|
+
}
|
|
82
|
+
return { config: out, problems }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 读配置。**只读**:读坏/形状不对 ⇒ 回默认值 + 一条如实 note,**绝不在这一步写盘**
|
|
87
|
+
* (用户的文件不能被"读一下就改掉",规矩见本文件头 ①)。
|
|
88
|
+
* 返回 `{ config, file, present, corrupt, note }`。
|
|
89
|
+
*/
|
|
90
|
+
function readAutoUpdateConfig({ home = undefined, deps = {} } = {}) {
|
|
91
|
+
const dir = home ?? deps.home
|
|
92
|
+
const file = autoUpdateConfigPath(dir)
|
|
93
|
+
const read = typeof deps.readFile === 'function' ? deps.readFile : readFileSync
|
|
94
|
+
const exists = typeof deps.exists === 'function' ? deps.exists : existsSync
|
|
95
|
+
if (exists(file) !== true) return { config: defaultAutoUpdateConfig(), file, present: false, corrupt: false, note: null }
|
|
96
|
+
let raw = null
|
|
97
|
+
try {
|
|
98
|
+
raw = JSON.parse(read(file, 'utf8'))
|
|
99
|
+
} catch (error) {
|
|
100
|
+
return {
|
|
101
|
+
config: defaultAutoUpdateConfig(), file, present: true, corrupt: true,
|
|
102
|
+
note: `自动更新配置读不出来(${file}):${label(error)} —— 本次按默认值(开启、每 ${DEFAULT_INTERVAL_HOURS} 小时)继续,**没有覆盖你的文件**`,
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const { config, problems } = normalizeAutoUpdateConfig(raw)
|
|
106
|
+
if (problems.length === 0) return { config, file, present: true, corrupt: false, note: null }
|
|
107
|
+
return {
|
|
108
|
+
config, file, present: true, corrupt: true,
|
|
109
|
+
note: `自动更新配置有 ${problems.length} 处说不过去(${problems.join(';')},文件 ${file})—— 这几项按默认值处理,**没有覆盖你的文件**`,
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 写配置(**原子**:临时文件 + rename;失败只回 note,绝不抛)。
|
|
115
|
+
* 只覆盖调用方点名的字段,其余沿用磁盘上的现值(读坏了就按默认值)。
|
|
116
|
+
* `patch.lastResult === null` 表示**清空**该字段;`undefined` 表示不动它。
|
|
117
|
+
*/
|
|
118
|
+
function writeAutoUpdateConfig(patch = {}, { home = undefined, deps = {} } = {}) {
|
|
119
|
+
const dir = home ?? deps.home
|
|
120
|
+
const file = autoUpdateConfigPath(dir)
|
|
121
|
+
const read = readAutoUpdateConfig({ home: dir, deps })
|
|
122
|
+
const next = { ...read.config }
|
|
123
|
+
if (typeof patch.enabled === 'boolean') next.enabled = patch.enabled
|
|
124
|
+
if (Number.isFinite(Number(patch.intervalHours)) && Number(patch.intervalHours) > 0) {
|
|
125
|
+
next.intervalHours = Math.min(168, Math.max(1, Number(patch.intervalHours)))
|
|
126
|
+
}
|
|
127
|
+
if (Number.isFinite(Number(patch.lastCheckAt)) && Number(patch.lastCheckAt) > 0) next.lastCheckAt = Number(patch.lastCheckAt)
|
|
128
|
+
if (patch.lastResult !== undefined) next.lastResult = patch.lastResult
|
|
129
|
+
const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`
|
|
130
|
+
let keepNote = null
|
|
131
|
+
// 磁盘上那份**读不出来**时,先把原件改名留档再写 —— 用户的内容一个字都不丢(规矩见 readAutoUpdateConfig):
|
|
132
|
+
// 换个名字放着,并如实写进 note(`auto-update.json.corrupt-<时间戳>`)。
|
|
133
|
+
if (read.corrupt === true && read.present === true) {
|
|
134
|
+
const kept = `${file}.corrupt-${String(nowOf(deps)).padStart(13, '0')}`
|
|
135
|
+
try {
|
|
136
|
+
renameSync(file, kept)
|
|
137
|
+
keepNote = `原来那份读不出来的配置已改名保留为 ${kept}(没有删除)`
|
|
138
|
+
} catch (error) {
|
|
139
|
+
keepNote = `原来那份读不出来的配置没能改名保留(${label(error)})`
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
144
|
+
writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, 'utf8')
|
|
145
|
+
renameSync(tmp, file) // 同目录 rename:要么旧文件、要么新文件,不会读到写了一半的 JSON
|
|
146
|
+
return { ok: true, config: next, file, note: [read.note, keepNote].filter((x) => typeof x === 'string' && x !== '').join(';') || null }
|
|
147
|
+
} catch (error) {
|
|
148
|
+
try { rmSync(tmp, { force: true }) } catch {}
|
|
149
|
+
return { ok: false, config: next, file, note: `写自动更新配置失败(${file}):${label(error)}` }
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── ② 忙判据(都是既有实现的复用,不自己猜状态)───────────────────────────────────────
|
|
154
|
+
/** 正在运行的这一份包根的版本(profile 里读不到时用它兜底;读不到返回 null)。 */
|
|
155
|
+
function runningVersionOf(deps = {}) {
|
|
156
|
+
const read = typeof deps.readFile === 'function' ? deps.readFile : readFileSync
|
|
157
|
+
try {
|
|
158
|
+
const pkg = JSON.parse(read(join(pluginRoot(), 'package.json'), 'utf8'))
|
|
159
|
+
return typeof pkg?.version === 'string' && pkg.version !== '' ? pkg.version : null
|
|
160
|
+
} catch { return null }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** 框架升级状态(`<DSH_HOME>/plugin-console/fw-upgrade-state.txt`,格式 `status|message`)。 */
|
|
164
|
+
function readFrameworkUpgradeStatus({ home = undefined, deps = {} } = {}) {
|
|
165
|
+
const file = join(home ?? deps.home ?? dshHome(), 'plugin-console', 'fw-upgrade-state.txt')
|
|
166
|
+
const exists = typeof deps.exists === 'function' ? deps.exists : existsSync
|
|
167
|
+
if (exists(file) !== true) return null
|
|
168
|
+
let raw = ''
|
|
169
|
+
try { raw = String((typeof deps.readFile === 'function' ? deps.readFile : readFileSync)(file, 'utf8')) } catch { return null }
|
|
170
|
+
if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1) // PS5.1 Set-Content -Encoding UTF8 会写 BOM
|
|
171
|
+
const head = String(raw.split('|')[0] ?? '').trim()
|
|
172
|
+
let at = null
|
|
173
|
+
try { at = statSync(file).mtimeMs } catch { at = null }
|
|
174
|
+
return { status: head === '' ? 'idle' : head, at }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* 框架升级是否**正在进行中**(是 ⇒ 返回 `{ status, ageMs }`,否则 null)。
|
|
179
|
+
* 判据与 `/framework-upgrade-status` 同口径:非终止状态 + 状态文件在 15 分钟窗口内。
|
|
180
|
+
* 为什么要窗口:那条路由把"非终止状态但超过 15 分钟没动静"判为上次升级的残留并自愈成 idle,
|
|
181
|
+
* 这里如果照旧当"在升级",自动更新会被一份陈旧记录永久挡住。
|
|
182
|
+
*/
|
|
183
|
+
function frameworkUpgradeBusy({ home = undefined, deps = {} } = {}) {
|
|
184
|
+
const state = readFrameworkUpgradeStatus({ home, deps })
|
|
185
|
+
if (state === null) return null
|
|
186
|
+
if (FW_TERMINAL_STATUS.has(state.status) === true) return null
|
|
187
|
+
if (state.at === null) return { status: state.status, ageMs: null }
|
|
188
|
+
const ageMs = nowOf(deps) - state.at
|
|
189
|
+
if (ageMs > FW_STATE_STALE_MS) return null
|
|
190
|
+
return { status: state.status, ageMs }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** 有安装作业在跑?宿主侧能注入 `deps.installBusy`(内存作业表最准);没注入就只看**包锁**。 */
|
|
194
|
+
function installBusyOf({ profileDir = '', packageName = '', deps = {} } = {}) {
|
|
195
|
+
if (typeof deps.installBusy === 'function') {
|
|
196
|
+
let verdict = null
|
|
197
|
+
try { verdict = deps.installBusy() } catch (error) { return `安装作业表读不出来:${label(error)}` }
|
|
198
|
+
if (typeof verdict === 'string' && verdict !== '') return verdict
|
|
199
|
+
if (verdict !== null && verdict !== undefined && verdict !== false) return '有安装作业正在跑'
|
|
200
|
+
}
|
|
201
|
+
const holder = installLockHolder(profileDir, packageName)
|
|
202
|
+
if (holder === null) return null
|
|
203
|
+
return `这个包正在安装中(另一个作业 ${holder.jobId ?? '未命名'} 从 ${holder.since ?? '?'} 起占着锁)`
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** 远端是否**严格高于**本地:`1` 是、`0/-1` 否、`null` = 版本号不可比(不敢动)。 */
|
|
207
|
+
function compareVersions(remote, local) {
|
|
208
|
+
const a = parseSemverText(remote)
|
|
209
|
+
const b = parseSemverText(local)
|
|
210
|
+
if (a === null || b === null) return null
|
|
211
|
+
return compareSemverText(a, b)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── ③ 一次检查 + 一次安装(编排;装/备份/自愈全是既有实现)──────────────────────────────
|
|
215
|
+
/**
|
|
216
|
+
* 在**包锁**里做一次真实的覆盖式安装(拿不到锁 ⇒ 什么都不做)。
|
|
217
|
+
* 返回 `{ ok, reason, version, backedUp, healed, manualCommand, overlay, entry }`。
|
|
218
|
+
*/
|
|
219
|
+
async function attemptOverlayUpdate({ target, latest, profileDir, registries, deps = {} } = {}) {
|
|
220
|
+
const overlay = typeof deps.runOverlay === 'function' ? deps.runOverlay : runSelfOverlayInstall
|
|
221
|
+
const beginGuard = typeof deps.beginGuard === 'function' ? deps.beginGuard : beginSelfUpdateGuard
|
|
222
|
+
const settleGuard = typeof deps.settleGuard === 'function' ? deps.settleGuard : settleSelfUpdateGuard
|
|
223
|
+
const out = { ok: false, reason: null, version: null, backedUp: false, healed: false, manualCommand: null, overlay: null }
|
|
224
|
+
const locked = await withInstallLockOr(
|
|
225
|
+
{ profileDir, packageName: target, waitMs: 0 }, // waitMs=0:有人占着就立刻放弃,绝不排队等
|
|
226
|
+
async () => {
|
|
227
|
+
// 更新前备份 + "磁盘上本来就有可解析的自身副本"这道闸都在 beginSelfUpdateGuard 里(规矩② 的既有实现)
|
|
228
|
+
const session = beginGuard({ packageName: target, profileDir, targetVersion: latest, deps })
|
|
229
|
+
if (session === null) {
|
|
230
|
+
return { noSession: true }
|
|
231
|
+
}
|
|
232
|
+
out.backedUp = session.backedUp === true
|
|
233
|
+
// 点名目标版本 ⇒ 覆盖式安装自己会跳过 registry 探测,并按"读回版本 === 目标版本"核对(既有实现)
|
|
234
|
+
const overlayResult = await overlay({
|
|
235
|
+
job: { packageName: target, version: latest },
|
|
236
|
+
packageName: target, profileDir, registries, deps,
|
|
237
|
+
})
|
|
238
|
+
out.overlay = overlayResult
|
|
239
|
+
out.version = overlayResult?.version ?? null
|
|
240
|
+
out.ok = overlayResult?.ok === true && out.version === latest
|
|
241
|
+
// 收尾(**无论成败都跑**):校验 → 不过就改名挪开 + 从备份恢复 + 同一判据复核 → 产出 manualCommand。
|
|
242
|
+
// 期望版本只在"这次真装上了"时才要求是 latest;装失败时期望值置空 =
|
|
243
|
+
// "目录里得仍是一份完整的自身副本",坏了才自愈 —— 否则每失败一次就把好目录改名成 .broken-。
|
|
244
|
+
const settled = settleGuard(session, { targetVersion: out.ok === true ? latest : null, landed: out.ok === true, deps })
|
|
245
|
+
out.healed = settled?.healed === true
|
|
246
|
+
out.manualCommand = settled?.manualCommand ?? null
|
|
247
|
+
if (out.ok !== true) {
|
|
248
|
+
const parts = [overlayResult?.reason ?? '覆盖式安装没有成功']
|
|
249
|
+
if (settled?.healed === true) parts.push(`已从更新前的备份恢复成 v${settled.restoredVersion ?? '?'}(本次更新**没有生效**)`)
|
|
250
|
+
else if (settled?.verified === true) parts.push(`磁盘上仍是完整的一份旧副本(v${settled.restoredVersion ?? '?'}),没有动它`)
|
|
251
|
+
else parts.push(`校验也没过:${settled?.reason ?? '原因未知'}`)
|
|
252
|
+
out.reason = parts.join(';')
|
|
253
|
+
} else {
|
|
254
|
+
out.reason = null
|
|
255
|
+
}
|
|
256
|
+
return { done: true }
|
|
257
|
+
},
|
|
258
|
+
(error) => ({ lockTimeout: true, error }),
|
|
259
|
+
)
|
|
260
|
+
if (locked?.lockTimeout === true) {
|
|
261
|
+
return { ...out, ok: false, busy: true, reason: `拿不到安装锁(${label(locked.error)})—— 本轮不做自更新` }
|
|
262
|
+
}
|
|
263
|
+
if (locked?.noSession === true) {
|
|
264
|
+
return {
|
|
265
|
+
...out, ok: false, noSession: true,
|
|
266
|
+
reason: `磁盘上没有可解析的自身副本(${join(profileDir, 'node_modules', ...String(target).split('/'))})—— `
|
|
267
|
+
+ '自动更新不接管首次安装 / 链接安装;需要时请用卡片上的「更新」按钮',
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return out
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* **一次检查(必要时安装)** —— 本模块的唯一编排入口。
|
|
275
|
+
* 返回 `{ checked, from, to, updated, reason, skipped? }`(另附 ok/at/version/backedUp/healed/manualCommand/
|
|
276
|
+
* packageName/channel/configNote 等如实字段,只增不删)。
|
|
277
|
+
* `targetPackage` 只用于**拒绝**:不是本插件自己的包名一律 `skipped:'not-self'`(规矩 ①)。
|
|
278
|
+
*/
|
|
279
|
+
async function checkAndUpdate({ deps = {}, profileDir = null, targetPackage = null } = {}) {
|
|
280
|
+
const io = deps
|
|
281
|
+
const at = nowOf(io)
|
|
282
|
+
const selfName = selfPackageNameOf(io)
|
|
283
|
+
const dir = typeof profileDir === 'string' && profileDir !== ''
|
|
284
|
+
? profileDir
|
|
285
|
+
: (typeof io.profileDir === 'string' ? io.profileDir : '')
|
|
286
|
+
const out = {
|
|
287
|
+
checked: false, from: null, to: null, updated: false, reason: null, skipped: null,
|
|
288
|
+
ok: false, at, version: null, channel: null, packageName: null,
|
|
289
|
+
backedUp: false, healed: false, manualCommand: null, configNote: null,
|
|
290
|
+
}
|
|
291
|
+
const target = typeof targetPackage === 'string' && targetPackage !== '' ? targetPackage : selfName
|
|
292
|
+
out.packageName = target
|
|
293
|
+
if (isSelfUpdate(target, io) !== true) {
|
|
294
|
+
out.skipped = 'not-self'
|
|
295
|
+
out.reason = `自动更新只对控制台自己生效(${target} 不是 ${selfName}),已拒绝`
|
|
296
|
+
return out
|
|
297
|
+
}
|
|
298
|
+
const read = readAutoUpdateConfig({ deps: io })
|
|
299
|
+
out.configNote = read.note
|
|
300
|
+
const config = read.config
|
|
301
|
+
out.from = readInstalledVersion(dir, target) ?? runningVersionOf(io)
|
|
302
|
+
if (config.enabled !== true) {
|
|
303
|
+
out.skipped = 'disabled'
|
|
304
|
+
out.reason = '自动更新已关闭(功能包 → [自更新] 开关)'
|
|
305
|
+
return out
|
|
306
|
+
}
|
|
307
|
+
// ② 忙判据:有安装作业 / 包锁被占 ⇒ 本轮零动作(连 registry 都不打)
|
|
308
|
+
const installBusy = installBusyOf({ profileDir: dir, packageName: target, deps: io })
|
|
309
|
+
if (installBusy !== null) {
|
|
310
|
+
out.skipped = 'busy'
|
|
311
|
+
out.reason = `${installBusy} —— 本轮不做自更新`
|
|
312
|
+
return out
|
|
313
|
+
}
|
|
314
|
+
// ③ 框架升级进行中 ⇒ 本轮零动作(框架正在换整棵树,这时候动插件目录毫无意义)
|
|
315
|
+
const fwBusy = typeof io.frameworkUpgradeBusy === 'function' ? io.frameworkUpgradeBusy() : frameworkUpgradeBusy({ deps: io })
|
|
316
|
+
if (fwBusy !== null && fwBusy !== undefined && fwBusy !== false) {
|
|
317
|
+
out.skipped = 'framework-upgrade'
|
|
318
|
+
out.reason = `框架升级进行中(状态 ${typeof fwBusy === 'object' ? (fwBusy.status ?? '?') : String(fwBusy)})—— 本轮不做自更新`
|
|
319
|
+
return out
|
|
320
|
+
}
|
|
321
|
+
// ④ 查远端版本(复用既有探针 probeRegistryPackage:与 install-self-overlay 同一个口径)
|
|
322
|
+
const registries = Array.isArray(io.registries) ? io.registries : preferredRegistries()
|
|
323
|
+
const probe = typeof io.probeRegistry === 'function' ? io.probeRegistry : probeRegistryPackage
|
|
324
|
+
out.checked = true
|
|
325
|
+
let latest = null
|
|
326
|
+
try {
|
|
327
|
+
const found = await probe(target, registries, { fetchJson: io.fetchJson, timeoutMs: io.metaTimeoutMs })
|
|
328
|
+
latest = typeof found?.latest === 'string' && found.latest !== '' ? found.latest : null
|
|
329
|
+
if (found?.resolvable !== true || latest === null) {
|
|
330
|
+
const tried = Array.isArray(found?.tried) && found.tried.length > 0 ? `(${found.tried.join(';')})` : ''
|
|
331
|
+
out.reason = `探测不到 ${target} 的最新版本${tried}`
|
|
332
|
+
return persistResult(out, { deps: io })
|
|
333
|
+
}
|
|
334
|
+
} catch (error) {
|
|
335
|
+
out.reason = `探测最新版本失败:${label(error)}`
|
|
336
|
+
return persistResult(out, { deps: io })
|
|
337
|
+
}
|
|
338
|
+
out.to = latest
|
|
339
|
+
// ⑤ 只在远端**严格更高**时才装(相等 / 更低 / 不可比 ⇒ 不装)
|
|
340
|
+
const cmp = compareVersions(latest, out.from)
|
|
341
|
+
if (cmp === null) {
|
|
342
|
+
out.reason = `版本号不可比(本地 ${out.from ?? '读不到'},远端 ${latest})—— 不敢自动更新`
|
|
343
|
+
return persistResult(out, { deps: io })
|
|
344
|
+
}
|
|
345
|
+
if (cmp <= 0) {
|
|
346
|
+
out.skipped = 'up-to-date'
|
|
347
|
+
out.ok = true
|
|
348
|
+
out.reason = `已是最新(本地 ${out.from},远端 ${latest})`
|
|
349
|
+
return persistResult(out, { deps: io, lastResult: { failedVersion: null, failedAt: null } })
|
|
350
|
+
}
|
|
351
|
+
// ⑥ 失败退避:同一个目标版本在 6 小时窗口内失败过 ⇒ 不重试(防无限循环刷日志)
|
|
352
|
+
const last = config.lastResult !== null && typeof config.lastResult === 'object' ? config.lastResult : null
|
|
353
|
+
const failedVersion = last !== null && typeof last.failedVersion === 'string' ? last.failedVersion : null
|
|
354
|
+
const failedAt = last !== null && Number.isFinite(Number(last.failedAt)) ? Number(last.failedAt) : null
|
|
355
|
+
if (failedVersion !== null && failedVersion === latest && failedAt !== null && at - failedAt < RETRY_BACKOFF_MS) {
|
|
356
|
+
const minutes = Math.max(1, Math.round((RETRY_BACKOFF_MS - (at - failedAt)) / 60000))
|
|
357
|
+
out.skipped = 'backoff'
|
|
358
|
+
out.ok = true
|
|
359
|
+
out.reason = `${latest} 这个版本上次装失败了,${Math.round(RETRY_BACKOFF_MS / HOUR_MS)} 小时冷却中(还要等约 ${minutes} 分钟)`
|
|
360
|
+
return persistResult(out, { deps: io })
|
|
361
|
+
}
|
|
362
|
+
// ⑦ 真装(覆盖式,零 pnpm)
|
|
363
|
+
out.channel = 'manual-overlay'
|
|
364
|
+
const attempt = await attemptOverlayUpdate({ target, latest, profileDir: dir, registries, deps: io })
|
|
365
|
+
out.backedUp = attempt.backedUp === true
|
|
366
|
+
out.healed = attempt.healed === true
|
|
367
|
+
out.manualCommand = attempt.manualCommand ?? null
|
|
368
|
+
out.version = attempt.version ?? null
|
|
369
|
+
out.ok = attempt.ok === true
|
|
370
|
+
out.updated = attempt.ok === true
|
|
371
|
+
out.reason = attempt.ok === true
|
|
372
|
+
? `已用覆盖式安装升到 v${latest}(只覆盖、没有清空目录,全程没有调用包管理器)—— **重启服务后生效**`
|
|
373
|
+
: (attempt.reason ?? '覆盖式安装没有成功')
|
|
374
|
+
if (attempt.ok !== true && attempt.manualCommand !== null) out.reason += `;要手动处理可直接复制运行:${attempt.manualCommand}`
|
|
375
|
+
return persistResult(out, { deps: io })
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* 把本轮结论落盘(`lastCheckAt` + `lastResult`)。写盘失败只并入 note,绝不抛。
|
|
380
|
+
* `failedVersion/failedAt`:装失败时记下(退避要用);成功时清空;"没装"的轮次沿用上一轮(不能把退避弄丢)。
|
|
381
|
+
* 只有真的**查过**(`checked === true`)才写盘 —— 关闭 / 忙 / 框架升级中的轮次零写盘。
|
|
382
|
+
*/
|
|
383
|
+
function persistResult(out, { deps = {}, lastResult = null } = {}) {
|
|
384
|
+
if (out.checked !== true) return out
|
|
385
|
+
const previous = readAutoUpdateConfig({ deps }).config.lastResult
|
|
386
|
+
const prevFailedVersion = previous !== null && typeof previous === 'object' && typeof previous.failedVersion === 'string' ? previous.failedVersion : null
|
|
387
|
+
const prevFailedAt = previous !== null && typeof previous === 'object' && Number.isFinite(Number(previous.failedAt)) ? Number(previous.failedAt) : null
|
|
388
|
+
const installed = out.updated === true
|
|
389
|
+
const attempted = out.channel !== null
|
|
390
|
+
const next = {
|
|
391
|
+
at: out.at,
|
|
392
|
+
ok: out.ok === true,
|
|
393
|
+
updated: out.updated === true,
|
|
394
|
+
from: out.from ?? null,
|
|
395
|
+
to: out.to ?? null,
|
|
396
|
+
version: out.version ?? null,
|
|
397
|
+
skipped: out.skipped ?? null,
|
|
398
|
+
reason: out.reason ?? null,
|
|
399
|
+
channel: out.channel ?? null,
|
|
400
|
+
failedVersion: installed ? null : (attempted === true ? (out.to ?? null) : (lastResult?.failedVersion ?? prevFailedVersion)),
|
|
401
|
+
failedAt: installed ? null : (attempted === true ? out.at : (lastResult?.failedAt ?? prevFailedAt)),
|
|
402
|
+
}
|
|
403
|
+
const written = writeAutoUpdateConfig({ lastCheckAt: out.at, lastResult: next }, { deps })
|
|
404
|
+
// 写盘那边可能有话要说(比如"读不出来的原件已改名留档")—— 如实并进 configNote,绝不错过
|
|
405
|
+
if (typeof written.note === 'string' && written.note !== '') {
|
|
406
|
+
out.configNote = [out.configNote, written.note].filter((x) => typeof x === 'string' && x !== '').join(';')
|
|
407
|
+
}
|
|
408
|
+
if (written.ok !== true) out.reason = `${out.reason ?? ''}${out.reason === null ? '' : ';'}${written.note ?? '写配置失败'}`
|
|
409
|
+
return out
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ── ④ 给 /state 与面板用的只读视图 ─────────────────────────────────────────────────
|
|
413
|
+
/** `/state.selfAutoUpdate` 的形状(字段固定,**不编造**:拿不到的项就是 null,界面显示"—")。 */
|
|
414
|
+
function selfAutoUpdateState({ deps = {}, profileDir = null } = {}) {
|
|
415
|
+
const read = readAutoUpdateConfig({ deps })
|
|
416
|
+
const selfName = selfPackageNameOf(deps)
|
|
417
|
+
const dir = typeof profileDir === 'string' && profileDir !== ''
|
|
418
|
+
? profileDir
|
|
419
|
+
: (typeof deps.profileDir === 'string' ? deps.profileDir : '')
|
|
420
|
+
const currentVersion = readInstalledVersion(dir, selfName) ?? runningVersionOf(deps)
|
|
421
|
+
const view = {
|
|
422
|
+
enabled: read.config.enabled === true,
|
|
423
|
+
intervalHours: read.config.intervalHours,
|
|
424
|
+
lastCheckAt: read.config.lastCheckAt,
|
|
425
|
+
lastResult: read.config.lastResult,
|
|
426
|
+
currentVersion,
|
|
427
|
+
}
|
|
428
|
+
if (read.note !== null) view.note = read.note
|
|
429
|
+
return view
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ── ⑤ 调度:进程内定时器(单飞 / unref / 异常吞成一条记录)──────────────────────────────
|
|
433
|
+
const scheduler = { started: false, timer: null, running: false, deps: null, runs: 0, lastError: null }
|
|
434
|
+
|
|
435
|
+
/** 下一轮间隔(每次现读配置:用户改 intervalHours 立刻生效)。读不到就按默认值。 */
|
|
436
|
+
function nextIntervalMs(deps = {}) {
|
|
437
|
+
const read = readAutoUpdateConfig({ deps })
|
|
438
|
+
const hours = Number.isFinite(Number(read.config.intervalHours)) && Number(read.config.intervalHours) > 0
|
|
439
|
+
? Number(read.config.intervalHours) : DEFAULT_INTERVAL_HOURS
|
|
440
|
+
return Math.max(1, hours) * HOUR_MS
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** 调度里出的任何异常都只变成**一条如实记录**(绝不影响宿主,也绝不静默)。 */
|
|
444
|
+
function recordSchedulerFailure(error, { deps = {} } = {}) {
|
|
445
|
+
const at = nowOf(deps)
|
|
446
|
+
try {
|
|
447
|
+
const previous = readAutoUpdateConfig({ deps }).config.lastResult
|
|
448
|
+
writeAutoUpdateConfig({
|
|
449
|
+
lastCheckAt: at,
|
|
450
|
+
lastResult: {
|
|
451
|
+
at, ok: false, updated: false, from: null, to: null, version: null, skipped: null,
|
|
452
|
+
reason: `自更新检查抛错:${label(error)}`, channel: null,
|
|
453
|
+
failedVersion: previous !== null && typeof previous === 'object' ? (previous.failedVersion ?? null) : null,
|
|
454
|
+
failedAt: previous !== null && typeof previous === 'object' ? (previous.failedAt ?? null) : null,
|
|
455
|
+
},
|
|
456
|
+
}, { deps })
|
|
457
|
+
} catch { /* 连记录都写不进去:那也不许影响宿主 */ }
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** 单飞的一次检查(`/self-auto-update` 的"立即检查"与定时器共用):上一轮没跑完就直接回 busy。 */
|
|
461
|
+
async function runCheckOnce({ deps = {}, profileDir = null } = {}) {
|
|
462
|
+
if (scheduler.running === true) {
|
|
463
|
+
return {
|
|
464
|
+
checked: false, from: null, to: null, updated: false, ok: true, skipped: 'busy',
|
|
465
|
+
at: nowOf(deps), reason: '上一轮自更新检查还没跑完(单飞,不叠下一轮)',
|
|
466
|
+
version: null, channel: null, packageName: null, backedUp: false, healed: false, manualCommand: null, configNote: null,
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
scheduler.running = true
|
|
470
|
+
try {
|
|
471
|
+
const result = await checkAndUpdate({ deps, profileDir })
|
|
472
|
+
scheduler.runs += 1
|
|
473
|
+
return result
|
|
474
|
+
} finally {
|
|
475
|
+
scheduler.running = false
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function clearSchedulerTimer() {
|
|
480
|
+
if (scheduler.timer !== null) {
|
|
481
|
+
try { clearTimeout(scheduler.timer) } catch {}
|
|
482
|
+
scheduler.timer = null
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function scheduleNext(delayMs, deps) {
|
|
487
|
+
clearSchedulerTimer()
|
|
488
|
+
const timer = setTimeout(() => {
|
|
489
|
+
scheduler.timer = null
|
|
490
|
+
void tick(deps)
|
|
491
|
+
}, Math.max(0, Number(delayMs) || 0))
|
|
492
|
+
// 绝不阻塞进程退出(宿主关掉就该立刻退出,不等这一轮)
|
|
493
|
+
if (typeof timer?.unref === 'function') timer.unref()
|
|
494
|
+
scheduler.timer = timer
|
|
495
|
+
return timer
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** 一轮定时检查:异常吞成记录,之后永远排下一轮(除非已 stop)。 */
|
|
499
|
+
async function tick(deps) {
|
|
500
|
+
if (scheduler.started !== true) return
|
|
501
|
+
try {
|
|
502
|
+
await runCheckOnce({ deps })
|
|
503
|
+
} catch (error) {
|
|
504
|
+
scheduler.lastError = label(error)
|
|
505
|
+
recordSchedulerFailure(error, { deps })
|
|
506
|
+
} finally {
|
|
507
|
+
if (scheduler.started === true) scheduleNext(nextIntervalMs(deps), deps)
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* 启动调度(**幂等**:重复调用只返回"已经启动过",不会再排一个定时器)。
|
|
513
|
+
* 首检延迟默认 2 分钟(`deps.firstDelayMs` 可注入),之后按配置的 intervalHours 轮转。
|
|
514
|
+
*/
|
|
515
|
+
function startSelfAutoUpdateScheduler({ deps = {} } = {}) {
|
|
516
|
+
if (scheduler.started === true) return { started: true, alreadyStarted: true, firstDelayMs: null }
|
|
517
|
+
scheduler.started = true
|
|
518
|
+
scheduler.deps = deps
|
|
519
|
+
const firstDelayMs = Number.isFinite(Number(deps.firstDelayMs)) && Number(deps.firstDelayMs) >= 0
|
|
520
|
+
? Number(deps.firstDelayMs) : DEFAULT_FIRST_DELAY_MS
|
|
521
|
+
try {
|
|
522
|
+
scheduleNext(firstDelayMs, deps)
|
|
523
|
+
} catch (error) {
|
|
524
|
+
scheduler.lastError = label(error)
|
|
525
|
+
scheduler.started = false
|
|
526
|
+
return { started: false, alreadyStarted: false, firstDelayMs, note: `自更新调度没能启动:${label(error)}` }
|
|
527
|
+
}
|
|
528
|
+
return { started: true, alreadyStarted: false, firstDelayMs }
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** 停掉调度(宿主卸载 / 测试收尾用;不抛)。 */
|
|
532
|
+
function stopSelfAutoUpdateScheduler() {
|
|
533
|
+
scheduler.started = false
|
|
534
|
+
clearSchedulerTimer()
|
|
535
|
+
return { stopped: true }
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** 调度现状(诊断 / 测试用;`ref` = 定时器是否还"引用"着事件循环,`false` 才是不阻塞进程退出)。 */
|
|
539
|
+
function selfAutoUpdateSchedulerState() {
|
|
540
|
+
const timer = scheduler.timer
|
|
541
|
+
return {
|
|
542
|
+
started: scheduler.started, running: scheduler.running, hasTimer: timer !== null,
|
|
543
|
+
ref: timer !== null && typeof timer.hasRef === 'function' ? timer.hasRef() : null,
|
|
544
|
+
runs: scheduler.runs, lastError: scheduler.lastError,
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
export {
|
|
549
|
+
CONFIG_FILE, DEFAULT_FIRST_DELAY_MS, DEFAULT_INTERVAL_HOURS, RETRY_BACKOFF_MS, FW_STATE_STALE_MS,
|
|
550
|
+
autoUpdateConfigPath, defaultAutoUpdateConfig, normalizeAutoUpdateConfig, readAutoUpdateConfig, writeAutoUpdateConfig,
|
|
551
|
+
readFrameworkUpgradeStatus, frameworkUpgradeBusy, installBusyOf, runningVersionOf, compareVersions,
|
|
552
|
+
attemptOverlayUpdate, checkAndUpdate, persistResult, selfAutoUpdateState,
|
|
553
|
+
nextIntervalMs, runCheckOnce, startSelfAutoUpdateScheduler, stopSelfAutoUpdateScheduler, selfAutoUpdateSchedulerState,
|
|
554
|
+
recordSchedulerFailure,
|
|
555
|
+
}
|
|
@@ -40,6 +40,13 @@ import { routeGiteeOauthCallbackGet, routeGiteeOauthUrlGet, routeRegistryScan, r
|
|
|
40
40
|
import { routeCompatExempt, routeDetails, routeDevRoots, routeLinksGet, routePinDevDir, routeStateGet } from './state.js'
|
|
41
41
|
// 0.5.87 加法:link 面板的写动作(补依赖/垫片 + 取消钉住)—— 判据在 domain 层,这里只做白名单与回包
|
|
42
42
|
import { routeLinkRepair, routeUnpinDevDir } from './link-actions.js'
|
|
43
|
+
// ★ 加法(用户 2026-11-04:功能包里的 [自更新] 开关):控制台**只更新它自己**的自动更新。
|
|
44
|
+
// 写动作单独一条路由(能给这个动作腾行数的三个文件都在 600 行棘轮上,见 routes/self-auto-update.js 顶部)。
|
|
45
|
+
import { routeSelfAutoUpdate } from './self-auto-update.js'
|
|
46
|
+
// 调度:进程内定时器(启动后约 2 分钟首检 + 每 intervalHours 一次,单飞、unref,异常只吞成一条记录)。
|
|
47
|
+
// 为什么在这里启动而不是 lib/index.js 的 apply():那个文件贴着 142/142 行棘轮**一行都加不了**
|
|
48
|
+
// (tests/test-architecture-guard.mjs 的 INDEX_LIMIT 只允许下调),而本文件被它 import ⇒ 加载即插件启动。
|
|
49
|
+
import { startSelfAutoUpdateScheduler } from '../domain/self-auto-update.js'
|
|
43
50
|
|
|
44
51
|
const ROUTE_PREFIX = '/plugin-console'
|
|
45
52
|
|
|
@@ -208,7 +215,18 @@ const ROUTES = [
|
|
|
208
215
|
{ methods: ['POST'], path: `${ROUTE_PREFIX}/disk-cleanup`, handler: routeDiskCleanup },
|
|
209
216
|
// 结构化建议动作(2026-09-27 加法):只认白名单动作类型,argv 由服务端自拼(绝不接受命令字符串)
|
|
210
217
|
{ methods: ['POST'], path: `${ROUTE_PREFIX}/run-suggested`, handler: routeRunSuggested },
|
|
218
|
+
// ★ 加法(用户 2026-11-04):[自更新] 开关的读写 + 「立即检查」。
|
|
219
|
+
// 只对控制台自己生效:请求体里的 packageName 只用于**拒绝**非自身的包名(判据在 domain 层)。
|
|
220
|
+
{ methods: ['POST'], path: `${ROUTE_PREFIX}/self-auto-update`, handler: routeSelfAutoUpdate },
|
|
211
221
|
|
|
212
222
|
]
|
|
213
223
|
|
|
224
|
+
// ── 自更新调度(★ 加法)────────────────────────────────────────────────────────────
|
|
225
|
+
// 模块加载 = 插件启动:排一次首检(默认 2 分钟后),之后每 intervalHours 一轮。
|
|
226
|
+
// 三道保险,任何一道都不许省:
|
|
227
|
+
// ① try/catch —— 调度起不来**绝不影响宿主启动**(用户红线:自动更新失败不能拖垮服务);
|
|
228
|
+
// ② 幂等 —— 重复加载只启动一次(startSelfAutoUpdateScheduler 自带 started 标记);
|
|
229
|
+
// ③ unref —— 定时器不阻塞进程退出(见 domain/self-auto-update.js#scheduleNext)。
|
|
230
|
+
try { startSelfAutoUpdateScheduler() } catch { /* 静默:调度器自己会把失败记成一条 lastResult */ }
|
|
231
|
+
|
|
214
232
|
export { ROUTE_PREFIX, handle }
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// L2 · routes —— POST /plugin-console/self-auto-update(「自更新」开关的读写 + 「立即检查」;2026-11-04 加法)
|
|
2
|
+
//
|
|
3
|
+
// 为什么新开一条路由而不是塞进既有路由:能给这个动作腾位置的三个文件都贴着行数棘轮
|
|
4
|
+
// (`routes/framework.js` 600/600、`routes/framework-upgrade.js` 699/700、`domain/self-update-guard.js` 600/600),
|
|
5
|
+
// `/self-update` 所在的 `routes/plugins.js` 又只表示"按 latest 装一次"、语义不同(那个动作不看开关、不排队、不等定时器)。
|
|
6
|
+
// 所以按仓库既有做法**新开一条**,并同步登记 `tests/test-route-inventory.mjs`(漏登记会红)。
|
|
7
|
+
//
|
|
8
|
+
// 契约(只增不减):
|
|
9
|
+
// · `{ action: 'toggle' | 'set-enabled', enabled?: boolean }` → `{ ok, selfAutoUpdate, note }`
|
|
10
|
+
// 不给 `enabled` 就是"翻转当前值"(面板点一下的语义);给了就照给的值设。
|
|
11
|
+
// · `{ action: 'check' | 'check-now' }` → `{ ok, check, selfAutoUpdate }`
|
|
12
|
+
// `check` 是 domain/self-auto-update.js#checkAndUpdate 的**完整原样结构**
|
|
13
|
+
// (`{ checked, from, to, updated, reason, skipped? }` + 如实字段)。
|
|
14
|
+
// · 都认 `packageName`,但**只用于拒绝**:只要不是控制台自己 ⇒ `check.skipped = 'not-self'`。
|
|
15
|
+
// 本文件绝不把请求体里的包名当成"要装的目标"(自动更新只对自己生效)。
|
|
16
|
+
// · 入参不认识 ⇒ 400 + 可读原因(绝不 200 假装做过什么)。
|
|
17
|
+
//
|
|
18
|
+
// 判据全部在 domain 层(配置读坏降级、忙判据、版本比对、覆盖式安装、退避、自愈);这里只做
|
|
19
|
+
// 「取宿主信息 → 调 domain → 回包」,一个判断都不自己写。
|
|
20
|
+
import { dirname } from 'node:path'
|
|
21
|
+
import { readAutoUpdateConfig, runCheckOnce, selfAutoUpdateState, writeAutoUpdateConfig } from '../domain/self-auto-update.js'
|
|
22
|
+
import { findPatchPath } from '../infra/paths.js'
|
|
23
|
+
import { sendError, sendJson } from '../infra/httpd.js'
|
|
24
|
+
import { installJobs } from '../state.js'
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 宿主侧要注入给 domain 的两件事(domain 不认识 cordis,所以由这一层取好传下去):
|
|
28
|
+
* · profileDir —— 覆盖式安装的落点(与 /install、/self-update 同一个推导方式);
|
|
29
|
+
* · installBusy —— 内存里的作业表最准:只要还有作业在 `installing`,本轮就别动目录
|
|
30
|
+
* (包锁只覆盖"正在动这个目录"的那一小段,作业在换通道 / 等授权时锁是空的)。
|
|
31
|
+
*/
|
|
32
|
+
function autoUpdateDeps(ctx) {
|
|
33
|
+
let profileDir = ''
|
|
34
|
+
try { profileDir = dirname(findPatchPath(ctx)) } catch { profileDir = '' }
|
|
35
|
+
return {
|
|
36
|
+
profileDir,
|
|
37
|
+
installBusy: () => {
|
|
38
|
+
try {
|
|
39
|
+
for (const job of installJobs.values()) {
|
|
40
|
+
if (job !== null && job !== undefined && job.status === 'installing') {
|
|
41
|
+
return `有安装作业正在跑(${job.id ?? '未命名作业'}:${job.packageName ?? job.repo ?? '未知目标'})`
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
} catch { /* 作业表读不出来 ⇒ 交给包锁那道闸,别在这里编一个"忙" */ }
|
|
45
|
+
return null
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function routeSelfAutoUpdate(req, res, rc) {
|
|
51
|
+
const action = typeof rc.body?.action === 'string' ? rc.body.action.trim() : ''
|
|
52
|
+
const deps = autoUpdateDeps(rc.ctx)
|
|
53
|
+
const requested = typeof rc.body?.packageName === 'string' ? rc.body.packageName.trim() : ''
|
|
54
|
+
if (action === 'toggle' || action === 'set-enabled') {
|
|
55
|
+
const read = readAutoUpdateConfig({ deps })
|
|
56
|
+
const wanted = typeof rc.body?.enabled === 'boolean' ? rc.body.enabled : read.config.enabled !== true
|
|
57
|
+
const written = writeAutoUpdateConfig({ enabled: wanted }, { deps })
|
|
58
|
+
if (written.ok !== true) {
|
|
59
|
+
// 写不进去就如实失败(面板显示错误原文),绝不回一个"看起来切过去了"的状态
|
|
60
|
+
sendError(res, 500, written.note ?? '写自更新配置失败')
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
const note = [read.note, written.note].filter((x) => typeof x === 'string' && x !== '').join(';')
|
|
64
|
+
sendJson(res, 200, { ok: true, selfAutoUpdate: selfAutoUpdateState({ deps }), note: note === '' ? null : note })
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
if (action === 'check' || action === 'check-now') {
|
|
68
|
+
// 点「立即检查」与定时器是**同一条路**(单飞:定时器正在跑时这里回 skipped:'busy',不叠第二轮)
|
|
69
|
+
const check = await runCheckOnce({ deps, targetPackage: requested === '' ? null : requested })
|
|
70
|
+
sendJson(res, 200, { ok: true, check, selfAutoUpdate: selfAutoUpdateState({ deps }) })
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
sendError(res, 400, action === ''
|
|
74
|
+
? '没有提供 action(可用:toggle / set-enabled / check / check-now)'
|
|
75
|
+
: `不认识的 action:${action}(可用:toggle / set-enabled / check / check-now)`)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export { autoUpdateDeps, routeSelfAutoUpdate }
|
|
@@ -23,6 +23,9 @@ import { readBundlesSync } from '../domain/persist.js'
|
|
|
23
23
|
import { lockAutofixStatus, scheduleLockAutofix } from '../domain/lock-autofix.js'
|
|
24
24
|
import { failedJobEvidence } from '../domain/install-verify.js'
|
|
25
25
|
import { installJobView, readGithubAuth } from '../domain/install.js'
|
|
26
|
+
// ★ 加法(用户 2026-11-04 第 2 项):功能包里的 [自更新] 开关 —— /state **只增**这一个字段,
|
|
27
|
+
// 形状 { enabled, intervalHours, lastCheckAt, lastResult, currentVersion }(判据全在 domain/self-auto-update.js)。
|
|
28
|
+
import { selfAutoUpdateState } from '../domain/self-auto-update.js'
|
|
26
29
|
import { readPluginDetails } from '../domain/market.js'
|
|
27
30
|
import { healPatchSafety, readPatchState } from '../domain/patch.js'
|
|
28
31
|
import { pendingRestartJobs } from '../domain/revoke.js'
|
|
@@ -294,7 +297,7 @@ async function routeStateGet(req, res, rc) {
|
|
|
294
297
|
loadFailedCount: loadFailed.length,
|
|
295
298
|
}
|
|
296
299
|
})()
|
|
297
|
-
sendJson(res, 200, { ok: true, entries: allEntries, skippedBundles, host, health, patchPath, compat, installJobs: jobs, recentFailures, pendingRestart, github: { loggedIn: auth.loggedIn, login: auth.login }, patch: { disables: patch.disables, forced: patch.forced, inserts: patch.inserts }, framework, lockAutofix, patchHeal: patchHeal === null ? null : { healed: patchHeal.healed ?? [], autoDisabled: patchHeal.autoDisabled ?? [], healedAt: patchHeal.healedAt ?? 0, written: patchHeal.written === true, writeError: patchHeal.writeError ?? null, skippedFrameworkOwned: (patchHeal.skipped ?? []).map((s) => s.id ?? null), uncertain: (patchHeal.uncertain ?? []).map((u) => u.id ?? null) }, gating: gatingOut, compatPending: compatPending === null ? null : { frameworkVersion: compatPending.frameworkVersion ?? null, upgradeFrom: compatPending.upgradeFrom ?? null, pending: (compatPending.pending ?? []).filter((p) => (p.status ?? 'pending') === 'pending').map((p) => ({ rowId: p.rowId, moduleName: p.moduleName, version: p.version ?? null, checkNote: p.checkNote ?? null, check: p.check ?? null, riskyApprovedAt: p.riskyApprovedAt ?? null, adoptable: adoptable.get(p.rowId) ?? null, verdict: compatVerdicts.get(p.rowId) ?? null })), verdictRefresh: pendingState.refresh === null ? null : { version: pendingState.refresh.version ?? null, updated: pendingState.refresh.updated ?? [], unchanged: pendingState.refresh.unchanged ?? [], unresolved: pendingState.refresh.unresolved ?? [], wrote: pendingState.refresh.wrote === true, writeError: pendingState.refresh.writeError ?? null }, actionableCount: pendingCounts.actionableCount, staleCount: pendingCounts.staleCount, lastScan: compatPending.lastScan ?? null }, compatGate, rollback: rollbackRec, selfVersion, components: findComponents().map((c) => ({ id: c.id, name: c.name, kind: c.kind ?? 'server', pid: c.pid ?? null, port: c.port ?? null, healthUrl: c.healthUrl ?? null, uiUrl: compUiUrl(c), autoStart: c.autoStart === true })) })
|
|
300
|
+
sendJson(res, 200, { ok: true, entries: allEntries, skippedBundles, host, health, patchPath, compat, installJobs: jobs, recentFailures, pendingRestart, github: { loggedIn: auth.loggedIn, login: auth.login }, patch: { disables: patch.disables, forced: patch.forced, inserts: patch.inserts }, framework, lockAutofix, patchHeal: patchHeal === null ? null : { healed: patchHeal.healed ?? [], autoDisabled: patchHeal.autoDisabled ?? [], healedAt: patchHeal.healedAt ?? 0, written: patchHeal.written === true, writeError: patchHeal.writeError ?? null, skippedFrameworkOwned: (patchHeal.skipped ?? []).map((s) => s.id ?? null), uncertain: (patchHeal.uncertain ?? []).map((u) => u.id ?? null) }, gating: gatingOut, compatPending: compatPending === null ? null : { frameworkVersion: compatPending.frameworkVersion ?? null, upgradeFrom: compatPending.upgradeFrom ?? null, pending: (compatPending.pending ?? []).filter((p) => (p.status ?? 'pending') === 'pending').map((p) => ({ rowId: p.rowId, moduleName: p.moduleName, version: p.version ?? null, checkNote: p.checkNote ?? null, check: p.check ?? null, riskyApprovedAt: p.riskyApprovedAt ?? null, adoptable: adoptable.get(p.rowId) ?? null, verdict: compatVerdicts.get(p.rowId) ?? null })), verdictRefresh: pendingState.refresh === null ? null : { version: pendingState.refresh.version ?? null, updated: pendingState.refresh.updated ?? [], unchanged: pendingState.refresh.unchanged ?? [], unresolved: pendingState.refresh.unresolved ?? [], wrote: pendingState.refresh.wrote === true, writeError: pendingState.refresh.writeError ?? null }, actionableCount: pendingCounts.actionableCount, staleCount: pendingCounts.staleCount, lastScan: compatPending.lastScan ?? null }, compatGate, rollback: rollbackRec, selfVersion, selfAutoUpdate: selfAutoUpdateState({ deps: { profileDir } }), components: findComponents().map((c) => ({ id: c.id, name: c.name, kind: c.kind ?? 'server', pid: c.pid ?? null, port: c.port ?? null, healthUrl: c.healthUrl ?? null, uiUrl: compUiUrl(c), autoStart: c.autoStart === true })) })
|
|
298
301
|
return
|
|
299
302
|
}
|
|
300
303
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noob-stupid/dsh-plugin-console",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.109",
|
|
4
4
|
"description": "DSH 框架升级安全与插件升级门控:一键升级、失败自动回滚、升级后回滚上版、旧插件不适配自动禁用;内置多源插件市场为发现层,插件源全部可自定义,可指向公司内网私有源 / 私有索引 / 本地 Git 仓库,纯内网离线可用 | Framework upgrade safety & plugin version gating for DSH, with a customizable multi-source plugin market: point every source at internal mirrors or a local file:// repo for intranet-only, offline installs.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|