@noob-stupid/dsh-plugin-console 0.5.49 → 0.5.51
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 +24 -0
- package/README.zh.md +20 -0
- package/lib/server/domain/fw-preflight.js +351 -0
- package/lib/server/infra/exec.js +28 -7
- package/lib/server/infra/fw-closure.js +142 -0
- package/lib/server/infra/fw-root-detect.js +209 -0
- package/lib/server/infra/npm-cache.js +173 -0
- package/lib/server/infra/pnpm-release-gate.js +17 -0
- package/package.json +3 -2
- package/scripts/apply-framework-patch.cjs +134 -0
- package/scripts/apply-npm-names.cjs +36 -0
- package/scripts/build-index.cjs +190 -0
- package/scripts/diag-blocks.mjs +60 -0
- package/scripts/fw-preflight.mjs +186 -0
- package/scripts/fw-watchdog-task.ps1 +112 -0
- package/scripts/fw-watchdog.cmd +30 -0
- package/scripts/fw-watchdog.mjs +246 -0
- package/scripts/safe-boot.mjs +260 -0
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
|
+
}
|
package/lib/server/infra/exec.js
CHANGED
|
@@ -445,16 +445,30 @@ function execFileWithKillTree(bin, argv, opts = {}, deps = {}) {
|
|
|
445
445
|
* 仍然照需求注入这三个 env(CI 与非交互确实有用、registry 镜像照旧、fetch_* 是 npm 系标准写法,
|
|
446
446
|
* 对 npm/后续 pnpm 版本仍可能生效),但**不把健壮性只押在 env 上**。
|
|
447
447
|
*
|
|
448
|
-
* ★ 2026-10-
|
|
449
|
-
*
|
|
450
|
-
*
|
|
451
|
-
*
|
|
448
|
+
* ★ 2026-10-14(回校 0.5.48 此处的判断,**行为未动、只更正说明**。完整取证与五组实验见
|
|
449
|
+
* CHANGELOG「v0.5.51」那节;这里只留结论,因为本文件贴着架构守卫的 600 行硬顶):
|
|
450
|
+
* ① `npm_config_ci=true` 对 pnpm **自己的**配置项是**空操作**(只认 `pnpm_config_*` 前缀,
|
|
451
|
+
* 含 argv 的 `--config.*`)⇒ 这一行是安慰剂,别再当成非交互的保证。
|
|
452
|
+
* ② `CI=true` **确实**点亮 ci 位(pnpm 配置默认值 `ci: isCI`,`isCI` 判 `env.CI`)⇒
|
|
453
|
+
* `pnpm install` 面对过时 lock **会**被 frozen 拒绝(加 `--no-frozen-lockfile` 即恢复)。
|
|
454
|
+
* 这一位 **v0.5.30 就已注入**,不是 0.5.48 新增。
|
|
455
|
+
* ③ 「pnpm 弹提问 ⇒ 读 stdin 到 EOF ⇒ 永久挂住」**不成立**:`canPrompt` 需要
|
|
456
|
+
* `process.stdin.isTTY`,管道下恒为假 ⇒ pnpm 从不 prompt;五种 ci 配置实测**全部快速失败**。
|
|
457
|
+
* ④ 那"挂到 90s / 180s"**是我们自己的超时常量**(`runPnpmAdd` timeout=90000、
|
|
458
|
+
* `installTargetDependencies` timeoutMs=180000),与当时观察逐一吻合 ⇒ 是"超过我们的
|
|
459
|
+
* 超时被杀",不是"等输入"。**那次为什么慢至今没有证据**(最可能是网络)。
|
|
460
|
+
* ⑤ corepack 的下载提问也**默认是关的**(入口 shim `COREPACK_ENABLE_DOWNLOAD_PROMPT ??= '0'`),
|
|
461
|
+
* 只有显式设成 1 才会问 ⇒ 也不是默认真凶。
|
|
462
|
+
* ⇒ 0.5.48 那条「永久挂住」的**根因至今未查明**,能确定的只有"不是 pnpm 的交互提问"。
|
|
463
|
+
* `--config.ci=true` 仍保留独立价值:钉死 ci 位 + 让 install 在 lock 过时走 frozen 拒绝
|
|
464
|
+
* 而非静默改写;`pnpm add` 不受 frozen 影响(实测带/不带此旗结局相同)。 */
|
|
452
465
|
function pnpmEnvOverrides(registry) {
|
|
453
466
|
const overrides = {
|
|
454
467
|
npm_config_fetch_timeout: '60000',
|
|
455
468
|
npm_config_fetch_retries: '1',
|
|
456
469
|
CI: 'true',
|
|
457
|
-
// pnpm 自己的 ci
|
|
470
|
+
// 0.5.48 原注:「pnpm 自己的 ci 位(非交互的权威开关);CI=true 单独不够」——
|
|
471
|
+
// 2026-10-14 实测更正:这一行对 pnpm 是空操作(见上一段)。保留只为兼容与无害。
|
|
458
472
|
npm_config_ci: 'true',
|
|
459
473
|
}
|
|
460
474
|
if (typeof registry === 'string' && registry !== '') overrides.COREPACK_NPM_REGISTRY = registry
|
|
@@ -490,8 +504,15 @@ function pnpmFetchArgs(timeoutMs = 60000, retries = 1) {
|
|
|
490
504
|
*
|
|
491
505
|
* ★ 2026-10-13(0.5.48):追加 `--config.ci=true` —— pnpm **官方支持的非交互开关**
|
|
492
506
|
* (`--config.<key>=<value>` 是 pnpm 设任意配置项的通用语法,**不是**绕过闸门的手段)。
|
|
493
|
-
*
|
|
494
|
-
*
|
|
507
|
+
* ★ 2026-10-14(实测更正 0.5.48 的理由陈述,**行为未动**):0.5.48 写"命中供应链闸 ⇒ 弹提问 ⇒
|
|
508
|
+
* 管道永不关闭 ⇒ 永久挂住",这条因果链实测**不成立**:pnpm 的
|
|
509
|
+
* `canPrompt = !inCi && Boolean(process.stdin.isTTY)`,管道下 isTTY 恒假 ⇒ pnpm 从不 prompt,
|
|
510
|
+
* 五种 ci 配置实测全部快速失败(无一挂住)。真正读 stdin 等人的是 **corepack 的下载提问**
|
|
511
|
+
* (corepack.cjs L22083,判据 `process.stdin.isTTY && !process.env.CI`)。
|
|
512
|
+
* 所以这一位的真实价值是:**把 ci 位钉死**(与 `CI=true` 同向、但不受 env 被覆盖影响),
|
|
513
|
+
* 并让 install 类命令在 lock 过时走 frozen 拒绝而非静默改写。`pnpm add` 不受 frozen 影响
|
|
514
|
+
* (实测:带/不带此旗,add 都照常装上并更新 lock)⇒ 不会把安装变成拒绝。
|
|
515
|
+
* 详见 pnpmEnvOverrides 上方的完整更正说明。 */
|
|
495
516
|
function pnpmAddArgs(spec, registry, { fetchFlags = true, fetchTimeoutMs = 60000, fetchRetries = 1 } = {}) {
|
|
496
517
|
const args = ['add', spec]
|
|
497
518
|
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 }
|