@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.107",
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",