@noob-stupid/dsh-plugin-console 0.5.77 → 0.5.87

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.
@@ -103,6 +103,18 @@ function checkInsideDevRoots(home, candidate) {
103
103
  const rootNorm = normalizePath(root)
104
104
  if (isInside(norm, rootNorm)) return { ok: true, reason: null, normalized: norm, root }
105
105
  }
106
+ // 2026-10-18 改错(0.5.87):**它就是某个已注册的根本身**时,旧文案说"不在任何已注册的开发区根里"
107
+ // —— 而它恰恰就在里面(只是"根"不算"根之内",这条边界是刻意的、不改)。那句话会把人引向错误的
108
+ // 下一步(去注册一个已经注册过的根),所以这里单列一句准确的话:要钉的是**根里的那个插件目录**。
109
+ const rootNormItself = roots.map((root) => normalizePath(root)).find((rootNorm) => rootNorm === norm) ?? null
110
+ if (rootNormItself !== null) {
111
+ return {
112
+ ok: false,
113
+ reason: `这是开发区根本身,不是根里的插件目录:${candidate}\n请指向根里的那个目录(例如 ${join(candidate, '<插件目录名>')})——「根」不能当依赖目标,这是刻意的边界`,
114
+ normalized: norm,
115
+ root: null,
116
+ }
117
+ }
106
118
  return {
107
119
  ok: false,
108
120
  reason: `不在任何已注册的开发区根里:${candidate}\n已注册:${roots.join(' / ')}\n(这是刻意的限制:只允许钉住开发区里的目录,不允许任意路径)`,
@@ -0,0 +1,330 @@
1
+ // L1 · domain —— disk-audit.js(`<DSH_HOME>/plugin-console` 的**只读体检** + **用户确认后**的清理;0.5.84 加法)
2
+ // 分层分组:L1 · domain(不认识 cordis ctx;读盘/删除全部可注入)
3
+ //
4
+ // ── 为什么(用户 2026-10 第 7 项)────────────────────────────────────────────────
5
+ // 这个目录涨到 2.1 GB:`framework-backups/` 里躺着历次框架升级前的**整棵树**(含全平台 office kit)。
6
+ // 用户要的是「先看清楚谁占的,再决定删什么」——**绝不自动删**。
7
+ //
8
+ // ── 本模块的死规矩(每一条都有测试盯着;违背任何一条都比不清理更糟)───────────────
9
+ // ① **只读体检**:`auditConsoleDir()` 只统计,绝不删、不写、不改任何东西。
10
+ // ② **候选分级**:每一项标 `level`:
11
+ // · 'safe' —— 删掉不影响任何功能(日志、旧自删守护留下的计数残渣、`*.bak-*` 状态备份、
12
+ // 本机测试残留 `persist-e2e-*`);
13
+ // · 'tradeoff' —— 删掉**会失去某种回退能力**(旧版本的框架备份、profile 快照备份)——
14
+ // 必须让人知道代价,仍然允许,但要说清楚;
15
+ // · 'protected' —— **绝不**列为可删:当前回滚点指向的检查点、当前框架版本的备份、
16
+ // 控制台自己的状态文件(compat-* / framework-state.json / install-jobs.json / sources…)。
17
+ // ⚠️ 真机实测(2026-10):最大的那 2.1 GB 正是 `framework-backups/0.2.0-rc.1` ——
18
+ // 而 `framework-rollback.json` 的 `checkpointDir` **就指向它**。所以"最大的那个"恰恰是**不能删**的:
19
+ // 删了等于把当前唯一的框架回滚点销毁。判据必须从回滚记录里读,不能按体积猜。
20
+ // ③ **边界**:只允许删 `<root>` 之内、且经 `planCleanup()` 白名单放行的项;任何绝对路径/`..`一律拒。
21
+ // ④ **必须显式确认**:`runCleanup({ confirm: true })` 才动;否则返回 plan("将删什么、能省多少")。
22
+ // ⑤ **删完读回核实**:删不干净就如实回报(本仓 fsx.js 记过:Windows 上 rmSync 会静默落空)。
23
+ import { existsSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'
24
+ import { join, resolve, sep } from 'node:path'
25
+
26
+ /** 单次体检最多统计多少个文件(防止在异常目录上跑到天荒地老;超了如实标记 truncated)。 */
27
+ const MAX_FILES = 60000
28
+ /** 认为是"日志"的文件名。 */
29
+ const LOG_RE = /\.log$/iu
30
+ /** 状态备份(旧实现留下的 `.bak-<时间>`):删了只失去"那时候的状态"。 */
31
+ const STATE_BAK_RE = /\.bak-\d/iu
32
+ /** 旧实现留下的自删守护计数(0.5.84 的代码已经不再创建它们)。 */
33
+ const GUARD_RESIDUE_RE = /^restart-guard-\d+\.count$/iu
34
+ /** 本机测试残留(含旧实现留下的 console-restart.log)。 */
35
+ const TEST_RESIDUE_RE = /^(?:persist-e2e-|console-restart\.log$)/iu
36
+ /** 需要**按子项**展开的目录:判据要细到"哪一份备份",而不是整个目录一刀切。
37
+ * 为什么必须展开(真机 2026-10 实测):`framework-backups/` 里 2.1 GB 全在 0.2.0-rc.1 这一份,
38
+ * 而回滚点正好指向它;其余 9 份加起来不到 2 MB。若按顶层整目录判,整块只能标"受保护"⇒
39
+ * 用户永远清不掉那 9 份旧备份;展开成子项后,只有被回滚点引用的那份受保护,其余标 tradeoff。 */
40
+ const EXPAND_DIRS = new Set(['framework-backups', 'profile-sync-backups'])
41
+ /** 绝不能删的状态文件(名字精确匹配或前缀匹配)。 */
42
+ const PROTECTED_FILES = new Set([
43
+ 'framework-rollback.json', 'framework-state.json', 'install-jobs.json', 'compat-pending.json',
44
+ 'compat-gate.json', 'compat-mode.json', 'compat-ack.json', 'components.json', 'repo-land.json',
45
+ 'preset-sources.json', 'auto-preflight.json', 'env-fingerprint.json', 'fw-quarantine.json',
46
+ 'fw-quarantine-candidates.json', 'sources.json', 'ai-jobs.json', 'lock-autofix.json',
47
+ ])
48
+ const PROTECTED_PREFIX = ['fw-upgrade-state.txt', 'link-self-sufficiency', 'source-specs', 'safe-boot', 'subdir-src']
49
+
50
+ /** 递归量体积(字节 + 文件数);只读,且带 MAX_FILES 上限。 */
51
+ function measure(dir, { readdir = readdirSync, stat = statSync, budget = { files: MAX_FILES } } = {}) {
52
+ let bytes = 0
53
+ let files = 0
54
+ let truncated = false
55
+ const walk = (current) => {
56
+ let entries = []
57
+ try {
58
+ entries = readdir(current, { withFileTypes: true })
59
+ } catch {
60
+ return
61
+ }
62
+ for (const entry of entries) {
63
+ if (budget.files <= 0) { truncated = true; return }
64
+ const full = join(current, entry.name)
65
+ if (entry.isDirectory()) {
66
+ walk(full)
67
+ continue
68
+ }
69
+ budget.files -= 1
70
+ files += 1
71
+ try {
72
+ bytes += stat(full).size
73
+ } catch {}
74
+ }
75
+ }
76
+ walk(dir)
77
+ return { bytes, files, truncated }
78
+ }
79
+
80
+ /** 当前框架回滚记录(读不到返回 null)——**最大的那份备份往往就是它指向的**。 */
81
+ function readRollbackPointer({ root, readFile = (p) => readFileSync(p, 'utf8') } = {}) {
82
+ try {
83
+ const rec = JSON.parse(readFile(join(root, 'framework-rollback.json')))
84
+ return {
85
+ from: typeof rec?.from === 'string' ? rec.from : null,
86
+ to: typeof rec?.to === 'string' ? rec.to : null,
87
+ checkpointDir: typeof rec?.checkpointDir === 'string' ? rec.checkpointDir : null,
88
+ cliBackupDir: typeof rec?.cliBackupDir === 'string' ? rec.cliBackupDir : null,
89
+ at: typeof rec?.at === 'number' ? rec.at : null,
90
+ }
91
+ } catch {
92
+ return null
93
+ }
94
+ }
95
+
96
+ const mb = (bytes) => Math.round((bytes / 1024 / 1024) * 100) / 100
97
+
98
+ /**
99
+ * **只读体检**:返回
100
+ * `{ ok, root, totalBytes, totalFiles, truncated, rollback, entries:[{ id, name, kind, bytes, files, level, reason }], reclaimableBytes, safeBytes }`。
101
+ * `entries` 按体积降序;`level` 见文件头 ②。任何一项都不删、不写。
102
+ */
103
+ function auditConsoleDir({ root, deps = {} } = {}) {
104
+ const base = typeof root === 'string' && root !== '' ? resolve(root) : null
105
+ if (base === null) return { ok: false, error: '拿不到 plugin-console 目录', entries: [], totalBytes: 0, totalFiles: 0, truncated: false, rollback: null, reclaimableBytes: 0, safeBytes: 0 }
106
+ if (!existsSync(base)) return { ok: true, root: base, totalBytes: 0, totalFiles: 0, truncated: false, rollback: null, entries: [], reclaimableBytes: 0, safeBytes: 0, note: '目录还不存在(没产生过备份/日志)' }
107
+ const readdir = deps.readdir ?? readdirSync
108
+ const stat = deps.stat ?? statSync
109
+ const rollback = readRollbackPointer({ root: base, ...(deps.readFile === undefined ? {} : { readFile: deps.readFile }) })
110
+ const budget = { files: MAX_FILES }
111
+ let names = []
112
+ try {
113
+ names = readdir(base)
114
+ } catch (error) {
115
+ return { ok: false, error: error instanceof Error ? error.message : String(error), entries: [], totalBytes: 0, totalFiles: 0, truncated: false, rollback, reclaimableBytes: 0, safeBytes: 0 }
116
+ }
117
+ const entries = []
118
+ let totalBytes = 0
119
+ let totalFiles = 0
120
+ let truncated = false
121
+ for (const name of names) {
122
+ const full = join(base, name)
123
+ let isDir = false
124
+ let fileBytes = 0
125
+ try {
126
+ const st = stat(full)
127
+ isDir = st.isDirectory()
128
+ if (!isDir) fileBytes = st.size
129
+ } catch {
130
+ continue
131
+ }
132
+ if (isDir && EXPAND_DIRS.has(name)) {
133
+ // 展开成子项(见 EXPAND_DIRS 的注释:判据要细到"哪一份备份")
134
+ let children = []
135
+ try {
136
+ children = readdir(full)
137
+ } catch {
138
+ children = []
139
+ }
140
+ for (const child of children) {
141
+ const childFull = join(full, child)
142
+ let childIsDir = false
143
+ try {
144
+ childIsDir = stat(childFull).isDirectory()
145
+ } catch {
146
+ continue
147
+ }
148
+ const m = childIsDir ? measure(childFull, { readdir, stat, budget }) : { bytes: stat(childFull).size, files: 1, truncated: false }
149
+ if (m.truncated) truncated = true
150
+ totalBytes += m.bytes
151
+ totalFiles += m.files
152
+ entries.push({ id: `${name}/${child}`, name: child, parent: name, full: childFull, isDir: childIsDir, bytes: m.bytes, files: m.files })
153
+ }
154
+ continue
155
+ }
156
+ let bytes = fileBytes
157
+ let files = isDir ? 0 : 1
158
+ if (isDir) {
159
+ const m = measure(full, { readdir, stat, budget })
160
+ bytes = m.bytes
161
+ files = m.files
162
+ if (m.truncated) truncated = true
163
+ }
164
+ totalBytes += bytes
165
+ totalFiles += files
166
+ entries.push({ id: name, name, parent: null, full, isDir, bytes, files })
167
+ }
168
+ // ── 分级(唯一判据;面板与清理都不许另判一套)──────────────────────────────────
169
+ const inRollback = []
170
+ for (const p of [rollback?.checkpointDir, rollback?.cliBackupDir]) {
171
+ if (typeof p === 'string' && p !== '') inRollback.push(resolve(p))
172
+ }
173
+ const classify = (entry) => {
174
+ const { name, full, isDir, parent } = entry
175
+ // ① 回滚点指向的东西:绝不列为可删(真机实测:最大的 2.1 GB 就是这个)
176
+ if (inRollback.some((p) => resolve(full) === p || resolve(full) === resolve(p, '..') || p.startsWith(resolve(full) + sep))) {
177
+ return { level: 'protected', reason: '当前框架回滚点(framework-rollback.json)指向这里 —— 删掉就等于销毁唯一的回滚点' }
178
+ }
179
+ // ② 纯垃圾类(**先于**状态文件判据:`fw-upgrade-state.txt.bak-<时间>` 是那份状态的旧备份,
180
+ // 删它不丢当前状态;而 `fw-upgrade-state.txt` 本身仍然受下面的前缀保护)
181
+ if (!isDir && (LOG_RE.test(name) || GUARD_RESIDUE_RE.test(name) || STATE_BAK_RE.test(name) || TEST_RESIDUE_RE.test(name))) {
182
+ return { level: 'safe', reason: LOG_RE.test(name) ? '日志:删了只是丢历史输出'
183
+ : GUARD_RESIDUE_RE.test(name) ? '旧实现自删守护留下的计数残渣(现在的代码已不创建)'
184
+ : STATE_BAK_RE.test(name) ? '旧状态文件的 .bak 备份(当前状态文件不在清理范围)'
185
+ : '本机测试残留' }
186
+ }
187
+ if (isDir && TEST_RESIDUE_RE.test(name)) return { level: 'safe', reason: '本机测试残留' }
188
+ // ③ 控制台状态:一律不许动
189
+ if (PROTECTED_FILES.has(name) || PROTECTED_PREFIX.some((p) => name === p || name.startsWith(p))) {
190
+ return { level: 'protected', reason: '控制台状态文件/受保护目录(删了会丢配置、记录或已安装子目录插件的源码)' }
191
+ }
192
+ // ④ 展开出来的子项(framework-backups/<版本> / profile-sync-backups/<快照>)
193
+ if (parent === 'framework-backups') {
194
+ return { level: 'tradeoff', reason: '某一版本框架升级前的整棵树备份:删掉会失去回滚到该版本的能力(当前回滚点引用的那一份不在此列)' }
195
+ }
196
+ if (parent === 'profile-sync-backups') {
197
+ return { level: 'tradeoff', reason: '某一次 profile 快照备份:删掉会失去那一次的快照,不影响当前运行' }
198
+ }
199
+ if (isDir && (name === 'backups' || /^pre-upgrade-snapshot-/u.test(name))) {
200
+ return { level: 'tradeoff', reason: '历史快照备份:删掉会失去那几次的快照,不影响当前运行' }
201
+ }
202
+ if (isDir && name === 'release-0.5.38') {
203
+ return { level: 'tradeoff', reason: '旧版本的发布物副本:不影响当前运行' }
204
+ }
205
+ return { level: 'protected', reason: '未知/状态类条目:不在清理白名单里(宁可不删)' }
206
+ }
207
+ const graded = entries.map((entry) => {
208
+ const g = classify(entry)
209
+ return {
210
+ id: entry.id,
211
+ name: entry.name,
212
+ parent: entry.parent ?? null,
213
+ kind: entry.isDir ? 'dir' : 'file',
214
+ mb: mb(entry.bytes),
215
+ bytes: entry.bytes,
216
+ files: entry.files,
217
+ level: g.level,
218
+ reason: g.reason,
219
+ }
220
+ }).sort((a, b) => b.bytes - a.bytes)
221
+ const reclaimable = graded.filter((e) => e.level === 'safe' || e.level === 'tradeoff')
222
+ const safe = graded.filter((e) => e.level === 'safe')
223
+ return {
224
+ ok: true,
225
+ root: base,
226
+ totalBytes,
227
+ totalFiles,
228
+ totalMb: mb(totalBytes),
229
+ truncated,
230
+ rollback,
231
+ entries: graded,
232
+ reclaimableBytes: reclaimable.reduce((sum, e) => sum + e.bytes, 0),
233
+ reclaimableMb: mb(reclaimable.reduce((sum, e) => sum + e.bytes, 0)),
234
+ safeBytes: safe.reduce((sum, e) => sum + e.bytes, 0),
235
+ safeMb: mb(safe.reduce((sum, e) => sum + e.bytes, 0)),
236
+ }
237
+ }
238
+
239
+ /**
240
+ * 清理**计划**(纯函数,不删任何东西):把用户点名的 id 过一遍白名单与边界。
241
+ * @returns {{ ok, plan:[{id,bytes,level}], refused:[{id,reason}], freedBytes, note }}
242
+ * · 只接受 `audit.entries` 里出现过的 id;`protected` 一律拒绝并说明为什么;
243
+ * · 任何包含 `/` `\` `..` 的 id 直接拒绝(路径穿越)。
244
+ */
245
+ function planCleanup({ audit, ids } = {}) {
246
+ const list = Array.isArray(ids) ? ids : (typeof ids === 'string' && ids !== '' ? [ids] : [])
247
+ const byName = new Map((Array.isArray(audit?.entries) ? audit.entries : []).map((e) => [e.id, e]))
248
+ const plan = []
249
+ const refused = []
250
+ for (const raw of list) {
251
+ const id = typeof raw === 'string' ? raw.trim() : ''
252
+ if (id === '') continue
253
+ // 允许 `name` 或 `父目录/子项`(只展开一层:framework-backups/<版本>、profile-sync-backups/<快照>);
254
+ // 任何 `..`、绝对路径、多级路径一律拒 —— 删东西的口子越小越好。
255
+ if (id === '..' || id === '.' || id.startsWith('/') || /^[A-Za-z]:/u.test(id) || (id.match(/\//gu) ?? []).length > 1 || /\\/u.test(id) || id.includes('..')) {
256
+ refused.push({ id, reason: '不是本目录下的条目名(拒绝路径穿越)' })
257
+ continue
258
+ }
259
+ const entry = byName.get(id)
260
+ if (entry === undefined) { refused.push({ id, reason: '体检结果里没有这一项(请先做一次体检)' }); continue }
261
+ if (entry.level === 'protected') { refused.push({ id, reason: entry.reason }); continue }
262
+ plan.push({ id, bytes: entry.bytes, mb: entry.mb, level: entry.level, reason: entry.reason })
263
+ }
264
+ return {
265
+ ok: refused.length === 0 && plan.length > 0,
266
+ plan,
267
+ refused,
268
+ freedBytes: plan.reduce((sum, p) => sum + p.bytes, 0),
269
+ freedMb: mb(plan.reduce((sum, p) => sum + p.bytes, 0)),
270
+ note: plan.length === 0 ? (refused.length === 0 ? '没有指定要清理的条目' : '所有指定条目都被拒绝了') : null,
271
+ }
272
+ }
273
+
274
+ /**
275
+ * **真的删**(只有在 `confirm === true` 时才会动):删前再过一次 planCleanup,删后读回核实。
276
+ * @returns {{ ok, applied:boolean, removed:[], failed:[{id,reason}], needsConfirm:boolean, freedBytes, freedMb, plan }}
277
+ */
278
+ function runCleanup({ root, audit, ids, confirm = false, deps = {} } = {}) {
279
+ const exists = deps.exists ?? existsSync
280
+ const remove = deps.remove ?? ((p) => rmSync(p, { recursive: true, force: false }))
281
+ const planResult = planCleanup({ audit, ids })
282
+ const base = typeof root === 'string' && root !== '' ? resolve(root) : (typeof audit?.root === 'string' ? resolve(audit.root) : null)
283
+ if (base === null) return { ok: false, applied: false, needsConfirm: false, removed: [], failed: [], freedBytes: 0, freedMb: 0, plan: planResult, error: '拿不到 plugin-console 目录' }
284
+ if (confirm !== true) {
285
+ // 没确认 ⇒ 只回计划(面板据此渲染"确认清理"按钮),**一个文件都不动**
286
+ return { ok: false, applied: false, needsConfirm: true, removed: [], failed: [], freedBytes: 0, freedMb: 0, plan: planResult, note: '需要显式确认(confirm:true)才会删除;未确认前后一个字都没动' }
287
+ }
288
+ const removed = []
289
+ const failed = []
290
+ let freed = 0
291
+ for (const item of planResult.plan) {
292
+ const target = resolve(base, item.id)
293
+ // 边界:目标必须在 root 之内(resolve 过,`..` 已经被 planCleanup 挡掉,这里是第二道)
294
+ if (!target.startsWith(resolve(base) + sep)) {
295
+ failed.push({ id: item.id, reason: '目标不在 plugin-console 目录内(拒绝删除)' })
296
+ continue
297
+ }
298
+ try {
299
+ remove(target)
300
+ } catch (error) {
301
+ failed.push({ id: item.id, reason: error instanceof Error ? error.message : String(error) })
302
+ continue
303
+ }
304
+ // 读回核实:Windows 上 rmSync 可能静默落空(infra/fsx.js 记过这类事)
305
+ if (exists(target)) {
306
+ failed.push({ id: item.id, reason: '删除后目录仍在(静默落空)' })
307
+ continue
308
+ }
309
+ removed.push({ id: item.id, bytes: item.bytes, mb: item.mb })
310
+ freed += item.bytes
311
+ }
312
+ return {
313
+ ok: failed.length === 0 && removed.length > 0,
314
+ applied: true,
315
+ needsConfirm: false,
316
+ removed,
317
+ failed,
318
+ freedBytes: freed,
319
+ freedMb: mb(freed),
320
+ plan: planResult,
321
+ note: failed.length === 0
322
+ ? `已清理 ${removed.length} 项,释放约 ${mb(freed)} MB`
323
+ : `清理了 ${removed.length} 项(约 ${mb(freed)} MB),但有 ${failed.length} 项没清干净(原因见 failed)`,
324
+ }
325
+ }
326
+
327
+ export {
328
+ GUARD_RESIDUE_RE, LOG_RE, MAX_FILES, PROTECTED_FILES, PROTECTED_PREFIX, STATE_BAK_RE, TEST_RESIDUE_RE,
329
+ auditConsoleDir, measure, planCleanup, readRollbackPointer, runCleanup,
330
+ }
@@ -0,0 +1,242 @@
1
+ // L1 · domain —— enrich-throttle.js(市场卡片「富化请求」的**唯一出口闸**;0.5.84 加法)
2
+ // 分层分组:L1 · domain(边界由 tests/test-architecture-guard.mjs 断言;不认识 cordis ctx)
3
+ //
4
+ // ── 为什么要有这一层(真机症状)──────────────────────────────────────────────────
5
+ // 一次打开市场面板,服务端会对**每一条**卡片发出下面这些请求(domain/market.js#enrichItemOne):
6
+ // · package.json:curl 直连 + ghproxy 镜像(Promise.any 两条同时发)
7
+ // · SKILL.md :curl 直连 + ghproxy + jsDelivr(三条同时发,见 domain/skills.js#detectSkillRepo)
8
+ // · .gitmodules :rawTextFetch 的四条通道竞速(https / gh / curl / jsDelivr)
9
+ // ⇒ 单条卡片最坏 ≈ 9 个并发请求,而 enrichItems 的 worker 数是 12 ⇒ 面板一打开就是 ≈100 个连接
10
+ // 同时抢出去;raw.githubusercontent 在这个量级下几乎全部 ERR_TIMED_OUT / 404,
11
+ // 网络被占满,安装作业(pnpm / clone)跟着卡 —— 用户看到的是「市场一直在转圈」。
12
+ //
13
+ // ── 这一层干什么(四条,缺一条都还会打满网络)────────────────────────────────────
14
+ // ① **并发上限**:所有富化请求共用**一个** limiter(默认 4)。不是每个调用点各写一份
15
+ // Promise.all —— 那样上限会随调用点数量相乘而失效。
16
+ // ② **单飞(single-flight)**:同一个 URL 同时只发一次。市场面板 + 详情卡片 + 搜索并发打开时,
17
+ // 它们问的是同一批 URL,重复发没有意义。
18
+ // ③ **负缓存(短期记忆)**:失败的 URL 10 分钟内**不再发请求**,直接短路失败。
19
+ // 这就是"不要反复重试同一个失败 URL"——黑洞期里重试只会继续吃掉并发额度。
20
+ // ④ **短缓存**:成功结果缓存 5 分钟(富化判定本身另有 24h/1h 缓存,见 market.js)。
21
+ // 短缓存只解决"同一批卡片在几分钟内被反复打开"。
22
+ // 另:**单次超时不许长**。富化是"锦上添花"的判定,不该占用 10s 的抓取预算(那 10s 是给
23
+ // 安装路径上"必须读到清单"的场景的);本模块把富化探针的预算统一压到 4s。
24
+ //
25
+ // ── 语义边界(重要)──────────────────────────────────────────────────────────────
26
+ // · 本模块**只做闸门与记忆**,不做判定:成功/失败/404 的解释权仍在各通道自己手里。
27
+ // · `throttledCurlText` / `throttledCurlJson` **失败仍然抛错**(与裸实现一致)—— 上游用
28
+ // `Promise.any` / `allSettled` 写的分支逻辑一个字都不用改;负缓存命中时也抛(只是不发请求)。
29
+ // · `throttledRawText` **失败仍然返回 null**(与 rawTextWithFallback 一致)。
30
+ // · 生产不注入任何东西就是真实现(默认值直接来自 infra/http.js,与全仓出网单点同一套)。
31
+ import { FETCH_OK, curlJson, curlText, rawTextFetch } from '../infra/http.js'
32
+
33
+ /** 并发上限(真机结论:4 条就够快,更多只会把网络打满)。 */
34
+ const ENRICH_CONCURRENCY = 4
35
+ /** 失败记忆窗口:这段时间内同一个 key 不再发请求。 */
36
+ const FAILURE_COOLDOWN_MS = 10 * 60 * 1000
37
+ /** 成功短缓存:同一批卡片在几分钟内重复打开不再打网络。 */
38
+ const SHORT_CACHE_TTL_MS = 5 * 60 * 1000
39
+ /** 富化探针的单次预算上限("不要把单次超时设得太长")。 */
40
+ const ENRICH_TIMEOUT_MS = 4000
41
+ /** 记忆表上限(防止长时间运行后无限增长;满了淘汰最旧的一批)。 */
42
+ const MEMORY_LIMIT = 4000
43
+
44
+ /** 计数限流器:`run(fn)` 在有空位时才真正执行 fn(队列 FIFO,绝不丢弃)。 */
45
+ function makeLimiter(max) {
46
+ const size = Number.isFinite(max) && max > 0 ? Math.floor(max) : 1
47
+ let active = 0
48
+ let peak = 0
49
+ const queue = []
50
+ const pump = () => {
51
+ while (active < size && queue.length > 0) {
52
+ const job = queue.shift()
53
+ active += 1
54
+ if (active > peak) peak = active
55
+ Promise.resolve()
56
+ .then(job.fn)
57
+ .then(job.resolve, job.reject)
58
+ .then(() => { active -= 1; pump() }, () => { active -= 1; pump() })
59
+ }
60
+ }
61
+ return {
62
+ run(fn) {
63
+ return new Promise((resolve, reject) => { queue.push({ fn, resolve, reject }); pump() })
64
+ },
65
+ stats() { return { max: size, active, queued: queue.length, peak } },
66
+ }
67
+ }
68
+
69
+ /** 记忆表:`Map` + 上限淘汰(只清最旧的一批,不整体清空 —— 否则上限一到就等于没有记忆)。 */
70
+ function boundedSet(map, key, value) {
71
+ if (map.size >= MEMORY_LIMIT) {
72
+ const drop = Math.max(1, Math.floor(MEMORY_LIMIT / 4))
73
+ let n = 0
74
+ for (const k of map.keys()) { map.delete(k); if (++n >= drop) break }
75
+ }
76
+ map.set(key, value)
77
+ }
78
+
79
+ // ── 模块级状态(一个进程一份闸门;`__resetEnrichThrottle()` 供单测还原)─────────────
80
+ const state = {
81
+ limiter: makeLimiter(ENRICH_CONCURRENCY),
82
+ inflight: new Map(), // key → Promise(单飞)
83
+ okCache: new Map(), // key → { at, value }
84
+ failCache: new Map(), // key → at(负缓存)
85
+ skipped: 0, // 被负缓存短路掉的请求数(面板诊断用)
86
+ ran: 0, // 真的发出去的请求数
87
+ }
88
+
89
+ /** 出网实现的注入缝(默认 = infra/http.js 的真实现;单测换成 tripwire 即可证明"一个请求都没发")。 */
90
+ let impl = null
91
+ /** 单测注入出口实现:传对象 = 打桩(缺项回落真实现);传 null = 还原。返回是否已注入。 */
92
+ function __setEnrichThrottleImpl(next) {
93
+ impl = next !== null && typeof next === 'object' ? next : null
94
+ return impl !== null
95
+ }
96
+
97
+ /** 清空全部记忆与在途记录(单测 & 诊断用;已在途的请求不会被取消)。 */
98
+ function __resetEnrichThrottle() {
99
+ state.limiter = makeLimiter(ENRICH_CONCURRENCY)
100
+ state.inflight.clear()
101
+ state.okCache.clear()
102
+ state.failCache.clear()
103
+ state.skipped = 0
104
+ state.ran = 0
105
+ }
106
+
107
+ /** 闸门现状(面板诊断 / 单测断言"并发确实被压住"用)。 */
108
+ function enrichThrottleStats() {
109
+ return {
110
+ ...state.limiter.stats(),
111
+ inflight: state.inflight.size,
112
+ okCacheSize: state.okCache.size,
113
+ failCacheSize: state.failCache.size,
114
+ skipped: state.skipped,
115
+ ran: state.ran,
116
+ concurrencyLimit: ENRICH_CONCURRENCY,
117
+ failureCooldownMs: FAILURE_COOLDOWN_MS,
118
+ shortCacheTtlMs: SHORT_CACHE_TTL_MS,
119
+ timeoutMs: ENRICH_TIMEOUT_MS,
120
+ }
121
+ }
122
+
123
+ /** 占位错误:负缓存命中时用它短路(带上原因,别让上游以为是"文件不存在")。 */
124
+ function coolingDownError(key, waitMs) {
125
+ const error = new Error(`富化请求在冷却期(${Math.max(1, Math.round(waitMs / 1000))}s 内不再重试):${key}`)
126
+ error.code = 'ENRICH_COOLING_DOWN'
127
+ return error
128
+ }
129
+
130
+ /**
131
+ * 过闸执行一次富化请求(**唯一入口**)。
132
+ * @param {string} key 记忆键(同一份内容必须给出同一个 key)
133
+ * @param {() => Promise<any>} run 真请求(抛错 = 失败;返回值由 accept 判定算不算"读到了")
134
+ * @param {object} [opts]
135
+ * @param {(v:any) => boolean} [opts.accept] 什么算成功(默认"非空字符串")
136
+ * @param {number} [opts.okTtlMs] 成功短缓存时长
137
+ * @param {number} [opts.failTtlMs] 失败记忆时长
138
+ * @returns {Promise<any>} 成功值;冷却命中/失败由调用方包装成各自的语义
139
+ */
140
+ async function throughGate(key, run, opts = {}) {
141
+ const accept = typeof opts.accept === 'function' ? opts.accept : (v) => typeof v === 'string' && v.trim() !== ''
142
+ const okTtlMs = Number.isFinite(opts.okTtlMs) ? opts.okTtlMs : SHORT_CACHE_TTL_MS
143
+ const failTtlMs = Number.isFinite(opts.failTtlMs) ? opts.failTtlMs : FAILURE_COOLDOWN_MS
144
+ const now = Date.now()
145
+ // ④ 短缓存命中
146
+ const cached = state.okCache.get(key)
147
+ if (cached !== undefined) {
148
+ if (now - cached.at < okTtlMs) return cached.value
149
+ state.okCache.delete(key)
150
+ }
151
+ // ③ 负缓存命中 → **不发请求**,直接按"这次也不会有"短路
152
+ const failedAt = state.failCache.get(key)
153
+ if (failedAt !== undefined) {
154
+ if (now - failedAt < failTtlMs) {
155
+ state.skipped += 1
156
+ throw coolingDownError(key, failTtlMs - (now - failedAt))
157
+ }
158
+ state.failCache.delete(key)
159
+ }
160
+ // ② 单飞:同一 key 的并发调用共用同一个 Promise
161
+ const running = state.inflight.get(key)
162
+ if (running !== undefined) return running
163
+ const task = state.limiter
164
+ .run(() => { state.ran += 1; return run() })
165
+ .then((value) => {
166
+ if (accept(value)) {
167
+ boundedSet(state.okCache, key, { at: Date.now(), value })
168
+ state.failCache.delete(key)
169
+ return value
170
+ }
171
+ boundedSet(state.failCache, key, Date.now())
172
+ throw new Error('富化请求未读到内容')
173
+ }, (error) => {
174
+ boundedSet(state.failCache, key, Date.now())
175
+ throw error
176
+ })
177
+ .finally(() => { state.inflight.delete(key) })
178
+ state.inflight.set(key, task)
179
+ return task
180
+ }
181
+
182
+ /** 预算归一:富化探针一律压到 ENRICH_TIMEOUT_MS 以内。 */
183
+ function budgetOf(timeoutMs) {
184
+ const wanted = Number.isFinite(timeoutMs) ? timeoutMs : ENRICH_TIMEOUT_MS
185
+ return Math.min(Math.max(wanted, 800), ENRICH_TIMEOUT_MS)
186
+ }
187
+
188
+ /**
189
+ * 过闸的 `curlText`(**失败仍然抛错**,与裸实现同语义)。
190
+ * @param {string} url
191
+ * @param {number} [timeoutMs] 超过 ENRICH_TIMEOUT_MS 会被压到上限
192
+ */
193
+ async function throttledCurlText(url, timeoutMs = ENRICH_TIMEOUT_MS, opts = {}) {
194
+ const real = typeof impl?.curlText === 'function' ? impl.curlText : curlText
195
+ return await throughGate(`text:${url}`, () => real(url, budgetOf(timeoutMs), opts.curlOpts ?? {}), opts)
196
+ }
197
+
198
+ /** 过闸的 `curlJson`(**失败仍然抛错**)。 */
199
+ async function throttledCurlJson(url, timeoutMs = ENRICH_TIMEOUT_MS, opts = {}) {
200
+ const real = typeof impl?.curlJson === 'function' ? impl.curlJson : curlJson
201
+ return await throughGate(
202
+ `json:${url}`,
203
+ () => real(url, budgetOf(timeoutMs), opts.headers ?? {}),
204
+ { ...opts, accept: opts.accept ?? ((v) => v !== null && typeof v === 'object') },
205
+ )
206
+ }
207
+
208
+ /**
209
+ * 过闸的 raw 抓取(**失败仍然返回 null**,与 rawTextWithFallback 同语义)。
210
+ * 通道竞速仍在 infra/http.js(四条通道一条不删),只是**预算由这里压到 ENRICH_TIMEOUT_MS 以内**
211
+ * 并共用同一个闸门 —— 富化探针绝不允许占用安装路径那 10s 的抓取预算。
212
+ */
213
+ async function throttledRawText(repo, branch, path, opts = {}) {
214
+ const real = typeof impl?.rawTextFetch === 'function' ? impl.rawTextFetch : rawTextFetch
215
+ const budget = budgetOf(opts.budgetMs)
216
+ try {
217
+ const text = await throughGate(`raw:${repo}@${branch}/${path}`, async () => {
218
+ const { state: st, body } = await real(repo, branch, path, budget)
219
+ return st === FETCH_OK ? body : null
220
+ }, opts)
221
+ return typeof text === 'string' ? text : null
222
+ } catch {
223
+ // 冷却期 / 抓取失败 / 404 → 都是"这次没读到",与 rawTextWithFallback 的 null 同义
224
+ return null
225
+ }
226
+ }
227
+
228
+ export {
229
+ ENRICH_CONCURRENCY,
230
+ ENRICH_TIMEOUT_MS,
231
+ FAILURE_COOLDOWN_MS,
232
+ SHORT_CACHE_TTL_MS,
233
+ MEMORY_LIMIT,
234
+ makeLimiter,
235
+ throughGate,
236
+ throttledCurlText,
237
+ throttledCurlJson,
238
+ throttledRawText,
239
+ enrichThrottleStats,
240
+ __resetEnrichThrottle,
241
+ __setEnrichThrottleImpl,
242
+ }
@@ -0,0 +1,77 @@
1
+ // L1 · domain —— install-box.js(「盒子实验」:解压后的包目录先静态校验再覆盖正式位置)
2
+ //
3
+ // 2026-10-18 从 domain/install.js **原样搬出**(install.js 贴着 600 行架构硬顶,见
4
+ // tests/test-architecture-guard.mjs;这一块本来就是自洽的"装前只读校验",与 sources 无关)。
5
+ // 搬移只搬移未改逻辑:install.js 继续 re-export 这两个名字,**对外导出名与调用点一个字都没变**。
6
+ //
7
+ // 校验什么(都是"装了也起不来"的硬伤,所以要在覆盖旧版本**之前**拦住):
8
+ // · package.json 必须可解析且 name 与安装目标一致;
9
+ // · main / exports 入口文件必须真实存在;
10
+ // · bundle patch(cordis.patch.yml)引用的包必须已就位(防聚合包引用缺失崩溃)。
11
+ // 失败抛错 → 调用方保留旧版本(安装不中断服务)。
12
+ import { existsSync, readFileSync } from 'node:fs'
13
+ import { join } from 'node:path'
14
+
15
+ /** 盒子验证:解压后的包目录先通过静态校验再允许覆盖正式位置。
16
+ * refCheckRoot 指定引用检查根(宿主插件在根层 node_modules,与 profile 层不同)。 */
17
+ function verifyPackageBox(pkgPath, profileDir, packageName, refCheckRoot = null) {
18
+ const note = []
19
+ const pkgRaw = readFileSync(join(pkgPath, 'package.json'), 'utf8')
20
+ const pkg = JSON.parse(pkgRaw)
21
+ if (pkg.name !== packageName) {
22
+ throw new Error(`盒子验证失败:包名不符(tarball 内为 ${pkg.name},期望 ${packageName}),已保留旧版本`)
23
+ }
24
+ // 入口存在性:main 字段 / exports.'.'(字符串或对象 default)指向的文件必须存在
25
+ let entry = typeof pkg.main === 'string' ? pkg.main : null
26
+ if (entry === null && pkg.exports && typeof pkg.exports === 'object') {
27
+ const dot = pkg.exports['.'] ?? pkg.exports['./package.json'] === undefined ? pkg.exports['.'] : null
28
+ if (typeof dot === 'string') entry = dot
29
+ else if (dot && typeof dot === 'object') entry = typeof dot.default === 'string' ? dot.default : null
30
+ }
31
+ if (entry !== null) {
32
+ const entryPath = join(pkgPath, ...entry.split('/'))
33
+ if (!existsSync(entryPath)) {
34
+ throw new Error(`盒子验证失败:入口文件缺失(${entry}),已保留旧版本`)
35
+ }
36
+ }
37
+ // bundle patch 引用预检:引用的包必须已存在于目标 node_modules(防聚合包半更新崩溃)。
38
+ // refCheckRoot 指定检查根(宿主插件在根层 node_modules,与 profile 层不同)。
39
+ const checkRoot = refCheckRoot ?? join(profileDir, 'node_modules')
40
+ try {
41
+ const patchRel = pkg.dsh?.bundle?.patch
42
+ if (typeof patchRel === 'string') {
43
+ const bundlePatch = join(pkgPath, patchRel)
44
+ if (existsSync(bundlePatch)) {
45
+ const refs = parseBundlePatchRefs(readFileSync(bundlePatch, 'utf8'))
46
+ const missingRefs = refs.filter((r) => !existsSync(join(checkRoot, r.name)))
47
+ if (missingRefs.length > 0) {
48
+ note.push(`bundle 引用缺失:${missingRefs.map((r) => r.name).join('、')}(安装后由聚合完整性检查补齐/禁用)`)
49
+ }
50
+ if (refs.length > 0) note.push(`bundle 引用 ${refs.length} 个已就位`)
51
+ }
52
+ }
53
+ } catch (error) {
54
+ if (error instanceof Error && error.message.startsWith('盒子验证失败')) throw error
55
+ }
56
+ return { note: note.length > 0 ? note.join(';') : null }
57
+ }
58
+
59
+ /** 解析 bundle patch(cordis.patch.yml)的 insert 引用列表(id + 包名)。 */
60
+ function parseBundlePatchRefs(text) {
61
+ const refs = []
62
+ const lines = text.split(/\r?\n/u)
63
+ let inInsert = false
64
+ for (let i = 0; i < lines.length; i += 1) {
65
+ const line = lines[i]
66
+ if (/^- insert:\s*$/u.test(line)) { inInsert = true; continue }
67
+ if (/^- /u.test(line) && !/^ {4}- /u.test(line)) inInsert = false
68
+ if (!inInsert) continue
69
+ const idM = line.match(/^ {4}- id: ([A-Za-z0-9_.-]+)\s*$/u)
70
+ if (!idM) continue
71
+ const nameM = (lines[i + 1] ?? '').match(/^ {6}name: ['"]([^'"]+)['"]\s*$/u)
72
+ if (nameM) refs.push({ id: idM[1], name: nameM[1] })
73
+ }
74
+ return refs
75
+ }
76
+
77
+ export { verifyPackageBox, parseBundlePatchRefs }