@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.
@@ -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,23 @@
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
+ // · 那"挂到 90s / 180s"其实是**我们自己的超时常量**:`runPnpmAdd` 默认 timeout=90000、
19
+ // `link-self-sufficiency#installTargetDependencies` 默认 timeoutMs=180000 —— 与当时观察
20
+ // 逐一吻合 ⇒ 当时是"某次操作超过我们设的超时被我们杀掉",**不是**"等输入等到天荒地老"。
21
+ // 那次为什么慢**至今没有证据**(最可能是网络);corepack 的下载提问也**默认是关的**
22
+ // (入口 shim 第一行 `COREPACK_ENABLE_DOWNLOAD_PROMPT ??= '0'`)。
23
+ // ⇒ 本模块的定位不变(**识别 + 翻译成可操作错误**,绝不绕过闸门),仍然有价值;
24
+ // 只是「为什么会挂住」**至今未查明**,不要再照 0.5.48 那段因果链去找。
25
+ // 另两处一并更正(详见 infra/exec.js#pnpmEnvOverrides 上方的完整说明):
26
+ // · `npm_config_ci=true` 对 pnpm 自己的配置项是**空操作**(只认 `pnpm_config_*`);
27
+ // · `CI=true` **确实**点亮 ci 位(pnpm 配置默认值 `ci: isCI`,L145865),且 v0.5.30 就有。
28
+ //
12
29
  // 闸门实现(本机 pnpm 11.7.0 `dist/pnpm.mjs`,只读反编译取证):
13
30
  // L194277 const inCi = opts3.ci ?? isCI
14
31
  // 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.51",
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,8 @@
50
50
  "lib",
51
51
  "cordis.patch.yml",
52
52
  "SECURITY.md",
53
- "icon.svg"
53
+ "icon.svg",
54
+ "scripts"
54
55
  ],
55
56
  "license": "MIT",
