@noob-stupid/dsh-plugin-console 0.5.97 → 0.5.98
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
|
@@ -387,6 +387,24 @@
|
|
|
387
387
|
gateRowFromUpgrade: "来源:框架升级预扫",
|
|
388
388
|
gateRowAt: " · {t}",
|
|
389
389
|
gateRowAdoptable: " · 已通过源码扫描,可解锁",
|
|
390
|
+
// ★ 0.5.98 加法(用户 2026-10-28):适配门**记录**的如实标注 + 删除入口(两段式)。
|
|
391
|
+
// 现场:3 条 pending 记录的补丁行早已不存在、moduleName 也是 null —— 面板必须如实说出来。
|
|
392
|
+
gateRowModule: "模块:",
|
|
393
|
+
gateRowMissing: "该行已不存在",
|
|
394
|
+
gateStaleLine: "另有 {n} 条记录的补丁行已不存在(永远等不到适配,可以清掉)",
|
|
395
|
+
gateForgetRow: "删除记录",
|
|
396
|
+
gateForgetRowTitle: "只删这条记录:不动插件、不动补丁文件(点一下先确认)",
|
|
397
|
+
gateForgetStale: "清理无效记录",
|
|
398
|
+
gateForgetStaleTitle: "只清掉补丁行已不存在的记录:不动插件、不动补丁文件(点一下先确认)",
|
|
399
|
+
gateForgetArmRow: "将删除记录 {id}(模块:{name})。",
|
|
400
|
+
gateForgetArmStale: "将清理 {n} 条失效记录(补丁行已不存在的那几条)。真实待适配与历史记录都不会被删。",
|
|
401
|
+
gateForgetSafety: "只删 compat-pending.json 里的记录:不动插件、不动补丁文件,写盘前先备份。",
|
|
402
|
+
gateForgetConfirm: "确认删除",
|
|
403
|
+
gateForgetCancel: "取消",
|
|
404
|
+
gateForgetDoneRow: "已删除记录 {id}(插件与补丁一个字都没动)",
|
|
405
|
+
gateForgetDoneStale: "已清理 {n} 条失效记录(插件与补丁一个字都没动)",
|
|
406
|
+
gateForgetNone: "没有可删除的记录(文件未改动)",
|
|
407
|
+
gateForgetFailed: "删除失败:",
|
|
390
408
|
// ★ 0.5.x 加法(用户 2026-10-18):「安全启动」在界面上的入口 —— 门控面板里的一个分区。
|
|
391
409
|
// 用户原话「现在这能力藏得太深,只有命令行能用」;这里只做展示 + 两段式上膛,
|
|
392
410
|
// 判据与动作白名单全在服务端 POST /plugin-console/safe-boot。
|
|
@@ -1163,6 +1181,23 @@
|
|
|
1163
1181
|
gateRowReason: "Reason: {reason}",
|
|
1164
1182
|
gateRowFromBoot: "Source: boot-failure quarantine",
|
|
1165
1183
|
gateRowFromUpgrade: "Source: framework upgrade pre-scan",
|
|
1184
|
+
// ★ 0.5.98 additive: honest labelling of adaptation-gate **records** + a delete entry (two-step).
|
|
1185
|
+
gateRowModule: "Module: ",
|
|
1186
|
+
gateRowMissing: "this row no longer exists",
|
|
1187
|
+
gateStaleLine: "{n} more record(s) point to rows that no longer exist (they can never be adapted — safe to clean up)",
|
|
1188
|
+
gateForgetRow: "Delete record",
|
|
1189
|
+
gateForgetRowTitle: "Deletes this record only: no plugin and no patch file is touched (click once to confirm)",
|
|
1190
|
+
gateForgetStale: "Clean up invalid records",
|
|
1191
|
+
gateForgetStaleTitle: "Only removes records whose patch row no longer exists: no plugin and no patch file is touched (click once to confirm)",
|
|
1192
|
+
gateForgetArmRow: "Record {id} (module: {name}) will be deleted.",
|
|
1193
|
+
gateForgetArmStale: "{n} invalid record(s) (their patch rows no longer exist) will be removed. Real pending rows and history stay untouched.",
|
|
1194
|
+
gateForgetSafety: "Records in compat-pending.json only: no plugin and no patch file is touched, and a backup is written first.",
|
|
1195
|
+
gateForgetConfirm: "Confirm delete",
|
|
1196
|
+
gateForgetCancel: "Cancel",
|
|
1197
|
+
gateForgetDoneRow: "Deleted record {id} (plugins and patch file untouched)",
|
|
1198
|
+
gateForgetDoneStale: "Removed {n} invalid record(s) (plugins and patch file untouched)",
|
|
1199
|
+
gateForgetNone: "Nothing to delete (file unchanged)",
|
|
1200
|
+
gateForgetFailed: "Delete failed: ",
|
|
1166
1201
|
gateRowAt: " · {t}",
|
|
1167
1202
|
gateRowAdoptable: " · passed the source scan, can be unlocked",
|
|
1168
1203
|
safeBootSectionTitle: "Safe boot",
|
|
@@ -5776,12 +5811,72 @@ const RESTART_FAILED_NOTE = "重启请求失败";
|
|
|
5776
5811
|
const gateReady = state !== null && state.status === "ready";
|
|
5777
5812
|
const gateAutoDisable = !gateReady || (state.data.compatGate ?? {}).autoDisable !== false;
|
|
5778
5813
|
const gateAutoDetect = !gateReady || (state.data.compatGate ?? {}).autoDetect !== false;
|
|
5779
|
-
|
|
5814
|
+
// ★ 0.5.98 改错(用户 2026-10-28 实测):计数只数**真待适配**的 —— `status=pending`
|
|
5815
|
+
// **且**该行此刻仍在补丁里。旧口径取 `compatPending.pending.length`,把两种"不是待办"的
|
|
5816
|
+
// 记录算了进去:① 已经 adopted 的历史(服务端本来就滤掉了,但语义上也不该数);
|
|
5817
|
+
// ② **行早已不存在的 pending**(真机现场 3 条,moduleName 也是 null)——
|
|
5818
|
+
// 于是面板一直挂着"有 N 行待适配",而实际一行都没有。
|
|
5819
|
+
// 判据的唯一真源在服务端(domain/compat-pending-records.js#isActionablePending,
|
|
5820
|
+
// /state 下发 actionableCount / staleCount);拿不到这两个字段时(老服务端),
|
|
5821
|
+
// 这里按**同一个判据**用 state.data.patch 的既有行清单(disables/forced/inserts,
|
|
5822
|
+
// 就是补丁解析的产物)回退算,**绝不退回数组长度**。
|
|
5823
|
+
const gatePatchRowIds = new Set([
|
|
5824
|
+
...((state !== null && state.data && state.data.patch && Array.isArray(state.data.patch.disables)) ? state.data.patch.disables : []),
|
|
5825
|
+
...((state !== null && state.data && state.data.patch && Array.isArray(state.data.patch.forced)) ? state.data.patch.forced : []),
|
|
5826
|
+
...((state !== null && state.data && state.data.patch && Array.isArray(state.data.patch.inserts)) ? state.data.patch.inserts : []),
|
|
5827
|
+
]);
|
|
5828
|
+
const gatePendingRaw = gateReady ? (state.data.compatPending?.pending ?? []) : [];
|
|
5829
|
+
/** 一条记录"行还在不在"(服务端 rowMissing 优先;老服务端按本地行清单算 —— 同一判据)。 */
|
|
5830
|
+
const gateRowMissing = (r) => (r !== null && r !== undefined && typeof r.rowMissing === "boolean"
|
|
5831
|
+
? r.rowMissing
|
|
5832
|
+
: !(r !== null && r !== undefined && typeof r.rowId === "string" && gatePatchRowIds.has(r.rowId)));
|
|
5833
|
+
/** 一条记录"是不是真待适配"(服务端 actionable 优先;老服务端按同一判据回退)。 */
|
|
5834
|
+
const gateRowActionable = (r) => (r !== null && r !== undefined && typeof r.actionable === "boolean"
|
|
5835
|
+
? r.actionable
|
|
5836
|
+
: ((r?.status ?? "pending") === "pending" && !gateRowMissing(r)));
|
|
5837
|
+
const gateActionableRows = gatePendingRaw.filter((p) => (p?.status ?? "pending") === "pending" && !gateRowMissing(p));
|
|
5838
|
+
const gateStaleRows = gatePendingRaw.filter((p) => (p?.status ?? "pending") === "pending" && gateRowMissing(p));
|
|
5839
|
+
const gatePendingCount = gateReady
|
|
5840
|
+
? (Number.isFinite(state.data.compatPending?.actionableCount) ? state.data.compatPending.actionableCount : gateActionableRows.length)
|
|
5841
|
+
: 0;
|
|
5842
|
+
const gateStaleCount = gateReady
|
|
5843
|
+
? (Number.isFinite(state.data.compatPending?.staleCount) ? state.data.compatPending.staleCount : gateStaleRows.length)
|
|
5844
|
+
: 0;
|
|
5780
5845
|
// 门控明细清单(用户 2026-09-24 要求:面板要显示"是哪些插件",不只是数量)
|
|
5781
5846
|
const [gateShowAll, setGateShowAll] = react.useState(false);
|
|
5782
5847
|
const [compatNoticeClosed, setCompatNoticeClosed] = react.useState(false);
|
|
5783
5848
|
const gateRows = gateReady ? (state.data.gating ?? null) : null;
|
|
5784
5849
|
const gateTime = (ms) => (typeof ms === "number" && ms > 0 ? new Date(ms).toLocaleString() : "—");
|
|
5850
|
+
// ★ 0.5.98 加法(用户 2026-10-28 明确要求):**删记录**的两个入口(单条 / 批量清理失效)。
|
|
5851
|
+
// 三条硬约束:① 只删记录 —— 不动插件、不动补丁文件(服务端只改 compat-pending.json);
|
|
5852
|
+
// ② **两段式** —— 第一下只上膛(零请求,并把"要删什么、不影响什么"写清楚),确认后才真发;
|
|
5853
|
+
// ③ 失败一律把服务端原文摆出来(不静默、不谎报成功)。
|
|
5854
|
+
const [gateForgetArmed, setGateForgetArmed] = react.useState(null); // { kind: "row", rowId, moduleName } | { kind: "stale" }
|
|
5855
|
+
const [gateForgetBusy, setGateForgetBusy] = react.useState(false);
|
|
5856
|
+
const [gateForgetNote, setGateForgetNote] = react.useState(null); // { text, level }
|
|
5857
|
+
/** 两段式的第一段:只上膛,一个请求都不发(换目标 = 换载荷,旧载荷不会跟着发出去)。 */
|
|
5858
|
+
const gateForgetArm = (next) => { setGateForgetNote(null); setGateForgetArmed(next); };
|
|
5859
|
+
/** 唯一出口:真发删除请求(成功后刷新 /state,列表与计数一起变)。 */
|
|
5860
|
+
const gateForgetSend = (armed) => {
|
|
5861
|
+
if (armed === null || gateForgetBusy) return;
|
|
5862
|
+
setGateForgetArmed(null); // 一旦真发请求,上膛状态作废(绝不留"看着像待确认"的残影)
|
|
5863
|
+
setGateForgetBusy(true);
|
|
5864
|
+
setGateForgetNote(null);
|
|
5865
|
+
const body = armed.kind === "stale" ? { action: "forget-stale" } : { action: "forget", rowId: String(armed.rowId) };
|
|
5866
|
+
call("/plugin-console/compat-gate", body).then(
|
|
5867
|
+
(data) => {
|
|
5868
|
+
const removed = Array.isArray(data?.removed) ? data.removed : [];
|
|
5869
|
+
const text = data !== null && data !== undefined && data.changed === false
|
|
5870
|
+
? t("gateForgetNone")
|
|
5871
|
+
: (armed.kind === "stale"
|
|
5872
|
+
? t("gateForgetDoneStale").replace("{n}", String(removed.length))
|
|
5873
|
+
: t("gateForgetDoneRow").replace("{id}", String(armed.rowId)));
|
|
5874
|
+
setGateForgetNote({ text, level: null });
|
|
5875
|
+
refresh(); // 删完必须重拉 /state(计数与列表都跟着变,不留旧数字)
|
|
5876
|
+
},
|
|
5877
|
+
(error) => setGateForgetNote({ text: t("gateForgetFailed") + friendlyGithubError(error).message, level: "error" }),
|
|
5878
|
+
).finally(() => setGateForgetBusy(false));
|
|
5879
|
+
};
|
|
5785
5880
|
// ══ 安全启动(门控面板里的一个分区)══════════════════════════════════════════════
|
|
5786
5881
|
// 用户 2026-10-18 原话:「现在这能力藏得太深,只有命令行能用」。后端 POST /safe-boot 早就有了
|
|
5787
5882
|
// (lib/server/routes/safe-boot.js;动作白名单 list / restore-last-good / disable-suspects /
|
|
@@ -6711,20 +6806,40 @@ const RESTART_FAILED_NOTE = "重启请求失败";
|
|
|
6711
6806
|
onClick: () => setCompatGate({ autoDetect: !gateAutoDetect }),
|
|
6712
6807
|
}, el("div", { className: styles.pcSwitchKnob }))),
|
|
6713
6808
|
el("p", { className: styles.message }, t("compatGateHint")),
|
|
6714
|
-
el("p", { className: styles.status, "data-pending": gatePendingCount > 0 ? "true" : undefined },
|
|
6809
|
+
el("p", { className: styles.status, "data-pending": gatePendingCount > 0 ? "true" : undefined, "data-gate-pending": String(gatePendingCount), "data-gate-stale": String(gateStaleCount) },
|
|
6715
6810
|
gatePendingCount > 0 ? t("gatePendingInfo").replace("{n}", String(gatePendingCount)) : t("gatePendingNone")),
|
|
6716
6811
|
// 明细清单(2026-09-24 用户要求:面板要能看到是哪些插件,有列表数据)
|
|
6717
6812
|
gateRows !== null && gateRows.pendingCount + gateRows.adoptedCount > 0
|
|
6718
6813
|
? el("div", { style: { flexDirection: "column", gap: 6, display: "flex" } },
|
|
6719
|
-
|
|
6814
|
+
// ★ 0.5.98 改错:这里的数字改成"真待适配"的条数(与服务端 actionableCount 同一判据)。
|
|
6815
|
+
// 下面仍列出**全部** pending 记录(含失效的),失效的逐条标注「该行已不存在」——
|
|
6816
|
+
// 藏着不显示才是骗人,标出来才是如实。
|
|
6817
|
+
el("strong", { className: styles.name }, t("gateListPending").replace("{n}", String(gateRows.pending.filter(gateRowActionable).length))),
|
|
6720
6818
|
gateRows.pending.length === 0
|
|
6721
6819
|
? el("p", { className: styles.status }, t("gatePendingNone"))
|
|
6722
6820
|
: null,
|
|
6723
6821
|
(gateShowAll ? gateRows.pending : gateRows.pending.slice(0, 8)).map((r) => el("div", {
|
|
6724
6822
|
key: "p-" + String(r.rowId),
|
|
6725
6823
|
style: { flexDirection: "column", gap: 2, display: "flex", padding: "4px 0" },
|
|
6824
|
+
"data-gate-row": String(r.rowId),
|
|
6825
|
+
"data-row-missing": gateRowMissing(r) ? "true" : "false",
|
|
6726
6826
|
},
|
|
6727
6827
|
el("span", { className: styles.name }, (r.enabled === false ? "⛔ " : "• ") + String(r.name) + (r.version ? " v" + r.version : "")),
|
|
6828
|
+
// ★ 0.5.98 加法:模块名单独一行 —— 记录里没有 moduleName 就如实显示「—」,**绝不编造**。
|
|
6829
|
+
// 行已不在补丁里的,紧跟一个标记;删除入口就在这一行(两段式)。
|
|
6830
|
+
el("span", { className: styles.status, "data-gate-module": "true" },
|
|
6831
|
+
t("gateRowModule") + " " + (typeof r.moduleName === "string" && r.moduleName !== "" ? r.moduleName : "—"),
|
|
6832
|
+
gateRowMissing(r) ? " · " + t("gateRowMissing") : "",
|
|
6833
|
+
el("button", {
|
|
6834
|
+
type: "button",
|
|
6835
|
+
className: styles.toggle,
|
|
6836
|
+
style: { marginLeft: 8, fontSize: 11, padding: "1px 6px" },
|
|
6837
|
+
"aria-label": "gateRowForget",
|
|
6838
|
+
"data-gate-forget-row": String(r.rowId),
|
|
6839
|
+
disabled: gateForgetBusy,
|
|
6840
|
+
title: t("gateForgetRowTitle"),
|
|
6841
|
+
onClick: () => gateForgetArm({ kind: "row", rowId: String(r.rowId), moduleName: typeof r.moduleName === "string" ? r.moduleName : null }),
|
|
6842
|
+
}, t("gateForgetRow"))),
|
|
6728
6843
|
el("span", { className: styles.status }, t("gateRowReason").replace("{reason}", String(r.note ?? "—"))),
|
|
6729
6844
|
el("span", { className: styles.status },
|
|
6730
6845
|
(r.source === "boot-quarantine" ? t("gateRowFromBoot") : t("gateRowFromUpgrade"))
|
|
@@ -6734,6 +6849,21 @@ const RESTART_FAILED_NOTE = "重启请求失败";
|
|
|
6734
6849
|
? el("button", { type: "button", className: styles.toggle, onClick: () => setGateShowAll((v) => !v) },
|
|
6735
6850
|
gateShowAll ? t("gateListFold") : t("gateListMore").replace("{n}", String(gateRows.pendingCount)))
|
|
6736
6851
|
: null,
|
|
6852
|
+
// ★ 0.5.98 加法:失效记录摘要 + 「清理无效记录」(**只在真有可清理的**时才摆按钮 ——
|
|
6853
|
+
// 零失效时连按钮都不出现,绝不摆一个点了必然没反应的按钮)。
|
|
6854
|
+
gateStaleCount > 0
|
|
6855
|
+
? el("div", { "data-gate-stale-block": "true", style: { flexDirection: "column", gap: 4, display: "flex", paddingTop: 4 } },
|
|
6856
|
+
el("p", { className: styles.status, "data-gate-stale-line": "true" },
|
|
6857
|
+
t("gateStaleLine").replace("{n}", String(gateStaleCount))),
|
|
6858
|
+
el("button", {
|
|
6859
|
+
type: "button",
|
|
6860
|
+
className: styles.toggle,
|
|
6861
|
+
"aria-label": "gateForgetStale",
|
|
6862
|
+
disabled: gateForgetBusy,
|
|
6863
|
+
title: t("gateForgetStaleTitle"),
|
|
6864
|
+
onClick: () => gateForgetArm({ kind: "stale" }),
|
|
6865
|
+
}, t("gateForgetStale")))
|
|
6866
|
+
: null,
|
|
6737
6867
|
gateRows.adoptedCount > 0
|
|
6738
6868
|
? el("div", { style: { flexDirection: "column", gap: 2, display: "flex" } },
|
|
6739
6869
|
// ★ 0.5.80:默认折叠,只留一行摘要(点开才列明细)—— 历史不该把当前态挤下去
|
|
@@ -6745,10 +6875,53 @@ const RESTART_FAILED_NOTE = "重启请求失败";
|
|
|
6745
6875
|
+ " · " + t("gateListAdopted").replace("{n}", String(gateRows.adoptedCount))),
|
|
6746
6876
|
gateHistoryOpen
|
|
6747
6877
|
? gateRows.adopted.map((r) => el("span", { key: "a-" + String(r.rowId), className: styles.status },
|
|
6748
|
-
"✓ " + String(r.name) + (r.version ? " v" + r.version : "") + (r.adoptedAt ? " · " + gateTime(r.adoptedAt) : "")
|
|
6878
|
+
"✓ " + String(r.name) + (r.version ? " v" + r.version : "") + (r.adoptedAt ? " · " + gateTime(r.adoptedAt) : ""),
|
|
6879
|
+
// ★ 0.5.98:历史记录也能逐条删(同样是两段式)—— 每条记录都有删除入口
|
|
6880
|
+
el("button", {
|
|
6881
|
+
type: "button",
|
|
6882
|
+
className: styles.toggle,
|
|
6883
|
+
style: { marginLeft: 6, fontSize: 11, padding: "1px 6px" },
|
|
6884
|
+
"aria-label": "gateRowForget",
|
|
6885
|
+
"data-gate-forget-row": String(r.rowId),
|
|
6886
|
+
disabled: gateForgetBusy,
|
|
6887
|
+
title: t("gateForgetRowTitle"),
|
|
6888
|
+
onClick: () => gateForgetArm({ kind: "row", rowId: String(r.rowId), moduleName: typeof r.moduleName === "string" ? r.moduleName : null }),
|
|
6889
|
+
}, t("gateForgetRow"))))
|
|
6749
6890
|
: null)
|
|
6750
6891
|
: null)
|
|
6751
|
-
: null
|
|
6892
|
+
: null,
|
|
6893
|
+
// ★ 0.5.98 加法:删除的**两段式**确认区 + 结果反馈(都留在面板上,不弹系统对话框)。
|
|
6894
|
+
// 上膛时把"要删什么、不会动什么"写清楚:只删记录,不动插件、不动补丁文件。
|
|
6895
|
+
gateForgetArmed === null
|
|
6896
|
+
? null
|
|
6897
|
+
: el("div", { "data-gate-forget-armed": "true", style: { flexDirection: "column", gap: 6, display: "flex", borderLeft: "2px solid var(--dsw-alias-state-warning-primary)", paddingLeft: 8 } },
|
|
6898
|
+
el("p", { className: styles.message, "data-warn": "true", "data-gate-forget-text": "true" },
|
|
6899
|
+
gateForgetArmed.kind === "stale"
|
|
6900
|
+
? t("gateForgetArmStale").replace("{n}", String(gateStaleCount))
|
|
6901
|
+
: t("gateForgetArmRow").replace("{id}", String(gateForgetArmed.rowId)).replace("{name}", String(gateForgetArmed.moduleName ?? "—"))),
|
|
6902
|
+
el("p", { className: styles.status }, t("gateForgetSafety")),
|
|
6903
|
+
el("div", { style: { flexDirection: "row", gap: 8, display: "flex" } },
|
|
6904
|
+
el("button", {
|
|
6905
|
+
type: "button",
|
|
6906
|
+
className: styles.toggle,
|
|
6907
|
+
"aria-label": "gateForgetConfirm",
|
|
6908
|
+
disabled: gateForgetBusy,
|
|
6909
|
+
onClick: () => { gateForgetSend(gateForgetArmed); },
|
|
6910
|
+
}, t("gateForgetConfirm")),
|
|
6911
|
+
el("button", {
|
|
6912
|
+
type: "button",
|
|
6913
|
+
className: styles.toggle,
|
|
6914
|
+
"aria-label": "gateForgetCancel",
|
|
6915
|
+
onClick: () => { setGateForgetArmed(null); setGateForgetNote(null); },
|
|
6916
|
+
}, t("gateForgetCancel")))),
|
|
6917
|
+
gateForgetNote === null
|
|
6918
|
+
? null
|
|
6919
|
+
: el("p", {
|
|
6920
|
+
className: styles.message,
|
|
6921
|
+
"data-gate-forget-note": "true",
|
|
6922
|
+
"data-error": gateForgetNote.level === "error" ? "true" : undefined,
|
|
6923
|
+
style: gateForgetNote.level === "error" ? undefined : { color: "var(--dsw-alias-state-success-primary)" },
|
|
6924
|
+
}, String(gateForgetNote.text ?? "—"))),
|
|
6752
6925
|
// ★ 0.5.x 加法(用户 2026-10-18):「安全启动」分区 —— 挂在门控面板里(不新增工具栏按钮)。
|
|
6753
6926
|
// 默认收起,展开那一下才读一次快照列表;四个动作见 safeBootPanel(list / restore-last-good /
|
|
6754
6927
|
// disable-suspects / mark-good)。它排在门控正文**之后**,Star 行仍在最后(贴底那条不变)。
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
// L1 · domain —— compat-pending-records.js(「真待适配」判据的**唯一真源** + 记录删除的写盘纪律)
|
|
2
|
+
// 分层分组:L1 · domain(边界由 tests/test-architecture-guard.mjs 断言)
|
|
3
|
+
//
|
|
4
|
+
// ── 真机背景(2026-10-28,用户实测)────────────────────────────────────────────────
|
|
5
|
+
// `~/.dsh/plugin-console/compat-pending.json` 自 2026-10-04 之后就没再被写过,里面 24 条记录:
|
|
6
|
+
// · 20 条 `status:"adopted"`(已适配解锁 —— 历史,不是待办);
|
|
7
|
+
// · 3 条 `status:"pending"` 且 `moduleName: null`(web-ui-describe-image / web-ui-doctor /
|
|
8
|
+
// web-ui-better-sidebar)—— **对应的补丁行早已不存在**,永远不可能被适配;
|
|
9
|
+
// · 全部 `check:"unknown"`。
|
|
10
|
+
// 结果门控面板照旧把它们算成"待适配的行",用户看到"有 N 行待适配",其实**一行都没有**。
|
|
11
|
+
//
|
|
12
|
+
// 两处根因,本模块各治一处:
|
|
13
|
+
// ① **计数口径错**:数的是 `pending` 数组长度,而不是"status=pending 且该行仍在补丁里"。
|
|
14
|
+
// → `isActionablePending()` / `countPendingRecords()` 是唯一判据,路由与面板都只认它。
|
|
15
|
+
// ② **记录从不清理**:adopted 与"行已消失"的记录永久留在文件里。
|
|
16
|
+
// → `planForgetRecord()` / `planForgetStale()` + `applyPendingForget()` 提供删除(只删记录)。
|
|
17
|
+
//
|
|
18
|
+
// ── 三条硬边界(不许放宽)──────────────────────────────────────────────────────────
|
|
19
|
+
// · 「行是否存在」只取**补丁解析的既有产物**(`readPatchState()` 的 disables / forced / inserts,
|
|
20
|
+
// 见 domain/patch.js)—— 这里不另写一套正则,避免两套判据分叉(本仓已为分叉付过代价)。
|
|
21
|
+
// · 删除**只动 compat-pending.json**:不动插件、不动补丁、不动 bundles 清单。
|
|
22
|
+
// 「清理无效记录」只清 stale(status=pending 且行已不存在);真 pending 与 adopted **保留**。
|
|
23
|
+
// · 写盘:备份 → 原子写(临时文件 + rename)→ **读回核实** → 失败回滚;坏文件**拒绝覆盖**
|
|
24
|
+
// (那是用户的文件,覆盖等于丢数据),读侧则降级为空结构并如实报原因。
|
|
25
|
+
import { copyFileSync, existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
26
|
+
import { compatPendingFile } from './compat-verdict.js'
|
|
27
|
+
|
|
28
|
+
/** 适配门记录里"待办"这一侧的状态字(其它一切状态都算历史)。 */
|
|
29
|
+
const PENDING_STATUS = 'pending'
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 当前补丁里**真实存在**的行 id 集合(唯一真源:`readPatchState()` 的解析结果)。
|
|
33
|
+
* 三个来源都是"这一行在补丁里有位置"的等价证据:
|
|
34
|
+
* inserts —— 本控制台插入的启用行;disables / forced —— 针对该行的禁用/强制覆盖块。
|
|
35
|
+
* @param {{disables?: string[], forced?: string[], inserts?: string[]}|null} patchState
|
|
36
|
+
* @returns {Set<string>}
|
|
37
|
+
*/
|
|
38
|
+
function liveRowIdsFromPatch(patchState) {
|
|
39
|
+
const out = new Set()
|
|
40
|
+
if (patchState === null || typeof patchState !== 'object') return out
|
|
41
|
+
for (const key of ['disables', 'forced', 'inserts']) {
|
|
42
|
+
const list = Array.isArray(patchState[key]) ? patchState[key] : []
|
|
43
|
+
for (const id of list) if (typeof id === 'string' && id !== '') out.add(id)
|
|
44
|
+
}
|
|
45
|
+
return out
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const statusOf = (record) => (typeof record?.status === 'string' && record.status !== '' ? record.status : PENDING_STATUS)
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 「真待适配」判据(**纯函数、唯一真源**):status 仍是 pending,**且**该行此刻还在补丁里。
|
|
52
|
+
* 行已不存在 ⇒ 这条记录永远等不到"适配"(它的行都不在了),所以它不是待办。
|
|
53
|
+
*/
|
|
54
|
+
function isActionablePending(record, liveRowIds) {
|
|
55
|
+
if (record === null || typeof record !== 'object') return false
|
|
56
|
+
if (statusOf(record) !== PENDING_STATUS) return false
|
|
57
|
+
if (typeof record.rowId !== 'string' || record.rowId === '') return false
|
|
58
|
+
return liveRowIds instanceof Set && liveRowIds.has(record.rowId)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 「失效记录」判据:记录写着待适配,但它的补丁行已经不在了(用户眼前那条"删不掉的待适配")。
|
|
63
|
+
* 注意:adopted 记录**不算** stale —— 那是历史,用户明确要求保留、只在历史区展示。
|
|
64
|
+
*/
|
|
65
|
+
function isStalePending(record, liveRowIds) {
|
|
66
|
+
if (record === null || typeof record !== 'object') return false
|
|
67
|
+
if (statusOf(record) !== PENDING_STATUS) return false
|
|
68
|
+
if (typeof record.rowId !== 'string' || record.rowId === '') return false
|
|
69
|
+
return !(liveRowIds instanceof Set) || !liveRowIds.has(record.rowId)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 计数(面板/接口只认这三个数):真待适配 / 已失效 / 历史已适配。
|
|
74
|
+
* @returns {{ totalCount: number, actionableCount: number, staleCount: number, adoptedCount: number }}
|
|
75
|
+
*/
|
|
76
|
+
function countPendingRecords(records, liveRowIds) {
|
|
77
|
+
const list = Array.isArray(records) ? records : []
|
|
78
|
+
let actionableCount = 0
|
|
79
|
+
let staleCount = 0
|
|
80
|
+
let adoptedCount = 0
|
|
81
|
+
for (const rec of list) {
|
|
82
|
+
if (rec === null || typeof rec !== 'object') continue
|
|
83
|
+
if (statusOf(rec) !== PENDING_STATUS) { adoptedCount += 1; continue }
|
|
84
|
+
if (isActionablePending(rec, liveRowIds)) actionableCount += 1
|
|
85
|
+
else staleCount += 1
|
|
86
|
+
}
|
|
87
|
+
return { totalCount: list.length, actionableCount, staleCount, adoptedCount }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 给门控明细行补两个**只增不改**的字段(面板据此如实标注"该行已不存在"并给删除入口):
|
|
92
|
+
* rowMissing —— 这一行的 rowId 已不在当前补丁里(null/空 rowId 也算 missing:无所指)
|
|
93
|
+
* actionable —— 与 `isActionablePending` 同一个判据(pending 且行还在)
|
|
94
|
+
*/
|
|
95
|
+
function annotateGateRows(rows, liveRowIds) {
|
|
96
|
+
const list = Array.isArray(rows) ? rows : []
|
|
97
|
+
return list.map((row) => {
|
|
98
|
+
const hasRow = typeof row?.rowId === 'string' && row.rowId !== '' && liveRowIds instanceof Set && liveRowIds.has(row.rowId)
|
|
99
|
+
return { ...row, rowMissing: !hasRow, actionable: isActionablePending(row, liveRowIds) }
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 读记录文件(**只读**)。坏文件/空文件按"没有内容"处理,但**把原因如实带出来**:
|
|
105
|
+
* 调用方(写动作)据此拒绝覆盖,界面据此如实显示"读不出来,所以没有可清理的记录"。
|
|
106
|
+
* @returns {{ file: string, exists: boolean, corrupt: boolean, raw: object|null, records: object[], error: string|null }}
|
|
107
|
+
*/
|
|
108
|
+
function readPendingRecords(file = compatPendingFile()) {
|
|
109
|
+
const empty = { file, exists: false, corrupt: false, raw: null, records: [], error: null }
|
|
110
|
+
if (typeof file !== 'string' || file === '') return { ...empty, corrupt: true, error: '拿不到记录文件路径' }
|
|
111
|
+
if (!existsSync(file)) return empty
|
|
112
|
+
let text = null
|
|
113
|
+
try {
|
|
114
|
+
text = readFileSync(file, 'utf8')
|
|
115
|
+
} catch (error) {
|
|
116
|
+
return { ...empty, exists: true, corrupt: true, error: `读取失败:${String(error?.message ?? error)}` }
|
|
117
|
+
}
|
|
118
|
+
// 空文件(含只有空白 / BOM 的)按**没有内容**处理:它不是坏文件 ⇒ 读侧降级为空结构,
|
|
119
|
+
// 写侧照旧"没有可删的记录"(绝不为了删一条记录就把空文件改写成别的形状)。
|
|
120
|
+
if (text.replace(/^\uFEFF/u, '').trim() === '') return { ...empty, exists: true, empty: true, raw: null, records: [] }
|
|
121
|
+
let raw = null
|
|
122
|
+
try {
|
|
123
|
+
raw = JSON.parse(text)
|
|
124
|
+
} catch (error) {
|
|
125
|
+
return { ...empty, exists: true, corrupt: true, error: `不是合法 JSON:${String(error?.message ?? error)}` }
|
|
126
|
+
}
|
|
127
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
128
|
+
return { ...empty, exists: true, corrupt: true, error: '内容不是 JSON 对象' }
|
|
129
|
+
}
|
|
130
|
+
const records = (Array.isArray(raw.pending) ? raw.pending : []).filter((r) => r !== null && typeof r === 'object')
|
|
131
|
+
return { file, exists: true, corrupt: false, raw, records, error: null }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** 写动作的公共前置校验:坏文件**拒绝覆盖**(保住用户数据),并给出可读原因。 */
|
|
135
|
+
function refuseIfUnsafe(read) {
|
|
136
|
+
if (read.exists !== true) return { ok: false, status: 404, error: `${read.file} 不存在(没有可删的记录)` }
|
|
137
|
+
if (read.corrupt === true) {
|
|
138
|
+
return { ok: false, status: 409, error: `${read.file} 读不出来(${read.error ?? '格式非法'});为保住你的数据,本动作已停止(请先手工修好或删掉它)` }
|
|
139
|
+
}
|
|
140
|
+
return null
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 规划「删掉某一条记录」(**纯函数,不写盘**)。
|
|
145
|
+
* 不存在的 rowId ⇒ 404 + 明确原因(绝不静默成功)。
|
|
146
|
+
*/
|
|
147
|
+
function planForgetRecord({ read, rowId } = {}) {
|
|
148
|
+
const base = { ok: false, status: 400, error: null, file: read?.file ?? null, next: null, changed: false, removedRowIds: [], keptRowIds: [] }
|
|
149
|
+
if (read === null || typeof read !== 'object') return { ...base, error: '内部错误:没有读到记录文件' }
|
|
150
|
+
const refused = refuseIfUnsafe(read)
|
|
151
|
+
if (refused !== null) return { ...base, ...refused }
|
|
152
|
+
if (typeof rowId !== 'string' || rowId === '') return { ...base, error: '缺少 rowId(要删哪一条?)' }
|
|
153
|
+
const index = read.records.findIndex((r) => r.rowId === rowId)
|
|
154
|
+
if (index < 0) return { ...base, status: 404, error: `没有 rowId 为 ${rowId} 的记录(文件未改动)` }
|
|
155
|
+
const kept = read.records.filter((_, i) => i !== index)
|
|
156
|
+
return {
|
|
157
|
+
ok: true,
|
|
158
|
+
status: 200,
|
|
159
|
+
error: null,
|
|
160
|
+
file: read.file,
|
|
161
|
+
next: { ...read.raw, pending: kept },
|
|
162
|
+
changed: true,
|
|
163
|
+
removedRowIds: [rowId],
|
|
164
|
+
keptRowIds: kept.map((r) => r.rowId).filter((id) => typeof id === 'string'),
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 规划「清掉所有失效记录」(**纯函数,不写盘**):只删 status=pending 且行已不存在的;
|
|
170
|
+
* 真 pending 与 adopted 一条都不动。没有可清的 ⇒ `changed:false`(**零写盘**,不是错误)。
|
|
171
|
+
*/
|
|
172
|
+
function planForgetStale({ read, liveRowIds } = {}) {
|
|
173
|
+
const base = { ok: false, status: 400, error: null, file: read?.file ?? null, next: null, changed: false, removedRowIds: [], keptRowIds: [], staleCount: 0 }
|
|
174
|
+
if (read === null || typeof read !== 'object') return { ...base, error: '内部错误:没有读到记录文件' }
|
|
175
|
+
const refused = refuseIfUnsafe(read)
|
|
176
|
+
if (refused !== null) return { ...base, ...refused }
|
|
177
|
+
const stale = read.records.filter((r) => isStalePending(r, liveRowIds))
|
|
178
|
+
if (stale.length === 0) {
|
|
179
|
+
return { ...base, ok: true, status: 200, changed: false, keptRowIds: read.records.map((r) => r.rowId).filter((id) => typeof id === 'string'), staleCount: 0 }
|
|
180
|
+
}
|
|
181
|
+
const staleIds = new Set(stale.map((r) => r.rowId))
|
|
182
|
+
const kept = read.records.filter((r) => !staleIds.has(r.rowId))
|
|
183
|
+
return {
|
|
184
|
+
ok: true,
|
|
185
|
+
status: 200,
|
|
186
|
+
error: null,
|
|
187
|
+
file: read.file,
|
|
188
|
+
next: { ...read.raw, pending: kept },
|
|
189
|
+
changed: true,
|
|
190
|
+
removedRowIds: [...staleIds],
|
|
191
|
+
keptRowIds: kept.map((r) => r.rowId).filter((id) => typeof id === 'string'),
|
|
192
|
+
staleCount: stale.length,
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 执行写入:备份 → 原子写(临时文件 + rename)→ **读回核实** → 任何一步不过就回滚并如实报错。
|
|
198
|
+
* `deps` 全是注入缝(写失败 / 读回不符的单测靠它,生产路径不传 ⇒ 真实 fs)。
|
|
199
|
+
* @returns 计划的字段 + `{ backup, verified, rolledBack }`
|
|
200
|
+
*/
|
|
201
|
+
function applyPendingForget(plan, deps = {}) {
|
|
202
|
+
if (plan === null || typeof plan !== 'object' || plan.ok !== true || plan.changed !== true) return plan
|
|
203
|
+
const io = {
|
|
204
|
+
copy: deps.copyFileSync ?? copyFileSync,
|
|
205
|
+
write: deps.writeFileSync ?? writeFileSync,
|
|
206
|
+
rename: deps.renameSync ?? renameSync,
|
|
207
|
+
remove: deps.unlinkSync ?? unlinkSync,
|
|
208
|
+
exists: deps.existsSync ?? existsSync,
|
|
209
|
+
read: deps.readFileSync ?? readFileSync,
|
|
210
|
+
}
|
|
211
|
+
const { file, next } = plan
|
|
212
|
+
const stamp = Date.now()
|
|
213
|
+
const backup = `${file}.bak-forget-${stamp}`
|
|
214
|
+
const tmp = `${file}.tmp-forget-${stamp}`
|
|
215
|
+
const rollback = (extra) => {
|
|
216
|
+
try { if (io.exists(backup)) io.copy(backup, file) } catch {}
|
|
217
|
+
try { if (io.exists(tmp)) io.remove(tmp) } catch {}
|
|
218
|
+
return { ...plan, ...extra, backup, verified: false, rolledBack: io.exists(backup) }
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
if (io.exists(file)) io.copy(file, backup)
|
|
222
|
+
io.write(tmp, `${JSON.stringify(next, null, 2)}\n`, 'utf8')
|
|
223
|
+
io.rename(tmp, file)
|
|
224
|
+
} catch (error) {
|
|
225
|
+
return rollback({ ok: false, status: 500, error: `写入 ${file} 失败:${String(error?.message ?? error)}(已回滚,文件保持原样)` })
|
|
226
|
+
}
|
|
227
|
+
// 读回核实:删掉的那条必须真的不在、保留的每一条必须还在 —— "写了但没生效"是最坏的一种假绿
|
|
228
|
+
let back = null
|
|
229
|
+
try {
|
|
230
|
+
back = JSON.parse(io.read(file, 'utf8'))
|
|
231
|
+
} catch (error) {
|
|
232
|
+
return rollback({ ok: false, status: 500, error: `写入后读回失败:${String(error?.message ?? error)}(已回滚)` })
|
|
233
|
+
}
|
|
234
|
+
const rows = (Array.isArray(back?.pending) ? back.pending : []).filter((r) => r !== null && typeof r === 'object')
|
|
235
|
+
const stillThere = plan.removedRowIds.filter((id) => rows.some((r) => r.rowId === id))
|
|
236
|
+
const lost = plan.keptRowIds.filter((id) => !rows.some((r) => r.rowId === id))
|
|
237
|
+
if (stillThere.length > 0 || lost.length > 0) {
|
|
238
|
+
const why = stillThere.length > 0 ? `要删的记录仍在(${stillThere.join('、')})` : `保留的记录丢了(${lost.slice(0, 5).join('、')})`
|
|
239
|
+
return rollback({ ok: false, status: 500, error: `写入后读回内容与期望不一致(${why}),已回滚` })
|
|
240
|
+
}
|
|
241
|
+
return { ...plan, backup, verified: true, rolledBack: false }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export {
|
|
245
|
+
PENDING_STATUS,
|
|
246
|
+
liveRowIdsFromPatch,
|
|
247
|
+
isActionablePending,
|
|
248
|
+
isStalePending,
|
|
249
|
+
countPendingRecords,
|
|
250
|
+
annotateGateRows,
|
|
251
|
+
readPendingRecords,
|
|
252
|
+
planForgetRecord,
|
|
253
|
+
planForgetStale,
|
|
254
|
+
applyPendingForget,
|
|
255
|
+
}
|
|
@@ -26,6 +26,9 @@ import { DEFAULT_TIMEOUT_MS, autoPreflightView, runAutoPreflight } from '../doma
|
|
|
26
26
|
import { runAutoPreflightScan } from '../domain/auto-preflight-run.js'
|
|
27
27
|
import { NOTICE_MODES, detectCompat, readCompatAck, readCompatGate, readCompatPending, rowIdModuleMap, writeCompatAck, writeCompatGate, writeCompatPending } from '../domain/compat.js'
|
|
28
28
|
import { compareFingerprint, computeFingerprint, planDesktopHostSwitch, readCompatMode, readFingerprint, readRollbackRecord, stampFingerprint, writeCompatMode } from '../domain/compat-state.js'
|
|
29
|
+
// 0.5.98 加法:适配门**记录**的删除(真待适配判据 + 写盘纪律都在该模块;本路由只映射状态码)
|
|
30
|
+
import { applyPendingForget, countPendingRecords, liveRowIdsFromPatch, planForgetRecord, planForgetStale, readPendingRecords } from '../domain/compat-pending-records.js'
|
|
31
|
+
import { readPatchState } from '../domain/patch.js'
|
|
29
32
|
import { currentFrameworkVersion, detectHostShape } from '../domain/framework.js'
|
|
30
33
|
import { applyDependencyBackfill, planDependencyBackfill } from '../domain/manifest.js'
|
|
31
34
|
import { isProtectedModule } from '../domain/runtime.js'
|
|
@@ -455,4 +458,74 @@ async function routeCompatAck(req, res, rc) {
|
|
|
455
458
|
const ack = writeCompatAck(version)
|
|
456
459
|
sendJson(res, 200, { ok: true, ack, noticeMode: readCompatGate().noticeMode })
|
|
457
460
|
}
|
|
458
|
-
|
|
461
|
+
/**
|
|
462
|
+
* POST /plugin-console/compat-gate `{ action: 'forget' | 'forget-stale' }`
|
|
463
|
+
* —— 适配门**记录**的删除(0.5.98 加法,用户 2026-10-28 明确要求)。
|
|
464
|
+
*
|
|
465
|
+
* 为什么挂在**既有的 /compat-gate** 上而不是新开一条路由:门控面板的所有写入口必须同源
|
|
466
|
+
* (否则又要登记一份路由清单,两处判据迟早分叉)。body 里没有 `action` 时**逐字**走原来的
|
|
467
|
+
* 总开关逻辑(既有契约零变化,装配层的分流见 routes/index.js#routeCompatGateAny)。
|
|
468
|
+
*
|
|
469
|
+
* 语义(三条硬边界,用户定案):
|
|
470
|
+
* · 只删 **记录**:不动插件、不动补丁文件、不动 bundles 清单;
|
|
471
|
+
* · `forget` 删指定 rowId;`forget-stale` 只清「status=pending 且该行已不在补丁里」的失效记录,
|
|
472
|
+
* 真 pending 与 adopted 一条都不动;没有可清的 ⇒ `changed:false` 且**零写盘**;
|
|
473
|
+
* · 删不存在的 rowId ⇒ 404 + 明确原因(绝不静默成功);坏文件 ⇒ 409 拒绝覆盖(保住用户数据)。
|
|
474
|
+
*
|
|
475
|
+
* 写盘纪律全在 domain/compat-pending-records.js:备份 → 原子写(临时文件 + rename)→ 读回核实 → 失败回滚。
|
|
476
|
+
* 「行是否存在」只取补丁解析的既有产物(domain/patch.js#readPatchState),不另写一套正则。
|
|
477
|
+
*/
|
|
478
|
+
async function routeCompatPendingForget(req, res, rc) {
|
|
479
|
+
const body = rc.body ?? {}
|
|
480
|
+
const action = typeof body.action === 'string' ? body.action.trim() : ''
|
|
481
|
+
if (action !== 'forget' && action !== 'forget-stale') {
|
|
482
|
+
sendError(res, 400, `未知动作 ${JSON.stringify(body.action)}(可选 forget / forget-stale)`, { ok: false, error: '未知动作', code: 'unknown-action' })
|
|
483
|
+
return
|
|
484
|
+
}
|
|
485
|
+
const read = readPendingRecords()
|
|
486
|
+
// 判据唯一真源:补丁此刻真实存在的行(与 /state 里 patch.disables/forced/inserts 同源)
|
|
487
|
+
let liveRowIds = new Set()
|
|
488
|
+
try {
|
|
489
|
+
const patchPath = findPatchPath(rc.ctx)
|
|
490
|
+
liveRowIds = liveRowIdsFromPatch(await readPatchState(patchPath))
|
|
491
|
+
} catch {
|
|
492
|
+
// 读不到补丁 ⇒ 只能认定"没有任何行存在";此时 forget-stale 会把 pending 全判成 stale,
|
|
493
|
+
// 那等于凭一个读失败删用户记录 —— 所以这条通道**必须停下**(宁可红,也不报假的绿)。
|
|
494
|
+
sendError(res, 503, '读不到 profile 补丁文件,无法判断哪些记录还指向真实的行;本次删除已停止(文件未改动)', { ok: false, code: 'patch-unreadable' })
|
|
495
|
+
return
|
|
496
|
+
}
|
|
497
|
+
const plan = action === 'forget'
|
|
498
|
+
? planForgetRecord({ read, rowId: body.rowId })
|
|
499
|
+
: planForgetStale({ read, liveRowIds })
|
|
500
|
+
const result = plan.ok === true && plan.changed === true ? applyPendingForget(plan) : plan
|
|
501
|
+
const removed = Array.isArray(result.removedRowIds) ? result.removedRowIds : []
|
|
502
|
+
const removedCount = removed.length
|
|
503
|
+
const note = result.ok !== true
|
|
504
|
+
? null
|
|
505
|
+
: (action === 'forget'
|
|
506
|
+
? `已删除记录 ${String(body.rowId)}(其余记录原样保留;插件与补丁一个字都没动)`
|
|
507
|
+
: (removedCount > 0
|
|
508
|
+
? `已清理 ${removedCount} 条失效记录(真实待适配与历史记录一条没动;插件与补丁一个字都没动)`
|
|
509
|
+
: '没有可清理的失效记录(文件未改动)'))
|
|
510
|
+
const payload = {
|
|
511
|
+
ok: result.ok === true,
|
|
512
|
+
action,
|
|
513
|
+
changed: result.changed === true,
|
|
514
|
+
removed,
|
|
515
|
+
removedCount,
|
|
516
|
+
keptCount: Array.isArray(result.keptRowIds) ? result.keptRowIds.length : 0,
|
|
517
|
+
// 删完之后按**同一判据**重算一遍(面板据此重绘;不复用旧计数,避免"删了但数字没动")
|
|
518
|
+
counts: (() => {
|
|
519
|
+
try { return countPendingRecords(readPendingRecords().records, liveRowIds) } catch { return null }
|
|
520
|
+
})(),
|
|
521
|
+
file: read.file,
|
|
522
|
+
backup: result.backup ?? null,
|
|
523
|
+
verified: result.verified === true,
|
|
524
|
+
error: result.error ?? null,
|
|
525
|
+
note,
|
|
526
|
+
}
|
|
527
|
+
if (result.ok === true) sendJson(res, 200, payload)
|
|
528
|
+
else sendError(res, result.status ?? 400, result.error ?? '删除记录失败', payload)
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
export { hostShapeOf, maybeSwitchToObserver, maybeWriteSnapshot, preflightTimeoutMs, resetHostSwitchChecked, routeCompatAck, routeCompatMode, routeCompatPendingForget, routeCompatStamp, routeCompatStatusGet, routeDeclareInstalled, scheduleAutoPreflight }
|
|
@@ -16,7 +16,7 @@ import { readBody, sendError } from '../infra/httpd.js'
|
|
|
16
16
|
import { routeAiConsent, routeAiEmpowerCancel, routeAiEmpowerList, routeAiEmpowerPlan, routeAiEmpowerRun, routeAiEmpowerStatus } from './ai.js'
|
|
17
17
|
import { routeRunSuggested } from './actions.js'
|
|
18
18
|
import { routeComponentAutostart, routeComponentStart, routeComponentStatus, routeComponentStop, routeComponents, routeRepoClone, routeRepoLandConfig, routeRepoList, routeRepoOpen, routeRepoRemove } from './components.js'
|
|
19
|
-
import { routeCompatAck, routeCompatMode, routeCompatStamp, routeCompatStatusGet, routeDeclareInstalled } from './compat.js'
|
|
19
|
+
import { routeCompatAck, routeCompatMode, routeCompatPendingForget, routeCompatStamp, routeCompatStatusGet, routeDeclareInstalled } from './compat.js'
|
|
20
20
|
import { routeSafeBoot } from './safe-boot.js'
|
|
21
21
|
// issue #22 ②③ 加法:用户备注 / 用户徽章 / 用户自建分组(独立文件,与插件列表完全解耦)
|
|
22
22
|
import { routeAnnotationsGet, routeAnnotationsPost } from './annotations.js'
|
|
@@ -81,6 +81,23 @@ function routeDeps() {
|
|
|
81
81
|
return { runInstallJob, runSuiteInstallJob, webPort, readExtraBundleRows, detectAdoptablePending, listEntries, detectCompat, detectFrameworkUpgrade, DEFAULT_SEARCH, aiEmpowerExecute, aiEmpowerPlan, backupProfileSnapshot, currentFrameworkVersion, frameworkCompatReportFor, isProtectedModule, pnpmRemove, preflightDisableIncompatible, rowIdModuleMap, fetchJson: fetchJsonUrl }
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* `/compat-gate` 的**只增不改**分流(0.5.98 加法)。
|
|
86
|
+
*
|
|
87
|
+
* 为什么分流装在这一层:门控面板的写入口必须挂在**既有的 /compat-gate** 上(不新开路由,
|
|
88
|
+
* 否则又要登记一份清单);而那条路由的 handler(routes/framework.js#routeCompatGate)所在文件
|
|
89
|
+
* 已顶格 600/600 行(tests/test-architecture-guard.mjs 的硬门槛),**一个字都不能加**。
|
|
90
|
+
* 于是"分流"留在装配层,"动作实现"落在 routes/compat.js(适配门记录那一族)。
|
|
91
|
+
*
|
|
92
|
+
* 契约:body 里没有 `action`(或不是非空字符串)⇒ **逐字**走原来的总开关逻辑,
|
|
93
|
+
* 既有请求/响应一字不变;带 `action` 才进删除记录那一族(forget / forget-stale)。
|
|
94
|
+
*/
|
|
95
|
+
async function routeCompatGateAny(req, res, rc) {
|
|
96
|
+
const action = typeof rc.body?.action === 'string' ? rc.body.action : ''
|
|
97
|
+
if (action !== '') return routeCompatPendingForget(req, res, rc)
|
|
98
|
+
return routeCompatGate(req, res, rc)
|
|
99
|
+
}
|
|
100
|
+
|
|
84
101
|
async function handle(ctx, req, res) {
|
|
85
102
|
const url = new URL(req.url ?? '/', 'http://x')
|
|
86
103
|
const pathname = url.pathname
|
|
@@ -126,7 +143,7 @@ const ROUTES = [
|
|
|
126
143
|
{ methods: ['POST'], path: `${ROUTE_PREFIX}/skill-toggle`, handler: routeSkillToggle },
|
|
127
144
|
{ methods: ['POST'], path: `${ROUTE_PREFIX}/install`, handler: routeInstall },
|
|
128
145
|
{ methods: ['POST'], path: `${ROUTE_PREFIX}/install-status`, handler: routeInstallStatus }, { methods: ['POST'], path: `${ROUTE_PREFIX}/framework-check`, handler: routeFrameworkCheck },
|
|
129
|
-
{ methods: ['POST'], path: `${ROUTE_PREFIX}/compat-gate`, handler:
|
|
146
|
+
{ methods: ['POST'], path: `${ROUTE_PREFIX}/compat-gate`, handler: routeCompatGateAny },
|
|
130
147
|
{ methods: ['POST'], path: `${ROUTE_PREFIX}/check-update`, handler: routeCheckUpdate },
|
|
131
148
|
// issue #22 ① 加法:批量检测(已安装列表的「一键检测全部更新」)。
|
|
132
149
|
// ★ 与 /check-update 共用同一个探针(probePackageUpdate),判据不分叉;并发上限在 domain/update-batch.js。
|
|
@@ -12,6 +12,8 @@ import { bundleVetoList } from '../domain/bundle-veto.js'
|
|
|
12
12
|
import { buildLinkInventory } from '../domain/link-inventory.js'
|
|
13
13
|
import { buildInstallHealth } from '../domain/install-health.js'
|
|
14
14
|
import { applyExemption } from '../domain/compat-exempt.js'
|
|
15
|
+
// 0.5.98 加法:适配门记录的「真待适配」计数(唯一真源,见 domain/compat-pending-records.js)
|
|
16
|
+
import { annotateGateRows, countPendingRecords, liveRowIdsFromPatch } from '../domain/compat-pending-records.js'
|
|
15
17
|
import { addDevRoot, readDevRoots, removeDevRoot } from '../domain/dev-roots.js'
|
|
16
18
|
import { applyPinDevDir, planPinDevDir } from '../domain/pin-dev-dir.js'
|
|
17
19
|
import { reconcileLockfile } from '../domain/selfupdate.js'
|
|
@@ -68,6 +70,13 @@ async function routeStateGet(req, res, rc) {
|
|
|
68
70
|
const compatPending = pendingState.pending
|
|
69
71
|
const compatVerdicts = pendingState.verdicts
|
|
70
72
|
const compatPendingRows = new Set((compatPending?.pending ?? []).filter((p) => (p.status ?? 'pending') === 'pending').map((p) => p.rowId))
|
|
73
|
+
// ★ 0.5.98 加法(用户 2026-10-28 实测):**「真待适配」的唯一口径**。
|
|
74
|
+
// 现场:compat-pending.json 里 20 条 adopted + 3 条 pending 且 moduleName 为 null(补丁行早已不存在),
|
|
75
|
+
// 面板把 `pending` 数组长度当"待适配",用户看到"有 N 行待适配",其实一行都没有。
|
|
76
|
+
// 判据只认 domain/compat-pending-records.js#isActionablePending(status=pending 且该行仍在补丁里);
|
|
77
|
+
// 「行是否存在」取的是**补丁解析的既有产物**(上面 patch 的 disables/forced/inserts),不另写正则。
|
|
78
|
+
const patchRowIds = liveRowIdsFromPatch(patch)
|
|
79
|
+
const pendingCounts = countPendingRecords(compatPending?.pending ?? [], patchRowIds)
|
|
71
80
|
// 兼容门总开关 + 自动检测(只提示不自动开)
|
|
72
81
|
const compatGate = readCompatGate()
|
|
73
82
|
const adoptable = compatGate.autoDetect ? detectAdoptablePending(ctx) : new Map()
|
|
@@ -264,7 +273,14 @@ async function routeStateGet(req, res, rc) {
|
|
|
264
273
|
} catch (error) {
|
|
265
274
|
lockAutofix = { error: error instanceof Error ? error.message : String(error), scheduled: false }
|
|
266
275
|
}
|
|
267
|
-
|
|
276
|
+
// ★ 0.5.98 加法:门控明细行补两个**只增不改**的字段 —— `rowMissing`(这一行的 rowId 已不在
|
|
277
|
+
// 当前补丁里)与 `actionable`(与计数同一个判据)。面板据此如实标注「该行已不存在」并给删除入口;
|
|
278
|
+
// 既有字段(pendingCount / rows 的全部旧字段)一个不少、语义不变。
|
|
279
|
+
const gatingOut = (() => {
|
|
280
|
+
const g = gatingSummary(ctx, compatPending, entries, compatVerdicts)
|
|
281
|
+
return { ...g, pending: annotateGateRows(g.pending, patchRowIds), adopted: annotateGateRows(g.adopted, patchRowIds) }
|
|
282
|
+
})()
|
|
283
|
+
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 }, 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 })) })
|
|
268
284
|
return
|
|
269
285
|
}
|
|
270
286
|
|
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.98",
|
|
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",
|