@noob-stupid/dsh-plugin-console 0.5.49 → 0.5.50

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/README.md CHANGED
@@ -411,6 +411,30 @@ node scripts/safe-boot.mjs --mark-good # mark a snapshot as good (nev
411
411
 
412
412
  Safe-boot snapshots live under `dshHome()/plugin-console/safe-boot/` (**not** in the profile directory),
413
413
  keeping the newest 3 plus the one you marked as good.
414
+
415
+ ### When the service won't start because **framework packages were wiped**
416
+
417
+ The rollback above restores the **config layer** (`cordis.patch.yml`). There is a second failure
418
+ class where the **package payloads** are gone — framework packages emptied or deleted. Then the
419
+ patch is perfectly fine, restoring it changes nothing, and **the console itself runs inside dsh**:
420
+ if dsh cannot boot, the console was never loaded (real outage, 2026-10-14: `cordis`'s store entity
421
+ emptied plus `dsh-llm`/`dsh-tools`/`dsh-web` turned into empty directories ⇒
422
+ `54 plugins failed to import` ⇒ `exit code=1`).
423
+
424
+ That class needs the **out-of-process** closure preflight (also needs no running service):
425
+
426
+ ```bash
427
+ node scripts/fw-preflight.mjs --check # diagnose only: list unresolvable packages, write nothing
428
+ node scripts/fw-preflight.mjs --repair # restore from the **local npm cache** by lock integrity, then re-verify
429
+ ```
430
+
431
+ The criterion is "**can the dependency closure be resolved**", not "is the directory empty" —
432
+ the latter is completely blind to "the shell is still there, the inside was emptied"
433
+ (measured: a naive empty-dir scan reported 237 false positives; the closure criterion passed all
434
+ 287 packages and named exactly the one that broke). `--repair` is **offline and never guesses a
435
+ version** (a package missing from the lock is reported as `no-lock-entry`), and it **never kills or
436
+ spawns a process and never grabs a port** (that is exactly what produces
437
+ "The application failed to start or stopped unexpectedly") — restart the service yourself afterwards.
414
438
  ## Framework compatibility
415
439
 
416
440
  Measured on isolated instances (fresh `DSH_HOME`, real HTTP probes) — **supported: framework ≥ `0.1.5-rc.2`**, no workarounds needed.
package/README.zh.md CHANGED
@@ -346,6 +346,26 @@ node scripts/safe-boot.mjs --mark-good # 把某份快照标记为"良
346
346
 
347
347
  安全启动快照落在 `dshHome()/plugin-console/safe-boot/`(**不在** profile 目录里),保留最近 3 份 +
348
348
  永不删你标记为"良好"的那份。
349
+
350
+ ### 服务打不开、且原因是**框架包被掏空**时
351
+
352
+ 上面那套恢复的是**配置层**(`cordis.patch.yml`)。另有一类事故坏的是**包实体**——
353
+ 框架包被清空或删除,此时补丁好端端的,恢复补丁没有任何用,而**控制台自己也是跑在 dsh 里的**,
354
+ dsh 起不来它就从未加载(真机停机事故 2026-10-14:`cordis` 实体被掏空 +
355
+ `dsh-llm`/`dsh-tools`/`dsh-web` 成了空目录 ⇒ `54 个插件 failed to import` ⇒ `exit code=1`)。
356
+
357
+ 这类事故要用**进程外**的闭包预检(同样不需要服务在跑):
358
+
359
+ ```bash
360
+ node scripts/fw-preflight.mjs --check # 只诊断:列出解析不了的包,一个字节都不写
361
+ node scripts/fw-preflight.mjs --repair # 按 lock 的 integrity 从**本地 npm 缓存**装回,再复验
362
+ ```
363
+
364
+ 判据是"**依赖闭包能不能解析**",不是"目录空不空"—— 后者对"目录壳还在、里面被掏空"这种形态
365
+ 完全免疫(真机实测:按"目录空不空"粗扫会误报 237 个;按闭包判据 287 个包精准通过)。
366
+ `--repair` **不联网、不猜版本**(lock 里没有的包如实报 `no-lock-entry`),
367
+ 也**绝不 kill / spawn 进程或抢端口**(那正是「应用无法启动或已意外停止」的来源)——
368
+ 修完请自己重启服务。
349
369
  ## 框架兼容性
350
370
 
351
371
  在隔离实例(全新 `DSH_HOME` + 真实 HTTP 探针)实测:**支持框架 ≥ `0.1.5-rc.2`**,无需任何变通。