56
57
  "scripts": {
@@ -0,0 +1,134 @@
1
+ /**
2
+ * 框架层补丁:dsh-app-boot 的 cordis.patch.yml 解析容错(议题 #5)。
3
+ *
4
+ * 问题:cordis.patch.yml 若含顶格 `[]` 占位符 + 后续条目(两个 YAML 根节点),
5
+ * DSH 启动时 parsePatchList 解析崩溃("end of the stream or a document separator is expected")。
6
+ *
7
+ * 修复:parsePatchList 解析失败时,自动移除顶格空数组占位行(视为 no-op)后重试。
8
+ *
9
+ * 用法:node scripts/apply-framework-patch.cjs
10
+ * 说明:DSH 升级(npx 重新拉取)后框架文件会被覆盖,重新运行本脚本即可。
11
+ */
12
+ const fs = require('node:fs')
13
+ const path = require('node:path')
14
+ const os = require('node:os')
15
+
16
+ /** npm 缓存下的 npx 缓存目录候选(**不写死盘符/用户名**)。
17
+ * npm 的 cache 位置是用户配置(把 cache 指到别的盘很常见)→ 按 npm 自己的来源读:
18
+ * 环境变量 npm_config_cache / NPM_CONFIG_CACHE + 用户级 ~/.npmrc 的 `cache=`。
19
+ * (原实现第一条写死开发机上的缓存绝对路径:某个盘符下的 node_cache\_npx。) */
20
+ function npmNpxCacheRoots() {
21
+ const roots = []
22
+ const push = (value) => {
23
+ if (typeof value !== 'string') return
24
+ const dir = value.trim().replace(/^["']|["']$/g, '').replace(/^~(?=[\\/]|$)/, os.homedir())
25
+ if (dir !== '') roots.push(path.join(dir, '_npx'))
26
+ }
27
+ for (const key of ['npm_config_cache', 'NPM_CONFIG_CACHE']) push(process.env[key])
28
+ try {
29
+ for (const line of fs.readFileSync(path.join(os.homedir(), '.npmrc'), 'utf8').split(/\r?\n/)) {
30
+ const hit = /^\s*cache\s*=\s*(.*?)\s*$/i.exec(line)
31
+ if (hit) push(hit[1])
32
+ }
33
+ } catch {}
34
+ return roots
35
+ }
36
+
37
+ // 定位 @deepseek-ai/dsh-app-boot(与 @deepseek-ai/dsh 同级)
38
+ function locateAppBoot() {
39
+ try {
40
+ const dshPkg = require.resolve('@deepseek-ai/dsh/package.json')
41
+ const aiDir = path.dirname(dshPkg) // .../node_modules/@deepseek-ai
42
+ const candidate = path.join(aiDir, 'dsh-app-boot', 'lib', 'index.js')
43
+ if (fs.existsSync(candidate)) return candidate
44
+ } catch {}
45
+ const cacheRoots = [
46
+ process.env.NODE_CACHE || '',
47
+ ...npmNpxCacheRoots(),
48
+ path.join(os.homedir(), '.npm', '_npx'),
49
+ process.env.LOCALAPPDATA ? path.join(process.env.LOCALAPPDATA, 'node_cache', '_npx') : '',
50
+ ].filter(Boolean)
51
+ for (const root of cacheRoots) {
52
+ if (!fs.existsSync(root)) continue
53
+ let entries = []
54
+ try { entries = fs.readdirSync(root, { withFileTypes: true }) } catch { continue }
55
+ for (const entry of entries) {
56
+ if (!entry.isDirectory()) continue
57
+ const candidate = path.join(root, entry.name, 'node_modules', '@deepseek-ai', 'dsh-app-boot', 'lib', 'index.js')
58
+ if (fs.existsSync(candidate)) return candidate
59
+ }
60
+ }
61
+ return null
62
+ }
63
+
64
+ const MARKER = 'tryDropEmptyArrayPlaceholder'
65
+ const OLD = `function parsePatchList(binName, file, content, label) {
66
+ let parsed;
67
+ try {
68
+ parsed = yaml.load(content, { schema: userPatchesSchema });
69
+ } catch (error) {
70
+ throw new Error(\`${'${binName}'}: failed to parse ${'${label}'} ${'${file}'}: ${'${String(error)}'}\`);
71
+ }`
72
+ const NEW = `function parsePatchList(binName, file, content, label) {
73
+ let parsed;
74
+ try {
75
+ parsed = yaml.load(content, { schema: userPatchesSchema });
76
+ } catch (error) {
77
+ // 容错(issue #5):文件可能是「[] 空数组占位符 + 后续条目」两个根节点,
78
+ // YAML 解析必然失败。移除顶层空数组占位行(视为 no-op)后重试。
79
+ const retried = tryDropEmptyArrayPlaceholder(content);
80
+ if (retried !== null) {
81
+ try {
82
+ parsed = yaml.load(retried, { schema: userPatchesSchema });
83
+ } catch {
84
+ throw new Error(\`${'${binName}'}: failed to parse ${'${label}'} ${'${file}'}: ${'${String(error)}'}\`);
85
+ }
86
+ } else {
87
+ throw new Error(\`${'${binName}'}: failed to parse ${'${label}'} ${'${file}'}: ${'${String(error)}'}\`);
88
+ }
89
+ }`
90
+
91
+ const HELPER = `
92
+ /**
93
+ * 容错辅助(issue #5):若文件含顶格空数组占位行(\`[]\` / \`[ ]\`,可带行尾注释),
94
+ * 视为 no-op 全部移除;无此模式返回 null。
95
+ */
96
+ function tryDropEmptyArrayPlaceholder(content) {
97
+ const lines = String(content).split("\\n");
98
+ const kept = [];
99
+ let dropped = false;
100
+ for (const line of lines) {
101
+ if (/^\\[\\s*\\]\\s*(?:#.*)?$/u.test(line)) {
102
+ dropped = true;
103
+ continue;
104
+ }
105
+ kept.push(line);
106
+ }
107
+ if (!dropped) return null;
108
+ return kept.join("\\n");
109
+ }
110
+ `
111
+
112
+ function main() {
113
+ const target = locateAppBoot()
114
+ if (!target) {
115
+ console.error('未找到 @deepseek-ai/dsh-app-boot/lib/index.js,请确认 DSH 已安装(或手动指定路径)')
116
+ process.exit(1)
117
+ }
118
+ const source = fs.readFileSync(target, 'utf8')
119
+ if (source.includes(MARKER)) {
120
+ console.log('已打过补丁,跳过:' + target)
121
+ return
122
+ }
123
+ if (!source.includes('function parsePatchList')) {
124
+ console.error('未找到 parsePatchList(框架版本可能已变更),跳过。' + target)
125
+ process.exit(1)
126
+ }
127
+ fs.copyFileSync(target, target + '.bak-issue5')
128
+ const next = source.replace(OLD, NEW) + HELPER
129
+ fs.writeFileSync(target, next, 'utf8')
130
+ console.log('补丁已应用:' + target)
131
+ console.log('备份:' + target + '.bak-issue5')
132
+ }
133
+
134
+ main()
@@ -0,0 +1,36 @@
1
+ /**
2
+ * 给已提交的 marketplace/index.json 就地补 npmName(批次 C-⑪)。
3
+ *
4
+ * 为什么单独有这个脚本:build-index.cjs 需要 gh CLI + 网络(本机 github 不可达时跑不了),
5
+ * 而"补 npmName"这件事**完全离线** —— 映射来自 marketplace/npm-name-hints.json(人工核对)。
6
+ * 用法:
7
+ * node scripts/apply-npm-names.cjs # 就地更新 marketplace/index.json
8
+ * node scripts/apply-npm-names.cjs --dry-run # 只报告会改哪几条
9
+ * node scripts/apply-npm-names.cjs --file=<index.json> # 指定索引文件
10
+ * 幂等:已经带 npmName 的条目内容不变;映射里没有的仓库保持原样(老索引兼容)。
11
+ */
12
+ const fs = require('node:fs')
13
+ const path = require('node:path')
14
+ const { readNpmNameHints, withNpmNames } = require('./build-index.cjs')
15
+
16
+ const args = process.argv.slice(2)
17
+ const dryRun = args.includes('--dry-run')
18
+ const fileArg = args.find((a) => a.startsWith('--file='))
19
+ const hintsArg = args.find((a) => a.startsWith('--hints='))
20
+ const indexFile = fileArg ? fileArg.split('=')[1] : path.join(__dirname, '..', 'marketplace', 'index.json')
21
+ const hints = readNpmNameHints(hintsArg ? hintsArg.split('=')[1] : undefined)
22
+
23
+ const raw = JSON.parse(fs.readFileSync(indexFile, 'utf8'))
24
+ const before = Array.isArray(raw.items) ? raw.items : []
25
+ const after = withNpmNames(before, hints)
26
+ const changed = after.filter((it, i) => typeof it.npmName === 'string' && before[i]?.npmName !== it.npmName)
27
+
28
+ console.log(`映射条目 ${Object.keys(hints).length} 条;索引 ${before.length} 条命中 ${changed.length} 条`)
29
+ for (const it of changed) console.log(` ${it.fullName} → ${it.npmName}`)
30
+
31
+ if (dryRun) {
32
+ console.log('(dry-run:未写入)')
33
+ process.exit(0)
34
+ }
35
+ fs.writeFileSync(indexFile, JSON.stringify({ ...raw, items: after }, null, 2) + '\n', 'utf8')
36
+ console.log(`已写入 ${indexFile}`)