@noob-stupid/dsh-plugin-console 0.5.48 → 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.
@@ -0,0 +1,209 @@
1
+ // L0 · infra —— fw-root-detect.js(框架树根的**多基准探测**;0.5.50 加法)
2
+ //
3
+ // 为什么要多基准(真机实证,2026-10-14):
4
+ // 这套代码会在**两种完全不同的位置**被运行,解析语义天然不同:
5
+ // ① 插件装在 profile 里(正常形态)—— 从插件自己的 `lib/index.js` 能解析到
6
+ // `@deepseek-ai/dsh`(本机实测:profile 的 node_modules 把它指向 npx 缓存的
7
+ // `D:\node_cache\_npx\<hash>\node_modules\@deepseek-ai\dsh`);
8
+ // ② 在**开发仓库**里直接跑(`node scripts/fw-preflight.mjs`)—— 仓库根没有 node_modules,
9
+ // `require.resolve('@deepseek-ai/dsh/package.json')` 直接 `MODULE_NOT_FOUND`。
10
+ // **而"服务起不来"时最可能的调用方式恰恰是这一种**(用户手里只有一条命令)。
11
+ // ⇒ 只写一种基准等于在最需要它的场景里失效。本模块把候选收在一处,按"最准 → 最宽"排序。
12
+ //
13
+ // 与 domain/framework-root.js 的分工(**不是同一件事,别合并**):
14
+ // · 那边:给**补丁自愈/只读体检**判断"某个 `@deepseek-ai/*` 包在不在",产出**多个**解析基准;
15
+ // · 这边:给**预检治包**判断"框架树根本身在哪",产出**一个**根目录(供走闭包与定 .pnpm 位置)。
16
+ //
17
+ // 分层:L0 · infra(不得 import domain/routes/index —— tests/test-architecture-guard.mjs 断言②)。
18
+
19
+ import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs'
20
+ import { createRequire } from 'node:module'
21
+ import { homedir } from 'node:os'
22
+ import { dirname, join, resolve } from 'node:path'
23
+ import { candidateCacheRoots } from './npm-cache.js'
24
+
25
+ /** 该目录像不像一棵框架树:有 `@deepseek-ai/dsh/lib/bin.js`,或有 `.pnpm` 且其中有 dsh 实体。 */
26
+ function looksLikeFwRoot(dir, { deps = {} } = {}) {
27
+ const exists = deps.exists ?? existsSync
28
+ if (typeof dir !== 'string' || dir === '') return false
29
+ if (exists(join(dir, '@deepseek-ai', 'dsh', 'lib', 'bin.js'))) return true
30
+ try {
31
+ for (const name of readdirSync(join(dir, '.pnpm'))) {
32
+ if (!name.startsWith('@deepseek-ai+dsh@')) continue
33
+ if (exists(join(dir, '.pnpm', name, 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js'))) return true
34
+ }
35
+ } catch {}
36
+ return false
37
+ }
38
+
39
+ /** 从某个基准文件/目录解析 `@deepseek-ai/dsh`,成功则反推承载它的那层 `node_modules`。 */
40
+ function fwRootFromBase(base, { deps = {} } = {}) {
41
+ const exists = deps.exists ?? existsSync
42
+ const realpath = deps.realpath ?? ((p) => realpathSync(p))
43
+ const makeReq = deps.createRequire ?? createRequire
44
+ let pkgPath = null
45
+ try { pkgPath = makeReq(base).resolve('@deepseek-ai/dsh/package.json') } catch { return null }
46
+ if (typeof pkgPath !== 'string') return null
47
+ // `<…>/node_modules/@deepseek-ai/dsh/package.json` → 退三层 = 那层 node_modules
48
+ let nm = dirname(dirname(dirname(pkgPath)))
49
+ // ★ 必须 resolve 成**真身**:profile 侧的 `node_modules/@deepseek-ai/dsh` 常是 junction 投影
50
+ // (真机实测:`C:\Users\<u>\.dsh\profiles\node_modules\@deepseek-ai\dsh` → `D:\node_cache\_npx\<hash>\…`)。
51
+ // 拿投影当框架树根 ⇒ `--repair` 会按"投影的 node_modules / .pnpm"去算目标路径 ⇒ 写到不存在的
52
+ // 结构里,或者把包装进投影层而不是真身所在的那棵树。解析到真身才能与"服务实际加载的那棵树"对齐。
53
+ try { nm = dirname(dirname(dirname(realpath(pkgPath)))) } catch {}
54
+ if (looksLikeFwRoot(nm, { deps })) return nm
55
+ // rc.1 之后实体在 .pnpm 内层:再向上找最近的含 .pnpm 的 node_modules
56
+ let dir = nm
57
+ for (let i = 0; i < 8; i += 1) {
58
+ if (exists(join(dir, '.pnpm'))) return dir
59
+ const parent = dirname(dir)
60
+ if (parent === dir) break
61
+ dir = parent
62
+ }
63
+ return exists(nm) ? nm : null
64
+ }
65
+
66
+ /** 扫 npx 缓存(`<cacheBase>/_npx/<hash>/node_modules`),按 dsh 的 bin.js 修改时间取最新那个。 */
67
+ function fwRootFromNpxCache({ env = process.env, home = homedir(), deps = {} } = {}) {
68
+ const exists = deps.exists ?? existsSync
69
+ const stat = deps.stat ?? ((p) => statSync(p))
70
+ const readdir = deps.readdir ?? ((p) => readdirSync(p))
71
+
72
+ const bases = []
73
+ const pushBase = (dir) => {
74
+ if (typeof dir !== 'string' || dir === '') return
75
+ if (!bases.includes(dir)) bases.push(dir)
76
+ }
77
+ pushBase(env.npm_config_cache)
78
+ pushBase(env.NPM_CONFIG_CACHE)
79
+ pushBase('D:\\node_cache')
80
+ pushBase(join(env.LOCALAPPDATA ?? '', 'npm-cache'))
81
+ pushBase(join(home, 'AppData', 'Local', 'npm-cache'))
82
+ // npm cacache 的根通常就是 `<cacheBase>/_cacache`,故从它的父目录取 `_npx`
83
+ for (const cacache of candidateCacheRoots({ env, home })) pushBase(dirname(cacache))
84
+
85
+ const hits = []
86
+ for (const base of bases) {
87
+ const npxRoot = join(base, '_npx')
88
+ if (!exists(npxRoot)) continue
89
+ let dirs = []
90
+ try { dirs = readdir(npxRoot) } catch { continue }
91
+ for (const d of dirs) {
92
+ const nm = join(npxRoot, d, 'node_modules')
93
+ const bin = join(nm, '@deepseek-ai', 'dsh', 'lib', 'bin.js')
94
+ if (!exists(bin)) continue
95
+ let mtime = 0
96
+ try { mtime = stat(bin).mtimeMs ?? 0 } catch { mtime = 0 }
97
+ hits.push({ fwRoot: nm, via: 'npx-cache', mtime })
98
+ }
99
+ }
100
+ if (hits.length === 0) return null
101
+ // 桌面端启动器就是按 mtime 取最新的那个 —— 这里沿用同一取舍,保证"预检的树"与"会被启动的树"一致
102
+ hits.sort((a, b) => b.mtime - a.mtime)
103
+ return hits[0]
104
+ }
105
+
106
+ /** 扫 `<dshHome>/profiles/**` 与各类 node_modules,找一棵像框架树的。 */
107
+ function fwRootFromDshHome({ env = process.env, home = homedir(), deps = {} } = {}) {
108
+ const exists = deps.exists ?? existsSync
109
+ const readdir = deps.readdir ?? ((p) => readdirSync(p))
110
+ const dshHome = (typeof env.DSH_HOME === 'string' && env.DSH_HOME.trim() !== '') ? env.DSH_HOME.trim() : join(home, '.dsh')
111
+
112
+ const cands = []
113
+ // ① 显式 profile 目录(桌面端/CLI 都会设 DSH_PROFILE_DIR)
114
+ if (typeof env.DSH_PROFILE_DIR === 'string' && env.DSH_PROFILE_DIR.trim() !== '') cands.push(env.DSH_PROFILE_DIR.trim())
115
+ // ② 按 profile 名
116
+ if (typeof env.DSH_PROFILE === 'string' && env.DSH_PROFILE.trim() !== '') cands.push(join(dshHome, 'profiles', env.DSH_PROFILE.trim()))
117
+ // ③ 共享投影层与各 profile
118
+ cands.push(join(dshHome, 'profiles', 'node_modules'))
119
+ try {
120
+ for (const d of readdir(join(dshHome, 'profiles'))) {
121
+ if (d === 'node_modules') continue
122
+ cands.push(join(dshHome, 'profiles', d, 'node_modules'))
123
+ }
124
+ } catch {}
125
+
126
+ for (const dir of cands) {
127
+ if (!looksLikeFwRoot(dir, { deps })) continue
128
+ return { fwRoot: dir, via: 'dsh-home' }
129
+ }
130
+ return null
131
+ }
132
+
133
+ /**
134
+ * 把"看起来像框架树根"的目录**归一到真身**。
135
+ *
136
+ * 为什么必须单独做这一步(真机实测 2026-10-14):
137
+ * `C:\Users\<u>\.dsh\profiles\node_modules` **本身是真实目录**(不是链接),但它下面的
138
+ * `@deepseek-ai\dsh` 是 **junction** → `D:\node_cache\_npx\<hash>\node_modules\@deepseek-ai\dsh`。
139
+ * 于是"这个目录像框架树"与"框架包的真身在哪"是两件事:只对根目录做 realpath 什么也变不了。
140
+ * ⇒ 拿投影当根,`--repair` 会按"投影的 node_modules / .pnpm"去算目标路径,写到不存在的结构里;
141
+ * 而服务实际加载的是**真身那棵树**(启动器命令行 = `…\_npx\<hash>\node_modules\@deepseek-ai\dsh\lib\bin.js`)。
142
+ * ⇒ 归一到真身,预检的那棵树才与"会被启动的那棵树"一致。
143
+ */
144
+ function realRootOf(dir, { deps = {} } = {}) {
145
+ const exists = deps.exists ?? existsSync
146
+ const realpath = deps.realpath ?? ((p) => realpathSync(p))
147
+ if (typeof dir !== 'string' || dir === '') return dir
148
+ const pkg = join(dir, '@deepseek-ai', 'dsh', 'package.json')
149
+ if (exists(pkg)) {
150
+ try { return dirname(dirname(dirname(realpath(pkg)))) } catch { return dir }
151
+ }
152
+ // `.pnpm` 内层形态:解析 store 里那份 dsh 的清单,同样退三层
153
+ try {
154
+ for (const name of readdirSync(join(dir, '.pnpm'))) {
155
+ if (!name.startsWith('@deepseek-ai+dsh@')) continue
156
+ const inner = join(dir, '.pnpm', name, 'node_modules', '@deepseek-ai', 'dsh', 'package.json')
157
+ if (!exists(inner)) continue
158
+ return dirname(dirname(dirname(realpath(inner))))
159
+ }
160
+ } catch {}
161
+ return dir
162
+ }
163
+
164
+ /**
165
+ * 探测框架树根(**有序**,先准后宽)。返回 `{ fwRoot, via, tried }`;一个都拿不到时
166
+ * `fwRoot: null` 且 `tried` 列出试过的基准 —— 调用方据此如实报"探测不到",而不是猜一个。
167
+ *
168
+ * 所有分支的返回值都过一道 `realRootOf` —— 保证调用方拿到的永远是**真身所在的那棵树**。
169
+ *
170
+ * @param {object} p
171
+ * @param {string} [p.pluginDir] 插件包根(调用方传 `<repo>/`,用于第 ① 基准)。
172
+ * ★ 名字不叫 `pluginRoot`:那是 infra/paths.js 的导出名,
173
+ * 同名参数会被架构守卫断言⑧(导入绑定只读)判成"给导出绑定赋值"。
174
+ * @param {string} [p.entryHint] 已知的入口文件(如 `dsh/lib/bin.js`)—— 给了就优先用它反推。
175
+ */
176
+ function detectFrameworkRoot({ pluginDir = null, entryHint = null, env = process.env, home = homedir(), deps = {} } = {}) {
177
+ const realpath = deps.realpath ?? ((p) => realpathSync(p))
178
+ const tried = []
179
+ const real = (p) => { try { return realpath(p) } catch { return p } }
180
+
181
+ // ⓪ 调用方直接给了入口文件:它所在的那层 node_modules 最准(同样要落到真身)
182
+ if (typeof entryHint === 'string' && entryHint !== '') {
183
+ const nm = real(dirname(dirname(dirname(resolve(entryHint)))))
184
+ tried.push({ via: 'entry-hint', base: nm })
185
+ if (looksLikeFwRoot(nm, { deps })) return { fwRoot: nm, via: 'entry-hint', tried }
186
+ }
187
+
188
+ // ① 从插件自己解析(插件装在 profile 里时最准)
189
+ if (typeof pluginDir === 'string' && pluginDir !== '') {
190
+ const base = join(pluginDir, 'lib', 'index.js')
191
+ tried.push({ via: 'plugin-root', base })
192
+ const hit = fwRootFromBase(base, { deps })
193
+ if (hit !== null) return { fwRoot: realRootOf(hit, { deps }), via: 'plugin-root', tried }
194
+ }
195
+
196
+ // ② DSH_HOME / profile 环境变量
197
+ tried.push({ via: 'dsh-home', base: '(DSH_HOME/profiles)' })
198
+ const fromHome = fwRootFromDshHome({ env, home, deps })
199
+ if (fromHome !== null) return { fwRoot: realRootOf(fromHome.fwRoot, { deps }), via: fromHome.via, tried }
200
+
201
+ // ③ npx 缓存(开发仓库里直接跑时的主要出路;与桌面端启动器同一取舍)
202
+ tried.push({ via: 'npx-cache', base: '(cacheBase/_npx/*)' })
203
+ const fromNpx = fwRootFromNpxCache({ env, home, deps })
204
+ if (fromNpx !== null) return { fwRoot: realRootOf(fromNpx.fwRoot, { deps }), via: fromNpx.via, tried }
205
+
206
+ return { fwRoot: null, via: null, tried }
207
+ }
208
+
209
+ export { detectFrameworkRoot, fwRootFromBase, fwRootFromDshHome, fwRootFromNpxCache, looksLikeFwRoot, realRootOf }
@@ -0,0 +1,173 @@
1
+ // L0 · infra —— npm-cache.js(npm cacache **直读** + 零依赖解 tgz;0.5.50 加法)
2
+ //
3
+ // 为什么需要它(2026-10-14 真机停机事故的修复侧):
4
+ // 框架包被掏空后,`node_modules` 里只剩空壳,但**本地 npm 缓存里通常还留着原始 tarball**。
5
+ // 实测本机:`cordis` / `dsh-llm` / `dsh-tools` / `dsh-web` 四个包的 tarball 全在
6
+ // `D:\node_cache\_cacache`,按 `package-lock.json` 的 `integrity` **逐字节命中**,110KB 的包
7
+ // 从"发现"到"装回"在 1 秒内完成 —— 全程离线、不联网、不 spawn、不需要 pnpm。
8
+ //
9
+ // 两条硬约束(都是真机踩出来的):
10
+ // ① **零依赖**:不 require npm 自带的 `cacache` / `tar`。
11
+ // 实测在 `D:\dsh` 目录下 `require('npm/node_modules/cacache')` 会炸
12
+ // `ERR_INVALID_PACKAGE_CONFIG`(那儿有个 0 字节的 `package.json`)—— 靠 npm 内部模块
13
+ // 就等于把"打不开时唯一的出路"押在另一个可能坏的东西上。
14
+ // ② **cacache 布局是确定性的**,直接按哈希路径取文件即可:
15
+ // `content-v2/sha512/<hex[0:2]>/<hex[2:4]>/<hex[4:]>`,其中 `hex = base64 解码 integrity 后的十六进制`。
16
+ // 这条路不依赖 index-v5 的元数据,也不受 registry 地址(npmjs / npmmirror)影响。
17
+ //
18
+ // 解 tgz 自带一个最小 tar 解析器(ustar + GNU 长名 + pax 头跳过)——见 extractTgz。
19
+ //
20
+ // 分层:L0 · infra(不得 import domain/routes/index —— tests/test-architecture-guard.mjs 断言②)。
21
+
22
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
23
+ import { dirname, join, resolve, sep } from 'node:path'
24
+ import { homedir } from 'node:os'
25
+ import { gunzipSync } from 'node:zlib'
26
+
27
+ /**
28
+ * 候选 npm 缓存根(按"最可能先命中"排序)。
29
+ * 显式 env 优先(`npm_config_cache` / `NPM_CONFIG_CACHE`),再退到用户 .npmrc 的常见落点。
30
+ */
31
+ function candidateCacheRoots({ env = process.env, home = homedir() } = {}) {
32
+ const out = []
33
+ const push = (dir) => {
34
+ if (typeof dir !== 'string' || dir === '') return
35
+ const withCache = dir.endsWith('_cacache') ? dir : join(dir, '_cacache')
36
+ if (!out.includes(withCache)) out.push(withCache)
37
+ }
38
+ push(env.npm_config_cache)
39
+ push(env.NPM_CONFIG_CACHE)
40
+ push('D:\\node_cache')
41
+ push(join(env.LOCALAPPDATA ?? '', 'npm-cache'))
42
+ push(join(home, 'AppData', 'Local', 'npm-cache'))
43
+ push(join(home, '.npm'))
44
+ // 只要真的存在 `content-v2` 才算数(光有目录名不算)
45
+ return out.filter((p) => existsSync(join(p, 'content-v2')))
46
+ }
47
+
48
+ /**
49
+ * `sha512-<base64>` → cacache 内容文件的绝对路径。
50
+ * 非法 integrity / 长度不对时返回 null(**不猜**:宁可报"取不到",也不去读一个碰巧存在的路径)。
51
+ */
52
+ function integrityToContentPath(cacheRoot, integrity) {
53
+ const m = /^sha512-(.+)$/u.exec(String(integrity ?? ''))
54
+ if (m === null) return null
55
+ let hex = ''
56
+ try { hex = Buffer.from(m[1], 'base64').toString('hex') } catch { return null }
57
+ if (hex.length !== 128 || !/^[0-9a-f]+$/u.test(hex)) return null
58
+ return join(cacheRoot, 'content-v2', 'sha512', hex.slice(0, 2), hex.slice(2, 4), hex.slice(4))
59
+ }
60
+
61
+ /** 按 integrity 从缓存读出 tarball 字节;取不到返回 null(绝不抛)。 */
62
+ function readTarball(cacheRoot, integrity, { deps = {} } = {}) {
63
+ const exists = deps.exists ?? existsSync
64
+ const read = deps.read ?? ((p) => readFileSync(p))
65
+ const p = integrityToContentPath(cacheRoot, integrity)
66
+ if (p === null) return null
67
+ try { return exists(p) ? read(p) : null } catch { return null }
68
+ }
69
+
70
+ /** 依次在多个缓存根里找同一个 integrity,返回第一个命中的 `{ buf, cacheRoot }`。 */
71
+ function readTarballFromAny(roots, integrity, { deps = {} } = {}) {
72
+ for (const root of Array.isArray(roots) ? roots : []) {
73
+ const buf = readTarball(root, integrity, { deps })
74
+ if (buf !== null && buf.length > 0) return { buf, cacheRoot: root }
75
+ }
76
+ return null
77
+ }
78
+
79
+ /** 读 tar 头里的一个数值字段(八进制,容忍空格/NUL 结尾与 GNU base-256)。 */
80
+ function tarNumber(header, offset, length) {
81
+ const raw = header.subarray(offset, offset + length)
82
+ if ((raw[0] & 0x80) !== 0) {
83
+ // GNU base-256(大文件/长数字):低位大端
84
+ let value = 0
85
+ for (const byte of raw) value = value * 256 + (byte & 0x7f)
86
+ return value
87
+ }
88
+ const text = raw.toString('utf8').replace(/\0.*$/u, '').trim()
89
+ const n = parseInt(text, 8)
90
+ return Number.isFinite(n) ? n : 0
91
+ }
92
+
93
+ /** 读 tar 头里的一个字符串字段(NUL 截断)。 */
94
+ function tarString(header, offset, length) {
95
+ return header.subarray(offset, offset + length).toString('utf8').replace(/\0.*$/u, '')
96
+ }
97
+
98
+ /**
99
+ * 解开 tar 归档(**零依赖**最小实现:ustar 前缀 + GNU longname/longlink + pax 头跳过)。
100
+ *
101
+ * 安全:条目路径必须落在 `destDir` 内(拒绝绝对路径与 `..` 穿越)—— 归档来自本地缓存,
102
+ * 但仍按"不可信输入"处理,避免一次坏缓存把文件写到树外。
103
+ *
104
+ * @returns {{ files: number, dirs: number, skipped: number }}
105
+ */
106
+ function extractTar(tarBuf, destDir, { deps = {} } = {}) {
107
+ const mkdir = deps.mkdir ?? ((d) => mkdirSync(d, { recursive: true }))
108
+ const write = deps.write ?? ((p, data) => writeFileSync(p, data))
109
+ const root = resolve(destDir)
110
+ const rootWithSep = root.endsWith(sep) ? root : root + sep
111
+
112
+ let files = 0
113
+ let dirs = 0
114
+ let skipped = 0
115
+ let offset = 0
116
+ let longName = null
117
+ let longLink = null
118
+
119
+ while (offset + 512 <= tarBuf.length) {
120
+ const header = tarBuf.subarray(offset, offset + 512)
121
+ // 全零块 = 归档结束
122
+ let allZero = true
123
+ for (const byte of header) { if (byte !== 0) { allZero = false; break } }
124
+ if (allZero) break
125
+
126
+ const size = tarNumber(header, 124, 12)
127
+ const type = String.fromCharCode(header[156] === 0 ? 48 : header[156])
128
+ const prefix = tarString(header, 345, 155)
129
+ const rawName = tarString(header, 0, 100)
130
+ const body = tarBuf.subarray(offset + 512, offset + 512 + size)
131
+ offset += 512 + Math.ceil(size / 512) * 512
132
+
133
+ // GNU 长名/长链接:内容即下一条目的真名,本条自身不落盘
134
+ if (type === 'L') { longName = body.toString('utf8').replace(/\0.*$/u, ''); continue }
135
+ if (type === 'K') { longLink = body.toString('utf8').replace(/\0.*$/u, ''); continue }
136
+ // pax 扩展头:按"不认识就跳过"处理(不改判据、不猜语义)
137
+ if (type === 'x' || type === 'g') { skipped += 1; continue }
138
+
139
+ const name = longName !== null ? longName : (prefix === '' ? rawName : `${prefix}/${rawName}`)
140
+ longName = null
141
+ longLink = null
142
+ if (name === '') { skipped += 1; continue }
143
+
144
+ // npm tarball 统一带 `package/` 前缀 —— 剥掉后落到目标目录
145
+ const rel = name.replace(/^package\//u, '').replace(/^\.\//u, '')
146
+ if (rel === '') { skipped += 1; continue }
147
+
148
+ const out = resolve(join(root, rel))
149
+ if (out !== root && !out.startsWith(rootWithSep)) { skipped += 1; continue }
150
+
151
+ if (type === '5') { mkdir(out); dirs += 1; continue }
152
+ if (type === '1' || type === '2' || type === '3' || type === '4' || type === '6') { skipped += 1; continue }
153
+ mkdir(dirname(out))
154
+ write(out, body)
155
+ files += 1
156
+ }
157
+ return { files, dirs, skipped }
158
+ }
159
+
160
+ /** 解开 `.tgz`(gunzip + extractTar)。 */
161
+ function extractTgz(tgzBuf, destDir, { deps = {} } = {}) {
162
+ const gunzip = deps.gunzip ?? gunzipSync
163
+ return extractTar(gunzip(tgzBuf), destDir, { deps })
164
+ }
165
+
166
+ export {
167
+ candidateCacheRoots,
168
+ extractTar,
169
+ extractTgz,
170
+ integrityToContentPath,
171
+ readTarball,
172
+ readTarballFromAny,
173
+ }
@@ -66,20 +66,45 @@ function baseDirOf(baseUrl) {
66
66
  * 成功后从解析结果**向上找最近的 package.json** 作为该包的清单。
67
67
  * ⇒ 负控不变:真不存在的子路径,三条路都失败 ⇒ 仍然返回 null(**绝不放松成"一律放行"**)。
68
68
  */
69
- function nearestPackageJson(fromPath) {
69
+ /** 从解析结果向上找清单。**优先认包名匹配的那份**(★ 0.5.49 改错,见下)。
70
+ *
71
+ * ★ 2026-10-14(0.5.49)改错:不能只取"最近的 package.json"。
72
+ * 真机现场:`@linxin666/dsh-web-all/plugin-manager` 的 exports 目标是 `./lib/shells/shell.js`,
73
+ * 而 `lib/shells/` 下还有一个**构建产物用的私有子清单**
74
+ * (`{"name":"@linxin666/dsh-web-all-shells","private":true}` —— **没有 version 字段**)。
75
+ * 只认"最近" ⇒ 拿到那份子清单 ⇒ 版本/仓库全空(和"解不出来"一样是坏结果,只是换了个坏法)。
76
+ * 所以向上找时:命中"name 与引用包名相同"的清单立即返回;一路都没匹配才退回最近的那份
77
+ * (不返回 null —— 包确实存在,只是清单不规范,此时宁可给出最近的那份也不谎报"没有这个包")。
78
+ */
79
+ function nearestPackageJson(fromPath, expectedName = null) {
70
80
  try {
71
81
  let dir = dirname(fromPath)
82
+ let fallback = null
72
83
  for (let depth = 0; depth < 40; depth += 1) {
73
84
  const candidate = join(dir, 'package.json')
74
- if (existsSync(candidate)) return candidate
85
+ if (existsSync(candidate)) {
86
+ if (fallback === null) fallback = candidate
87
+ if (typeof expectedName === 'string' && expectedName !== '' && manifestNameOf(candidate) === expectedName) return candidate
88
+ }
75
89
  const parent = dirname(dir)
76
90
  if (parent === dir) break
77
91
  dir = parent
78
92
  }
93
+ return fallback
79
94
  } catch {}
80
95
  return null
81
96
  }
82
97
 
98
+ /** 读一份清单的 `name`(读不到/不是 JSON 就返回 null)。 */
99
+ function manifestNameOf(file) {
100
+ try {
101
+ const parsed = JSON.parse(readFileSync(file, 'utf8'))
102
+ return typeof parsed?.name === 'string' ? parsed.name : null
103
+ } catch {
104
+ return null
105
+ }
106
+ }
107
+
83
108
  /** 该引用是不是"包 + 子路径"形态(`pkg/sub`、`@scope/pkg/sub`)。
84
109
  * 判据与 packageNameOf 同源:剥掉包名后还剩东西 ⇒ 是子路径引用。 */
85
110
  function isSubpathRef(refName) {
@@ -87,12 +112,15 @@ function isSubpathRef(refName) {
87
112
  return refName !== packageNameOf(refName)
88
113
  }
89
114
 
90
- /** 用真实解析语义解析「包/子路径」引用的清单路径:先解析引用本身,再向上找最近的清单。 */
115
+ /** 用真实解析语义解析「包/子路径」引用的清单路径:先解析引用本身,再向上找**该包**的清单。
116
+ * 向上找时把包名传下去(`nearestPackageJson(refName, expectedName)`)—— 真机上就有子目录里放
117
+ * 私有子清单的包(`@linxin666/dsh-web-all/lib/shells/package.json`,name 是 `…-shells` 且无 version),
118
+ * 只取"最近"会拿到那份、版本照样是空的。详见 nearestPackageJson 的注释。 */
91
119
  function resolveViaRealSemantics(refName, base) {
92
120
  if (typeof base !== 'string' || base === '') return null
93
121
  try {
94
122
  const entry = createRequire(join(base, 'package.json')).resolve(refName)
95
- return nearestPackageJson(entry)
123
+ return nearestPackageJson(entry, packageNameOf(refName))
96
124
  } catch {
97
125
  return null
98
126
  }
@@ -103,13 +131,27 @@ function resolvePackageJson(pkgName, baseDir, fallbackBase) {
103
131
  // ① 真实解析语义(认 exports 子路径)—— 优先,且**不**拼 `/package.json`
104
132
  const real = resolveViaRealSemantics(pkgName, baseDir)
105
133
  if (real !== null) return real
106
- // ★ 子路径引用(`包/子路径`)到此为止:**不许**再退到"按包名找清单"的那两条 fallback。
107
- // 为什么:那两条是按**包名**(packageNameOf 剥掉子路径后的 name)找 `node_modules/<包名>/package.json`,
108
- // 对不存在的子路径也会命中**包根**那份清单 ⇒ 把一个坏引用判成好的
109
- // —— 正是"校验被放松成一律放行",任务红线明令不许。
134
+ // ★ 子路径引用(`包/子路径`)单独一条路:只认「真实解析语义」,但**两个基准都认**。
135
+ //
136
+ // 为什么要挡住后两条:那两条是按**包名**(packageNameOf 剥掉子路径后的 name)找
137
+ // `node_modules/<包名>/package.json`,对不存在的子路径也会命中**包根**那份清单
138
+ // ⇒ 把一个坏引用判成好的 —— 正是"校验被放松成一律放行",任务红线明令不许。
110
139
  // (本机实测:`@scope/tool/nope` 在 @scope/tool 存在时会从 fallback 拿到包根清单。)
111
- // 而真实解析语义对这一类已经给出权威答案:能解析就是好,不能解析就是坏 ⇒ 直接返回 null。
112
- if (isSubpathRef(pkgName)) return null
140
+ //
141
+ // ★ 2026-10-14(0.5.49)改错:这道闸原先写成**在 fallbackBase 那次真实解析之前**就 `return null`,
142
+ // 于是把 profile 回退这条**合法**路径也一起掐了 —— 0.5.48 上 `@linxin666/dsh-web-all/plugin-manager`
143
+ // (框架树里没有、只在 profile 里)从"解得出"退回 null,面板里那批第三方子路径行的
144
+ // 版本/安装日期/仓库全变空(实测 /plugin-console/state 确认),而官方子路径行走的是框架基准、
145
+ // 不受影响 ⇒ 现场不显眼。判据本身没错,错在**位置**:要挡的是"按包名找清单"那两条兜底,
146
+ // 不是"解析这个引用本身"。所以现在:baseDir 真实解析 → fallbackBase 真实解析 → 再 return null。
147
+ // ⇒ 负控不变(`@scope/tool/nope` 两条真实解析都失败 ⇒ 仍是 null,绝不放松成一律放行)。
148
+ if (isSubpathRef(pkgName)) {
149
+ if (typeof fallbackBase === 'string' && fallbackBase !== '' && fallbackBase !== baseDir) {
150
+ const realFallback = resolveViaRealSemantics(pkgName, fallbackBase)
151
+ if (realFallback !== null) return realFallback
152
+ }
153
+ return null
154
+ }
113
155
  // ② 既有两条(顺序与行为不变):`<name>/package.json` 解析 → node_modules 物理路径
114
156
  try {
115
157
  return createRequire(join(baseDir, 'package.json')).resolve(`${name}/package.json`)
@@ -9,6 +9,21 @@
9
9
  // 桌面壳/服务进程给的是**永不关闭的 stdin 管道** ⇒ 不抛错、不超时,**永久挂住**
10
10
  // (实测两次分别挂到 90s / 180s 被杀)。
11
11
  //
12
+ // ★ 2026-10-14(实测更正上面这段"现场描述",**本模块行为未动**):
13
+ // 上述因果链里"pnpm 弹提问 ⇒ 读 stdin 到 EOF ⇒ 挂住"这一段**实测不成立**:
14
+ // · pnpm 的 canPrompt = `!inCi && Boolean(process.stdin.isTTY)`(L194277-278);
15
+ // 管道下 isTTY 恒假 ⇒ pnpm **从不**走 prompt 分支,直接 `throw failOnImmature`。
16
+ // · 实测(pnpm 11.7.0,minimumReleaseAge 设成天文数字 + 管道 stdin):无 ci 位 / CI=true /
17
+ // npm_config_ci=true / --config.ci=true / 两者齐上 —— **五种配置全部快速失败,无一挂住**。
18
+ // · 真正读 stdin 等人的是 **corepack 的下载提问**(corepack.cjs L22083:
19
+ // `if (process.stdin.isTTY && !process.env.CI) { stderr.write('? Do you want to continue?');
20
+ // stdin.resume(); await once(stdin,'data') }`)—— 判据恰恰是 `process.env.CI`。
21
+ // ⇒ 本模块的定位不变(**识别 + 翻译成可操作错误**,绝不绕过闸门),仍然有价值;
22
+ // 只是"为什么会挂住"要记在 corepack 头上,不是 pnpm 的供应链闸。
23
+ // 顺带更正另两处(详见 infra/exec.js#pnpmEnvOverrides 上方的完整说明):
24
+ // · `npm_config_ci=true` 对 pnpm 自己的配置项是**空操作**(只认 `pnpm_config_*`);
25
+ // · `CI=true` **确实**点亮 ci 位(pnpm 配置默认值 `ci: isCI`,L145865)。
26
+ //
12
27
  // 闸门实现(本机 pnpm 11.7.0 `dist/pnpm.mjs`,只读反编译取证):
13
28
  // L194277 const inCi = opts3.ci ?? isCI
14
29
  // L194278 const canPrompt = !inCi && Boolean(process.stdin.isTTY)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noob-stupid/dsh-plugin-console",
3
- "version": "0.5.48",
3
+ "version": "0.5.50",
4
4
  "description": "DSH 框架升级安全与插件升级门控:一键升级、失败自动回滚、升级后回滚上版、旧插件不适配自动禁用;内置多源插件市场为发现层,插件源全部可自定义,可指向公司内网私有源 / 私有索引 / 本地 Git 仓库,纯内网离线可用 | Framework upgrade safety & plugin version gating for DSH, with a customizable multi-source plugin market: point every source at internal mirrors or a local file:// repo for intranet-only, offline installs.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -50,7 +50,9 @@
50
50
  "lib",
51
51
  "cordis.patch.yml",
52
52
  "SECURITY.md",
53
- "icon.svg"
53
+ "icon.svg",
54
+ "scripts/safe-boot.mjs",
55
+ "scripts/fw-preflight.mjs"
54
56
  ],
55
57
  "license": "MIT",
56
58
  "scripts": {