@noob-stupid/dsh-plugin-console 0.5.98 → 0.5.100
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/domain/patch.js +76 -7
- package/lib/server/infra/fw-root-detect.js +129 -11
- 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
|
|