@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.
- 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 +32 -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/paths.js +52 -10
- package/lib/server/infra/pnpm-release-gate.js +15 -0
- package/package.json +4 -2
- package/scripts/fw-preflight.mjs +186 -0
- package/scripts/safe-boot.mjs +260 -0
|
@@ -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
|
-
|
|
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))
|
|
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
|
-
// ★
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
134
|
+
// ★ 子路径引用(`包/子路径`)单独一条路:只认「真实解析语义」,但**两个基准都认**。
|
|
135
|
+
//
|
|
136
|
+
// 为什么要挡住后两条:那两条是按**包名**(packageNameOf 剥掉子路径后的 name)找
|
|
137
|
+
// `node_modules/<包名>/package.json`,对不存在的子路径也会命中**包根**那份清单
|
|
138
|
+
// ⇒ 把一个坏引用判成好的 —— 正是"校验被放松成一律放行",任务红线明令不许。
|
|
110
139
|
// (本机实测:`@scope/tool/nope` 在 @scope/tool 存在时会从 fallback 拿到包根清单。)
|
|
111
|
-
//
|
|
112
|
-
|
|
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.
|
|
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": {
|