@noob-stupid/dsh-plugin-console 0.5.98 → 0.5.99
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 +190 -1
- package/lib/server/domain/compat-pending-records.js +100 -0
- package/lib/server/domain/compat-scan.js +106 -0
- package/lib/server/domain/framework-cleanup.js +63 -28
- package/lib/server/domain/framework-stale-repair.js +23 -8
- package/lib/server/domain/framework-stale-restore.js +215 -0
- package/lib/server/infra/paths.js +32 -3
- package/lib/server/routes/compat-scan.js +123 -0
- package/lib/server/routes/compat.js +21 -5
- package/lib/server/routes/index.js +3 -3
- package/lib/server/routes/state.js +16 -2
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
2
|
id: "@noob-stupid/dsh-plugin-console",
|
|
3
3
|
factory: (require) => {
|
|
4
4
|
var module = { exports: {} };
|
|
@@ -405,6 +405,29 @@
|
|
|
405
405
|
gateForgetDoneStale: "已清理 {n} 条失效记录(插件与补丁一个字都没动)",
|
|
406
406
|
gateForgetNone: "没有可删除的记录(文件未改动)",
|
|
407
407
|
gateForgetFailed: "删除失败:",
|
|
408
|
+
// ★ 0.5.100 加法(用户 2026-11-02):「按需检查」—— 预检只在升级前跑,平时门控是空的,
|
|
409
|
+
// 0 条会被误读成"全部兼容"。这里给一个"现在查一次"的入口 + 空态说明 + 上次检查摘要。
|
|
410
|
+
gateScanBtn: "检查当前是否适配",
|
|
411
|
+
gateScanBusy: "正在检查…",
|
|
412
|
+
gateScanTitle: "对当前补丁里的行跑一次适配判定:只记录结论,不禁用任何行",
|
|
413
|
+
gateScanNoTargets: "当前没有可检查的行(补丁里没有可判定的插件行),所以不显示检查按钮",
|
|
414
|
+
gateScanDone: "检查 {n} 行:通过 {p} / 不适配 {f} / 无法判定 {u}",
|
|
415
|
+
gateScanFailed: "检查失败:",
|
|
416
|
+
gateNoRecordsHint: "本次没有裁决记录(预检只在升级前运行,可点上面按钮检查)",
|
|
417
|
+
gateLastScan: "上次检查({at}):{n} 行 · 通过 {p} / 不适配 {f} / 无法判定 {u}",
|
|
418
|
+
// ★ 0.5.101 加法(用户 2026-11-02):**加载失败的行**(运行期挂载失败)分区 + 两段式停用。
|
|
419
|
+
// 与"适配裁决"分开:这类失败不经过预扫/隔离链,**不写 compat-pending.json**。
|
|
420
|
+
gateLoadFailedTitle: "加载失败的行({n} 行 · 运行期挂载失败)",
|
|
421
|
+
gateLoadFailedHint: "这些行是**运行期加载失败**(例:某个依赖包解析不到),不是适配裁决,也不会写进适配记录;可在此停用它们。",
|
|
422
|
+
gateLoadDisable: "停用",
|
|
423
|
+
gateLoadDisableTitle: "停用这一行(会往补丁里写一条 disabled 块;点一下先确认)",
|
|
424
|
+
gateLoadArmRow: "将停用 {id}({name})。",
|
|
425
|
+
gateLoadSafety: "这会往 profile 补丁里写一条 disabled 块(可随时在列表里重新启用);不会卸载包、不会动其它行。",
|
|
426
|
+
gateLoadConfirm: "确认停用",
|
|
427
|
+
gateLoadCancel: "取消",
|
|
428
|
+
gateLoadFailedDone: "已请求停用 {id}(补丁已更新,HMR 应用后列表会随之变化)",
|
|
429
|
+
gateLoadFailedGone: "该行已不在运行时列表里(可能已被删除),本动作没有发请求",
|
|
430
|
+
gateLoadFailedFailed: "停用失败:",
|
|
408
431
|
// ★ 0.5.x 加法(用户 2026-10-18):「安全启动」在界面上的入口 —— 门控面板里的一个分区。
|
|
409
432
|
// 用户原话「现在这能力藏得太深,只有命令行能用」;这里只做展示 + 两段式上膛,
|
|
410
433
|
// 判据与动作白名单全在服务端 POST /plugin-console/safe-boot。
|
|
@@ -1198,6 +1221,27 @@
|
|
|
1198
1221
|
gateForgetDoneStale: "Removed {n} invalid record(s) (plugins and patch file untouched)",
|
|
1199
1222
|
gateForgetNone: "Nothing to delete (file unchanged)",
|
|
1200
1223
|
gateForgetFailed: "Delete failed: ",
|
|
1224
|
+
// ★ 0.5.100 additive (user 2026-11-02): on-demand adaptation check in the gating panel.
|
|
1225
|
+
gateScanBtn: "Check current compatibility",
|
|
1226
|
+
gateScanBusy: "Checking…",
|
|
1227
|
+
gateScanTitle: "Runs the adaptation verdict over the rows in the current patch: records conclusions only, disables nothing",
|
|
1228
|
+
gateScanNoTargets: "No rows to check (the patch has no judgeable plugin rows), so the check button is not shown",
|
|
1229
|
+
gateScanDone: "Checked {n} row(s): pass {p} / incompatible {f} / undecidable {u}",
|
|
1230
|
+
gateScanFailed: "Check failed: ",
|
|
1231
|
+
gateNoRecordsHint: "No verdict records yet (the pre-scan only runs before an upgrade — use the button above to check now)",
|
|
1232
|
+
gateLastScan: "Last check ({at}): {n} row(s) · pass {p} / incompatible {f} / undecidable {u}",
|
|
1233
|
+
// ★ 0.5.101 additive (user 2026-11-02): rows that FAILED TO LOAD at runtime.
|
|
1234
|
+
gateLoadFailedTitle: "Rows that failed to load ({n} · runtime mount failure)",
|
|
1235
|
+
gateLoadFailedHint: "These rows failed to load at runtime (e.g. a dependency cannot be resolved). This is not an adaptation verdict and is never written to the adaptation records; you can disable them here.",
|
|
1236
|
+
gateLoadDisable: "Disable",
|
|
1237
|
+
gateLoadDisableTitle: "Disable this row (writes a disabled block into the patch; click once to confirm)",
|
|
1238
|
+
gateLoadArmRow: "Row {id} ({name}) will be disabled.",
|
|
1239
|
+
gateLoadSafety: "This writes one disabled block into the profile patch (you can re-enable it from the list at any time); it does not uninstall the package and does not touch other rows.",
|
|
1240
|
+
gateLoadConfirm: "Confirm disable",
|
|
1241
|
+
gateLoadCancel: "Cancel",
|
|
1242
|
+
gateLoadFailedDone: "Disable requested for {id} (the patch is updated; the list follows once HMR applies)",
|
|
1243
|
+
gateLoadFailedGone: "This row is no longer in the runtime list (it may have been removed), so no request was sent",
|
|
1244
|
+
gateLoadFailedFailed: "Disable failed: ",
|
|
1201
1245
|
gateRowAt: " · {t}",
|
|
1202
1246
|
gateRowAdoptable: " · passed the source scan, can be unlocked",
|
|
1203
1247
|
safeBootSectionTitle: "Safe boot",
|
|
@@ -5854,6 +5898,56 @@ const RESTART_FAILED_NOTE = "重启请求失败";
|
|
|
5854
5898
|
const [gateForgetArmed, setGateForgetArmed] = react.useState(null); // { kind: "row", rowId, moduleName } | { kind: "stale" }
|
|
5855
5899
|
const [gateForgetBusy, setGateForgetBusy] = react.useState(false);
|
|
5856
5900
|
const [gateForgetNote, setGateForgetNote] = react.useState(null); // { text, level }
|
|
5901
|
+
// ★ 0.5.100 加法:「按需检查」的按钮状态与结果反馈(用户 2026-11-02「平时不预检」)。
|
|
5902
|
+
// `gateScanTargets` = 当前**可检查**的行数(有 rowId 且不是控制台自己):为 0 时不摆按钮。
|
|
5903
|
+
const gateScanTargets = gateReady && Array.isArray(state.data.entries)
|
|
5904
|
+
? state.data.entries.filter((e) => typeof e?.rowId === "string" && e.rowId !== "" && e.rowId !== "plugin-console").length
|
|
5905
|
+
: 0;
|
|
5906
|
+
const gateLastScan = gateReady && state.data.compatPending !== null && state.data.compatPending !== undefined
|
|
5907
|
+
? (state.data.compatPending.lastScan ?? null)
|
|
5908
|
+
: null;
|
|
5909
|
+
const [gateScanBusy, setGateScanBusy] = react.useState(false);
|
|
5910
|
+
const [gateScanNote, setGateScanNote] = react.useState(null); // { text, level }
|
|
5911
|
+
/** 一键「检查当前是否适配」:服务端只记录结论、绝不禁用任何行;成功/失败都要有明确反馈。 */
|
|
5912
|
+
const gateScanSend = () => {
|
|
5913
|
+
if (gateScanBusy) return;
|
|
5914
|
+
setGateScanBusy(true);
|
|
5915
|
+
setGateScanNote(null);
|
|
5916
|
+
call("/plugin-console/compat-gate", { action: "scan-current" }).then(
|
|
5917
|
+
(data) => {
|
|
5918
|
+
const n = Number(data?.checked ?? 0);
|
|
5919
|
+
const p = Number(data?.pass ?? 0);
|
|
5920
|
+
const f = Number(data?.fail ?? 0);
|
|
5921
|
+
const u = Number(data?.unknown ?? 0);
|
|
5922
|
+
setGateScanNote({ text: t("gateScanDone").replace("{n}", String(n)).replace("{p}", String(p)).replace("{f}", String(f)).replace("{u}", String(u)), level: null });
|
|
5923
|
+
refresh(); // 结论落盘了 ⇒ 重拉 /state(列表、计数、"上次检查"一起变)
|
|
5924
|
+
},
|
|
5925
|
+
(error) => setGateScanNote({ text: t("gateScanFailed") + friendlyGithubError(error).message, level: "error" }),
|
|
5926
|
+
).finally(() => setGateScanBusy(false));
|
|
5927
|
+
};
|
|
5928
|
+
// ★ 0.5.101 加法(用户 2026-11-02):**运行期加载失败**的行(`fiberPhase === 'failed'`)。
|
|
5929
|
+
// 真机:`experimental-inspector` 因 `@deepseek-ai/cordis` 解析失败挂载失败,门控完全看不到
|
|
5930
|
+
// —— 那条失败不经过预扫/隔离链。这里只做**视图**(服务端 gating.loadFailed;**从不**写
|
|
5931
|
+
// compat-pending.json,那是适配裁决记录);停用仍是用户的动作(两段式,落到既有 /toggle)。
|
|
5932
|
+
const gateLoadFailed = gateReady && gateRows !== null && Array.isArray(gateRows.loadFailed) ? gateRows.loadFailed : [];
|
|
5933
|
+
const [gateLoadArmed, setGateLoadArmed] = react.useState(null); // { rowId, name }
|
|
5934
|
+
const [gateLoadNote, setGateLoadNote] = react.useState(null); // { text, level }
|
|
5935
|
+
const gateLoadEntry = (rowId) => (gateReady && Array.isArray(state.data.entries)
|
|
5936
|
+
? (state.data.entries.find((e) => e.rowId === rowId) ?? null)
|
|
5937
|
+
: null);
|
|
5938
|
+
/** 两段式第一段:只上膛(零请求)。 */
|
|
5939
|
+
const gateLoadArm = (next) => { setGateLoadNote(null); setGateLoadArmed(next); };
|
|
5940
|
+
/** 确认后才真发:走**既有** /toggle(enabled:false),成功后刷新。 */
|
|
5941
|
+
const gateLoadDisable = (armed) => {
|
|
5942
|
+
const entry = armed === null ? null : gateLoadEntry(armed.rowId);
|
|
5943
|
+
setGateLoadArmed(null);
|
|
5944
|
+
setGateLoadNote(null);
|
|
5945
|
+
if (entry === null) { setGateLoadNote({ text: t("gateLoadFailedFailed") + t("gateLoadFailedGone"), level: "error" }); return; }
|
|
5946
|
+
call("/plugin-console/toggle", { entryId: entry.entryId, enabled: false }).then(
|
|
5947
|
+
() => { setGateLoadNote({ text: t("gateLoadFailedDone").replace("{id}", String(armed.rowId)), level: null }); refresh(); },
|
|
5948
|
+
(error) => setGateLoadNote({ text: t("gateLoadFailedFailed") + friendlyGithubError(error).message, level: "error" }),
|
|
5949
|
+
);
|
|
5950
|
+
};
|
|
5857
5951
|
/** 两段式的第一段:只上膛,一个请求都不发(换目标 = 换载荷,旧载荷不会跟着发出去)。 */
|
|
5858
5952
|
const gateForgetArm = (next) => { setGateForgetNote(null); setGateForgetArmed(next); };
|
|
5859
5953
|
/** 唯一出口:真发删除请求(成功后刷新 /state,列表与计数一起变)。 */
|
|
@@ -6808,6 +6902,43 @@ const RESTART_FAILED_NOTE = "重启请求失败";
|
|
|
6808
6902
|
el("p", { className: styles.message }, t("compatGateHint")),
|
|
6809
6903
|
el("p", { className: styles.status, "data-pending": gatePendingCount > 0 ? "true" : undefined, "data-gate-pending": String(gatePendingCount), "data-gate-stale": String(gateStaleCount) },
|
|
6810
6904
|
gatePendingCount > 0 ? t("gatePendingInfo").replace("{n}", String(gatePendingCount)) : t("gatePendingNone")),
|
|
6905
|
+
// ★ 0.5.100 加法(用户 2026-11-02「我们一般情况下都不预检吧」):
|
|
6906
|
+
// 预检只在**升级前**跑,所以平时门控是空的 —— 而 0 条会被误读成"全部兼容"。
|
|
6907
|
+
// 这里给两件事:① 一个「现在查一次」的按钮(判据复用服务端框架升级预扫那条链,
|
|
6908
|
+
// **只记录结论、不禁用任何行**);② 没有裁决记录时的说明 + 上次检查的摘要。
|
|
6909
|
+
// 零行可查时**不摆按钮**,并说明为什么(不许出现点了必然没反应的按钮)。
|
|
6910
|
+
gateScanTargets === 0
|
|
6911
|
+
? el("p", { className: styles.status, "data-gate-scan-none": "true" }, t("gateScanNoTargets"))
|
|
6912
|
+
: el("div", { style: { flexDirection: "row", gap: 8, display: "flex", alignItems: "center" } },
|
|
6913
|
+
el("button", {
|
|
6914
|
+
type: "button",
|
|
6915
|
+
className: styles.toggle,
|
|
6916
|
+
"aria-label": "gateScanCurrent",
|
|
6917
|
+
disabled: gateScanBusy,
|
|
6918
|
+
title: t("gateScanTitle"),
|
|
6919
|
+
onClick: () => { gateScanSend(); },
|
|
6920
|
+
}, gateScanBusy ? t("gateScanBusy") : t("gateScanBtn"))),
|
|
6921
|
+
gateScanNote === null
|
|
6922
|
+
? null
|
|
6923
|
+
: el("p", {
|
|
6924
|
+
className: styles.message,
|
|
6925
|
+
"data-gate-scan-note": "true",
|
|
6926
|
+
"data-error": gateScanNote.level === "error" ? "true" : undefined,
|
|
6927
|
+
style: gateScanNote.level === "error" ? undefined : { color: "var(--dsw-alias-state-success-primary)" },
|
|
6928
|
+
}, String(gateScanNote.text ?? "—")),
|
|
6929
|
+
gateLastScan === null
|
|
6930
|
+
? null
|
|
6931
|
+
: el("p", { className: styles.status, "data-gate-lastscan": "true" },
|
|
6932
|
+
t("gateLastScan")
|
|
6933
|
+
.replace("{at}", gateTime(gateLastScan.at))
|
|
6934
|
+
.replace("{n}", String(gateLastScan.checked ?? 0))
|
|
6935
|
+
.replace("{p}", String(gateLastScan.pass ?? 0))
|
|
6936
|
+
.replace("{f}", String(gateLastScan.fail ?? 0))
|
|
6937
|
+
.replace("{u}", String(gateLastScan.unknown ?? 0))),
|
|
6938
|
+
// 门控为空时的明确文案:不让 0 条看起来像"全绿"(用户 2026-11-02 点名)
|
|
6939
|
+
gateRows === null || gateRows.pendingCount + gateRows.adoptedCount === 0
|
|
6940
|
+
? el("p", { className: styles.status, "data-gate-empty-hint": "true" }, t("gateNoRecordsHint"))
|
|
6941
|
+
: null,
|
|
6811
6942
|
// 明细清单(2026-09-24 用户要求:面板要能看到是哪些插件,有列表数据)
|
|
6812
6943
|
gateRows !== null && gateRows.pendingCount + gateRows.adoptedCount > 0
|
|
6813
6944
|
? el("div", { style: { flexDirection: "column", gap: 6, display: "flex" } },
|
|
@@ -6890,6 +7021,64 @@ const RESTART_FAILED_NOTE = "重启请求失败";
|
|
|
6890
7021
|
: null)
|
|
6891
7022
|
: null)
|
|
6892
7023
|
: null,
|
|
7024
|
+
// ★ 0.5.101 加法(用户 2026-11-02):**加载失败的行**分区。
|
|
7025
|
+
// 与"适配裁决"是两件事:这些行是**运行期挂载失败**(例:某个依赖包解析不到),
|
|
7026
|
+
// 不经过预扫/隔离链,所以也**不会写进 compat-pending.json**;这里给的是视图 +
|
|
7027
|
+
// 「停用」(两段式,落到既有 /toggle,真的写补丁 disabled 块)。
|
|
7028
|
+
// 零失败行时整个分区不出现(不摆空壳)。
|
|
7029
|
+
gateLoadFailed.length === 0
|
|
7030
|
+
? null
|
|
7031
|
+
: el("div", {
|
|
7032
|
+
"data-gate-loadfailed": "true",
|
|
7033
|
+
style: { flexDirection: "column", gap: 6, display: "flex", marginTop: 4, paddingTop: 8, borderTop: "1px solid var(--dsw-alias-border-l2)" },
|
|
7034
|
+
},
|
|
7035
|
+
el("strong", { className: styles.name }, t("gateLoadFailedTitle").replace("{n}", String(gateLoadFailed.length))),
|
|
7036
|
+
el("p", { className: styles.status }, t("gateLoadFailedHint")),
|
|
7037
|
+
gateLoadFailed.map((r) => el("div", {
|
|
7038
|
+
key: "lf-" + String(r.rowId),
|
|
7039
|
+
"data-gate-loadfailed-row": String(r.rowId),
|
|
7040
|
+
style: { flexDirection: "column", gap: 2, display: "flex", padding: "4px 0" },
|
|
7041
|
+
},
|
|
7042
|
+
el("span", { className: styles.name }, "⛔ " + String(r.moduleName ?? r.rowId) + (r.version ? " v" + r.version : "")),
|
|
7043
|
+
el("span", { className: styles.status, "data-gate-loadfailed-module": "true" },
|
|
7044
|
+
t("gateRowModule") + " " + (typeof r.moduleName === "string" && r.moduleName !== "" ? r.moduleName : "—")
|
|
7045
|
+
+ " · " + String(r.fiberPhase ?? "failed"),
|
|
7046
|
+
el("button", {
|
|
7047
|
+
type: "button",
|
|
7048
|
+
className: styles.toggle,
|
|
7049
|
+
style: { marginLeft: 8, fontSize: 11, padding: "1px 6px" },
|
|
7050
|
+
"aria-label": "gateLoadDisable",
|
|
7051
|
+
"data-gate-load-disable": String(r.rowId),
|
|
7052
|
+
title: t("gateLoadDisableTitle"),
|
|
7053
|
+
onClick: () => gateLoadArm({ rowId: String(r.rowId), name: typeof r.moduleName === "string" ? r.moduleName : null }),
|
|
7054
|
+
}, t("gateLoadDisable"))))),
|
|
7055
|
+
gateLoadArmed === null
|
|
7056
|
+
? null
|
|
7057
|
+
: el("div", { "data-gate-load-armed": "true", style: { flexDirection: "column", gap: 6, display: "flex", borderLeft: "2px solid var(--dsw-alias-state-warning-primary)", paddingLeft: 8 } },
|
|
7058
|
+
el("p", { className: styles.message, "data-warn": "true", "data-gate-load-text": "true" },
|
|
7059
|
+
t("gateLoadArmRow").replace("{id}", String(gateLoadArmed.rowId)).replace("{name}", String(gateLoadArmed.name ?? "—"))),
|
|
7060
|
+
el("p", { className: styles.status }, t("gateLoadSafety")),
|
|
7061
|
+
el("div", { style: { flexDirection: "row", gap: 8, display: "flex" } },
|
|
7062
|
+
el("button", {
|
|
7063
|
+
type: "button",
|
|
7064
|
+
className: styles.toggle,
|
|
7065
|
+
"aria-label": "gateLoadConfirm",
|
|
7066
|
+
onClick: () => { gateLoadDisable(gateLoadArmed); },
|
|
7067
|
+
}, t("gateLoadConfirm")),
|
|
7068
|
+
el("button", {
|
|
7069
|
+
type: "button",
|
|
7070
|
+
className: styles.toggle,
|
|
7071
|
+
"aria-label": "gateLoadCancel",
|
|
7072
|
+
onClick: () => { setGateLoadArmed(null); setGateLoadNote(null); },
|
|
7073
|
+
}, t("gateLoadCancel")))),
|
|
7074
|
+
gateLoadNote === null
|
|
7075
|
+
? null
|
|
7076
|
+
: el("p", {
|
|
7077
|
+
className: styles.message,
|
|
7078
|
+
"data-gate-load-note": "true",
|
|
7079
|
+
"data-error": gateLoadNote.level === "error" ? "true" : undefined,
|
|
7080
|
+
style: gateLoadNote.level === "error" ? undefined : { color: "var(--dsw-alias-state-success-primary)" },
|
|
7081
|
+
}, String(gateLoadNote.text ?? "—"))),
|
|
6893
7082
|
// ★ 0.5.98 加法:删除的**两段式**确认区 + 结果反馈(都留在面板上,不弹系统对话框)。
|
|
6894
7083
|
// 上膛时把"要删什么、不会动什么"写清楚:只删记录,不动插件、不动补丁文件。
|
|
6895
7084
|
gateForgetArmed === null
|
|
@@ -241,6 +241,104 @@ function applyPendingForget(plan, deps = {}) {
|
|
|
241
241
|
return { ...plan, backup, verified: true, rolledBack: false }
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
+
/**
|
|
245
|
+
* 规划一次「把扫描结论并进记录」(**纯函数,不写盘**;0.5.100 加法)。
|
|
246
|
+
*
|
|
247
|
+
* 语义(只加不删):
|
|
248
|
+
* · 传入的记录按 `rowId` **合并**进 `pending` 数组:已有则覆盖结论字段(并保持**同一条记录**的位置),
|
|
249
|
+
* 没有则追加;其余记录(含历史 adopted、别人的记录)**一个字都不动**;
|
|
250
|
+
* · 只接受 `status`/`check`/`checkNote`/`version`/`moduleName`/`source`/`scanVerdict` 这些结论字段的更新;
|
|
251
|
+
* · 额外写一条 `lastScan` 摘要(`{ at, frameworkVersion, checked, pass, fail, unknown, skipped, source }`)——
|
|
252
|
+
* 它让面板能说"上次检查了几个、结果如何",而不是让 0 条记录看起来像"全绿";
|
|
253
|
+
* · 没有变化时 `changed:false` ⇒ 调用方**零写盘**(幂等)。
|
|
254
|
+
*/
|
|
255
|
+
function planScanMerge({ read, records, lastScan, frameworkVersion = null } = {}) {
|
|
256
|
+
const base = { ok: false, status: 400, error: null, file: read?.file ?? null, next: null, changed: false, added: [], updated: [], lastScan: lastScan ?? null }
|
|
257
|
+
if (read === null || typeof read !== 'object') return { ...base, error: '内部错误:没有读到记录文件' }
|
|
258
|
+
if (read.exists === true && read.corrupt === true) {
|
|
259
|
+
return { ...base, status: 409, error: `${read.file} 读不出来(${read.error ?? '格式非法'});为保住你的数据,本次扫描结果**没有写盘**(请先手工修好或删掉它)` }
|
|
260
|
+
}
|
|
261
|
+
const incoming = (Array.isArray(records) ? records : []).filter((r) => r !== null && typeof r === 'object' && typeof r.rowId === 'string' && r.rowId !== '')
|
|
262
|
+
const raw = read.raw === null || typeof read.raw !== 'object' ? { pending: [] } : read.raw
|
|
263
|
+
const current = (Array.isArray(raw.pending) ? raw.pending : []).filter((r) => r !== null && typeof r === 'object')
|
|
264
|
+
const next = { ...raw, pending: [...current] }
|
|
265
|
+
const added = []
|
|
266
|
+
const updated = []
|
|
267
|
+
// 「结论」= 只有这些字段变了才算真的变了;`scannedAt` 之类的时间戳**不算**变化 ——
|
|
268
|
+
// 否则每点一次「检查」都会重写一遍文件(也就每次留一份备份),幂等就名存实亡了。
|
|
269
|
+
const conclusionKey = (r) => JSON.stringify([
|
|
270
|
+
r?.status ?? null, r?.check ?? null, r?.checkNote ?? null, r?.version ?? null,
|
|
271
|
+
r?.moduleName ?? null, r?.source ?? null, r?.frameworkVersion ?? null,
|
|
272
|
+
r?.scanVerdict?.version ?? null, r?.scanVerdict?.decision ?? null,
|
|
273
|
+
])
|
|
274
|
+
for (const rec of incoming) {
|
|
275
|
+
const at = next.pending.findIndex((p) => p?.rowId === rec.rowId)
|
|
276
|
+
if (at < 0) {
|
|
277
|
+
next.pending.push({ ...rec })
|
|
278
|
+
added.push(rec.rowId)
|
|
279
|
+
continue
|
|
280
|
+
}
|
|
281
|
+
const before = next.pending[at]
|
|
282
|
+
if (conclusionKey(before) === conclusionKey(rec)) continue // 结论没变 ⇒ 这条记录一个字都不动
|
|
283
|
+
next.pending[at] = { ...before, ...rec }
|
|
284
|
+
updated.push(rec.rowId)
|
|
285
|
+
}
|
|
286
|
+
if (typeof frameworkVersion === 'string' && frameworkVersion !== '') next.frameworkVersion = frameworkVersion
|
|
287
|
+
// lastScan 摘要:只在**摘要本身**变了(或还没有摘要)时才更新 —— 时间戳不算变化
|
|
288
|
+
if (lastScan !== null && lastScan !== undefined) {
|
|
289
|
+
const summaryKey = (s) => (s === null || s === undefined ? '' : JSON.stringify([s.checked, s.pass, s.fail, s.unknown, s.skipped, s.frameworkVersion, s.source]))
|
|
290
|
+
if (summaryKey(raw.lastScan) !== summaryKey(lastScan)) next.lastScan = lastScan
|
|
291
|
+
}
|
|
292
|
+
// 文件还不存在、也没有任何结论要写 ⇒ **连文件都不建**(零行可查时不留空壳文件)
|
|
293
|
+
if (read.exists !== true && added.length === 0 && updated.length === 0) {
|
|
294
|
+
return { ...base, ok: true, status: 200, next: raw, changed: false, added, updated }
|
|
295
|
+
}
|
|
296
|
+
const changed = JSON.stringify(next) !== JSON.stringify(raw)
|
|
297
|
+
return { ...base, ok: true, status: 200, next: changed ? next : raw, changed, added, updated }
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* 执行一次「整份记录的安全写入」:备份 → 原子写(临时文件 + rename)→ **读回核实** → 失败回滚。
|
|
302
|
+
* `verify(back)` 返回 `null` 表示核实通过,返回字符串表示为什么不通过(写进错误信息)。
|
|
303
|
+
* 与 `applyPendingForget` 同一套纪律,只是判据由调用方给(扫描合并与删除要核实的东西不一样)。
|
|
304
|
+
*/
|
|
305
|
+
function applyPendingWrite({ file, next, verify = null, tag = 'scan', deps = {} } = {}) {
|
|
306
|
+
const plan = { ok: true, status: 200, error: null, file: typeof file === 'string' ? file : null, changed: true, backup: null, verified: false, rolledBack: false }
|
|
307
|
+
if (plan.file === null || next === null || typeof next !== 'object') {
|
|
308
|
+
return { ...plan, ok: false, status: 400, error: '内部错误:缺少目标文件或内容' }
|
|
309
|
+
}
|
|
310
|
+
const io = {
|
|
311
|
+
copy: deps.copyFileSync ?? copyFileSync,
|
|
312
|
+
write: deps.writeFileSync ?? writeFileSync,
|
|
313
|
+
rename: deps.renameSync ?? renameSync,
|
|
314
|
+
remove: deps.unlinkSync ?? unlinkSync,
|
|
315
|
+
exists: deps.existsSync ?? existsSync,
|
|
316
|
+
read: deps.readFileSync ?? readFileSync,
|
|
317
|
+
}
|
|
318
|
+
const stamp = Date.now()
|
|
319
|
+
const backup = `${plan.file}.bak-${tag}-${stamp}`
|
|
320
|
+
const tmp = `${plan.file}.tmp-${tag}-${stamp}`
|
|
321
|
+
const rollback = (extra) => {
|
|
322
|
+
try { if (io.exists(backup)) io.copy(backup, plan.file) } catch {}
|
|
323
|
+
try { if (io.exists(tmp)) io.remove(tmp) } catch {}
|
|
324
|
+
return { ...plan, ...extra, backup, verified: false, rolledBack: io.exists(backup) }
|
|
325
|
+
}
|
|
326
|
+
try {
|
|
327
|
+
if (io.exists(plan.file)) io.copy(plan.file, backup)
|
|
328
|
+
io.write(tmp, `${JSON.stringify(next, null, 2)}\n`, 'utf8')
|
|
329
|
+
io.rename(tmp, plan.file)
|
|
330
|
+
} catch (error) {
|
|
331
|
+
return rollback({ ok: false, status: 500, error: `写入 ${plan.file} 失败:${String(error?.message ?? error)}(已回滚,文件保持原样)` })
|
|
332
|
+
}
|
|
333
|
+
let back = null
|
|
334
|
+
try { back = JSON.parse(io.read(plan.file, 'utf8')) } catch (error) {
|
|
335
|
+
return rollback({ ok: false, status: 500, error: `写入后读回失败:${String(error?.message ?? error)}(已回滚)` })
|
|
336
|
+
}
|
|
337
|
+
const why = typeof verify === 'function' ? verify(back) : null
|
|
338
|
+
if (why !== null && why !== undefined) return rollback({ ok: false, status: 500, error: `写入后读回内容与期望不一致(${String(why)}),已回滚` })
|
|
339
|
+
return { ...plan, backup, verified: true, rolledBack: false }
|
|
340
|
+
}
|
|
341
|
+
|
|
244
342
|
export {
|
|
245
343
|
PENDING_STATUS,
|
|
246
344
|
liveRowIdsFromPatch,
|
|
@@ -252,4 +350,6 @@ export {
|
|
|
252
350
|
planForgetRecord,
|
|
253
351
|
planForgetStale,
|
|
254
352
|
applyPendingForget,
|
|
353
|
+
planScanMerge,
|
|
354
|
+
applyPendingWrite,
|
|
255
355
|
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// L1 · domain —— compat-scan.js(门控的「按需检查」:对当前补丁里的行跑一次适配判定;0.5.100 加法)
|
|
2
|
+
//
|
|
3
|
+
// ── 为什么需要(用户 2026-11-02 原话:「我们一般情况下都不预检吧」)──────────────────────────
|
|
4
|
+
// compat-pending 只在**框架升级前预扫**与**启动失败隔离**时写入。平时门控里一条记录都没有,
|
|
5
|
+
// 而"0 条"会被误读成"全部兼容" —— 这不是事实,只是**没人裁决过**。
|
|
6
|
+
// 这个模块给门控一个「现在查一下」的入口:对当前补丁里的行逐行跑判定,把结论如实记下来。
|
|
7
|
+
//
|
|
8
|
+
// ── 三条硬约束(一条都不许放宽)──────────────────────────────────────────────────────
|
|
9
|
+
// ① **判据只有一份**:复用 `checkPluginFrameworkCompat`(框架升级预扫那条唯一链路里的判定函数,
|
|
10
|
+
// 含"源码里是否还引用已删除的 dsh-settings API"这条硬判据)。这里不新增任何兼容判据。
|
|
11
|
+
// ② **只记录结论,绝不禁用任何行**:禁用是用户的动作(`/toggle`)。本模块不 import
|
|
12
|
+
// `disableEntry`、不碰补丁文件 —— 扫描前后补丁必须逐字节不变。
|
|
13
|
+
// ③ **只把判为不适配(fail)的行写进记录**:pass / unknown 只进统计。
|
|
14
|
+
// 为什么不能全写:`status:'pending'` 的记录会让 `/toggle` 对该行走"软禁"确认流程(409 +
|
|
15
|
+
// 风险确认),把通过的行也写进去等于凭空给用户加一道墙 —— 那是行为变更,不是记录。
|
|
16
|
+
//
|
|
17
|
+
// 读/写 compat-pending.json 的纪律在 domain/compat-pending-records.js(备份 → 原子写 → 读回核实 → 回滚)。
|
|
18
|
+
import { readFileSync } from 'node:fs'
|
|
19
|
+
import { dirname } from 'node:path'
|
|
20
|
+
import { resolvePackageJson } from '../infra/paths.js'
|
|
21
|
+
import { SCANNER_VERSION } from './compat-verdict.js'
|
|
22
|
+
import { checkPluginFrameworkCompat, isFrameworkOwnedPackage } from './compat.js'
|
|
23
|
+
|
|
24
|
+
/** 记录里标注的来源(面板/日志靠它区分"这次是谁裁决的")。 */
|
|
25
|
+
const SCAN_SOURCE = 'manual-scan'
|
|
26
|
+
/** 控制台自己永不参与扫描(它要是被判 fail,用户连面板都打不开)。 */
|
|
27
|
+
const SELF_ROW_ID = 'plugin-console'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 对**当前 loader 行**逐行跑一次适配判定(纯读:不写盘、不禁用、不改补丁)。
|
|
31
|
+
*
|
|
32
|
+
* @param {object} p
|
|
33
|
+
* @param {Array<object>} p.entries loader 条目(`listEntries()` 的结果)
|
|
34
|
+
* @param {string} p.profileDir profile 目录(解析已装包用)
|
|
35
|
+
* @param {string|null} p.frameworkVersion 当前框架版本(拿不到 => 全部 unknown,如实标注)
|
|
36
|
+
* @param {object} [p.deps] 注入缝:`resolvePkg` / `readPkg`
|
|
37
|
+
* @returns {{ ok: boolean, results: Array<object>, checked: number, pass: number, fail: number, unknown: number,
|
|
38
|
+
* skipped: Array<{rowId: string|null, reason: string}>, frameworkVersion: string|null, error: string|null }}
|
|
39
|
+
*/
|
|
40
|
+
function planCurrentScan({ entries, profileDir, frameworkVersion = null, deps = {} } = {}) {
|
|
41
|
+
const resolvePkg = deps.resolvePkg ?? resolvePackageJson
|
|
42
|
+
const readPkg = deps.readPkg ?? ((p) => JSON.parse(readFileSync(p, 'utf8')))
|
|
43
|
+
const results = []
|
|
44
|
+
const skipped = []
|
|
45
|
+
const fwVer = typeof frameworkVersion === 'string' && frameworkVersion !== '' ? frameworkVersion : null
|
|
46
|
+
for (const entry of Array.isArray(entries) ? entries : []) {
|
|
47
|
+
const rowId = typeof entry?.rowId === 'string' ? entry.rowId : ''
|
|
48
|
+
if (rowId === '') { skipped.push({ rowId: null, reason: 'no-row-id' }); continue }
|
|
49
|
+
if (rowId === SELF_ROW_ID) { skipped.push({ rowId, reason: 'console-self' }); continue }
|
|
50
|
+
const moduleName = typeof entry?.moduleName === 'string' ? entry.moduleName : ''
|
|
51
|
+
let pkgPath = null
|
|
52
|
+
try { pkgPath = moduleName === '' ? null : resolvePkg(moduleName, profileDir) } catch { pkgPath = null }
|
|
53
|
+
if (pkgPath === null) { skipped.push({ rowId, reason: 'package-unreadable', moduleName }); continue }
|
|
54
|
+
let pkg = null
|
|
55
|
+
try { pkg = readPkg(pkgPath) } catch { pkg = null }
|
|
56
|
+
if (pkg === null || typeof pkg !== 'object') { skipped.push({ rowId, reason: 'package-unreadable', moduleName }); continue }
|
|
57
|
+
const pkgDir = dirname(pkgPath)
|
|
58
|
+
let check = null
|
|
59
|
+
if (fwVer === null) check = { decision: 'unknown', reason: '拿不到当前框架版本(无法判定,不是"兼容")' }
|
|
60
|
+
else {
|
|
61
|
+
try { check = checkPluginFrameworkCompat(pkg, fwVer, pkgDir) } catch (error) { check = { decision: 'unknown', reason: '判定出错:' + String(error?.message ?? error) } }
|
|
62
|
+
}
|
|
63
|
+
const decision = check?.decision === 'pass' || check?.decision === 'fail' ? check.decision : 'unknown'
|
|
64
|
+
results.push({
|
|
65
|
+
rowId,
|
|
66
|
+
moduleName: moduleName === '' ? null : moduleName,
|
|
67
|
+
version: typeof pkg.version === 'string' ? pkg.version : null,
|
|
68
|
+
decision,
|
|
69
|
+
reason: check?.reason ?? null,
|
|
70
|
+
frameworkOwned: isFrameworkOwnedPackage(pkgDir, profileDir),
|
|
71
|
+
enabled: entry?.enabled === true,
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
const count = (d) => results.filter((r) => r.decision === d).length
|
|
75
|
+
return {
|
|
76
|
+
ok: true,
|
|
77
|
+
results,
|
|
78
|
+
checked: results.length,
|
|
79
|
+
pass: count('pass'),
|
|
80
|
+
fail: count('fail'),
|
|
81
|
+
unknown: count('unknown'),
|
|
82
|
+
skipped,
|
|
83
|
+
frameworkVersion: fwVer,
|
|
84
|
+
error: fwVer === null ? '拿不到当前框架版本:本次所有行都只能判成"无法判定"' : null,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 要写进 compat-pending 的记录(**只有 fail**;pass/unknown 只进统计,见模块头注释 ③)。 */
|
|
89
|
+
function recordsFromScan(scan, { at = Date.now() } = {}) {
|
|
90
|
+
const list = Array.isArray(scan?.results) ? scan.results : []
|
|
91
|
+
return list.filter((r) => r.decision === 'fail').map((r) => ({
|
|
92
|
+
rowId: r.rowId,
|
|
93
|
+
moduleName: r.moduleName,
|
|
94
|
+
version: r.version,
|
|
95
|
+
status: 'pending',
|
|
96
|
+
check: 'fail',
|
|
97
|
+
checkNote: r.reason,
|
|
98
|
+
source: SCAN_SOURCE,
|
|
99
|
+
scannedAt: at,
|
|
100
|
+
frameworkVersion: scan?.frameworkVersion ?? null,
|
|
101
|
+
// 判据戳:这次用的就是**当前**判据(SCANNER_VERSION),所以"读取代发"不会把它当过期结论重算
|
|
102
|
+
scanVerdict: { version: SCANNER_VERSION, decision: 'fail', reason: r.reason, at, previous: null, rescinded: false, evidence: null },
|
|
103
|
+
}))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export { SCAN_SOURCE, SELF_ROW_ID, planCurrentScan, recordsFromScan }
|
|
@@ -35,6 +35,8 @@ import { basename, dirname, join, resolve, sep } from 'node:path'
|
|
|
35
35
|
import { removeDirVerifiedAsync } from '../infra/fsx.js'
|
|
36
36
|
import { dshHome, resolvePackageJson } from '../infra/paths.js'
|
|
37
37
|
import { FRAMEWORK_BOOT_PACKAGES, classifyTopLevel, planBackupRetention, relinkTargetCandidates } from './framework-residuals.js'
|
|
38
|
+
// 0.5.99 改错:改名残留能不能恢复的**判据**(可用 = 目录里有 package.json;候选包名必须与真名一致)
|
|
39
|
+
import { emptyShellName, isUsablePackageDir, planStaleRestore } from './framework-stale-restore.js'
|
|
38
40
|
|
|
39
41
|
/** 目录体积(失败返回 0;只用于报告「能释放多少」)。 */
|
|
40
42
|
function dirSizeBytes(dir) {
|
|
@@ -424,54 +426,87 @@ function readRollbackReferenced({ home = undefined, deps = {} } = {}) {
|
|
|
424
426
|
} catch { return [] }
|
|
425
427
|
}
|
|
426
428
|
/**
|
|
427
|
-
* 升级失败后的补偿:把 `<name>.stale-<ver>-<ts>`
|
|
429
|
+
* 升级失败后的补偿:把 `<name>.stale-<ver>-<ts>` **改回真名**。
|
|
428
430
|
*
|
|
429
431
|
* 背景(2026-10-28 真机):升级在「拉起」阶段失败,向上层报「自动回滚未生效」,而清扫步
|
|
430
432
|
* 已经把 10 个框架包改名成 `.stale-*`、真名处什么都没有。结果框架起不来:
|
|
431
433
|
* `Cannot find package '@deepseek-ai/cordis' imported from @deepseek-ai/dsh-app-boot`
|
|
432
434
|
* 那一刻用户只能手改目录名 —— 这个函数就是那条手改的自动化版本。
|
|
433
435
|
*
|
|
434
|
-
*
|
|
436
|
+
* ★ 0.5.99 改错(真机 2026-11-02,用户实测):旧判据是「真名**缺失**才恢复;真名存在就跳过」,
|
|
437
|
+
* 而现场恰恰是「真名存在但是**空壳**(没有 package.json),内容在 `.stale-*` 里」⇒ 被跳过 ⇒
|
|
438
|
+
* 框架一直解析不到包(`ERR_MODULE_NOT_FOUND`),用户只能手工把 178 个空壳恢复回来。
|
|
439
|
+
* 现在判据收敛到 domain/framework-stale-restore.js:**可用 = 目录里有 package.json**,
|
|
440
|
+
* 空壳在判据里与"缺失"同等对待;候选还必须 `package.json.name === <scope>/<real>`(防张冠李戴)。
|
|
441
|
+
* 空壳**挪成 `<real>.empty-<ts>`**(挪开不删,可回溯),恢复后**读回核实**,不成就回滚并如实报。
|
|
442
|
+
*
|
|
443
|
+
* 纪律(一条都没放宽):
|
|
435
444
|
* · **绝不删除**任何东西(只 rename);
|
|
436
|
-
* ·
|
|
437
|
-
* ·
|
|
438
|
-
* ·
|
|
445
|
+
* · 真名**可用**就跳过(宁可不动,也不覆盖可能更新的目录)—— 健康树上零动作(幂等);
|
|
446
|
+
* · 只看 `@deepseek-ai` 这一层(顶层 scope),不递归、不碰 `.pnpm` 内部;
|
|
447
|
+
* · 三类结果分开回报:`restored` / `noCandidate`(真缺内容,无可用候选)/ `failed`(名字不符、改名失败、读回不符)。
|
|
439
448
|
*/
|
|
440
|
-
function restoreStaleBackups({ scopeDir, deps = {} } = {}) {
|
|
449
|
+
function restoreStaleBackups({ scopeDir, stamp = null, deps = {} } = {}) {
|
|
441
450
|
const exists = deps.exists ?? existsSync
|
|
442
|
-
const readdir = deps.readdir ?? ((dir) => readdirSync(dir, { withFileTypes: true }))
|
|
443
451
|
const rename = deps.rename ?? renameSync
|
|
444
452
|
const restored = []
|
|
445
453
|
const skipped = []
|
|
454
|
+
const noCandidate = []
|
|
455
|
+
const emptied = []
|
|
446
456
|
const failed = []
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
const missing =
|
|
451
|
-
return { ok: missing, restored, skipped, failed, problem: missing ? null :
|
|
457
|
+
const io = { lstat: deps.lstat, readText: deps.readText, readdir: deps.readdir }
|
|
458
|
+
const plan = planStaleRestore({ scopeDir, deps: io })
|
|
459
|
+
if (plan.problem !== null) {
|
|
460
|
+
const missing = typeof scopeDir === 'string' && scopeDir !== '' && !exists(scopeDir)
|
|
461
|
+
return { ok: missing, restored, skipped, noCandidate, emptied, failed, problem: missing ? null : plan.problem }
|
|
452
462
|
}
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
const
|
|
456
|
-
if (
|
|
457
|
-
|
|
458
|
-
if (
|
|
459
|
-
|
|
460
|
-
const source = join(scopeDir,
|
|
461
|
-
let
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
463
|
+
const ts = stamp === null ? String(Date.now()) : String(stamp)
|
|
464
|
+
for (const item of plan.items) {
|
|
465
|
+
const target = join(scopeDir, item.real)
|
|
466
|
+
if (item.action === 'skip') { skipped.push({ name: item.candidates[0], real: item.real, reason: 'real-name-exists' }); continue }
|
|
467
|
+
if (item.action === 'noCandidate') { noCandidate.push({ real: item.real, candidates: item.candidates, reason: item.reason, detail: item.detail, unusable: item.unusable ?? [] }); continue }
|
|
468
|
+
if (item.action === 'failed') { failed.push({ name: item.candidates[0] ?? item.real, real: item.real, reason: item.reason, error: item.detail }); continue }
|
|
469
|
+
// ① 空壳(存在但不可用)先挪开 —— **改个名,不删**;挪不动(或改名没生效)就整条不动
|
|
470
|
+
const source = join(scopeDir, item.staleName)
|
|
471
|
+
let emptyFull = null
|
|
472
|
+
if (item.realOccupied && item.realUsable !== true) {
|
|
473
|
+
const aside = join(scopeDir, emptyShellName(item.real, ts))
|
|
474
|
+
try {
|
|
475
|
+
rename(target, aside)
|
|
476
|
+
} catch (error) {
|
|
477
|
+
failed.push({ name: item.staleName, real: item.real, reason: 'empty-shell-move-failed', error: `真名是空壳但挪不开(${errText(error)})—— 本次不动它` })
|
|
478
|
+
continue
|
|
479
|
+
}
|
|
480
|
+
// 挪开这个动作也要核实:注入桩/文件系统异常下"调用成功但没生效"不能当成功(否则报告会编造 emptied)
|
|
481
|
+
if (exists(target) || !exists(aside)) {
|
|
482
|
+
failed.push({ name: item.staleName, real: item.real, reason: 'empty-shell-move-failed', error: '把空壳挪成 .empty-* 没有生效(真名处仍有东西)—— 本次不动它' })
|
|
483
|
+
continue
|
|
484
|
+
}
|
|
485
|
+
emptyFull = aside
|
|
466
486
|
}
|
|
487
|
+
// ② 候选 → 真名
|
|
467
488
|
try {
|
|
468
489
|
rename(source, target)
|
|
469
|
-
restored.push({ from: name, to: real })
|
|
470
490
|
} catch (error) {
|
|
471
|
-
|
|
491
|
+
// 挪开的空壳放回去(尽力而为;失败也要写在 failed 里,绝不静默)
|
|
492
|
+
let back = true
|
|
493
|
+
if (emptyFull !== null) { try { rename(emptyFull, target) } catch { back = false } }
|
|
494
|
+
failed.push({ name: item.staleName, real: item.real, reason: 'rename-failed', error: `改回真名失败:${errText(error)}${emptyFull === null ? '' : (back ? '(空壳已放回原处)' : '(**空壳没能放回原处**,现场可能不一致)')}` })
|
|
495
|
+
continue
|
|
496
|
+
}
|
|
497
|
+
// ③ 读回核实:真名目录里必须真的有 package.json("改了名"不等于"能解析")
|
|
498
|
+
if (isUsablePackageDir(target, io)) {
|
|
499
|
+
if (emptyFull !== null) emptied.push({ from: item.real, to: basename(emptyFull) })
|
|
500
|
+
restored.push({ from: item.staleName, to: item.real, emptied: emptyFull === null ? null : basename(emptyFull) })
|
|
501
|
+
continue
|
|
472
502
|
}
|
|
503
|
+
// ④ 核实不过:把候选改回去、空壳放回原处,如实报失败(绝不谎报已恢复)
|
|
504
|
+
let rolledBack = true
|
|
505
|
+
try { if (!exists(source)) rename(target, source) } catch { rolledBack = false }
|
|
506
|
+
if (emptyFull !== null) { try { rename(emptyFull, target) } catch { rolledBack = false } }
|
|
507
|
+
failed.push({ name: item.staleName, real: item.real, reason: 'verify-failed', error: `改名后读回核实不通过(${item.real} 里仍读不到 package.json)${rolledBack ? ';已回滚到操作前' : ';**回滚未完成**,现场可能不一致'}` })
|
|
473
508
|
}
|
|
474
|
-
return { ok: failed.length === 0, restored, skipped, failed, problem: null }
|
|
509
|
+
return { ok: failed.length === 0, restored, skipped, noCandidate, emptied, failed, problem: null }
|
|
475
510
|
}
|
|
476
511
|
|
|
477
512
|
export { cleanFrameworkBackups, cleanFrameworkTopLevel, dirSizeBytes, isLinkEntry, readRollbackReferenced, removeLinkSafe, reportFrameworkEntities, resolveFrameworkLayout, restoreStaleBackups, verifyFrameworkTreeResolvable }
|
|
@@ -4,27 +4,42 @@
|
|
|
4
4
|
// 真名处什么都没有 ⇒ 框架起不来:`Cannot find package '@deepseek-ai/cordis'`。
|
|
5
5
|
// 当时只能手工改目录名;这里把那件事自动化,并**只返回人类可读的步骤**,
|
|
6
6
|
// 让调用方(routes/framework-upgrade.js)保持精简(那个文件有 700 行硬顶)。
|
|
7
|
+
//
|
|
8
|
+
// ★ 0.5.99 改错(真机 2026-11-02):「真名存在但只是空壳(没有 package.json)」以前被当成
|
|
9
|
+
// "已存在 ⇒ 跳过",于是坏状态一直留着。判据已收敛到 domain/framework-stale-restore.js
|
|
10
|
+
// (可用 = 目录里有 package.json;候选包名必须与真名一致)。本文件把三类结果**都**翻成
|
|
11
|
+
// 升级日志里的可读步骤:restored / noCandidate(真缺内容,无可用候选)/ failed。
|
|
7
12
|
import { join } from 'node:path'
|
|
8
13
|
import { restoreStaleBackups } from './framework-cleanup.js'
|
|
9
14
|
|
|
15
|
+
/** 一列名字最多列几个(升级日志是单行步骤,别把几百个包名全塞进去)。 */
|
|
16
|
+
const LIST_LIMIT = 8
|
|
17
|
+
const brief = (names) => (names.length <= LIST_LIMIT ? names.join('、') : `${names.slice(0, LIST_LIMIT).join('、')} 等 ${names.length} 个`)
|
|
18
|
+
|
|
10
19
|
/**
|
|
11
|
-
* @returns {{ steps: string[], restored: Array<
|
|
20
|
+
* @returns {{ steps: string[], restored: Array<object>, emptied: Array<object>, noCandidate: Array<object>, failed: Array<object> }}
|
|
12
21
|
*/
|
|
13
|
-
function repairStaleRenameResidue({ fwRoot } = {}) {
|
|
22
|
+
function repairStaleRenameResidue({ fwRoot, deps = {} } = {}) {
|
|
14
23
|
const steps = []
|
|
15
|
-
|
|
24
|
+
const empty = { steps, restored: [], emptied: [], noCandidate: [], failed: [] }
|
|
25
|
+
if (typeof fwRoot !== 'string' || fwRoot === '') return empty
|
|
16
26
|
try {
|
|
17
|
-
const repair = restoreStaleBackups({ scopeDir: join(fwRoot, '@deepseek-ai') })
|
|
27
|
+
const repair = restoreStaleBackups({ scopeDir: join(fwRoot, '@deepseek-ai'), deps })
|
|
18
28
|
if (repair.restored.length > 0) {
|
|
19
|
-
steps.push(`修复上次失败留下的改名残留:${repair.restored.length} 个包已改回真名(${repair.restored.map((r) => r.to)
|
|
29
|
+
steps.push(`修复上次失败留下的改名残留:${repair.restored.length} 个包已改回真名(${brief(repair.restored.map((r) => r.to))})`
|
|
30
|
+
+ (Array.isArray(repair.emptied) && repair.emptied.length > 0 ? `;${repair.emptied.length} 个空壳已挪成 .empty-*(未删除)` : ''))
|
|
31
|
+
}
|
|
32
|
+
// ★ 0.5.99:真缺内容(真名不可用且没有可用候选)必须**说出来** —— 否则"框架解析不到包"在日志里无声无息
|
|
33
|
+
if (Array.isArray(repair.noCandidate) && repair.noCandidate.length > 0) {
|
|
34
|
+
steps.push(`有 ${repair.noCandidate.length} 个框架包真名不可用、且没有可用的 .stale-* 候选(真缺内容,需要重装/重新安装该版本):${brief(repair.noCandidate.map((n) => n.real))}`)
|
|
20
35
|
}
|
|
21
36
|
if (repair.failed.length > 0) {
|
|
22
|
-
steps.push(`改名残留修复未全部成功:${repair.failed.map((f) => f.real
|
|
37
|
+
steps.push(`改名残留修复未全部成功:${brief(repair.failed.map((f) => `${f.real}${f.reason ? `(${f.reason})` : ''}`))}(不阻塞升级)`)
|
|
23
38
|
}
|
|
24
|
-
return { steps, restored: repair.restored, failed: repair.failed }
|
|
39
|
+
return { steps, restored: repair.restored, emptied: repair.emptied ?? [], noCandidate: repair.noCandidate ?? [], failed: repair.failed }
|
|
25
40
|
} catch (error) {
|
|
26
41
|
steps.push(`改名残留修复失败(不阻塞升级):${error instanceof Error ? error.message : String(error)}`)
|
|
27
|
-
return {
|
|
42
|
+
return { ...empty, steps, failed: [{ error: String(error) }] }
|
|
28
43
|
}
|
|
29
44
|
}
|
|
30
45
|
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// L1 · domain —— framework-stale-restore.js(「改名残留」能不能恢复的**判据**;0.5.99 改错)
|
|
2
|
+
//
|
|
3
|
+
// ── 真机现场(2026-11-02,用户实测)────────────────────────────────────────────────
|
|
4
|
+
// 框架包里出现大量「**真名目录存在,但是空壳**(没有 package.json),真正内容被改名成了
|
|
5
|
+
// `<name>.stale-<ver>-<ts>`」:
|
|
6
|
+
// 真名 cordis\ → 空(无 package.json)
|
|
7
|
+
// cordis.stale-4.0.4-1793185607671\ → 有内容(package.json + lib)
|
|
8
|
+
// 该目录下总计 564 项,其中 276 个是 .stale-*。后果:`@deepseek-ai/cordis` 解析失败
|
|
9
|
+
// (`ERR_MODULE_NOT_FOUND`),插件的 `import` 直接起不来;把 178 个空壳从 .stale-* 恢复后成功。
|
|
10
|
+
//
|
|
11
|
+
// ── 旧判据的缺陷(本次修的正是它)──────────────────────────────────────────────────
|
|
12
|
+
// 旧实现是「真名**缺失**(exists=false)才恢复;真名已存在就跳过」。真机现场恰恰是
|
|
13
|
+
// 「真名存在但是空壳」⇒ 被跳过 ⇒ 坏状态一直留着,框架一直起不来。
|
|
14
|
+
//
|
|
15
|
+
// ── 新判据(三条,全部可离线断言)──────────────────────────────────────────────────
|
|
16
|
+
// ① **可用**的定义是「这个目录里有 package.json」;目录存在但没有 package.json = **空壳 = 不可用**,
|
|
17
|
+
// 在判据里与"缺失"同等对待(都算"真名此刻不可用")。
|
|
18
|
+
// ② 恢复前必须校验候选的 `package.json.name` 与真名一致(`<scope>/<real>`)——
|
|
19
|
+
// 不一致一律不动,记 `failed`(防张冠李戴:把别的包改成这个名字)。
|
|
20
|
+
// ③ 三类结果分开报告:`restore`(可恢复)/ `noCandidate`(真缺内容且没有可用的 .stale-*)/
|
|
21
|
+
// `failed`(候选名字不符、候选本身不可用等)—— 调用方要能原样写进升级日志。
|
|
22
|
+
//
|
|
23
|
+
// 本模块只做**判据与计划**(读目录 + 读 package.json,纯读、零写盘);改名/读回核实/回滚在
|
|
24
|
+
// domain/framework-cleanup.js#restoreStaleBackups(动作层),两处调用方共用同一条路。
|
|
25
|
+
import { readdirSync, readFileSync, lstatSync } from 'node:fs'
|
|
26
|
+
import { basename, join } from 'node:path'
|
|
27
|
+
|
|
28
|
+
/** 空壳挪开后用的后缀(**挪开不删**,保留可回溯)。 */
|
|
29
|
+
const EMPTY_SUFFIX = '.empty-'
|
|
30
|
+
/** 改名残留的后缀(`<real>.stale-<ver>-<ts>`)。 */
|
|
31
|
+
const STALE_MARK = '.stale-'
|
|
32
|
+
|
|
33
|
+
function errText(error) {
|
|
34
|
+
return String(error?.message ?? error)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 从目录名拆出真名(`cordis.stale-4.0.4-123` → `cordis`);不是残留就返回 null。 */
|
|
38
|
+
function staleRealNameOf(name) {
|
|
39
|
+
if (typeof name !== 'string') return null
|
|
40
|
+
const at = name.indexOf(STALE_MARK)
|
|
41
|
+
if (at <= 0) return null
|
|
42
|
+
return name.slice(0, at)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 真名对应的**包名**(scope 目录下是 `@scope/real`;不是 scope 目录就用真名本身)。 */
|
|
46
|
+
function expectedPackageName(scopeDir, real) {
|
|
47
|
+
const scope = basename(String(scopeDir ?? ''))
|
|
48
|
+
return scope.startsWith('@') ? `${scope}/${real}` : real
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 空壳挪开后的名字:`<real>.empty-<stamp>`。 */
|
|
52
|
+
function emptyShellName(real, stamp) {
|
|
53
|
+
return `${real}${EMPTY_SUFFIX}${stamp}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 读 `<dir>/package.json`:`{ ok, name, version, error }`(读不到/解析不了都如实带原因)。 */
|
|
57
|
+
function packageJsonOf(dir, { readText = (p) => readFileSync(p, 'utf8') } = {}) {
|
|
58
|
+
try {
|
|
59
|
+
const raw = JSON.parse(readText(join(dir, 'package.json')))
|
|
60
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return { ok: false, name: null, version: null, error: 'package.json 不是对象' }
|
|
61
|
+
return { ok: true, name: typeof raw.name === 'string' ? raw.name : null, version: typeof raw.version === 'string' ? raw.version : null, error: null }
|
|
62
|
+
} catch (error) {
|
|
63
|
+
return { ok: false, name: null, version: null, error: errText(error) }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 名字里那个"被占用"的东西(空壳 / 链接 / 文件 / 真实目录):lstat 能立住就算被占着。 */
|
|
68
|
+
function isOccupied(dir, { lstat = lstatSync } = {}) {
|
|
69
|
+
try { lstat(dir); return true } catch { return false }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* **可用**的判据(用户 2026-11-02 定案):目录里有**能读出来的 package.json**。
|
|
74
|
+
* 注意它不看包名 —— 「是不是这个包」由 `packageJsonOf().name` 与真名比对,两件事分开判。
|
|
75
|
+
*/
|
|
76
|
+
function isUsablePackageDir(dir, deps = {}) {
|
|
77
|
+
const lstat = deps.lstat ?? lstatSync
|
|
78
|
+
let stat = null
|
|
79
|
+
try { stat = lstat(dir) } catch { return false }
|
|
80
|
+
try { if (stat?.isDirectory?.() === false && stat?.isSymbolicLink?.() !== true) return false } catch {}
|
|
81
|
+
return packageJsonOf(dir, deps).ok === true
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 候选排序用的时间戳(`<real>.stale-<ver>-<ts>` 里尾巴那串数字;没有就是 0)。 */
|
|
85
|
+
function candidateStamp(name) {
|
|
86
|
+
const m = String(name ?? '').match(/-(\d{6,})$/u)
|
|
87
|
+
return m === null ? 0 : Number(m[1])
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 挑要恢复的那份候选:包名必须与真名一致(防张冠李戴),多份时取**时间戳最新**的那份。
|
|
92
|
+
* 返回 `{ picked, mismatch, unusable }` —— `mismatch` 是"有 package.json 但名字不对"的候选,
|
|
93
|
+
* 它们不是可以恢复的对象,但**必须**在报告里点名(否则用户不知道为什么没恢复)。
|
|
94
|
+
*/
|
|
95
|
+
function pickRestoreCandidate({ scopeDir, real, candidates, deps = {} } = {}) {
|
|
96
|
+
const expected = expectedPackageName(scopeDir, real)
|
|
97
|
+
const usable = []
|
|
98
|
+
const mismatch = []
|
|
99
|
+
const unusable = []
|
|
100
|
+
for (const name of Array.isArray(candidates) ? candidates : []) {
|
|
101
|
+
const info = packageJsonOf(join(scopeDir, name), deps)
|
|
102
|
+
if (info.ok !== true) { unusable.push({ name, error: info.error }); continue }
|
|
103
|
+
if (info.name !== expected) { mismatch.push({ name, nameInPkg: info.name, expected }); continue }
|
|
104
|
+
usable.push({ name, version: info.version, stamp: candidateStamp(name) })
|
|
105
|
+
}
|
|
106
|
+
usable.sort((a, b) => (b.stamp - a.stamp) || (a.name < b.name ? 1 : a.name > b.name ? -1 : 0))
|
|
107
|
+
return { picked: usable.length > 0 ? usable[0] : null, usableCount: usable.length, mismatch, unusable }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 计划一次「改名残留修复」(**纯读,零写盘**)。返回每个真名一项:
|
|
112
|
+
* `{ real, action: 'restore'|'skip'|'noCandidate'|'failed', staleName, reason, detail,
|
|
113
|
+
* realOccupied, realUsable, candidates, mismatch, unusable }`
|
|
114
|
+
*
|
|
115
|
+
* 分类规则(顺序即优先级):
|
|
116
|
+
* ① 真名**可用** ⇒ `skip`(reason `real-name-exists`)—— 健康树上一个字节都不动(幂等);
|
|
117
|
+
* ② 真名不可用(缺失 **或空壳**)+ 有可用候选 ⇒ `restore`;
|
|
118
|
+
* ③ 真名不可用 + 候选里**有名字不符的** ⇒ `failed`(reason `name-mismatch`,一个都不动);
|
|
119
|
+
* ④ 真名不可用 + 候选都不可用(没 package.json)或压根没有候选 ⇒ `noCandidate`。
|
|
120
|
+
*/
|
|
121
|
+
function planStaleRestore({ scopeDir, deps = {} } = {}) {
|
|
122
|
+
const empty = { ok: false, items: [], problem: null }
|
|
123
|
+
if (typeof scopeDir !== 'string' || scopeDir === '') return { ...empty, problem: '没有给 scopeDir' }
|
|
124
|
+
let entries = []
|
|
125
|
+
try { entries = deps.readdir ? deps.readdir(scopeDir) : readdirSync(scopeDir, { withFileTypes: true }) } catch (error) {
|
|
126
|
+
const missing = error?.code === 'ENOENT'
|
|
127
|
+
return { ...empty, ok: missing, problem: missing ? null : '列不出目录:' + errText(error) }
|
|
128
|
+
}
|
|
129
|
+
const byReal = new Map()
|
|
130
|
+
for (const entry of entries) {
|
|
131
|
+
const name = typeof entry === 'string' ? entry : entry?.name
|
|
132
|
+
const isDir = typeof entry === 'string' ? true : entry?.isDirectory?.() === true
|
|
133
|
+
if (typeof name !== 'string' || !isDir) continue
|
|
134
|
+
const real = staleRealNameOf(name)
|
|
135
|
+
if (real === null || real === '') continue
|
|
136
|
+
if (!byReal.has(real)) byReal.set(real, [])
|
|
137
|
+
byReal.get(real).push(name)
|
|
138
|
+
}
|
|
139
|
+
// 真名一侧也要扫:**空壳本身没有 .stale-* 兄弟**时(真缺内容)同样要报 `noCandidate`,
|
|
140
|
+
// 否则升级日志里那批"真名在、内容没了"的行一个字都不会出现(真机就是这样被漏掉的)。
|
|
141
|
+
const realNames = new Set()
|
|
142
|
+
for (const entry of entries) {
|
|
143
|
+
const name = typeof entry === 'string' ? entry : entry?.name
|
|
144
|
+
const isDir = typeof entry === 'string' ? true : entry?.isDirectory?.() === true
|
|
145
|
+
if (typeof name !== 'string' || !isDir) continue
|
|
146
|
+
if (name.includes(STALE_MARK) || name.includes(EMPTY_SUFFIX)) continue // 残留/挪开的空壳不是真名
|
|
147
|
+
realNames.add(name)
|
|
148
|
+
}
|
|
149
|
+
for (const real of realNames) if (!byReal.has(real)) byReal.set(real, [])
|
|
150
|
+
const items = []
|
|
151
|
+
for (const real of [...byReal.keys()].sort()) {
|
|
152
|
+
const candidates = byReal.get(real).slice().sort()
|
|
153
|
+
const target = join(scopeDir, real)
|
|
154
|
+
const realOccupied = isOccupied(target, deps)
|
|
155
|
+
const realUsable = realOccupied && isUsablePackageDir(target, deps)
|
|
156
|
+
const base = { real, staleName: null, realOccupied, realUsable, candidates }
|
|
157
|
+
if (realUsable) {
|
|
158
|
+
// 健康行:有候选也只是"旁边留着一份旧残留",不动(幂等:健康树上零动作、零报告项)
|
|
159
|
+
if (candidates.length === 0) continue
|
|
160
|
+
items.push({ ...base, action: 'skip', reason: 'real-name-exists', detail: `真名目录里已有 package.json —— 不动旁边那份 ${candidates[0]}` })
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
163
|
+
const pick = pickRestoreCandidate({ scopeDir, real, candidates, deps })
|
|
164
|
+
if (pick.picked !== null) {
|
|
165
|
+
items.push({
|
|
166
|
+
...base,
|
|
167
|
+
action: 'restore',
|
|
168
|
+
staleName: pick.picked.name,
|
|
169
|
+
reason: realOccupied ? 'real-name-unusable' : 'real-name-missing',
|
|
170
|
+
detail: realOccupied
|
|
171
|
+
? `真名目录存在但没有可读的 package.json(空壳)—— 用 ${pick.picked.name} 恢复,空壳挪成 ${EMPTY_SUFFIX}<ts>`
|
|
172
|
+
: `真名缺失 —— 用 ${pick.picked.name} 恢复`,
|
|
173
|
+
mismatch: pick.mismatch,
|
|
174
|
+
unusable: pick.unusable,
|
|
175
|
+
})
|
|
176
|
+
continue
|
|
177
|
+
}
|
|
178
|
+
if (pick.mismatch.length > 0) {
|
|
179
|
+
items.push({
|
|
180
|
+
...base,
|
|
181
|
+
action: 'failed',
|
|
182
|
+
reason: 'name-mismatch',
|
|
183
|
+
detail: `候选的 package.json.name 与真名不符(应为 ${expectedPackageName(scopeDir, real)}):${pick.mismatch.map((m) => `${m.name}=${String(m.nameInPkg)}`).join('、')} —— 一个都不动`,
|
|
184
|
+
mismatch: pick.mismatch,
|
|
185
|
+
unusable: pick.unusable,
|
|
186
|
+
})
|
|
187
|
+
continue
|
|
188
|
+
}
|
|
189
|
+
items.push({
|
|
190
|
+
...base,
|
|
191
|
+
action: 'noCandidate',
|
|
192
|
+
reason: 'no-usable-stale',
|
|
193
|
+
detail: pick.unusable.length > 0
|
|
194
|
+
? `真名不可用,且 ${pick.unusable.length} 份 .stale-* 都没有可读的 package.json(不可作为候选)`
|
|
195
|
+
: '真名不可用,且没有任何 .stale-* 候选(框架真缺内容,需要重装)',
|
|
196
|
+
mismatch: pick.mismatch,
|
|
197
|
+
unusable: pick.unusable,
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
return { ok: true, items, problem: null }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export {
|
|
204
|
+
EMPTY_SUFFIX,
|
|
205
|
+
STALE_MARK,
|
|
206
|
+
candidateStamp,
|
|
207
|
+
emptyShellName,
|
|
208
|
+
expectedPackageName,
|
|
209
|
+
isOccupied,
|
|
210
|
+
isUsablePackageDir,
|
|
211
|
+
packageJsonOf,
|
|
212
|
+
pickRestoreCandidate,
|
|
213
|
+
planStaleRestore,
|
|
214
|
+
staleRealNameOf,
|
|
215
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// L0 · infra(paths.js)—— 分层 Step 从 lib/index.js 搬出,只搬移未改逻辑
|
|
2
2
|
// 分层分组:L0 · infra(边界由 tests/test-architecture-guard.mjs 断言)
|
|
3
3
|
|
|
4
|
-
import { readFileSync, existsSync, statSync } from 'node:fs'
|
|
4
|
+
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'
|
|
5
5
|
import { dirname, join, resolve } from 'node:path'
|
|
6
6
|
import { homedir } from 'node:os'
|
|
7
7
|
import { fileURLToPath } from 'node:url'
|
|
@@ -12,10 +12,39 @@ function dshHome() {
|
|
|
12
12
|
return process.env.DSH_HOME?.trim() || join(homedir(), '.dsh')
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* 默认 profile 用户补丁层路径(无 include 条目可推导时的兜底)。
|
|
17
|
+
*
|
|
18
|
+
* 硬编码 `profiles/web` 是真实事故(issue #27):桌面端机器上 `cordis.yml` 里没有
|
|
19
|
+
* `cordis:include` 条目时兜底必然生效,于是所有补丁写入都落到早已废弃的 `web` profile,
|
|
20
|
+
* 正在用的 `desktop` 静默收不到——开关插件、改禁用都没反应,而且不报错。
|
|
21
|
+
*
|
|
22
|
+
* 现在按可信度排候选,绝不写死 profile 名:
|
|
23
|
+
* ① DSH_PROFILE_DIR(桌面端/CLI 会显式设置,最可信)
|
|
24
|
+
* ② DSH_PROFILE(只给名字时拼路径)
|
|
25
|
+
* ③ `profiles/` 下真实存在补丁文件的目录(desktop 优先,其余按名字排序保证确定性)
|
|
26
|
+
*/
|
|
16
27
|
function defaultPatchPath() {
|
|
17
28
|
const home = dshHome()
|
|
18
|
-
|
|
29
|
+
const explicitDir = process.env.DSH_PROFILE_DIR?.trim()
|
|
30
|
+
if (explicitDir !== undefined && explicitDir !== '') return join(explicitDir, 'cordis.patch.yml')
|
|
31
|
+
const candidates = []
|
|
32
|
+
const profileName = process.env.DSH_PROFILE?.trim()
|
|
33
|
+
if (profileName !== undefined && profileName !== '') candidates.push(join(home, 'profiles', profileName, 'cordis.patch.yml'))
|
|
34
|
+
candidates.push(join(home, 'profiles', 'desktop', 'cordis.patch.yml'))
|
|
35
|
+
try {
|
|
36
|
+
const base = join(home, 'profiles')
|
|
37
|
+
for (const name of readdirSync(base).sort()) {
|
|
38
|
+
if (name === 'node_modules' || name === 'desktop') continue
|
|
39
|
+
candidates.push(join(base, name, 'cordis.patch.yml'))
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
// profiles 读不到就只用已知候选
|
|
43
|
+
}
|
|
44
|
+
for (const candidate of candidates) {
|
|
45
|
+
if (existsSync(candidate)) return candidate
|
|
46
|
+
}
|
|
47
|
+
return candidates[0]
|
|
19
48
|
}
|
|
20
49
|
|
|
21
50
|
/** 从 loader 树推导 profile 的 cordis.patch.yml 绝对路径。 */
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// L2 · routes —— 门控的「按需检查」(0.5.100 加法):`POST /plugin-console/compat-gate {action:'scan-current'}`
|
|
2
|
+
//
|
|
3
|
+
// 用户 2026-11-02 原话:「我们一般情况下都不预检吧」——compat-pending 只在升级前预扫与启动失败
|
|
4
|
+
// 隔离时写入,平时门控一条记录都没有,而"0 条"会被误读成"全部兼容"。这个动作让用户随时查一次:
|
|
5
|
+
// 对当前补丁里的行逐行跑适配判定,把**判为不适配**的结论记进 compat-pending(`source: 'manual-scan'`),
|
|
6
|
+
// 并回一份统计(检查了几行 / 通过 / 不适配 / 无法判定)。
|
|
7
|
+
//
|
|
8
|
+
// 三条硬边界(判据在 domain/compat-scan.js,写盘纪律在 domain/compat-pending-records.js):
|
|
9
|
+
// · 判据复用 `checkPluginFrameworkCompat`(与框架升级预扫同一条链),本文件不新写任何判据;
|
|
10
|
+
// · **绝不禁用任何行、绝不碰补丁文件** —— 禁用是用户的动作(/toggle);
|
|
11
|
+
// · 写盘:备份 → 原子写 → 读回核实 → 失败回滚;坏文件 ⇒ 409 拒绝覆盖(保住用户数据)。
|
|
12
|
+
import { dirname } from 'node:path'
|
|
13
|
+
import { planCurrentScan, recordsFromScan } from '../domain/compat-scan.js'
|
|
14
|
+
import { applyPendingWrite, countPendingRecords, liveRowIdsFromPatch, planScanMerge, readPendingRecords } from '../domain/compat-pending-records.js'
|
|
15
|
+
import { currentFrameworkVersion } from '../domain/framework.js'
|
|
16
|
+
import { readPatchState } from '../domain/patch.js'
|
|
17
|
+
import { listEntries } from '../domain/runtime.js'
|
|
18
|
+
import { sendError, sendJson } from '../infra/httpd.js'
|
|
19
|
+
import { findPatchPath } from '../infra/paths.js'
|
|
20
|
+
|
|
21
|
+
/** 读回核实的判据:本次写进去的每条 fail 记录都必须真的在盘上,且 check === 'fail'。 */
|
|
22
|
+
function verifyScanWrite(expected) {
|
|
23
|
+
return (back) => {
|
|
24
|
+
const rows = (Array.isArray(back?.pending) ? back.pending : []).filter((r) => r !== null && typeof r === 'object')
|
|
25
|
+
const missing = expected.filter((id) => !rows.some((r) => r.rowId === id && r.check === 'fail'))
|
|
26
|
+
if (missing.length > 0) return `这些记录没真正落盘:${missing.slice(0, 5).join('、')}`
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function routeCompatScanCurrent(req, res, rc) {
|
|
32
|
+
const at = Date.now()
|
|
33
|
+
let profileDir = null
|
|
34
|
+
let patchRowIds = new Set()
|
|
35
|
+
try {
|
|
36
|
+
const patchPath = findPatchPath(rc.ctx)
|
|
37
|
+
profileDir = dirname(patchPath)
|
|
38
|
+
patchRowIds = liveRowIdsFromPatch(await readPatchState(patchPath))
|
|
39
|
+
} catch {
|
|
40
|
+
sendError(res, 503, '读不到 profile 目录/补丁文件,无法确定要检查的行;本次扫描没有写任何东西', { ok: false, code: 'patch-unreadable' })
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
let entries = []
|
|
44
|
+
try { entries = listEntries(rc.ctx) } catch { entries = [] }
|
|
45
|
+
let frameworkVersion = null
|
|
46
|
+
try { frameworkVersion = currentFrameworkVersion(rc.ctx) } catch { frameworkVersion = null }
|
|
47
|
+
const scan = planCurrentScan({ entries, profileDir, frameworkVersion })
|
|
48
|
+
const records = recordsFromScan(scan, { at })
|
|
49
|
+
const read = readPendingRecords()
|
|
50
|
+
const merge = planScanMerge({
|
|
51
|
+
read,
|
|
52
|
+
records,
|
|
53
|
+
frameworkVersion,
|
|
54
|
+
lastScan: {
|
|
55
|
+
at,
|
|
56
|
+
frameworkVersion: scan.frameworkVersion,
|
|
57
|
+
checked: scan.checked,
|
|
58
|
+
pass: scan.pass,
|
|
59
|
+
fail: scan.fail,
|
|
60
|
+
unknown: scan.unknown,
|
|
61
|
+
skipped: scan.skipped.length,
|
|
62
|
+
source: 'manual-scan',
|
|
63
|
+
},
|
|
64
|
+
})
|
|
65
|
+
const counts = (() => {
|
|
66
|
+
try { return countPendingRecords(read.records, patchRowIds) } catch { return null }
|
|
67
|
+
})()
|
|
68
|
+
const base = {
|
|
69
|
+
ok: true,
|
|
70
|
+
action: 'scan-current',
|
|
71
|
+
frameworkVersion: scan.frameworkVersion,
|
|
72
|
+
// 扫描统计(面板只放一句短句;明细在 skipped/results 里)
|
|
73
|
+
checked: scan.checked,
|
|
74
|
+
pass: scan.pass,
|
|
75
|
+
fail: scan.fail,
|
|
76
|
+
unknown: scan.unknown,
|
|
77
|
+
skipped: scan.skipped,
|
|
78
|
+
results: scan.results,
|
|
79
|
+
// 落盘情况
|
|
80
|
+
recorded: records.length,
|
|
81
|
+
added: merge.added ?? [],
|
|
82
|
+
updated: merge.updated ?? [],
|
|
83
|
+
changed: false,
|
|
84
|
+
file: read.file,
|
|
85
|
+
backup: null,
|
|
86
|
+
verified: false,
|
|
87
|
+
counts,
|
|
88
|
+
error: scan.error,
|
|
89
|
+
note: null,
|
|
90
|
+
}
|
|
91
|
+
if (merge.ok !== true) {
|
|
92
|
+
// 坏文件:结论不写盘,但扫描统计照样如实回(用户至少知道"现在查出来是什么")
|
|
93
|
+
sendError(res, merge.status ?? 500, merge.error ?? '扫描结果写入失败', { ...base, error: merge.error ?? scan.error })
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
if (merge.changed !== true) {
|
|
97
|
+
sendJson(res, 200, {
|
|
98
|
+
...base,
|
|
99
|
+
changed: false,
|
|
100
|
+
note: scan.checked === 0
|
|
101
|
+
? '没有可检查的行(补丁里没有可判定的插件行),未写任何记录'
|
|
102
|
+
: `检查 ${scan.checked} 行:通过 ${scan.pass} / 不适配 ${scan.fail} / 无法判定 ${scan.unknown};记录没有变化(无需写盘)`,
|
|
103
|
+
})
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
const write = applyPendingWrite({ file: read.file, next: merge.next, verify: verifyScanWrite(records.map((r) => r.rowId)), tag: 'scan' })
|
|
107
|
+
const payload = {
|
|
108
|
+
...base,
|
|
109
|
+
ok: write.ok === true,
|
|
110
|
+
changed: write.ok === true,
|
|
111
|
+
backup: write.backup ?? null,
|
|
112
|
+
verified: write.verified === true,
|
|
113
|
+
error: write.error ?? scan.error,
|
|
114
|
+
note: write.ok === true
|
|
115
|
+
? `检查 ${scan.checked} 行:通过 ${scan.pass} / 不适配 ${scan.fail} / 无法判定 ${scan.unknown}`
|
|
116
|
+
+ (records.length > 0 ? `;已记下 ${records.length} 条待适配结论(只记录,未禁用任何行)` : ';没有不适配的行,未新增记录')
|
|
117
|
+
: null,
|
|
118
|
+
}
|
|
119
|
+
if (write.ok === true) sendJson(res, 200, payload)
|
|
120
|
+
else sendError(res, write.status ?? 500, write.error ?? '扫描结果写入失败', payload)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export { routeCompatScanCurrent }
|
|
@@ -29,6 +29,8 @@ import { compareFingerprint, computeFingerprint, planDesktopHostSwitch, readComp
|
|
|
29
29
|
// 0.5.98 加法:适配门**记录**的删除(真待适配判据 + 写盘纪律都在该模块;本路由只映射状态码)
|
|
30
30
|
import { applyPendingForget, countPendingRecords, liveRowIdsFromPatch, planForgetRecord, planForgetStale, readPendingRecords } from '../domain/compat-pending-records.js'
|
|
31
31
|
import { readPatchState } from '../domain/patch.js'
|
|
32
|
+
// 0.5.100 加法:门控的「按需检查」(只记录结论,绝不禁用任何行)
|
|
33
|
+
import { routeCompatScanCurrent } from './compat-scan.js'
|
|
32
34
|
import { currentFrameworkVersion, detectHostShape } from '../domain/framework.js'
|
|
33
35
|
import { applyDependencyBackfill, planDependencyBackfill } from '../domain/manifest.js'
|
|
34
36
|
import { isProtectedModule } from '../domain/runtime.js'
|
|
@@ -458,14 +460,28 @@ async function routeCompatAck(req, res, rc) {
|
|
|
458
460
|
const ack = writeCompatAck(version)
|
|
459
461
|
sendJson(res, 200, { ok: true, ack, noticeMode: readCompatGate().noticeMode })
|
|
460
462
|
}
|
|
463
|
+
/**
|
|
464
|
+
* POST /plugin-console/compat-gate 的**动作分派**(0.5.98 起;0.5.100 多一个 `scan-current`)。
|
|
465
|
+
*
|
|
466
|
+
* `{ action: 'forget', rowId }` 删一条记录 → 见下(routeCompatPendingForget 的本体)
|
|
467
|
+
* `{ action: 'forget-stale' }` 清掉失效记录 → 同上
|
|
468
|
+
* `{ action: 'scan-current' }` **按需检查**(0.5.100 加法,用户「平时不预检」的痛点)
|
|
469
|
+
* → routes/compat-scan.js(只记录结论,绝不禁用任何行)
|
|
470
|
+
* 没有 `action`(或不是已知动作)⇒ **逐字**走原来的总开关逻辑(既有契约零变化)
|
|
471
|
+
*
|
|
472
|
+
* 为什么都挤在**既有的 /compat-gate** 上而不新开路由:门控面板的所有写入口必须同源
|
|
473
|
+
* (否则又要登记一份路由清单,两处判据迟早分叉)。装配层的分流见 routes/index.js#routeCompatGateAny。
|
|
474
|
+
*/
|
|
475
|
+
async function routeCompatGateAction(req, res, rc) {
|
|
476
|
+
const action = typeof rc.body?.action === 'string' ? rc.body.action.trim() : ''
|
|
477
|
+
if (action === 'scan-current') return routeCompatScanCurrent(req, res, rc)
|
|
478
|
+
return routeCompatPendingForget(req, res, rc)
|
|
479
|
+
}
|
|
480
|
+
|
|
461
481
|
/**
|
|
462
482
|
* POST /plugin-console/compat-gate `{ action: 'forget' | 'forget-stale' }`
|
|
463
483
|
* —— 适配门**记录**的删除(0.5.98 加法,用户 2026-10-28 明确要求)。
|
|
464
484
|
*
|
|
465
|
-
* 为什么挂在**既有的 /compat-gate** 上而不是新开一条路由:门控面板的所有写入口必须同源
|
|
466
|
-
* (否则又要登记一份路由清单,两处判据迟早分叉)。body 里没有 `action` 时**逐字**走原来的
|
|
467
|
-
* 总开关逻辑(既有契约零变化,装配层的分流见 routes/index.js#routeCompatGateAny)。
|
|
468
|
-
*
|
|
469
485
|
* 语义(三条硬边界,用户定案):
|
|
470
486
|
* · 只删 **记录**:不动插件、不动补丁文件、不动 bundles 清单;
|
|
471
487
|
* · `forget` 删指定 rowId;`forget-stale` 只清「status=pending 且该行已不在补丁里」的失效记录,
|
|
@@ -528,4 +544,4 @@ async function routeCompatPendingForget(req, res, rc) {
|
|
|
528
544
|
else sendError(res, result.status ?? 400, result.error ?? '删除记录失败', payload)
|
|
529
545
|
}
|
|
530
546
|
|
|
531
|
-
export { hostShapeOf, maybeSwitchToObserver, maybeWriteSnapshot, preflightTimeoutMs, resetHostSwitchChecked, routeCompatAck, routeCompatMode, routeCompatPendingForget, routeCompatStamp, routeCompatStatusGet, routeDeclareInstalled, scheduleAutoPreflight }
|
|
547
|
+
export { hostShapeOf, maybeSwitchToObserver, maybeWriteSnapshot, preflightTimeoutMs, resetHostSwitchChecked, routeCompatAck, routeCompatGateAction, 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, routeCompatPendingForget, routeCompatStamp, routeCompatStatusGet, routeDeclareInstalled } from './compat.js'
|
|
19
|
+
import { routeCompatAck, routeCompatGateAction, 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'
|
|
@@ -90,11 +90,11 @@ function routeDeps() {
|
|
|
90
90
|
* 于是"分流"留在装配层,"动作实现"落在 routes/compat.js(适配门记录那一族)。
|
|
91
91
|
*
|
|
92
92
|
* 契约:body 里没有 `action`(或不是非空字符串)⇒ **逐字**走原来的总开关逻辑,
|
|
93
|
-
* 既有请求/响应一字不变;带 `action`
|
|
93
|
+
* 既有请求/响应一字不变;带 `action` 才进动作分派那一族(0.5.98 删记录 / 0.5.100 按需检查)。
|
|
94
94
|
*/
|
|
95
95
|
async function routeCompatGateAny(req, res, rc) {
|
|
96
96
|
const action = typeof rc.body?.action === 'string' ? rc.body.action : ''
|
|
97
|
-
if (action !== '') return
|
|
97
|
+
if (action !== '') return routeCompatGateAction(req, res, rc)
|
|
98
98
|
return routeCompatGate(req, res, rc)
|
|
99
99
|
}
|
|
100
100
|
|
|
@@ -276,11 +276,25 @@ async function routeStateGet(req, res, rc) {
|
|
|
276
276
|
// ★ 0.5.98 加法:门控明细行补两个**只增不改**的字段 —— `rowMissing`(这一行的 rowId 已不在
|
|
277
277
|
// 当前补丁里)与 `actionable`(与计数同一个判据)。面板据此如实标注「该行已不存在」并给删除入口;
|
|
278
278
|
// 既有字段(pendingCount / rows 的全部旧字段)一个不少、语义不变。
|
|
279
|
+
// ★ 0.5.101 加法(用户 2026-11-02):**运行期加载失败**的行(`fiberPhase === 'failed'`)。
|
|
280
|
+
// 真机现场:`experimental-inspector` 因 `@deepseek-ai/cordis` 解析失败而挂载失败,而门控**完全
|
|
281
|
+
// 看不到它** —— 因为这条失败不经过预扫/隔离链。这里只补一个**视图**字段:
|
|
282
|
+
// **绝不写 compat-pending.json**(那是适配裁决记录,不能被运行期故障污染)、不改任何既有字段。
|
|
283
|
+
const loadFailed = entries
|
|
284
|
+
.filter((e) => e.fiberPhase === 'failed' && typeof e.rowId === 'string' && e.rowId !== '')
|
|
285
|
+
.map((e) => ({ rowId: e.rowId, moduleName: e.moduleName ?? null, version: e.version ?? null, fiberPhase: 'failed', enabled: e.enabled === true }))
|
|
279
286
|
const gatingOut = (() => {
|
|
280
287
|
const g = gatingSummary(ctx, compatPending, entries, compatVerdicts)
|
|
281
|
-
return {
|
|
288
|
+
return {
|
|
289
|
+
...g,
|
|
290
|
+
pending: annotateGateRows(g.pending, patchRowIds),
|
|
291
|
+
adopted: annotateGateRows(g.adopted, patchRowIds),
|
|
292
|
+
// 只增字段:加载失败的行 + 计数(面板据此决定分区显不显示)
|
|
293
|
+
loadFailed,
|
|
294
|
+
loadFailedCount: loadFailed.length,
|
|
295
|
+
}
|
|
282
296
|
})()
|
|
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 })) })
|
|
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 })) })
|
|
284
298
|
return
|
|
285
299
|
}
|
|
286
300
|
|
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.99",
|
|
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",
|