@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.
@@ -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
+ }
@@ -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.49",
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": {
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env node
2
+ // scripts/fw-preflight.mjs —— **服务起不来时的预检与治包**(0.5.50 加法)
3
+ //
4
+ // 为什么要在**服务之外**再来一条命令(真机停机事故 2026-10-14):
5
+ // 那次的坏法是「框架包被掏空」—— `cordis` 顶层 junction 还在、它指向的 `.pnpm` 实体被清空;
6
+ // `dsh-llm` / `dsh-tools` / `dsh-web` 直接成了空目录。后果是 dsh 启动即
7
+ // `54 个插件 failed to import`、web 服务 exit code=1。
8
+ //
9
+ // 而既有的 safe-boot 快照恢复的是 **`cordis.patch.yml` 的文本**(配置层)—— 那次补丁好端端的,
10
+ // 恢复它等于"把一份没坏的配置换成另一份没坏的配置",四个空目录纹丝不动;
11
+ // 指纹数的是 `.pnpm` 里的**目录个数** —— "壳还在、里面被掏空"⇒ 计数不变 ⇒ 不触发。
12
+ // 最根本的是:**控制台插件是跑在 dsh 里的**,dsh 起不来它就从未加载(事故日志里出现 0 次)。
13
+ // ⇒ 这类事故必须在**服务之外**有一条出路。这就是本脚本。
14
+ //
15
+ // 三条硬约束(与 scripts/safe-boot.mjs 同族):
16
+ // · **零依赖**:只用 Node 内置模块 + 本插件自己的 infra/domain 纯函数(不装包、**不联网**);
17
+ // · **服务未运行也能跑**:不读端口、不连 webServer、不需要 profile 正被使用;
18
+ // · **绝不 kill / spawn / 抢端口 / 建计划任务**:只做文件级修复。
19
+ // (2026-10-10 的教训:那套"按端口 kill 再拉起"会与宿主争端口 ⇒ `listen EADDRINUSE`,
20
+ // 或让桌面端外壳弹出「应用无法启动或已意外停止」。跑完要不要重启,由你自己决定。)
21
+ //
22
+ // 用法(在插件目录下,或 `node <插件目录>/scripts/fw-preflight.mjs`):
23
+ // node scripts/fw-preflight.mjs --check 只诊断,列出坏掉的包(**一个字节都不写**)
24
+ // node scripts/fw-preflight.mjs --repair 从本地 npm 缓存把缺的包装回来,再复验
25
+ // node scripts/fw-preflight.mjs --check --json 机器可读输出
26
+ // 可选:--fw-root <dir> 指定框架树(默认自动探测:从本插件解析 @deepseek-ai/dsh 反推)
27
+ // --cache <dir> 指定 npm 缓存根(默认自动找 npm_config_cache / %LOCALAPPDATA%\npm-cache / 常见位置)
28
+ // --help 显示本帮助
29
+
30
+ import { isAbsolute, resolve } from 'node:path'
31
+ import { dirname } from 'node:path'
32
+ import { fileURLToPath } from 'node:url'
33
+ import { detectFrameworkRoot } from '../lib/server/infra/fw-root-detect.js'
34
+ import { runFwPreflight } from '../lib/server/domain/fw-preflight.js'
35
+
36
+ const PLUGIN_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
37
+
38
+ const HELP = `框架闭包预检与治包(fw-preflight)—— 服务起不来时用这个,不需要服务在跑。
39
+
40
+ 用法:
41
+ node scripts/fw-preflight.mjs --check
42
+ 只诊断:从 dsh 的 lib/bin.js 出发走一遍 @deepseek-ai/* 依赖闭包,
43
+ 列出**解析不了**的包(= 真正的损坏面)。一个字节都不写。
44
+
45
+ node scripts/fw-preflight.mjs --repair
46
+ 把解析不了的包装回来:按 package-lock.json 的 integrity 从**本地 npm 缓存**取 tarball 解开。
47
+ 改前若目标已有内容,先改名成 <name>.preflight-bak-<时间戳>(绝不直接删)。
48
+ 装完**重新走一遍闭包复验**,不靠"我装完了"自证。
49
+
50
+ 可选参数:
51
+ --fw-root <dir> 框架树根(含 @deepseek-ai/ 与 .pnpm/)。默认自动探测。
52
+ --cache <dir> npm 缓存根(含 content-v2/)。默认自动探测,可重复。
53
+ --json 机器可读输出(面板/子代理可直接解析)。
54
+ --help 显示本帮助。
55
+
56
+ 判据为什么是"能不能解析"而不是"目录空不空":
57
+ 这次事故的形态是「目录壳还在、里面被掏空」—— 数目录个数完全看不见
58
+ (2026-10-14 实测:按"目录空不空"粗扫本机会误报 237 个;换成闭包判据后 287 个包精准通过,
59
+ 清空一个立刻只点名那一个)。
60
+
61
+ 它不做什么:
62
+ 不 kill 进程、不 spawn 服务、不抢端口、不建计划任务、不联网。
63
+ 修完请自己重启服务(桌面端:完全退出再打开;独立 dsh web:终端里 Ctrl+C 后重跑)。
64
+ `
65
+
66
+ /** 解析命令行(只认本脚本声明的这几个开关;未知参数如实报错,不静默忽略)。 */
67
+ function parseArgv(argv) {
68
+ const out = { mode: null, fwRoot: null, caches: [], json: false, help: false, bad: [] }
69
+ for (let i = 0; i < argv.length; i += 1) {
70
+ const a = argv[i]
71
+ if (a === '--check') out.mode = 'check'
72
+ else if (a === '--repair') out.mode = 'repair'
73
+ else if (a === '--json') out.json = true
74
+ else if (a === '--help' || a === '-h') out.help = true
75
+ else if (a === '--fw-root') { out.fwRoot = argv[i + 1] ?? null; i += 1 }
76
+ else if (a === '--cache') { const v = argv[i + 1]; if (v) out.caches.push(v); i += 1 }
77
+ else out.bad.push(a)
78
+ }
79
+ return out
80
+ }
81
+
82
+ /**
83
+ * 探测框架树根:多基准(见 lib/server/infra/fw-root-detect.js 的头注释)。
84
+ * 这里只做转发,判据收在 infra 一处 —— 脚本与(日后的)路由不会分叉。
85
+ */
86
+ function detectFwRoot() {
87
+ return detectFrameworkRoot({ pluginDir: PLUGIN_ROOT })
88
+ }
89
+
90
+ /** 把探测结果 + 修复结果渲染成人读报告。 */
91
+ function renderReport(report, { attempted }) {
92
+ const lines = []
93
+ const bad = report.before?.broken ?? []
94
+ lines.push(`框架树 : ${report.fwRoot ?? '(未探测到)'}`)
95
+ lines.push(`解析入口: ${report.entry ?? '(无)'}${report.entryVia === null || report.entryVia === undefined ? '' : ` [${report.entryVia}]`}`)
96
+ lines.push(`npm 缓存: ${(report.cacheRoots ?? []).join(' ; ') || '(未找到 —— 只能诊断,不能修复)'}`)
97
+ lines.push('')
98
+ if (report.error !== undefined && report.error !== null) {
99
+ lines.push(`✗ ${report.error}`)
100
+ return lines.join('\n')
101
+ }
102
+ const walked = report.before?.walked?.length ?? 0
103
+ lines.push(attempted ? `① 修复前:走了 ${walked} 个包,${bad.length} 个解析不了` : `走了 ${walked} 个包,${bad.length} 个解析不了`)
104
+ for (const b of bad) lines.push(` ✗ ${b.name} ← ${b.from} (${b.reason})`)
105
+ if (bad.length === 0) lines.push(' ✓ 依赖闭包完整 —— 框架树是好的,起不来的原因不在这里')
106
+
107
+ if (!attempted) {
108
+ if (bad.length > 0) lines.push('\n用 --repair 试着把它们装回来(只动文件,不碰任何进程)。')
109
+ return lines.join('\n')
110
+ }
111
+
112
+ lines.push('')
113
+ lines.push(`② 本次修复:成功 ${report.repaired.length} 个,失败 ${report.failed.length} 个,不可修 ${report.skipped.length} 个`)
114
+ for (const r of report.repaired) lines.push(` ✓ ${r.name}@${r.version} → ${r.via} ${r.note}`)
115
+ for (const f of report.failed) lines.push(` ✗ ${f.name} (${f.action}) ${f.note}`)
116
+ for (const s of report.skipped) {
117
+ const why = { 'no-lock-entry': 'package-lock.json 里没有它的记录 —— 不猜版本', 'no-integrity': '记录里没有 integrity 校验和', 'no-target': '定不出该装到哪个目录' }[s.reason] ?? s.reason
118
+ lines.push(` · ${s.name} ${why}`)
119
+ }
120
+
121
+ lines.push('')
122
+ const after = report.after?.broken ?? []
123
+ lines.push(`③ 复验:走了 ${report.after?.walked?.length ?? 0} 个包,${after.length} 个仍解析不了`)
124
+ for (const b of after) lines.push(` ✗ ${b.name} ← ${b.from}`)
125
+ if (report.okAfter === true) {
126
+ lines.push(' ✓ 闭包已完整')
127
+ lines.push('')
128
+ lines.push('接下来:重启服务让修复生效(本脚本**不会**替你 kill / 拉起进程)。')
129
+ lines.push(' · 桌面端:完全退出 DeepSeek Harness,再重新打开')
130
+ lines.push(' · 独立 dsh web:在启动它的终端里 Ctrl+C,然后重新运行 dsh web')
131
+ } else {
132
+ lines.push('')
133
+ lines.push('仍有包解析不了。可能原因:本地 npm 缓存里也没有那些 tarball(本脚本不联网)。')
134
+ lines.push('可对该包单独装一次(会联网):dsh plugin --profile <名> add <包名>')
135
+ }
136
+ return lines.join('\n')
137
+ }
138
+
139
+ async function main() {
140
+ const args = parseArgv(process.argv.slice(2))
141
+ if (args.help) { process.stdout.write(HELP); return 0 }
142
+ if (args.bad.length > 0) {
143
+ process.stderr.write(`未知参数:${args.bad.join(' ')}\n\n${HELP}`)
144
+ return 2
145
+ }
146
+ if (args.mode === null) {
147
+ process.stderr.write(`请指定 --check 或 --repair(或 --help)。\n\n${HELP}`)
148
+ return 2
149
+ }
150
+
151
+ let fwRoot = args.fwRoot
152
+ let via = 'explicit'
153
+ let tried = []
154
+ if (typeof fwRoot !== 'string' || fwRoot === '') {
155
+ const d = detectFwRoot()
156
+ fwRoot = d.fwRoot
157
+ via = d.via
158
+ tried = d.tried ?? []
159
+ } else {
160
+ fwRoot = isAbsolute(fwRoot) ? fwRoot : resolve(process.cwd(), fwRoot)
161
+ }
162
+
163
+ if (fwRoot === null || fwRoot === undefined || fwRoot === '') {
164
+ const msg = '探测不到框架树根 —— 请用 --fw-root <dir> 显式指定(该目录下应有 @deepseek-ai/ 与 .pnpm/)。'
165
+ const detail = tried.map((t) => ` · 试过 ${t.via}: ${t.base}`).join('\n')
166
+ if (args.json) process.stdout.write(`${JSON.stringify({ ok: false, error: msg, tried }, null, 2)}\n`)
167
+ else process.stderr.write(`✗ ${msg}\n${detail}\n`)
168
+ return 1
169
+ }
170
+
171
+ const cacheRoots = args.caches.length > 0 ? args.caches.map((c) => (isAbsolute(c) ? c : resolve(process.cwd(), c))) : null
172
+ const report = runFwPreflight({ fwRoot, repair: args.mode === 'repair', cacheRoots })
173
+ const payload = { ...report, fwRootVia: via, mode: args.mode }
174
+
175
+ if (args.json) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`)
176
+ else process.stdout.write(`${renderReport(payload, { attempted: args.mode === 'repair' })}\n`)
177
+
178
+ // 退出码:诊断模式 = 闭包是否完整;修复模式 = 修复后是否完整(供脚本/CI 判断)
179
+ const good = args.mode === 'repair' ? report.okAfter === true : report.ok === true
180
+ return good ? 0 : 1
181
+ }
182
+
183
+ main().then((code) => { process.exitCode = code }, (error) => {
184
+ process.stderr.write(`fw-preflight 异常:${error?.stack ?? error}\n`)
185
+ process.exitCode = 1
186
+ })