@noob-stupid/dsh-plugin-console 0.5.29 → 0.5.30
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 +89 -3
- package/lib/server/domain/compat.js +68 -2
- package/lib/server/domain/framework-cleanup.js +225 -0
- package/lib/server/domain/framework-residuals.js +59 -0
- package/lib/server/domain/patch-composition-audit.js +218 -0
- package/lib/server/domain/patch.js +68 -15
- package/lib/server/domain/preset-audit.js +144 -0
- package/lib/server/infra/exec.js +23 -2
- package/lib/server/infra/framework-root.js +99 -0
- package/lib/server/infra/package-resolve.js +73 -0
- package/lib/server/infra/registry-versions.js +64 -0
- package/lib/server/routes/compat.js +41 -1
- package/lib/server/routes/framework-preflight.js +43 -2
- package/lib/server/routes/index.js +13 -1
- package/lib/server/routes/patch-audit.js +59 -0
- package/lib/server/routes/plugins.js +20 -2
- package/lib/server/routes/preset-audit.js +24 -0
- package/lib/server/routes/state.js +9 -3
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -342,6 +342,17 @@ window.__ModuleLoader__.load({
|
|
|
342
342
|
githubLoginFail: "登录失败",
|
|
343
343
|
hubUpdateAvailable: "下载更新",
|
|
344
344
|
cleanResiduals: "清理残余备份",
|
|
345
|
+
cleanFrameworkResiduals: "清理框架残留",
|
|
346
|
+
cfStale: "旧目录 {n} 个",
|
|
347
|
+
cfBackups: "可清备份 {n} 条(约 {mb} MB)",
|
|
348
|
+
cfEntities: "旧 store 实体 {n} 个(只报告)",
|
|
349
|
+
cfConfirm: "确认执行?旧目录会改名备份并重建链接;超出保留数量的框架备份会被删除。",
|
|
350
|
+
cfNothing: "没有需要清理的项",
|
|
351
|
+
cfCancelled: "已取消(未改动任何文件)",
|
|
352
|
+
cfRunning: "正在执行清理…",
|
|
353
|
+
cfDone: "已改名 {renamed} / 重建链接 {linked} / 删备份 {removed} 条(释放约 {mb} MB)",
|
|
354
|
+
cfFailed: "{n} 项失败(详见 fw/console 日志)",
|
|
355
|
+
compatNoticeDismiss: "我知道了(此版本不再提示)",
|
|
345
356
|
searchInstalledPlaceholder: "搜索已安装插件(名称或 id)",
|
|
346
357
|
extraFilter: "已装",
|
|
347
358
|
extraFilterAll: "全部",
|
|
@@ -871,6 +882,17 @@ window.__ModuleLoader__.load({
|
|
|
871
882
|
githubLoggedIn: "GitHub signed in: ",
|
|
872
883
|
hubUpdateAvailable: "Download update",
|
|
873
884
|
cleanResiduals: "Clean residual backups",
|
|
885
|
+
cleanFrameworkResiduals: "Clean framework residuals",
|
|
886
|
+
cfStale: "{n} stale dirs",
|
|
887
|
+
cfBackups: "{n} backups removable (~{mb} MB)",
|
|
888
|
+
cfEntities: "{n} old store entities (report only)",
|
|
889
|
+
cfConfirm: "Proceed? Stale dirs get renamed+relinked; framework backups beyond the keep count are deleted.",
|
|
890
|
+
cfNothing: "nothing to clean",
|
|
891
|
+
cfCancelled: "cancelled (nothing changed)",
|
|
892
|
+
cfRunning: "cleaning…",
|
|
893
|
+
cfDone: "renamed {renamed} / relinked {linked} / backups removed {removed} (~{mb} MB freed)",
|
|
894
|
+
cfFailed: "{n} failed (see fw/console logs)",
|
|
895
|
+
compatNoticeDismiss: "Got it (hide for this version)",
|
|
874
896
|
githubCornerOut: "Not signed in to GitHub",
|
|
875
897
|
githubLoginViaApp: "GitHub login",
|
|
876
898
|
githubLoginOpened: "Authorization window opened — enter the code on the GitHub page to finish",
|
|
@@ -4038,6 +4060,7 @@ window.__ModuleLoader__.load({
|
|
|
4038
4060
|
const gatePendingCount = gateReady ? (state.data.compatPending?.pending ?? []).length : 0;
|
|
4039
4061
|
// 门控明细清单(用户 2026-09-24 要求:面板要显示"是哪些插件",不只是数量)
|
|
4040
4062
|
const [gateShowAll, setGateShowAll] = react.useState(false);
|
|
4063
|
+
const [compatNoticeClosed, setCompatNoticeClosed] = react.useState(false);
|
|
4041
4064
|
const gateRows = gateReady ? (state.data.gating ?? null) : null;
|
|
4042
4065
|
const gateTime = (ms) => (typeof ms === "number" && ms > 0 ? new Date(ms).toLocaleString() : "—");
|
|
4043
4066
|
// 「功能包 → 框架」按钮角标:升级进行中 ⟳ / 上次失败 ✕ / 有可用更新 ↑(优先级从高到低)
|
|
@@ -4045,9 +4068,29 @@ window.__ModuleLoader__.load({
|
|
|
4045
4068
|
&& frameworkStatus.status !== "idle" && frameworkStatus.status !== "done" && frameworkStatus.status !== "failed";
|
|
4046
4069
|
const fwBadge = fwRunning ? " ⟳" : (frameworkStatus?.status === "failed" ? " ✕" : (fwCheck?.target ? " ↑" : ""));
|
|
4047
4070
|
return el("section", { ref: sectionRef, className: styles.section, "aria-busy": state.status === "loading" },
|
|
4048
|
-
|
|
4049
|
-
|
|
4050
|
-
|
|
4071
|
+
// 兼容提示(2026-09-28 加法):以前是纯 <p>,关不掉。现在按服务端的 noticeVisible 显示
|
|
4072
|
+
// (老服务端没有该字段时回落 !supported),并给一个手动关闭入口 —— 关闭只影响展示:
|
|
4073
|
+
// 服务端记的是「这个框架版本我看过了」,版本一变会重新提示;supported 与门控语义一个字都不变。
|
|
4074
|
+
state.status === "ready" && state.data.compat
|
|
4075
|
+
&& (state.data.compat.noticeVisible === undefined
|
|
4076
|
+
? !state.data.compat.supported
|
|
4077
|
+
: state.data.compat.noticeVisible === true)
|
|
4078
|
+
&& !compatNoticeClosed
|
|
4079
|
+
? el("p", { className: styles.message, "data-error": "true" },
|
|
4080
|
+
state.data.compat.notice,
|
|
4081
|
+
" ",
|
|
4082
|
+
el("button", {
|
|
4083
|
+
type: "button",
|
|
4084
|
+
style: { marginLeft: "8px", cursor: "pointer" },
|
|
4085
|
+
title: t("compatNoticeDismiss"),
|
|
4086
|
+
onClick: () => {
|
|
4087
|
+
// 失败不收起(关了却还在服务端显示会造成「点了没反应」的错觉)
|
|
4088
|
+
call("/plugin-console/compat-ack", { webAppVersion: state.data.compat?.webAppVersion })
|
|
4089
|
+
.then(() => setCompatNoticeClosed(true), () => {});
|
|
4090
|
+
},
|
|
4091
|
+
}, t("compatNoticeDismiss")),
|
|
4092
|
+
)
|
|
4093
|
+
: null,
|
|
4051
4094
|
state.status === "ready" && comps.length > 0 && details === null && repoInfo === null
|
|
4052
4095
|
? el("button", {
|
|
4053
4096
|
type: "button",
|
|
@@ -4393,6 +4436,49 @@ window.__ModuleLoader__.load({
|
|
|
4393
4436
|
);
|
|
4394
4437
|
},
|
|
4395
4438
|
}, "🧹 " + t("cleanResiduals")),
|
|
4439
|
+
el("button", {
|
|
4440
|
+
type: "button",
|
|
4441
|
+
style: { position: "fixed", left: 12, bottom: 44, zIndex: 1000, opacity: 0.75 },
|
|
4442
|
+
title: t("cleanFrameworkResiduals"),
|
|
4443
|
+
onClick: () => {
|
|
4444
|
+
// 2026-09-28 加法:框架残留清理(顶层旧版本目录 / framework-backups)。两步走:
|
|
4445
|
+
// 先 dryRun 出报告 → window.confirm 让用户看清要动什么 → 才带 confirm:true 真执行。
|
|
4446
|
+
// 服务端默认 dryRun,所以「误点一下」不会动任何文件。
|
|
4447
|
+
const SCOPES = ["framework", "frameworkBackups", "pnpmEntities"];
|
|
4448
|
+
setMessage(t("cleanFrameworkResiduals") + "…");
|
|
4449
|
+
call("/plugin-console/clean-residuals", { scope: SCOPES }).then(
|
|
4450
|
+
(report) => {
|
|
4451
|
+
const fw = report && report.framework ? report.framework : null;
|
|
4452
|
+
const bk = report && report.frameworkBackups ? report.frameworkBackups : null;
|
|
4453
|
+
const pe = report && report.pnpmEntities ? report.pnpmEntities : null;
|
|
4454
|
+
const stale = fw && Array.isArray(fw.planned) ? fw.planned.length : 0;
|
|
4455
|
+
const pend = bk && Array.isArray(bk.plannedRemove) ? bk.plannedRemove : [];
|
|
4456
|
+
const mb = Math.round(pend.reduce((s, it) => s + (it && Number.isFinite(it.sizeBytes) ? it.sizeBytes : 0), 0) / 1048576);
|
|
4457
|
+
const entities = pe && Number.isFinite(pe.entityCount) ? pe.entityCount : 0;
|
|
4458
|
+
const head = t("cleanFrameworkResiduals") + ":" + t("cfStale").replace("{n}", String(stale))
|
|
4459
|
+
+ "、" + t("cfBackups").replace("{n}", String(pend.length)).replace("{mb}", String(mb))
|
|
4460
|
+
+ (entities > 0 ? ";" + t("cfEntities").replace("{n}", String(entities)) : "");
|
|
4461
|
+
if (stale === 0 && pend.length === 0) { setMessage(head + ";" + t("cfNothing")); return; }
|
|
4462
|
+
if (!window.confirm(head + "\n" + t("cfConfirm"))) { setMessage(head + ";" + t("cfCancelled")); return; }
|
|
4463
|
+
setMessage(t("cfRunning"));
|
|
4464
|
+
call("/plugin-console/clean-residuals", { scope: SCOPES, confirm: true, keep: 1 }).then(
|
|
4465
|
+
(done) => {
|
|
4466
|
+
const renamed = done && done.framework && Array.isArray(done.framework.renamed) ? done.framework.renamed.length : 0;
|
|
4467
|
+
const relinked = done && done.framework && Array.isArray(done.framework.relinked) ? done.framework.relinked.length : 0;
|
|
4468
|
+
const removed = done && done.frameworkBackups && Array.isArray(done.frameworkBackups.removed) ? done.frameworkBackups.removed.length : 0;
|
|
4469
|
+
const freed = done && done.frameworkBackups && Number.isFinite(done.frameworkBackups.freedBytes) ? Math.round(done.frameworkBackups.freedBytes / 1048576) : 0;
|
|
4470
|
+
const failed = (done && done.framework && Array.isArray(done.framework.failed) ? done.framework.failed.length : 0)
|
|
4471
|
+
+ (done && done.frameworkBackups && Array.isArray(done.frameworkBackups.failed) ? done.frameworkBackups.failed.length : 0);
|
|
4472
|
+
setMessage(t("cfDone").replace("{renamed}", String(renamed)).replace("{linked}", String(relinked)).replace("{removed}", String(removed)).replace("{mb}", String(freed))
|
|
4473
|
+
+ (failed > 0 ? ";" + t("cfFailed").replace("{n}", String(failed)) : ""));
|
|
4474
|
+
},
|
|
4475
|
+
(error) => setMessage(t("failed") + ":" + friendlyGithubError(error).message),
|
|
4476
|
+
);
|
|
4477
|
+
},
|
|
4478
|
+
(error) => setMessage(t("failed") + ":" + friendlyGithubError(error).message),
|
|
4479
|
+
);
|
|
4480
|
+
},
|
|
4481
|
+
}, "🧰 " + t("cleanFrameworkResiduals")),
|
|
4396
4482
|
fwShowCard
|
|
4397
4483
|
? el("div", { className: styles.detail },
|
|
4398
4484
|
el("div", { className: styles.rowTop },
|
|
@@ -197,7 +197,7 @@ function writeCompatPending(payload) {
|
|
|
197
197
|
* autoDetect —— 打开控制台时是否自动检测「已适配」(关掉 = 不显示可解锁提示) */
|
|
198
198
|
const COMPAT_GATE_FILE = () => join(dshHome(), 'plugin-console', 'compat-gate.json')
|
|
199
199
|
|
|
200
|
-
const COMPAT_GATE_DEFAULTS = { autoDisable: true, autoDetect: true }
|
|
200
|
+
const COMPAT_GATE_DEFAULTS = { autoDisable: true, autoDetect: true, noticeMode: 'once-per-version' }
|
|
201
201
|
|
|
202
202
|
function readCompatGate() {
|
|
203
203
|
try {
|
|
@@ -205,6 +205,8 @@ function readCompatGate() {
|
|
|
205
205
|
return {
|
|
206
206
|
autoDisable: raw?.autoDisable !== false,
|
|
207
207
|
autoDetect: raw?.autoDetect !== false,
|
|
208
|
+
// 兼容提示的展示模式(2026-09-28 加法):非法/缺失一律回落默认,**不影响 supported 判定**
|
|
209
|
+
noticeMode: NOTICE_MODES.includes(raw?.noticeMode) ? raw.noticeMode : DEFAULT_NOTICE_MODE,
|
|
208
210
|
}
|
|
209
211
|
} catch { return { ...COMPAT_GATE_DEFAULTS } }
|
|
210
212
|
}
|
|
@@ -218,6 +220,49 @@ function writeCompatGate(patch) {
|
|
|
218
220
|
return next
|
|
219
221
|
}
|
|
220
222
|
|
|
223
|
+
/** 兼容提示的展示模式(2026-09-28 加法):
|
|
224
|
+
* · `always` 每次都提示
|
|
225
|
+
* · `once-per-version` 同一框架版本只提示一次(默认;用户点「我知道了」后不再出现,**版本一变重新提示**)
|
|
226
|
+
* · `off` 不提示(需显式设置)
|
|
227
|
+
* ⚠ 它只影响**展示** —— `supported` 的判定与门控语义一个字都不变(披露不能被"关掉"成看不见的风险)。 */
|
|
228
|
+
const NOTICE_MODES = ['always', 'once-per-version', 'off']
|
|
229
|
+
const DEFAULT_NOTICE_MODE = 'once-per-version'
|
|
230
|
+
|
|
231
|
+
const COMPAT_ACK_FILE = () => join(dshHome(), 'plugin-console', 'compat-ack.json')
|
|
232
|
+
|
|
233
|
+
/** 读「已确认看过」的框架版本(按版本记:换版本自动重新提示)。 */
|
|
234
|
+
function readCompatAck() {
|
|
235
|
+
try {
|
|
236
|
+
const raw = JSON.parse(readFileSync(COMPAT_ACK_FILE(), 'utf8'))
|
|
237
|
+
return {
|
|
238
|
+
version: typeof raw?.version === 'string' && raw.version !== '' ? raw.version : null,
|
|
239
|
+
at: Number.isFinite(raw?.at) ? raw.at : null,
|
|
240
|
+
}
|
|
241
|
+
} catch { return { version: null, at: null } }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** 写 ack(整文件覆盖:只记最后一个确认过的版本,语义就是"这个版本我看过了")。 */
|
|
245
|
+
function writeCompatAck(version) {
|
|
246
|
+
const next = { version: String(version), at: Date.now() }
|
|
247
|
+
try {
|
|
248
|
+
mkdirSync(dirname(COMPAT_ACK_FILE()), { recursive: true })
|
|
249
|
+
writeFileSync(COMPAT_ACK_FILE(), JSON.stringify(next, null, 2), 'utf8')
|
|
250
|
+
} catch {}
|
|
251
|
+
return next
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** 提示该不该显示(纯函数,离线可断言)。**绝不修改 supported**。 */
|
|
255
|
+
function resolveNoticeState({ supported, notice, webAppVersion, noticeMode, ackVersion } = {}) {
|
|
256
|
+
if (notice === null || notice === undefined || notice === '') return { visible: false, reason: 'no-notice' }
|
|
257
|
+
if (supported === true) return { visible: false, reason: 'supported' }
|
|
258
|
+
const mode = NOTICE_MODES.includes(noticeMode) ? noticeMode : DEFAULT_NOTICE_MODE
|
|
259
|
+
if (mode === 'off') return { visible: false, reason: 'mode-off' }
|
|
260
|
+
if (mode === 'always') return { visible: true, reason: 'mode-always' }
|
|
261
|
+
const acked = typeof ackVersion === 'string' && ackVersion !== ''
|
|
262
|
+
&& typeof webAppVersion === 'string' && webAppVersion !== ''
|
|
263
|
+
&& ackVersion === webAppVersion
|
|
264
|
+
return acked ? { visible: false, reason: 'version-acked' } : { visible: true, reason: 'first-sight' }
|
|
265
|
+
}
|
|
221
266
|
/** 把框架适配检测结果格式化为给子代理的提示段。 */
|
|
222
267
|
function frameworkCheckPromptText(fc) {
|
|
223
268
|
if (fc === null || fc === undefined) return '(不可用)'
|
|
@@ -263,6 +308,27 @@ async function detectCompat(baseUrl) {
|
|
|
263
308
|
result.supported = false
|
|
264
309
|
result.notice = `当前 DSH web 包版本 ${result.webAppVersion} 不在受支持的 0.1.x 系列内,插件控制台的部分功能可能因官方破坏性更新而失效;请到 https://github.com/Noob-stupid/dsh-plugin-gating-hub 获取匹配的更新`
|
|
265
310
|
}
|
|
311
|
+
// 展示层(2026-09-28 加法):`supported` 与 `notice` 保持原样,这里只额外给出"该不该显示 + 为什么"。
|
|
312
|
+
try {
|
|
313
|
+
const gate = readCompatGate()
|
|
314
|
+
const ack = readCompatAck()
|
|
315
|
+
const resolved = resolveNoticeState({
|
|
316
|
+
supported: result.supported,
|
|
317
|
+
notice: result.notice,
|
|
318
|
+
webAppVersion: result.webAppVersion,
|
|
319
|
+
noticeMode: gate.noticeMode,
|
|
320
|
+
ackVersion: ack.version,
|
|
321
|
+
})
|
|
322
|
+
result.noticeMode = gate.noticeMode
|
|
323
|
+
result.noticeVisible = resolved.visible
|
|
324
|
+
result.noticeReason = resolved.reason
|
|
325
|
+
result.noticeAckedAt = ack.version !== null && ack.version === result.webAppVersion ? ack.at : null
|
|
326
|
+
} catch {
|
|
327
|
+
result.noticeMode = DEFAULT_NOTICE_MODE
|
|
328
|
+
result.noticeVisible = result.supported === false
|
|
329
|
+
result.noticeReason = 'fallback'
|
|
330
|
+
result.noticeAckedAt = null
|
|
331
|
+
}
|
|
266
332
|
return result
|
|
267
333
|
}
|
|
268
334
|
|
|
@@ -471,4 +537,4 @@ async function frameworkCompatReportFor(source, ports, profileDir) {
|
|
|
471
537
|
summary: parts.length > 0 ? parts.join(';') : `框架 ${fwVer},无待适配记录`,
|
|
472
538
|
}
|
|
473
539
|
}
|
|
474
|
-
export { gatingSummary, COMPAT_PENDING_FILE, COMPAT_GATE_FILE, COMPAT_GATE_DEFAULTS, REMOVED_SETTINGS_SYMBOLS, FIBER_STATE, FIBER_PHASE, SUPPORTED_WEB_APP_PATTERN, referencesRemovedSymbol, scanSettingsApiUsage, isFrameworkOwnedPackage, checkPluginFrameworkCompat, probePluginImport, readCompatPending, writeCompatPending, readCompatGate, writeCompatGate, frameworkCheckPromptText, CONSOLE_VERSION, detectCompat, detectAdoptablePending, rowIdModuleMap, frameworkCompatReportFor, maybeAutoAdaptCompat }
|
|
540
|
+
export { gatingSummary, COMPAT_PENDING_FILE, COMPAT_GATE_FILE, COMPAT_ACK_FILE, COMPAT_GATE_DEFAULTS, NOTICE_MODES, DEFAULT_NOTICE_MODE, REMOVED_SETTINGS_SYMBOLS, FIBER_STATE, FIBER_PHASE, SUPPORTED_WEB_APP_PATTERN, referencesRemovedSymbol, scanSettingsApiUsage, isFrameworkOwnedPackage, checkPluginFrameworkCompat, probePluginImport, readCompatPending, writeCompatPending, readCompatGate, writeCompatGate, readCompatAck, writeCompatAck, resolveNoticeState, frameworkCheckPromptText, CONSOLE_VERSION, detectCompat, detectAdoptablePending, rowIdModuleMap, frameworkCompatReportFor, maybeAutoAdaptCompat }
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// L1 · domain —— framework-cleanup.js(框架树残留的**动作**层:顶层版本漂移 / 框架备份保留;0.5.30 加法)
|
|
2
|
+
//
|
|
3
|
+
// 判据在 domain/framework-residuals.js(纯函数);这里只负责「读现场 → 执行 → 如实回报」。
|
|
4
|
+
// 真机背景见该模块头注释(顶层 239 个 0.1.5-rc.2 真实目录;framework-backups 12 条快照约 5.1 GB)。
|
|
5
|
+
//
|
|
6
|
+
// 三条安全约定(对齐仓库既有纪律):
|
|
7
|
+
// ① **一律先改名备份**:顶层旧目录 → `<name>.stale-<ver>-<ts>`,绝不直接删(可回滚)。
|
|
8
|
+
// ② 重建 junction 只在**目标确实存在**时做:目标从 `dsh` junction 的同级目录推(运行树的真实位置);
|
|
9
|
+
// 推不到或目标不存在就只改名,并如实记进 `relinkSkipped`(不假装成功)。
|
|
10
|
+
// ③ 框架备份只删 `fw-tree/<ts>` 目录,**绝不碰** `dsh-package-backup` 与目录下的快照文件。
|
|
11
|
+
|
|
12
|
+
import { existsSync, readdirSync, readFileSync, readlinkSync, renameSync, statSync, symlinkSync } from 'node:fs'
|
|
13
|
+
import { dirname, join } from 'node:path'
|
|
14
|
+
import { removeDirVerifiedAsync } from '../infra/fsx.js'
|
|
15
|
+
import { dshHome, resolvePackageJson } from '../infra/paths.js'
|
|
16
|
+
import { classifyTopLevel, planBackupRetention } from './framework-residuals.js'
|
|
17
|
+
|
|
18
|
+
/** 目录体积(失败返回 0;只用于报告「能释放多少」)。 */
|
|
19
|
+
function dirSizeBytes(dir) {
|
|
20
|
+
let total = 0
|
|
21
|
+
const walk = (d) => {
|
|
22
|
+
let entries = []
|
|
23
|
+
try { entries = readdirSync(d, { withFileTypes: true }) } catch { return }
|
|
24
|
+
for (const entry of entries) {
|
|
25
|
+
const full = join(d, entry.name)
|
|
26
|
+
try {
|
|
27
|
+
if (entry.isDirectory()) walk(full)
|
|
28
|
+
else if (entry.isFile()) total += statSync(full).size
|
|
29
|
+
} catch {}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
walk(dir)
|
|
33
|
+
return total
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 是不是链接(Windows 的 junction 在 dirent 上不一定报 symbolic link → readlink 兜底)。 */
|
|
37
|
+
function isLinkEntry(full, dirent, readLink = readlinkSync) {
|
|
38
|
+
try { if (dirent.isSymbolicLink()) return true } catch {}
|
|
39
|
+
try { readLink(full); return true } catch { return false }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A 档:顶层投影里的旧版本真实目录 → 改名备份(+ 可选重建 junction)。
|
|
44
|
+
* `topScopeDir` = `<frameworkRoot>/node_modules/@deepseek-ai`;`dshLinkPath` = 同目录下的 `dsh`(运行树入口)。
|
|
45
|
+
*/
|
|
46
|
+
async function cleanFrameworkTopLevel({ topScopeDir, dshLinkPath = null, runningVersion = null, relink = true, dryRun = false, stamp = null, deps = {} } = {}) {
|
|
47
|
+
const {
|
|
48
|
+
exists = existsSync, readdir = readdirSync, readText = (f) => readFileSync(f, 'utf8'),
|
|
49
|
+
rename = renameSync, link = symlinkSync, readLink = readlinkSync,
|
|
50
|
+
} = deps
|
|
51
|
+
if (topScopeDir === null || topScopeDir === undefined || !exists(topScopeDir)) {
|
|
52
|
+
return { ok: false, error: '框架顶层目录不存在(拿不到 @deepseek-ai/* 的真实位置)', renamed: [], relinked: [], relinkSkipped: [], failed: [] }
|
|
53
|
+
}
|
|
54
|
+
const entries = []
|
|
55
|
+
for (const dirent of readdir(topScopeDir, { withFileTypes: true })) {
|
|
56
|
+
const full = join(topScopeDir, dirent.name)
|
|
57
|
+
let version = null
|
|
58
|
+
try { version = JSON.parse(readText(join(full, 'package.json'))).version ?? null } catch { version = null }
|
|
59
|
+
entries.push({
|
|
60
|
+
name: dirent.name,
|
|
61
|
+
version,
|
|
62
|
+
isLink: isLinkEntry(full, dirent, readLink),
|
|
63
|
+
sizeBytes: dirSizeBytes(full),
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
const classified = classifyTopLevel(entries, { runningVersion })
|
|
67
|
+
const runningScopeDir = (() => {
|
|
68
|
+
if (dshLinkPath === null) return null
|
|
69
|
+
try { return dirname(readLink(dshLinkPath)) } catch { return null }
|
|
70
|
+
})()
|
|
71
|
+
const ts = stamp === null ? String(Date.now()) : String(stamp)
|
|
72
|
+
const renamed = []
|
|
73
|
+
const relinked = []
|
|
74
|
+
const relinkSkipped = []
|
|
75
|
+
const failed = []
|
|
76
|
+
for (const item of (dryRun === true ? [] : classified.stale)) {
|
|
77
|
+
const backupName = item.name + '.stale-' + item.version + '-' + ts
|
|
78
|
+
try {
|
|
79
|
+
rename(join(topScopeDir, item.name), join(topScopeDir, backupName))
|
|
80
|
+
} catch (error) {
|
|
81
|
+
failed.push({ name: item.name, error: String(error?.message ?? error) })
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
renamed.push({ name: item.name, version: item.version, backup: backupName, sizeBytes: item.sizeBytes ?? null })
|
|
85
|
+
if (relink !== true) { relinkSkipped.push({ name: item.name, reason: 'relink-off' }); continue }
|
|
86
|
+
if (runningScopeDir === null) { relinkSkipped.push({ name: item.name, reason: 'no-running-scope' }); continue }
|
|
87
|
+
const target = join(runningScopeDir, item.name)
|
|
88
|
+
let targetExists = false
|
|
89
|
+
try { targetExists = exists(target) } catch { targetExists = false }
|
|
90
|
+
if (!targetExists) { relinkSkipped.push({ name: item.name, reason: 'target-missing' }); continue }
|
|
91
|
+
try {
|
|
92
|
+
link(target, join(topScopeDir, item.name), 'junction')
|
|
93
|
+
relinked.push({ name: item.name, target })
|
|
94
|
+
} catch (error) {
|
|
95
|
+
failed.push({ name: item.name, error: '已改名但重建链接失败:' + String(error?.message ?? error) })
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
ok: failed.length === 0,
|
|
100
|
+
renamed,
|
|
101
|
+
relinked,
|
|
102
|
+
relinkSkipped,
|
|
103
|
+
failed,
|
|
104
|
+
staleCount: classified.stale.length,
|
|
105
|
+
sameVersionCount: classified.sameVersion.length,
|
|
106
|
+
missingVersion: classified.missingVersion,
|
|
107
|
+
dryRun,
|
|
108
|
+
planned: classified.stale.map((s) => ({ name: s.name, version: s.version, sizeBytes: s.sizeBytes ?? null })),
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* C 档:`framework-backups/<版本>/fw-tree/<ts>` 快照按「回滚点那条 ∪ 最近 N 条」保留,其余删除。
|
|
114
|
+
* 只删 fw-tree 快照目录;`dsh-package-backup` 与目录下的快照文件一律不碰。
|
|
115
|
+
*/
|
|
116
|
+
async function cleanFrameworkBackups({ backupsRoot, referenced = [], keep = 1, dryRun = false, deps = {} } = {}) {
|
|
117
|
+
const { exists = existsSync, readdir = readdirSync, stat = statSync, remove = removeDirVerifiedAsync, sizeOf = dirSizeBytes } = deps
|
|
118
|
+
if (backupsRoot === null || backupsRoot === undefined || !exists(backupsRoot)) {
|
|
119
|
+
return { ok: false, error: '没有框架备份目录(无需清理)', snapshots: 0, kept: [], removed: [], failed: [], freedBytes: 0 }
|
|
120
|
+
}
|
|
121
|
+
const snapshots = []
|
|
122
|
+
for (const versionDir of readdir(backupsRoot, { withFileTypes: true })) {
|
|
123
|
+
if (!versionDir.isDirectory()) continue
|
|
124
|
+
const fwTree = join(backupsRoot, versionDir.name, 'fw-tree')
|
|
125
|
+
if (!exists(fwTree)) continue
|
|
126
|
+
for (const snap of readdir(fwTree, { withFileTypes: true })) {
|
|
127
|
+
if (!snap.isDirectory()) continue
|
|
128
|
+
const full = join(fwTree, snap.name)
|
|
129
|
+
let mtimeMs = 0
|
|
130
|
+
try { mtimeMs = stat(full).mtimeMs } catch { mtimeMs = 0 }
|
|
131
|
+
let sizeBytes = 0
|
|
132
|
+
try { sizeBytes = sizeOf(full) } catch { sizeBytes = 0 }
|
|
133
|
+
snapshots.push({ path: full, version: versionDir.name, id: snap.name, mtimeMs, sizeBytes })
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const plan = planBackupRetention(snapshots, { referenced, keep })
|
|
137
|
+
const removed = []
|
|
138
|
+
const failed = []
|
|
139
|
+
for (const snap of (dryRun === true ? [] : plan.remove)) {
|
|
140
|
+
try {
|
|
141
|
+
const res = await remove(snap.path)
|
|
142
|
+
if (res !== null && res !== undefined && res.ok === false) failed.push({ path: snap.path, error: res.error ?? '删除失败' })
|
|
143
|
+
else removed.push({ path: snap.path, sizeBytes: snap.sizeBytes ?? 0 })
|
|
144
|
+
} catch (error) {
|
|
145
|
+
failed.push({ path: snap.path, error: String(error?.message ?? error) })
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const freedBytes = removed.reduce((sum, item) => sum + (Number.isFinite(item.sizeBytes) ? item.sizeBytes : 0), 0)
|
|
149
|
+
return {
|
|
150
|
+
ok: failed.length === 0,
|
|
151
|
+
snapshots: snapshots.length,
|
|
152
|
+
kept: plan.kept.map((s) => ({ path: s.path, reason: s.reason, sizeBytes: s.sizeBytes ?? 0 })),
|
|
153
|
+
removed,
|
|
154
|
+
failed,
|
|
155
|
+
freedBytes,
|
|
156
|
+
dryRun,
|
|
157
|
+
plannedRemove: plan.remove.map((s) => ({ path: s.path, version: s.version, sizeBytes: s.sizeBytes ?? 0 })),
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* B 档(**只报告,不删**):`.pnpm` 里版本 ≠ 运行版本的框架包实体。
|
|
163
|
+
* 手删 `.pnpm` 会让 lockfile 与实际不一致(真机教训:9/24 一个 package.json 缺失 → framework.version=null),
|
|
164
|
+
* 所以这里只点名 + 给建议(用 pnpm 自己同步),不提供删除。
|
|
165
|
+
*/
|
|
166
|
+
function reportFrameworkEntities({ pnpmDir, runningVersion = null } = {}) {
|
|
167
|
+
if (pnpmDir === null || pnpmDir === undefined || !existsSync(pnpmDir)) {
|
|
168
|
+
return { ok: false, error: '拿不到 .pnpm 目录', groups: [], entityCount: 0 }
|
|
169
|
+
}
|
|
170
|
+
const groups = new Map()
|
|
171
|
+
for (const dirent of readdirSync(pnpmDir, { withFileTypes: true })) {
|
|
172
|
+
if (!dirent.isDirectory()) continue
|
|
173
|
+
const m = dirent.name.match(/^@deepseek-ai\+dsh(?:-[a-z0-9]+)*@(\d+\.\d+\.\d+[^_]*)_/u)
|
|
174
|
+
if (m === null) continue
|
|
175
|
+
const version = m[1]
|
|
176
|
+
if (runningVersion !== null && version === String(runningVersion)) continue
|
|
177
|
+
if (!groups.has(version)) groups.set(version, [])
|
|
178
|
+
groups.get(version).push(dirent.name)
|
|
179
|
+
}
|
|
180
|
+
const out = [...groups.entries()]
|
|
181
|
+
.map(([version, names]) => ({ version, count: names.length, sample: names.slice(0, 3) }))
|
|
182
|
+
.sort((a, b) => a.version.localeCompare(b.version))
|
|
183
|
+
return {
|
|
184
|
+
ok: true,
|
|
185
|
+
groups: out,
|
|
186
|
+
entityCount: out.reduce((sum, g) => sum + g.count, 0),
|
|
187
|
+
hint: '这些是历史版本的虚拟 store 实体;建议用 pnpm 自己同步(在该框架根跑 pnpm install),不要手删目录',
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
/** 框架布局推导(compat.js 同款取法):找「含 .pnpm 的那层 node_modules」= 顶层投影目录。
|
|
193
|
+
* 返回 { fwRoot, topScopeDir, dshLinkPath, pnpmDir } —— 顶层 239 个旧真实目录就在 topScopeDir 下。 */
|
|
194
|
+
function resolveFrameworkLayout({ baseDir, profileDir, deps = {} } = {}) {
|
|
195
|
+
const { exists = existsSync, resolvePkg = resolvePackageJson } = deps
|
|
196
|
+
let fwRoot = null
|
|
197
|
+
try {
|
|
198
|
+
const pkgPath = resolvePkg('@deepseek-ai/dsh', baseDir, profileDir)
|
|
199
|
+
if (pkgPath !== null && pkgPath !== undefined) {
|
|
200
|
+
let dir = dirname(dirname(pkgPath))
|
|
201
|
+
for (let i = 0; i < 6; i += 1) {
|
|
202
|
+
if (exists(join(dir, '.pnpm'))) { fwRoot = dir; break }
|
|
203
|
+
const up = dirname(dir)
|
|
204
|
+
if (up === dir) break
|
|
205
|
+
dir = up
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
} catch {}
|
|
209
|
+
return {
|
|
210
|
+
fwRoot,
|
|
211
|
+
topScopeDir: fwRoot === null ? null : join(fwRoot, '@deepseek-ai'),
|
|
212
|
+
dshLinkPath: fwRoot === null ? null : join(fwRoot, '@deepseek-ai', 'dsh'),
|
|
213
|
+
pnpmDir: fwRoot === null ? null : join(fwRoot, '.pnpm'),
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** 回滚点引用的路径(framework-rollback.json 的 checkpointDir / cliBackupDir)—— 这些永远不删。 */
|
|
218
|
+
function readRollbackReferenced({ home = undefined, deps = {} } = {}) {
|
|
219
|
+
const { readText = (p) => readFileSync(p, 'utf8'), base = dshHome() } = deps
|
|
220
|
+
try {
|
|
221
|
+
const raw = JSON.parse(readText(join(home ?? base, 'plugin-console', 'framework-rollback.json')))
|
|
222
|
+
return [raw?.checkpointDir, raw?.cliBackupDir].filter((p) => typeof p === 'string' && p !== '')
|
|
223
|
+
} catch { return [] }
|
|
224
|
+
}
|
|
225
|
+
export { cleanFrameworkBackups, cleanFrameworkTopLevel, dirSizeBytes, isLinkEntry, readRollbackReferenced, reportFrameworkEntities, resolveFrameworkLayout }
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// L1 · domain —— framework-residuals.js(框架树残留判据 + 备份保留策略;0.5.30 加法)
|
|
2
|
+
//
|
|
3
|
+
// 真机背景(2026-09-28):
|
|
4
|
+
// ① 框架树顶层 `@deepseek-ai/` 有 **239 个非 junction 的真实目录**,其中 230 个还停在
|
|
5
|
+
// 0.1.5-rc.2(npm 时代残留):`dsh` 一个被 junction 到 0.2.0-rc.1 → 于是"从顶层解析"与
|
|
6
|
+
// "从运行树解析"会拿到**不同版本**(9/10 桌面端扫描错配、9/24 schemastery 3.18.1 漂移同族)。
|
|
7
|
+
// ② `~/.dsh/plugin-console/framework-backups` 堆了 12 条 fw-tree 快照(约 5.1 GB),
|
|
8
|
+
// 而回滚只需要**回滚点引用的那一条** + **最近 N 条**。
|
|
9
|
+
//
|
|
10
|
+
// 本模块只做纯判据(IO 全由调用方注入),动作在 routes/plugins.js 的 clean-residuals scope 里:
|
|
11
|
+
// · `classifyTopLevel` → 哪些顶层真实目录属于"旧版本残留"(同版本/链接一律不动 —— 宁可少动)
|
|
12
|
+
// · `planBackupRetention` → 保留「回滚点那一条 ∪ 最近 N 条」,其余进删除清单 + 可释放字节
|
|
13
|
+
//
|
|
14
|
+
// 行数预算:独立模块,留在 600 行架构硬顶内。
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 顶层条目分类(纯函数)。
|
|
18
|
+
* `entries` 形如 `[{ name, version, isLink, sizeBytes }]`;`isLink` 为真表示已被 junction/符号链接接管。
|
|
19
|
+
* **保守取舍**:同版本保留;缺版本(读不到 package.json)保留并单列 —— 判不准就不动。
|
|
20
|
+
*/
|
|
21
|
+
function classifyTopLevel(entries, { runningVersion = null } = {}) {
|
|
22
|
+
const stale = []
|
|
23
|
+
const sameVersion = []
|
|
24
|
+
const missingVersion = []
|
|
25
|
+
for (const entry of (Array.isArray(entries) ? entries : [])) {
|
|
26
|
+
if (entry === null || typeof entry !== 'object' || typeof entry.name !== 'string' || entry.name === '') continue
|
|
27
|
+
if (entry.name.startsWith('.')) continue
|
|
28
|
+
if (entry.isLink === true) continue
|
|
29
|
+
const version = entry.version === undefined || entry.version === null ? '' : String(entry.version)
|
|
30
|
+
if (version === '') { missingVersion.push({ name: entry.name, sizeBytes: entry.sizeBytes ?? null }); continue }
|
|
31
|
+
if (runningVersion !== null && version === String(runningVersion)) { sameVersion.push({ name: entry.name, version }); continue }
|
|
32
|
+
stale.push({ name: entry.name, version, sizeBytes: entry.sizeBytes ?? null })
|
|
33
|
+
}
|
|
34
|
+
return { stale, sameVersion, missingVersion }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 备份保留策略(纯函数):保留集合 = 「回滚点引用的路径」∪「按 mtime 最近的 N 条」。
|
|
39
|
+
* `keep` 非法/缺失时按 1 处理(最小保留)。返回 `{ kept, remove, freedBytes }`,
|
|
40
|
+
* `kept` 每条带 `reason: 'rollback-point' | 'recent'`(面板要说清为什么留)。
|
|
41
|
+
*/
|
|
42
|
+
function planBackupRetention(snapshots, { referenced = [], keep = 1 } = {}) {
|
|
43
|
+
const keepCount = Number.isFinite(keep) && keep >= 0 ? Math.floor(keep) : 1
|
|
44
|
+
const pinned = new Set((Array.isArray(referenced) ? referenced : []).filter((p) => typeof p === 'string' && p !== ''))
|
|
45
|
+
const list = (Array.isArray(snapshots) ? snapshots : [])
|
|
46
|
+
.filter((s) => s !== null && typeof s === 'object' && typeof s.path === 'string' && s.path !== '')
|
|
47
|
+
.sort((a, b) => (Number.isFinite(b.mtimeMs) ? b.mtimeMs : 0) - (Number.isFinite(a.mtimeMs) ? a.mtimeMs : 0))
|
|
48
|
+
const kept = []
|
|
49
|
+
const remove = []
|
|
50
|
+
list.forEach((snap, index) => {
|
|
51
|
+
if (pinned.has(snap.path)) { kept.push({ ...snap, reason: 'rollback-point' }); return }
|
|
52
|
+
if (index < keepCount) { kept.push({ ...snap, reason: 'recent' }); return }
|
|
53
|
+
remove.push(snap)
|
|
54
|
+
})
|
|
55
|
+
const freedBytes = remove.reduce((sum, snap) => sum + (Number.isFinite(snap.sizeBytes) ? snap.sizeBytes : 0), 0)
|
|
56
|
+
return { kept, remove, freedBytes }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export { classifyTopLevel, planBackupRetention }
|