@@ -0,0 +1,351 @@
1
+ // L1 · domain —— fw-preflight.js(框架闭包**预检与治包**:服务起不来时的自救;0.5.50 加法)
2
+ //
3
+ // ── 为什么必须有它(**真机停机事故 2026-10-14**)────────────────────────────────
4
+ // 四个框架包被掏空(`cordis` 的 junction 还在但 `.pnpm` 实体被清空;`dsh-llm` / `dsh-tools` /
5
+ // `dsh-web` 成了空的真实目录)⇒ dsh 启动即 `54 个插件 failed to import` ⇒ web 服务 exit code=1。
6
+ //
7
+ // **这次事故的所有既有防线都失效**,原因是结构性的:
8
+ // · safe-boot 快照存的是 `cordis.patch.yml` 的**文本**(63KB 全是配置)—— 坏的却是包实体,
9
+ // 而补丁当时好端端的 ⇒ 恢复动作等于"把一份没坏的配置换成另一份没坏的配置";
10
+ // · 指纹 `pnpmEntities` 数的是 `.pnpm` 里的**目录个数** —— "目录壳还在、里面被掏空"⇒ 计数不变
11
+ // ⇒ 判定环境未变 ⇒ 不触发任何动作;
12
+ // · 自动禁用针对"插件不适配" —— 这次是框架包没了,不是插件问题。
13
+ // 而最根本的一条:**它是跑在 dsh 里的**。dsh 起不来 ⇒ 插件从未加载(事故日志里出现 0 次)
14
+ // ⇒ 一切"面板里有按钮"的方案在这类事故面前等于没有。
15
+ //
16
+ // 所以本模块的定位是**进程外的预检与治包**,可由 `scripts/fw-preflight.mjs` 在服务之外调用:
17
+ // 探测(infra/fw-closure.js)→ 定计划 → 从本地 npm 缓存装回(infra/npm-cache.js)→ 复验。
18
+ // 路由侧(若日后接线)同用本模块,两处不会分叉。
19
+ //
20
+ // ── 四条硬规矩(对齐仓库既有纪律)──────────────────────────────────────────────
21
+ // ① **只补缺的**:能解析的包一个字节都不写(幂等;健康的框架树是 no-op)。
22
+ // ② **绝不 kill / spawn / 抢端口 / 建计划任务** —— 这正是 2026-10-10 `restart.js` 下线的
23
+ // 那套动作(会与宿主争端口 ⇒ `listen EADDRINUSE`;kill 宿主 ⇒ 外壳弹「应用无法启动或已意外停止」)。
24
+ // 本模块只做**文件级**修复,跑完由调用方决定要不要重启。
25
+ // ③ **修复源必须可解释**:只从 `package-lock.json` 的 `integrity` + 本地 npm 缓存取,
26
+ // **不联网、不猜版本**;取不到就如实报"缺什么",绝不假装成功。
27
+ // ④ **改前留痕**:有内容的目标目录先改名成 `<name>.preflight-bak-<ts>`(绝不直接删)。
28
+ //
29
+ // 分层:L1 · domain —— 不得出现 cordis ctx(test-architecture-guard 断言⑤)。
30
+ // 行数预算:独立模块,留在 600 行架构硬顶内。
31
+
32
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync } from 'node:fs'
33
+ import { createRequire } from 'node:module'
34
+ import { dirname, join } from 'node:path'
35
+ import { probeClosure } from '../infra/fw-closure.js'
36
+ import { candidateCacheRoots, extractTgz, readTarballFromAny } from '../infra/npm-cache.js'
37
+
38
+ /** 备份名后缀(与 `.stale-*` 同族但可区分:预检造的备份不该被"框架残留"判据当成残留改名)。 */
39
+ const PREFLIGHT_BAK = '.preflight-bak-'
40
+
41
+ /** 该目录名是不是预检留下的备份。 */
42
+ function isPreflightBak(name) {
43
+ return typeof name === 'string' && name.includes(PREFLIGHT_BAK)
44
+ }
45
+
46
+ /** 文件名里安全的时间戳(冒号/点在 Windows 上是非法字符)。 */
47
+ function stampOf(ms) {
48
+ return new Date(ms).toISOString().replace(/[:.]/gu, '-')
49
+ }
50
+
51
+ /**
52
+ * 定位 lock 文件:`fwRoot` 是**那层 `node_modules` 本身**,而 lock 住在它的**父目录**
53
+ * (真机实测:`D:\node_cache\_npx\<hash>\node_modules` 是框架树,而
54
+ * `D:\node_cache\_npx\<hash>\package-lock.json` 才是清单)。
55
+ *
56
+ * 候选顺序(先近后远,两种生态都认):
57
+ * ① `<fwRoot>/package-lock.json`(少数形态:树根就带清单)
58
+ * ② `<dirname(fwRoot)>/package-lock.json` ← **真机主力**
59
+ * ③ `<dirname(fwRoot)>/pnpm-lock.yaml`(pnpm 形态;本模块目前只解析 npm 的 JSON 清单,
60
+ * 命中 yaml 时返回它并由调用方如实记"该清单格式不支持",**不静默当成没有**)
61
+ *
62
+ * @returns {{ path: string|null, kind: 'npm'|'pnpm'|null, tried: string[] }}
63
+ */
64
+ function locateLockfile(fwRoot, { deps = {} } = {}) {
65
+ const exists = deps.exists ?? existsSync
66
+ const parent = dirname(fwRoot)
67
+ const tried = []
68
+ const cands = [
69
+ { p: join(fwRoot, 'package-lock.json'), kind: 'npm' },
70
+ { p: join(parent, 'package-lock.json'), kind: 'npm' },
71
+ { p: join(parent, 'pnpm-lock.yaml'), kind: 'pnpm' },
72
+ ]
73
+ for (const c of cands) {
74
+ tried.push(c.p)
75
+ if (exists(c.p)) return { path: c.p, kind: c.kind, tried }
76
+ }
77
+ return { path: null, kind: null, tried }
78
+ }
79
+
80
+ /**
81
+ * 从 `package-lock.json` 建索引:`名@版本` → `{ name, version, integrity }`。
82
+ * 兼容 lockfileVersion 3(`packages` 键形如 `node_modules/@scope/name`)与更老的 `dependencies` 树。
83
+ * 读不出/解析失败一律返回空 Map(调用方据此如实报"没有可用恢复源")。
84
+ */
85
+ function buildLockIndex(lockPath, { deps = {} } = {}) {
86
+ const exists = deps.exists ?? existsSync
87
+ const read = deps.read ?? ((p) => readFileSync(p, 'utf8'))
88
+ const idx = new Map()
89
+ if (typeof lockPath !== 'string' || !exists(lockPath)) return idx
90
+ let lock = null
91
+ try { lock = JSON.parse(read(lockPath)) } catch { return idx }
92
+ if (lock === null || typeof lock !== 'object') return idx
93
+
94
+ const add = (name, entry) => {
95
+ if (typeof name !== 'string' || name === '') return
96
+ if (entry === null || typeof entry !== 'object' || typeof entry.version !== 'string') return
97
+ const key = `${name}@${entry.version}`
98
+ if (!idx.has(key)) idx.set(key, { name, version: entry.version, integrity: entry.integrity ?? null, resolved: entry.resolved ?? null })
99
+ }
100
+
101
+ if (lock.packages !== null && typeof lock.packages === 'object') {
102
+ for (const [key, entry] of Object.entries(lock.packages)) {
103
+ if (!key.startsWith('node_modules/')) continue
104
+ // `node_modules/@scope/name` 与 `node_modules/a/node_modules/b` 都取**最后一段包名**
105
+ const m = /node_modules\/(@[^/]+\/[^/]+|[^/]+)$/u.exec(key)
106
+ if (m !== null) add(m[1], entry)
107
+ }
108
+ }
109
+ if (idx.size === 0 && lock.dependencies !== null && typeof lock.dependencies === 'object') {
110
+ const walkDeps = (tree) => {
111
+ for (const [name, entry] of Object.entries(tree ?? {})) {
112
+ add(name, entry)
113
+ if (entry?.dependencies) walkDeps(entry.dependencies)
114
+ }
115
+ }
116
+ walkDeps(lock.dependencies)
117
+ }
118
+ return idx
119
+ }
120
+
121
+ /** 宽松版本比较(够用于"多版本里挑一个",不追求完整 semver)。 */
122
+ function compareLoose(a, b) {
123
+ const pa = String(a ?? '').split(/[.\-+]/u)
124
+ const pb = String(b ?? '').split(/[.\-+]/u)
125
+ for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
126
+ const na = Number.parseInt(pa[i] ?? '0', 10)
127
+ const nb = Number.parseInt(pb[i] ?? '0', 10)
128
+ if (Number.isFinite(na) && Number.isFinite(nb)) {
129
+ if (na !== nb) return na - nb
130
+ } else {
131
+ const sa = String(pa[i] ?? '')
132
+ const sb = String(pb[i] ?? '')
133
+ if (sa !== sb) return sa < sb ? -1 : 1
134
+ }
135
+ }
136
+ return 0
137
+ }
138
+
139
+ /**
140
+ * **纯函数**:给定闭包探测结果与 lock 索引,产出修复计划(不碰 IO,可离线单测)。
141
+ *
142
+ * 目标目录判定(先问"它本该在哪",再退顶层投影 —— 真机 `dsh-llm` / `dsh-tools` / `dsh-web`
143
+ * 就住在顶层 `node_modules/@deepseek-ai/` 下,而 `cordis` 是顶层 junction 指向 `.pnpm` 实体):
144
+ * ① `resolveDir(name)` 给出的位置 ② `nodeModulesDir/<scope>/<name>` ③ `storeDir/<name>`
145
+ *
146
+ * 同包多版本时取**版本最高**那条(把包装回来时,新版更可能是对的那份)。
147
+ *
148
+ * @returns {{ items: Array, skippable: Array }}
149
+ * `skippable[].reason`:`no-lock-entry`(lock 里没有 ⇒ **不猜版本**)/`no-integrity`/`no-target`。
150
+ */
151
+ function planClosureRepair({ broken = [], lockIndex = new Map(), resolveDir = () => null, nodeModulesDir = null, storeDir = null } = {}) {
152
+ const items = []
153
+ const skippable = []
154
+ for (const bad of Array.isArray(broken) ? broken : []) {
155
+ const name = bad?.name
156
+ if (typeof name !== 'string' || name === '') continue
157
+
158
+ const hits = []
159
+ for (const [key, entry] of lockIndex) if (key.startsWith(name + '@')) hits.push(entry)
160
+ if (hits.length === 0) { skippable.push({ name, reason: 'no-lock-entry' }); continue }
161
+ hits.sort((a, b) => compareLoose(a.version, b.version))
162
+ const chosen = hits[hits.length - 1]
163
+ if (chosen.integrity === null || chosen.integrity === undefined) { skippable.push({ name, reason: 'no-integrity' }); continue }
164
+
165
+ const segments = name.startsWith('@') ? name.split('/') : [name]
166
+ const resolved = typeof resolveDir === 'function' ? resolveDir(name) : null
167
+ const topLevel = nodeModulesDir === null ? null : join(nodeModulesDir, ...segments)
168
+ const store = storeDir === null ? null : join(storeDir, ...segments)
169
+ let target = null
170
+ let via = null
171
+ for (const [cand, how] of [[resolved, 'resolve'], [topLevel, 'top-level'], [store, 'pnpm-store']]) {
172
+ if (typeof cand === 'string' && cand !== '') { target = cand; via = how; break }
173
+ }
174
+ if (target === null) { skippable.push({ name, reason: 'no-target' }); continue }
175
+ items.push({ name, version: chosen.version, integrity: chosen.integrity, target, via, reason: bad.reason ?? 'unresolvable' })
176
+ }
177
+ return { items, skippable }
178
+ }
179
+
180
+ /**
181
+ * 执行计划里的**一条**恢复:缓存命中 → 解包到目标。
182
+ *
183
+ * ★ **junction 关键判据**(真机 2026-10-14 演练抓到的设计错误):
184
+ * `cordis` 的坏法是"顶层 junction 还在、它指向的 `.pnpm` **实体**被掏空"。若把内容写进
185
+ * **顶层那个 junction 名字**,pnpm 的链接结构就被破坏了 —— 顶层从"链接"退化成"真实目录",
186
+ * 同一份包在树里于是有了**第二个实体**:
187
+ * · 模块身份不再共享(Cordis service 类 / LLM error 类靠 `instanceof` 与单例注册表相认 ——
188
+ * 这正是 domain/link-self-sufficiency.js 头注释里"必须链接而不是复制"的那条教训);
189
+ * · pnpm 后续重装会认为顶层"不是自己管的",产生版本漂移与二次损坏。
190
+ * ⇒ 目标是链接时,**只把内容写进它指向的真实目录**(`realpathSync`),链接原样保留。
191
+ *
192
+ * 其余三种形态照旧:空壳真实目录直接替换;有内容的真实目录先改名成 `.preflight-bak-<ts>`
193
+ * 再装(**绝不直接删**);目标不存在则新建后装。
194
+ *
195
+ * 返回 `{ ok, action, note }`,失败必须带可读原因(绝不静默吞)。
196
+ */
197
+ function applyRepairItem(item, { cacheRoots = [], deps = {} } = {}) {
198
+ const exists = deps.exists ?? existsSync
199
+ const rename = deps.rename ?? renameSync
200
+ const rm = deps.rm ?? ((p) => rmSync(p, { recursive: true, force: true }))
201
+ const mkdir = deps.mkdir ?? ((p) => mkdirSync(p, { recursive: true }))
202
+ const now = deps.now ?? (() => Date.now())
203
+ const realpath = deps.realpath ?? ((p) => realpathSync(p))
204
+ const lstat = deps.lstat ?? ((p) => lstatSync(p))
205
+ try {
206
+ const hit = readTarballFromAny(cacheRoots, item.integrity, { deps })
207
+ if (hit === null) return { ok: false, action: 'skipped', note: '本地 npm 缓存中找不到该 integrity(未联网,不猜版本)' }
208
+
209
+ // 目标是链接 ⇒ 写进它指向的实体,链接本身一个字节都不动
210
+ let target = item.target
211
+ let viaLink = false
212
+ let isLink = false
213
+ try { isLink = lstat(target).isSymbolicLink() } catch { isLink = false }
214
+ if (isLink) {
215
+ viaLink = true
216
+ try { target = realpath(target) } catch {
217
+ return { ok: false, action: 'failed', note: '目标是指向不存在实体的悬空链接,且无法解析真身 —— 不猜目标' }
218
+ }
219
+ }
220
+
221
+ let backupPath = null
222
+ if (exists(target)) {
223
+ let empty = false
224
+ try { empty = readdirSync(target).length === 0 } catch { empty = false }
225
+ if (empty) rm(target)
226
+ else {
227
+ backupPath = `${target}${PREFLIGHT_BAK}${stampOf(now())}`
228
+ rename(target, backupPath)
229
+ }
230
+ }
231
+ mkdir(target)
232
+ const stat = extractTgz(hit.buf, target, { deps })
233
+ if (!exists(join(target, 'package.json'))) {
234
+ return { ok: false, action: 'extracted-but-no-manifest', note: `解包完成但无 package.json(${stat.files} 个文件)`, backupPath }
235
+ }
236
+ return {
237
+ ok: true,
238
+ action: 'restored',
239
+ note: `${item.version}(${stat.files} 个文件)${viaLink ? ' → 经链接写入实体,链接保留' : ''}`,
240
+ backupPath,
241
+ cacheRoot: hit.cacheRoot,
242
+ viaLink,
243
+ }
244
+ } catch (error) {
245
+ return { ok: false, action: 'failed', note: String(error?.message ?? error) }
246
+ }
247
+ }
248
+
249
+ /** 定框架树的解析入口:优先顶层 `@deepseek-ai/dsh/lib/bin.js`,退 `.pnpm` 内层实体。 */
250
+ function locateFrameworkEntry(fwRoot, { deps = {} } = {}) {
251
+ const exists = deps.exists ?? existsSync
252
+ const topBin = join(fwRoot, '@deepseek-ai', 'dsh', 'lib', 'bin.js')
253
+ if (exists(topBin)) return { entry: topBin, via: 'top-level' }
254
+ try {
255
+ for (const name of readdirSync(join(fwRoot, '.pnpm'))) {
256
+ if (!name.startsWith('@deepseek-ai+dsh@')) continue
257
+ const cand = join(fwRoot, '.pnpm', name, 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js')
258
+ if (exists(cand)) return { entry: cand, via: 'pnpm-store' }
259
+ }
260
+ } catch {}
261
+ return { entry: null, via: null }
262
+ }
263
+
264
+ /**
265
+ * 预检 + 治包的**唯一入口**(探测 → 计划 → 执行 → 复验)。
266
+ *
267
+ * @param {object} p
268
+ * @param {string} p.fwRoot 框架树根(含 `@deepseek-ai/` 与 `.pnpm/`)。
269
+ * @param {boolean} [p.repair] true = 真装回;false(默认)= **只诊断,一个字节都不写**。
270
+ * @param {string} [p.lockPath] 默认 `<fwRoot>/package-lock.json`。
271
+ * @param {number} [p.maxRepair] 单次最多修几个(防御上限;默认 200)。
272
+ * @param {object} [p.deps] IO 注入(单测用)。
273
+ * @returns 结构化报告;`ok` = **修复前**闭包是否完整,`okAfter` = 修复后复验结果。
274
+ */
275
+ function runFwPreflight({ fwRoot, repair = false, lockPath = null, maxRepair = 200, deps = {}, cacheRoots = null } = {}) {
276
+ const read = deps.read ?? ((p) => readFileSync(p, 'utf8'))
277
+ const makeReq = deps.createRequire ?? createRequire
278
+ const probe = deps.probeClosure ?? probeClosure
279
+ const roots = Array.isArray(cacheRoots) && cacheRoots.length > 0
280
+ ? cacheRoots
281
+ : (typeof deps.cacheRoots === 'function' ? deps.cacheRoots() : candidateCacheRoots({ env: deps.env ?? process.env }))
282
+
283
+ const report = { fwRoot: fwRoot ?? null, entry: null, entryVia: null, before: null, after: null, plan: [], repaired: [], failed: [], skipped: [], cacheRoots: roots, repair, lockPath: null, lockKind: null }
284
+
285
+ if (typeof fwRoot !== 'string' || fwRoot === '') { report.error = 'fwRoot 未提供'; return report }
286
+
287
+ const located = locateFrameworkEntry(fwRoot, { deps })
288
+ report.entry = located.entry
289
+ report.entryVia = located.via
290
+ if (located.entry === null) { report.error = '找不到 dsh 的 lib/bin.js(顶层与 .pnpm 都没有)——框架树本身不完整'; report.ok = false; return report }
291
+
292
+ const before = probe({ entry: located.entry, deps })
293
+ report.before = before
294
+ report.ok = before.ok
295
+
296
+ let req = null
297
+ try { req = makeReq(located.entry) } catch { req = null }
298
+ const resolveDir = (name) => {
299
+ if (req === null) return null
300
+ try { return dirname(req.resolve(name + '/package.json')) } catch { return null }
301
+ }
302
+
303
+ const lockLocated = typeof lockPath === 'string' && lockPath !== ''
304
+ ? { path: lockPath, kind: 'npm', tried: [lockPath] }
305
+ : locateLockfile(fwRoot, { deps })
306
+ report.lockPath = lockLocated.path
307
+ report.lockKind = lockLocated.kind
308
+ const lock = lockLocated.kind === 'npm' ? buildLockIndex(lockLocated.path, { deps }) : new Map()
309
+ // ★ 基准收口:`fwRoot` 就是**那层 node_modules 本身**(真机实测:`…\_npx\<hash>\node_modules`、
310
+ // `…\profiles\node_modules`、桌面端 `…\dsh\node_modules` 都是这个形态)。
311
+ // 于是顶层投影就是 `fwRoot/@deepseek-ai/<name>` —— **不再拼一层 `node_modules`**。
312
+ // (2026-10-14 踩到:拼成 `fwRoot/node_modules/@deepseek-ai/…` ⇒ 目标路径不存在 ⇒
313
+ // 修复计划指向错误的树,真正坏掉的那棵树纹丝不动。)
314
+ const plan = planClosureRepair({
315
+ broken: before.broken,
316
+ lockIndex: lock,
317
+ resolveDir,
318
+ nodeModulesDir: fwRoot,
319
+ storeDir: join(fwRoot, '.pnpm', 'node_modules'),
320
+ })
321
+ report.plan = plan.items.map((i) => ({ name: i.name, version: i.version, target: i.target, via: i.via, reason: i.reason }))
322
+ report.skipped = plan.skippable
323
+
324
+ if (repair !== true) return report
325
+
326
+ for (const item of plan.items.slice(0, maxRepair)) {
327
+ const r = applyRepairItem(item, { cacheRoots: roots, deps })
328
+ if (r.ok) report.repaired.push({ name: item.name, version: item.version, target: item.target, via: item.via, note: r.note })
329
+ else report.failed.push({ name: item.name, target: item.target, action: r.action, note: r.note })
330
+ }
331
+
332
+ // 复验:修完必须**重新走一遍闭包** —— 不靠"我装完了"自证
333
+ const after = probe({ entry: located.entry, deps })
334
+ report.after = after
335
+ report.okAfter = after.ok
336
+ report.changed = report.repaired.length > 0
337
+ return report
338
+ }
339
+
340
+ export {
341
+ PREFLIGHT_BAK,
342
+ applyRepairItem,
343
+ buildLockIndex,
344
+ compareLoose,
345
+ isPreflightBak,
346
+ locateFrameworkEntry,
347
+ locateLockfile,
348
+ planClosureRepair,
349
+ runFwPreflight,
350
+ stampOf,
351
+ }
@@ -445,16 +445,34 @@ function execFileWithKillTree(bin, argv, opts = {}, deps = {}) {
445
445
  * 仍然照需求注入这三个 env(CI 与非交互确实有用、registry 镜像照旧、fetch_* 是 npm 系标准写法,
446
446
  * 对 npm/后续 pnpm 版本仍可能生效),但**不把健壮性只押在 env 上**。
447
447
  *
448
- * ★ 2026-10-13(0.5.48,实测"装 ds-bot 时无 TTY 永久挂住"):补 `npm_config_ci=true` ——
449
- * pnpm 判"能不能弹交互"用的是**它自己的 ci 配置项**(`opts3.ci`,env 形态即 `npm_config_ci`),
450
- * **不是** `process.env.CI`;只给 `CI=true` 时 `canPrompt` 可能仍为真 ⇒ 弹提问 ⇒ 读 stdin 到 EOF
451
- * ⇒ 桌面壳的管道永不关闭 ⇒ 永久挂住。取证与完整链条见 infra/pnpm-release-gate.js 头注释。 */
448
+ * ★ 2026-10-14(0.5.50 之后回校,实测更正 0.5.48 此处的判断):
449
+ * 0.5.48 在这里写的是「pnpm 判交互用它自己的 ci 配置项(opts3.ci,env 形态即 npm_config_ci),
450
+ * **不是** process.env.CI」。**这句只对了一半,而且是最关键的一半错了**:
451
+ * · `npm_config_ci=true` 对 pnpm **自己的**配置项是**空操作** —— pnpm 只认 `pnpm_config_*`
452
+ * 前缀(含 argv 的 `--config.*`)。实测:设了它,`pnpm config list --json` 里 `ci` 仍为空,
453
+ * 且 `pnpm install` 面对过时 lock 时**不会**被 frozen 拒绝。⇒ env 这一行是安慰剂。
454
+ * · 反过来,`CI=true` **确实**点亮 ci 位:pnpm 自己的配置默认值就是 `ci: isCI`
455
+ * (dist/pnpm.mjs L145865),而 `isCI` 判的是 `env.CI`(L143262)⇒ `opts3.ci` 随之变真。
456
+ * 实测:只设 `CI=true`,`pnpm install` 面对过时 lock **会**被 frozen 拒绝(证伪组:
457
+ * 再加 `--no-frozen-lockfile` 就恢复照常)。这一位在 v0.5.30 就已注入,不是 0.5.48 新增。
458
+ * ⇒ 真正管用的两位:`CI=true`(env,早就有)与 `--config.ci=true`(argv,只加在 pnpmAddArgs)。
459
+ * `npm_config_ci=true` 留着无害(对 npm 兼容项仍可能生效),但**别再把它当成非交互的保证**。
460
+ * ⚠️ 另有一处更重要的更正:0.5.48 说"命中供应链闸 ⇒ pnpm 弹提问 ⇒ 管道永不关闭 ⇒ 永久挂住",
461
+ * 这条因果链**实测不成立**。pnpm 的 `canPrompt = !inCi && Boolean(process.stdin.isTTY)`
462
+ * (L194277-278)—— 管道下 `isTTY` 恒为假 ⇒ pnpm **从来**不走 prompt 分支,五种配置
463
+ * (无 ci / CI=true / npm_config_ci / --config.ci / 两者齐上)实测全部快速失败,无一挂住。
464
+ * 真正读 stdin 等人的是 **corepack 的下载提问**(corepack.cjs L22083:
465
+ * `if (process.stdin.isTTY && !process.env.CI)` ⇒ `stdin.resume()` + `once(stdin,'data')`)
466
+ * —— 它的判据恰恰是 `process.env.CI`,也就是 0.5.48 说"不是 pnpm 读的那一位"的那个变量。
467
+ * 所以"无 TTY 挂住"的真凶是 corepack(且需 isTTY 为真才挂),不是 pnpm 的供应链闸。
468
+ * 本段只更正文档,**未改动任何行为代码**(三个 env 与 argv 的注入原样保留)。 */
452
469
  function pnpmEnvOverrides(registry) {
453
470
  const overrides = {
454
471
  npm_config_fetch_timeout: '60000',
455
472
  npm_config_fetch_retries: '1',
456
473
  CI: 'true',
457
- // pnpm 自己的 ci 位(非交互的权威开关);`CI=true` 单独不够,理由见上。
474
+ // 0.5.48 原注:「pnpm 自己的 ci 位(非交互的权威开关);CI=true 单独不够」——
475
+ // 2026-10-14 实测更正:这一行对 pnpm 是空操作(见上一段)。保留只为兼容与无害。
458
476
  npm_config_ci: 'true',
459
477
  }
460
478
  if (typeof registry === 'string' && registry !== '') overrides.COREPACK_NPM_REGISTRY = registry
@@ -490,8 +508,15 @@ function pnpmFetchArgs(timeoutMs = 60000, retries = 1) {
490
508
  *
491
509
  * ★ 2026-10-13(0.5.48):追加 `--config.ci=true` —— pnpm **官方支持的非交互开关**
492
510
  * (`--config.<key>=<value>` 是 pnpm 设任意配置项的通用语法,**不是**绕过闸门的手段)。
493
- * 理由与取证见 infra/pnpm-release-gate.js 头注释:命中供应链闸门时它会弹提问等人回答,
494
- * 而无 TTY 的子进程 stdin 是永不关闭的管道 ⇒ **永久挂住**;钉死 ci 位 ⇒ 改成快速如实失败。 */
511
+ * ★ 2026-10-14(实测更正 0.5.48 的理由陈述,**行为未动**):0.5.48 写"命中供应链闸 ⇒ 弹提问 ⇒
512
+ * 管道永不关闭 ⇒ 永久挂住",这条因果链实测**不成立**:pnpm 的
513
+ * `canPrompt = !inCi && Boolean(process.stdin.isTTY)`,管道下 isTTY 恒假 ⇒ pnpm 从不 prompt,
514
+ * 五种 ci 配置实测全部快速失败(无一挂住)。真正读 stdin 等人的是 **corepack 的下载提问**
515
+ * (corepack.cjs L22083,判据 `process.stdin.isTTY && !process.env.CI`)。
516
+ * 所以这一位的真实价值是:**把 ci 位钉死**(与 `CI=true` 同向、但不受 env 被覆盖影响),
517
+ * 并让 install 类命令在 lock 过时走 frozen 拒绝而非静默改写。`pnpm add` 不受 frozen 影响
518
+ * (实测:带/不带此旗,add 都照常装上并更新 lock)⇒ 不会把安装变成拒绝。
519
+ * 详见 pnpmEnvOverrides 上方的完整更正说明。 */
495
520
  function pnpmAddArgs(spec, registry, { fetchFlags = true, fetchTimeoutMs = 60000, fetchRetries = 1 } = {}) {
496
521
  const args = ['add', spec]
497
522
  if (typeof registry === 'string' && registry !== '') args.push('--registry', registry)
@@ -0,0 +1,142 @@
1
+ // L0 · infra —— fw-closure.js(框架**依赖闭包**探测:判据是"能不能解析出来";0.5.50 加法)
2
+ //
3
+ // 为什么需要它(**真机停机事故 2026-10-14**):
4
+ // npx 缓存里的四个框架包被掏空 —— `cordis` 顶层 junction 还在、它指向的 `.pnpm` 实体目录
5
+ // 被清空;`dsh-llm` / `dsh-tools` / `dsh-web` 直接成了**空的真实目录**。后果是 dsh 启动即
6
+ // `startup failed: 54 个插件 failed to import`,web 服务 exit code=1,**插件自己没机会运行**。
7
+ //
8
+ // 而既有指纹判据 `compat-state.js#pnpmEntities` 是**数 `.pnpm` 里 `@deepseek-ai*` 的目录个数**:
9
+ // 本事故形态恰好是"目录壳还在、里面被掏空" ⇒ 19 → 19,计数**一个都不变** ⇒ 指纹判定环境未变
10
+ // ⇒ 不触发任何动作。次生的:`dsh-llm` / `dsh-tools` / `dsh-web` **根本不在 `.pnpm` 里**
11
+ // (它们住在顶层 `node_modules/@deepseek-ai/`),那个计数从来就不覆盖它们。
12
+ //
13
+ // 本模块给出**面向解析**的判据:从 dsh 的 `lib/bin.js` 出发,把 `@deepseek-ai/*` 的依赖闭包
14
+ // 整条走一遍,谁 `resolve(name + '/package.json')` 失败谁就是坏的 —— 与目录长什么样无关,
15
+ // 不存在"数个数"那种对"掏空"免疫的盲区,也不会把 `@scope` 目录、`.pnpm` 历史残留误判成损坏
16
+ // (2026-10-14 实测:按"目录空不空"粗扫本机会误报 237 个;换成闭包判据后 287 个包精准通过)。
17
+ //
18
+ // 两条纪律:
19
+ // ① 判据只认"能解析"这一件事(与版本号无关)—— 与 domain/framework-residuals.js 的
20
+ // `FRAMEWORK_BOOT_PACKAGES` 同源取舍:版本号对 ≠ 装完整。
21
+ // ② IO 全部可注入(`deps`)—— 单测用内存实现即可驱动,不必真造一棵坏框架树、不联网、不起进程。
22
+ //
23
+ // 分层:L0 · infra(不得 import domain/routes/index —— tests/test-architecture-guard.mjs 断言②)。
24
+ // 行数预算:独立小模块,留在 600 行架构硬顶内。
25
+
26
+ import { existsSync, readFileSync } from 'node:fs'
27
+ import { createRequire } from 'node:module'
28
+ import { basename, dirname, join } from 'node:path'
29
+
30
+ /** 框架命名空间:`@deepseek-ai/*` 随框架版本走,承载者永远是框架安装树。 */
31
+ const FRAMEWORK_SCOPE = '@deepseek-ai/'
32
+
33
+ /**
34
+ * 包名归一:`@scope/name/sub/deep` → `@scope/name`;裸名 `name/sub` → `name`。
35
+ * 与 domain/framework-root.js#basePackageName 同判据(这里独立实现:那边服务"基准枚举",
36
+ * 这边服务"闭包遍历",两者失败模式不同,不互相牵制)。
37
+ */
38
+ function baseNameOf(spec) {
39
+ const text = String(spec ?? '')
40
+ if (text.startsWith('@')) {
41
+ const parts = text.split('/')
42
+ return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : text
43
+ }
44
+ return text.split('/')[0]
45
+ }
46
+
47
+ /** 该引用名是否属于要遍历的命名空间(默认 `@deepseek-ai/`)。 */
48
+ function isInScope(spec, scopePrefix = FRAMEWORK_SCOPE) {
49
+ const name = baseNameOf(spec)
50
+ return name === scopePrefix.slice(0, -1) || name.startsWith(scopePrefix)
51
+ }
52
+
53
+ /**
54
+ * 走一遍依赖闭包,返回**解析不了**的包(= 真正的损坏面)。
55
+ *
56
+ * @param {object} p
57
+ * @param {string} p.entry 入口文件绝对路径(如 `<fwRoot>/@deepseek-ai/dsh/lib/bin.js`);
58
+ * 解析基准由它决定 —— 与 Node 真实解析语义完全一致,不另造基准。
59
+ * @param {string} [p.rootName] 入口包名(默认 `@deepseek-ai/dsh`)。
60
+ * @param {string} [p.scopePrefix] 只遍历这个前缀(默认 `@deepseek-ai/`)。
61
+ * @param {number} [p.maxPackages] 遍历上限(防御病态闭包;默认 2000)。
62
+ * @param {object} [p.deps] IO 注入(单测用)。
63
+ * @returns {{ ok: boolean, walked: string[], broken: Array<{name:string, from:string, reason:string}>,
64
+ * total: number, truncated: boolean }}
65
+ * `broken[].reason` 取值:`unresolvable`(解析失败)/`unreadable`(清单读不出)/`malformed`(JSON 坏)。
66
+ */
67
+ function probeClosure({ entry, rootName = '@deepseek-ai/dsh', scopePrefix = FRAMEWORK_SCOPE, maxPackages = 2000, deps = {} } = {}) {
68
+ const createReq = deps.createRequire ?? createRequire
69
+ const readJson = deps.readJson ?? ((p) => JSON.parse(readFileSync(p, 'utf8')))
70
+ const exists = deps.exists ?? existsSync
71
+ const resolveSpec = deps.resolveSpec ?? null
72
+
73
+ const walked = []
74
+ const broken = []
75
+ const brokenSeen = new Set()
76
+ const seen = new Set()
77
+ if (typeof entry !== 'string' || entry === '') {
78
+ return { ok: false, walked, broken: [{ name: rootName, from: '(入口)', reason: 'entry-missing' }], total: 0, truncated: false }
79
+ }
80
+
81
+ /**
82
+ * 记录一个坏包。**同一个包只记一次**(保留第一个发现它的引用方)。
83
+ * 为什么去重:一个"枢纽包"(如 `dsh-tools`)坏了会波及整条下游 —— 真机实测清空 `dsh-tools`
84
+ * 会产出 46 条记录,而它们**全是同一个包**。逐条打印会把"到底哪个包坏了"淹没在噪声里;
85
+ * 修复计划也会对同一个目标重复下单。去重后:一个坏包 = 一条记录 = 一次修复。
86
+ */
87
+ const markBroken = (name, from, reason) => {
88
+ if (brokenSeen.has(name)) return
89
+ brokenSeen.add(name)
90
+ broken.push({ name, from, reason })
91
+ }
92
+
93
+ const entryReq = createReq(entry)
94
+ /** 解析一个引用名 → 它的包目录;失败返回 null。注入 `resolveSpec` 时完全走替身(单测)。 */
95
+ const resolveDir = (name, fromDir, fromReq) => {
96
+ if (typeof resolveSpec === 'function') return resolveSpec(name, fromDir)
97
+ try { return dirname(fromReq.resolve(name + '/package.json')) } catch { return null }
98
+ }
99
+
100
+ const walk = (name, from, fromReq, fromDir) => {
101
+ if (walked.length >= maxPackages) return
102
+ if (seen.has(name)) return
103
+ seen.add(name)
104
+ walked.push(name)
105
+
106
+ const dir = resolveDir(name, fromDir, fromReq)
107
+ if (dir === null) { markBroken(name, from, 'unresolvable'); return }
108
+ let pkg = null
109
+ try { pkg = readJson(join(dir, 'package.json')) } catch (error) { markBroken(name, from, 'unreadable'); return }
110
+ if (pkg === null || typeof pkg !== 'object') { markBroken(name, from, 'malformed'); return }
111
+
112
+ const depsOf = { ...(pkg.dependencies ?? {}), ...(pkg.peerDependencies ?? {}) }
113
+ const optionalPeers = pkg.peerDependenciesMeta ?? {}
114
+ let subReq = null
115
+ try { subReq = typeof resolveSpec === 'function' ? fromReq : createReq(join(dir, 'noop.js')) } catch { subReq = fromReq }
116
+ for (const spec of Object.keys(depsOf)) {
117
+ if (!isInScope(spec, scopePrefix)) continue
118
+ const child = baseNameOf(spec)
119
+ const optional = optionalPeers[spec]?.optional === true
120
+ const childDir = resolveDir(child, dir, subReq)
121
+ if (childDir === null) {
122
+ // 可选 peer 缺失不算坏(与既有判据一致:不放松成"一律放行",也不冤枉合法的可选依赖)
123
+ if (!optional) markBroken(child, name, 'unresolvable')
124
+ continue
125
+ }
126
+ // `exists` 仅用于让注入替身也能表达"目录在、内容空"这一形态
127
+ if (typeof deps.exists === 'function' && exists(childDir) === false) continue
128
+ walk(child, name, subReq, dir)
129
+ }
130
+ }
131
+
132
+ // 入口包本身也要认:它解析不出来时,后面所有包都会连带失败 —— 先把根报出来,才有可读的原因链
133
+ const rootDir = resolveDir(rootName, dirname(entry), entryReq)
134
+ if (rootDir === null) {
135
+ return { ok: false, walked, broken: [{ name: rootName, from: '(入口)', reason: 'unresolvable' }], total: 0, truncated: false }
136
+ }
137
+ walk(rootName, '(入口)', entryReq, dirname(entry))
138
+
139
+ return { ok: broken.length === 0, walked, broken, total: walked.length, truncated: walked.length >= maxPackages }
140
+ }
141
+
142
+ export { FRAMEWORK_SCOPE, baseNameOf, isInScope, probeClosure }