@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 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
- state.status === "ready" && state.data.compat && !state.data.compat.supported
4049
- ? el("p", { className: styles.message, "data-error": "true" }, state.data.compat.notice)
4050
- : null,
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 }