@noob-stupid/dsh-plugin-console 0.5.45 → 0.5.47

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,368 @@
1
+ // L1 · domain —— subdir-install.js(「从这个子目录安装」:先落地该子目录,再按既有**本地目录来源**装)
2
+ //
3
+ // 真机现场(2026-10-13 用户实测,第二轮):`Fei2411/DeepSeek-Bot` 根目录没有 package.json,
4
+ // 插件清单在 `ds-bot/` 里。0.5.46 已经能**认出来**并给出可复制的一段文字 —— 但**装不了**:
5
+ // 用户拿到"插件在 ds-bot/ 下"之后还得自己想办法(把子目录并进独立仓库 / 等作者发 npm 包)。
6
+ // 本轮补上**可执行的那一步**:面板上点「从这个子目录安装」→ 这里跑完整条路。
7
+ //
8
+ // 实现方式(三条备选里选"先落地再本地装",理由写死在这里):
9
+ // ① 本地目录来源**本来就是这个仓的既有能力**(source-spec.js 的判据 C:`link:` 是本地目录的正常路径,
10
+ // 外加 link-self-sufficiency 的依赖/peer 垫片补齐)—— 不需要给 pnpm 编一个新协议;
11
+ // ② GitHub 侧不需要新接口:既有 `gitCloneRepo(..., { sparse: [dir] })` 就能**只拉那一个目录**
12
+ // (批次 D-② 的能力,预设通道已在用),代价与定位子包同一量级;
13
+ // ③ 已落地仓库优先复用(`findLandedRepo`)⇒ 用户先点过「仓库落地」时**零网络请求**。
14
+ //
15
+ // 判据一个字都不放松(用户点名:"不许降低任何既有校验标准"):
16
+ // · **清单校验**:先去仓库读 `<dir>/package.json`(走 infra/http.js 的既有读取通道,
17
+ // 与根清单探测同一把尺子),必须是合法 JSON、必须有 name、必须带 dsh 标记(dsh.bundle.patch / dsh.client)
18
+ // —— 与识别阶段(subdir-detect.js)用**同一个** describeDshManifest/dshMarkersOf;
19
+ // · **来源校验**:包名以**清单**为准(前端传什么都不算数),且取到源码之后**读回本地清单再核一次**
20
+ // (名字不一致 ⇒ 当场拒绝,绝不按别人的包名装一份不知道是什么的东西);
21
+ // · **装后/收口**:与普通安装同一条路 —— verifyInstalledEntry(只提示)+ bundle patch 引用的包必须
22
+ // 能解析(缺一个就拒绝注册,理由同 install-job.js 那段崩溃事故)+ finalizeInstallPersistence
23
+ // (清单 + lock + 挂载行)。
24
+ //
25
+ // 分层:L1 · domain —— 不得出现 cordis ctx(test-architecture-guard 断言⑤);进程/出网都只经
26
+ // 可注入的单点(`deps.channels.pnpmInstall` / deps.gitClone / deps.readManifest),测试一律注入替身。
27
+
28
+ import { existsSync, readFileSync } from 'node:fs'
29
+ import { dirname, join } from 'node:path'
30
+ import { readBundlePatchRefNames } from './bundle-refs.js'
31
+ import { PLUGIN_SRC_DIR, linkSpecFor, sameSourceSpec } from './dep-source.js'
32
+ import { noteChannel } from './git-channel.js'
33
+ import { addBundleToManifest, detectBundleOnly } from './install.js'
34
+ import { finalizeInstallPersistence } from './install-finalize.js'
35
+ import { alreadyInstalledTarget, verifyInstalledEntry } from './install-verify.js'
36
+ import { ensureLinkSelfSufficiency } from './link-self-sufficiency.js'
37
+ import { setProfileDependency } from './manifest.js'
38
+ import { fetchRepoPathText } from './market.js'
39
+ import { findLandedRepo, gitCloneRepo } from './repoland.js'
40
+ import { listEntries } from './runtime.js'
41
+ import { orderedRegistries, readSources } from './sources.js'
42
+ import { describeDshManifest } from './subdir-detect.js'
43
+ import { fetchRepoMeta } from './suite.js'
44
+ import { copyTree, disposeDir, disposeNote } from '../infra/fsx.js'
45
+ import { dshHome, findPatchPath, packageNameOf, resolvePackageJson } from '../infra/paths.js'
46
+
47
+ /** 落地源码的暂存目录名(`<DSH_HOME>/plugin-console/<这个名字>/<repo>__<dir>`):
48
+ * 与 `source-specs/` / 自足记录同族都在 `plugin-console/` 下 —— 它是"取下来的工作副本",
49
+ * 插件的**正式家**仍是 `<DSH_HOME>/plugin-src/<包名>`(pnpm 管不到、跨 lock 重建稳定存在)。 */
50
+ const SUBDIR_SRC_DIR = 'subdir-src'
51
+
52
+ /** 子目录路径的一节:字母数字 . _ -,且**不许**是 `.` / `..`(路径穿越)。 */
53
+ const SUBDIR_SEGMENT_RE = /^(?!\.{1,2}$)[A-Za-z0-9._-]+$/u
54
+ /** 识别阶段的口径:只认第 1~2 层子目录(`<dir>` / `<dir>/<sub>`),与市场探针完全一致。 */
55
+ const SUBDIR_MAX_SEGMENTS = 2
56
+
57
+ /** 归一化用户/前端给的子目录:合法则返回规范形,否则 null(绝不抛)。 */
58
+ function normalizeSubdir(raw) {
59
+ const text = String(raw ?? '').trim().replace(/^\.\//u, '').replace(/\/+$/u, '')
60
+ if (text === '') return null
61
+ if (text.includes('\\') || text.startsWith('/') || /^[A-Za-z]:/u.test(text)) return null
62
+ const parts = text.split('/')
63
+ if (parts.length === 0 || parts.length > SUBDIR_MAX_SEGMENTS) return null
64
+ if (!parts.every((part) => SUBDIR_SEGMENT_RE.test(part))) return null
65
+ return parts.join('/')
66
+ }
67
+
68
+ /** 暂存目录名(同一仓库的同一个子目录复用同一格;重跑会覆盖前一次的骨架)。 */
69
+ function subdirSlug(repo, dir) {
70
+ return `${String(repo).replace(/[^A-Za-z0-9._-]+/gu, '__')}__${String(dir).replace(/[^A-Za-z0-9._-]+/gu, '_')}`
71
+ }
72
+
73
+ /** 失败收口(唯一出口):状态 + 文案 + 附带的展示字段,绝不留半句话的结论。 */
74
+ function failSubdirInstall(job, text, extra = {}) {
75
+ job.status = 'failed'
76
+ job.error = text
77
+ for (const [key, value] of Object.entries(extra)) job[key] = value
78
+ return false
79
+ }
80
+
81
+ /** 子目录安装的**前置校验**:读 `<dir>/package.json` 并核实"它确实是 DSH 插件"。
82
+ * 返回 `{ ok, packageName, markers }` 或 `{ ok:false, error }`(文案即用户可见结论)。
83
+ * `readManifest(repo, branch, path)` 可注入(单测 ⇒ 零出网);生产走 market.fetchRepoPathText。 */
84
+ async function verifySubdirManifest({ repo, branch, dir, readManifest }) {
85
+ const path = `${dir}/package.json`
86
+ let text = null
87
+ let readError = null
88
+ try {
89
+ text = await readManifest(repo, branch, path)
90
+ } catch (error) {
91
+ readError = error instanceof Error ? error.message : String(error)
92
+ }
93
+ if (text === null || text === undefined || readError !== null) {
94
+ // "没读到"绝不许冒充"没有"(本仓既有口径):只说没读到 + 为什么 + 出路。
95
+ return {
96
+ ok: false,
97
+ error: `读不到 ${repo}(${branch} 分支)的 ${path}${readError === null ? '(404 / 超时 / 网络不可达)' : `:${readError}`}`
98
+ + ` —— 没能核实这个子目录里到底是什么,**没有**下"它不是插件"的结论。请重试;`
99
+ + `或先按「仓库落地」把仓库克隆到本地,本控制台会直接复用那份已落地的副本(零网络请求)。`,
100
+ }
101
+ }
102
+ const info = describeDshManifest(text)
103
+ if (info === null) {
104
+ return { ok: false, error: `${path} 不是合法的包描述(JSON 解析失败或缺少 name 字段)—— 无法作为插件安装。` }
105
+ }
106
+ if (info.markers.length === 0) {
107
+ return {
108
+ ok: false,
109
+ error: `${path} 里那个包(${info.name})没有声明任何 dsh 标记(dsh.bundle.patch / dsh.client)`
110
+ + ` —— 它不是 DSH 插件(判据与"是不是插件"的识别完全一致,不为安装放宽)。`,
111
+ }
112
+ }
113
+ return { ok: true, packageName: info.name, markers: info.markers }
114
+ }
115
+
116
+ /** 取子目录源码:先复用**已落地**仓库(零网络),否则稀疏取源码只拉这一个目录。
117
+ * 返回 `{ ok, dir, reused, notes }` / `{ ok:false, notes, error }`。clone 可注入(单测零进程/零网络)。 */
118
+ async function acquireSubdirSource({ repo, dir, source = 'github', deps = {} }) {
119
+ const notes = []
120
+ const findLanded = typeof deps.findLandedRepo === 'function' ? deps.findLandedRepo : findLandedRepo
121
+ let landed = { hit: false, path: null }
122
+ try {
123
+ landed = findLanded(repo) ?? landed
124
+ } catch {
125
+ landed = { hit: false, path: null }
126
+ }
127
+ if (landed?.hit === true && typeof landed.path === 'string' && landed.path !== '') {
128
+ const candidate = join(landed.path, ...dir.split('/'))
129
+ if (existsSync(join(candidate, 'package.json'))) {
130
+ notes.push(`复用已落地仓库里的子目录:${candidate}(零网络请求)`)
131
+ return { ok: true, dir: candidate, reused: true, notes }
132
+ }
133
+ notes.push(`本机有已落地仓库 ${landed.path},但里面没有 ${dir}/package.json —— 改用稀疏取源码`)
134
+ }
135
+ const scratchRoot = typeof deps.srcRoot === 'string' && deps.srcRoot !== ''
136
+ ? deps.srcRoot
137
+ : join(dshHome(), 'plugin-console', SUBDIR_SRC_DIR)
138
+ const dest = join(scratchRoot, subdirSlug(repo, dir))
139
+ const clone = typeof deps.gitClone === 'function' ? deps.gitClone : gitCloneRepo
140
+ const timeout = Number.isFinite(deps.cloneTimeoutMs) && deps.cloneTimeoutMs > 0 ? deps.cloneTimeoutMs : undefined
141
+ let cloned = null
142
+ try {
143
+ cloned = await clone(repo, dest, source, timeout, { sparse: [dir] })
144
+ } catch (error) {
145
+ return { ok: false, notes, error: `取子目录源码失败(${dir}/):${error instanceof Error ? error.message : String(error)}` }
146
+ }
147
+ if (cloned?.sparse === true) notes.push(`已用稀疏取源码(--filter=blob:none --sparse + sparse-checkout set ${dir}):只拉这 1 个子目录`)
148
+ else notes.push('稀疏取源码不被本机/对端支持(已降级到普通 clone)—— 体积更大,结果一样')
149
+ const src = join(dest, ...dir.split('/'))
150
+ if (!existsSync(join(src, 'package.json'))) {
151
+ return { ok: false, notes, error: `取到的源码里没有 ${dir}/package.json(克隆目标:${dest})—— 仓库布局可能已经变了` }
152
+ }
153
+ return { ok: true, dir: src, reused: cloned?.reused === true, notes }
154
+ }
155
+
156
+ /** 把取到的子目录放成插件的**正式家**:`<DSH_HOME>/plugin-src/<包名>`。
157
+ * 为什么必须先落这里再装:`link:` 的目标要能跨 pnpm 的 node_modules 重建活下来(plugin-src 是
158
+ * pnpm 不管的独立目录,见 dep-source.js 的 materializePackageForLink 注释)。
159
+ * 目标已存在 ⇒ 先让开(删除失败会降级改名成 `.trash-*`,原路径照样让开)。 */
160
+ function placeSubdirSource({ srcDir, packageName, home = null, deps = {} }) {
161
+ const base = typeof home === 'string' && home !== '' ? home : dshHome()
162
+ const dest = join(base, PLUGIN_SRC_DIR, ...String(packageName).split('/'))
163
+ const dispose = typeof deps.disposeDir === 'function' ? deps.disposeDir : disposeDir
164
+ try {
165
+ if (existsSync(dest)) {
166
+ const result = dispose(dest)
167
+ if (result?.ok !== true) return { ok: false, dir: dest, error: `目标目录 ${dest} 无法让开(${disposeNote(result)})` }
168
+ }
169
+ copyTree(srcDir, dest)
170
+ } catch (error) {
171
+ return { ok: false, dir: dest, error: `把子目录源码铺进 ${dest} 失败:${error instanceof Error ? error.message : String(error)}` }
172
+ }
173
+ if (!existsSync(join(dest, 'package.json'))) return { ok: false, dir: dest, error: `铺进 ${dest} 后读不到 package.json(复制未生效)` }
174
+ return { ok: true, dir: dest }
175
+ }
176
+
177
+ /** 铺好之后**读回本地清单再核一次**(来源校验):名字必须与远端读到的那个一致。
178
+ * 不一致 ⇒ 拒绝安装(绝不按 A 的名字装一份 B 的东西)。返回 `{ ok, version, error }`。 */
179
+ function verifyPlacedManifest({ dir, packageName }) {
180
+ try {
181
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
182
+ if (pkg === null || typeof pkg !== 'object') return { ok: false, version: null, error: `${dir}/package.json 不是对象` }
183
+ if (pkg.name !== packageName) {
184
+ return { ok: false, version: null, error: `${dir}/package.json 里的包名是 ${String(pkg.name)},与远端读到的 ${packageName} 不一致 —— 已拒绝安装(来源存疑)` }
185
+ }
186
+ return { ok: true, version: typeof pkg.version === 'string' ? pkg.version : null, error: null }
187
+ } catch (error) {
188
+ return { ok: false, version: null, error: `读回 ${dir}/package.json 失败:${error instanceof Error ? error.message : String(error)}` }
189
+ }
190
+ }
191
+
192
+ /** 收口之后确保清单里记的是**来源规格**(`link:`),而不是被顺手写成的版本号。
193
+ *
194
+ * 为什么需要这一步(本轮施工实测到的一条真链路):`persist.js` 在 **bundle 模式**且该 bundle 的补丁里
195
+ * 读不到 insert 行时会调 `manifest.js#addBundleToManifest` 收尾,而那个实现会顺手把
196
+ * `dependencies[包名]` 写成**已装版本号**("安装即声明"的副产物)—— 对 registry 包没问题,
197
+ * 对**本地目录来源**就是丢了来源:下次任何 pnpm 操作都会按版本号去 registry 解析 → 404
198
+ * (本仓 0.5.20 / 0.5.38 / 0.5.39 反复修过的病根)。子目录安装天生就是"本地目录来源 + 可能带 bundle",
199
+ * 所以在**本模块自己的出口**把这件事故意补回来(只动"确实写歪了"的那一次;等价即零写盘)。
200
+ * 返回写入结果或 null(没动手)。 */
201
+ async function keepLinkSpec({ profileDir, packageName, spec, job = null }) {
202
+ if (typeof spec !== 'string' || !spec.startsWith('link:')) return null
203
+ let current = null
204
+ try {
205
+ current = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8'))?.dependencies?.[packageName] ?? null
206
+ } catch {
207
+ return null
208
+ }
209
+ if (sameSourceSpec(profileDir, current, spec)) return null
210
+ let written = null
211
+ try {
212
+ written = await setProfileDependency(profileDir, packageName, spec)
213
+ } catch (error) {
214
+ noteChannel(job, `${packageName}:清单里的依赖规格是 ${String(current)},改回来源规格失败(${error instanceof Error ? error.message : String(error)})—— 重试一次即可`)
215
+ return null
216
+ }
217
+ noteChannel(job, `${packageName}:清单里的依赖规格被写成了 ${String(current)}(bundle 收尾"安装即声明"的副作用),已改回**来源规格** ${spec} —— 否则下次 pnpm 操作会按版本号去 registry 解析并 404`)
218
+ return written
219
+ }
220
+
221
+ /** 「从这个子目录安装」的完整作业体(install-job 在 `job.subdir` 非空时把作业交给它)。
222
+ * 步骤与理由见文件头;任何一步失败都**如实失败 + 原因**,绝不留下"半装"的糊涂账。
223
+ * deps 是离线测试缝(与 marketProbes/sourceDeps 同一风格):readManifest / gitClone / channels /
224
+ * selfSufficiency / persistDeps / srcRoot / home 都可替换;生产调用方只传 `channels`。
225
+ * `deps.channels` 由 install-job 传入(沿用它的 `installChannels` 注入缝,不在这里再解释一遍那个缝)。 */
226
+ async function runSubdirInstallJob(job, ports, deps = {}) {
227
+ try {
228
+ return await runSubdirInstallJobInner(job, ports, deps)
229
+ } catch (error) {
230
+ // 作业体是"安装主路径"的一部分:**绝不把异常抛给调用方**(那会让面板永远转圈/整个请求 500)。
231
+ // 未预期的错误也如实收口:状态 + 原因 + 可重试。
232
+ return failSubdirInstall(job, `子目录安装失败(未预期错误):${error instanceof Error ? error.message : String(error)} —— 未完成的部分已如实停下,可重试。`)
233
+ }
234
+ }
235
+
236
+ async function runSubdirInstallJobInner(job, ports, deps = {}) {
237
+ const repo = String(job?.repo ?? '')
238
+ const requested = String(job?.packageName ?? '').trim() // 前端给的名字(只用来对账,绝不当判据)
239
+ const source = job?.source === 'gitee' ? 'gitee' : 'github'
240
+ const dir = normalizeSubdir(job?.subdir)
241
+ if (dir === null) {
242
+ return failSubdirInstall(job, `子目录路径不合法:${String(job?.subdir ?? '(空)')} —— 只接受仓库里第 1~2 层的相对目录(如 ds-bot 或 packages/foo),不接受绝对路径、盘符、反斜杠与 . / ..`)
243
+ }
244
+ // 分支:面板带着识别当时的默认分支来;没带就自己探一次(与其它路径同一个探测入口)。
245
+ let branch = typeof job?.branch === 'string' && job.branch.trim() !== '' ? job.branch.trim() : ''
246
+ if (branch === '') {
247
+ let meta = null
248
+ try {
249
+ meta = await (typeof deps.fetchRepoMeta === 'function' ? deps.fetchRepoMeta(repo) : fetchRepoMeta(repo))
250
+ } catch {
251
+ meta = null
252
+ }
253
+ branch = typeof meta?.default_branch === 'string' && meta.default_branch !== '' ? meta.default_branch : 'main'
254
+ }
255
+ job.stage = 'preparing'
256
+ const readManifest = typeof deps.readManifest === 'function'
257
+ ? deps.readManifest
258
+ : (r, b, p) => fetchRepoPathText(r, b, p, deps.readDeps ?? {})
259
+ // ① 清单校验(== 识别阶段同一把尺子)
260
+ const verified = await verifySubdirManifest({ repo, branch, dir, readManifest })
261
+ if (verified.ok !== true) {
262
+ // 核实没过 ⇒ **不**下"插件在 <子目录>/ 下"的结论(那正是要被纠正的误导):
263
+ // 只给"为什么没核实成 / 怎么办",下一步是重试或「仓库落地」(hint 沿用既有取值)。
264
+ return failSubdirInstall(job, verified.error, { hint: 'repo-land', subdirDir: dir, subplugin: null })
265
+ }
266
+ const packageName = verified.packageName
267
+ job.packageName = packageName
268
+ job.subdirDir = dir
269
+ job.subdirMarkers = verified.markers
270
+ if (requested !== '' && requested !== packageName) {
271
+ // 前端给的名字与清单不一致:以清单为准,但如实记一条(不悄悄改用户的意图)
272
+ noteChannel(job, `前端请求的包名是 ${requested},而 ${dir}/package.json 里的包名是 ${packageName} —— 以**清单**为准(来源校验一视同仁)`)
273
+ }
274
+ // ② 目标 profile
275
+ const patchPath = findPatchPath(ports)
276
+ const profileDir = dirname(patchPath)
277
+ const channels = deps.channels ?? {}
278
+ if (typeof channels.pnpmInstall !== 'function') {
279
+ return failSubdirInstall(job, '内部错误:子目录安装需要安装通道(ports.installChannels.pnpmInstall),本次没拿到 —— 未改动任何东西。')
280
+ }
281
+ const home = typeof deps.home === 'string' && deps.home !== '' ? deps.home : dshHome()
282
+ // ③ 已经装上了?(用户被"报失败"误导过的正是这一类)——命中就不重复下载,直接走收口/启用
283
+ let linkSpec = null
284
+ const already = alreadyInstalledTarget(profileDir, packageName, job)
285
+ if (already !== null) {
286
+ job.curlNote = already
287
+ noteChannel(job, already)
288
+ } else {
289
+ job.stage = 'installing'
290
+ // ④ 取源码(已落地仓库优先,零网络;否则稀疏只拉这一个子目录)
291
+ const acquired = await acquireSubdirSource({ repo, dir, source, deps })
292
+ for (const note of acquired.notes ?? []) noteChannel(job, note)
293
+ if (acquired.ok !== true) {
294
+ return failSubdirInstall(job, `${acquired.error}。请重试;也可先按「仓库落地」把仓库克隆到本地,本控制台会直接复用那份副本。`)
295
+ }
296
+ // ⑤′ 先核实**刚取到的那份源码**(读回本地清单核对包名)——绝不在核实之前覆盖用户已装好的副本
297
+ const srcCheck = verifyPlacedManifest({ dir: acquired.dir, packageName })
298
+ if (srcCheck.ok !== true) return failSubdirInstall(job, `${srcCheck.error}(取到的源码:${acquired.dir})`)
299
+ // ⑤ 放成插件的正式家(plugin-src/<包名>)并再核实一次(复制是否真的生效)
300
+ const placed = placeSubdirSource({ srcDir: acquired.dir, packageName, home, deps })
301
+ if (placed.ok !== true) return failSubdirInstall(job, placed.error)
302
+ const recheck = verifyPlacedManifest({ dir: placed.dir, packageName })
303
+ if (recheck.ok !== true) return failSubdirInstall(job, recheck.error)
304
+ const spec = linkSpecFor(placed.dir)
305
+ linkSpec = spec
306
+ job.subdirSource = { dir, from: acquired.dir, reused: acquired.reused === true, placed: placed.dir, version: recheck.version }
307
+ // ⑥ 本地目录来源 = 既有安装通道:`pnpm add link:<目录>` 建起 node_modules 里的那条链接
308
+ // (link: 是本地目录的正常形态,见 source-spec.js 判据 C;依赖由下一步的自足性补齐)
309
+ try {
310
+ await channels.pnpmInstall(profileDir, spec, undefined)
311
+ } catch (error) {
312
+ return failSubdirInstall(job, `从子目录 ${dir}/ 安装失败(本地目录来源 ${spec}):${error instanceof Error ? error.message : String(error)}。`
313
+ + `源码已取到本地(${placed.dir}),可重试;重试会复用已取到的源码。`)
314
+ }
315
+ // ⑦ 自足性(link: 的依赖 + 框架 peer 垫片):既有能力,失败只如实记 note,不影响安装判定
316
+ try {
317
+ const ensureSelf = typeof deps.selfSufficiency === 'function' ? deps.selfSufficiency : ensureLinkSelfSufficiency
318
+ const self = await ensureSelf({ profileDir, packageName, spec, home, deps: deps.selfDeps ?? {} })
319
+ if (self?.applies === true && typeof self.note === 'string' && self.note !== '') noteChannel(job, self.note)
320
+ } catch (error) {
321
+ noteChannel(job, `link 自足性补齐失败(不影响安装本身):${error instanceof Error ? error.message : String(error)}`)
322
+ }
323
+ }
324
+ job.stage = 'configuring'
325
+ job.entryCheck = verifyInstalledEntry(profileDir, packageName)
326
+ // ⑧ 挂载:bundle 层(dsh.bundle.patch)或普通 insert 行 —— 注册前的引用校验与 install-job 同一条规矩
327
+ const entries = listEntries(ports)
328
+ const served = entries.some((entry) => String(entry.moduleName).startsWith(packageName)
329
+ || String(entry.moduleName) === packageName
330
+ || packageNameOf(entry.moduleName) === packageName)
331
+ let mode = 'insert'
332
+ if (await detectBundleOnly(profileDir, packageName)) {
333
+ mode = 'bundle'
334
+ if (!served) {
335
+ const refs = readBundlePatchRefNames(profileDir, packageName)
336
+ const missing = refs.filter((n) => resolvePackageJson(n, profileDir) === null)
337
+ if (missing.length > 0) {
338
+ return failSubdirInstall(job, `该聚合包(${packageName})的插件组引用以下未安装模块:${missing.join('、')}。`
339
+ + `若这些是框架内部包(@deepseek-ai/*),该包不能作为插件安装(会导致服务启动失败);若为本应随包安装的依赖,请重试或检查网络。已取消注册。`)
340
+ }
341
+ await addBundleToManifest(profileDir, packageName)
342
+ job.bundle = true
343
+ } else {
344
+ job.bundleNote = `${packageName} 已由已安装条目提供,仅更新包本身,不再注册重复行`
345
+ }
346
+ } else if (served) {
347
+ mode = 'served'
348
+ job.note = `${packageName} 已由已安装条目提供,只更新包本身,不重复注册`
349
+ }
350
+ // ⑨ 收口:清单 + lock + 挂载行(唯一入口 domain/persist.js;结果 → 作业字段的唯一映射 install-finalize.js)
351
+ await finalizeInstallPersistence({
352
+ job, profileDir, patchPath, packageName, mode,
353
+ taken: new Set(entries.map((entry) => entry.rowId)), entries,
354
+ registries: orderedRegistries(readSources()),
355
+ source: { spec: null, origin: 'local-dir', kind: 'subdir' },
356
+ deps: deps.persistDeps ?? {},
357
+ })
358
+ // ⑩ 收口之后再看一眼**来源规格**:bundle 模式下的收尾补写可能把 link: 写成版本号(见 keepLinkSpec)
359
+ if (linkSpec !== null) await keepLinkSpec({ profileDir, packageName, spec: linkSpec, job })
360
+ job.status = 'done'
361
+ return true
362
+ }
363
+
364
+ export {
365
+ SUBDIR_SRC_DIR, SUBDIR_MAX_SEGMENTS,
366
+ normalizeSubdir, subdirSlug, verifySubdirManifest, acquireSubdirSource, placeSubdirSource, verifyPlacedManifest,
367
+ keepLinkSpec, runSubdirInstallJob,
368
+ }
@@ -134,7 +134,10 @@ async function shouldRunSuiteInstall(job, probes = {}) {
134
134
  }
135
135
  if (job.kind === 'skill') return decide(false, '技能请求不走套装通道')
136
136
  const gmProbe = typeof probes.probeGitmodules === 'function' ? probes.probeGitmodules : probeGitmodules
137
- const meta = await fetchRepoMeta(job.repo).catch(() => null)
137
+ // 默认分支探测也走 probes 袋子(2026-10-13,测试安全):离线测试注入桩即可一个请求都不发;
138
+ // 生产不注入 ⇒ 与改动前逐字相同(真 githubJson + curl 竞速 + 8s 降级)。
139
+ const metaProbe = typeof probes.fetchRepoMeta === 'function' ? probes.fetchRepoMeta : fetchRepoMeta
140
+ const meta = await metaProbe(job.repo).catch(() => null)
138
141
  job.repoMeta = meta
139
142
  const branch = meta?.default_branch ?? 'main'
140
143
  job.defaultBranch = branch
@@ -0,0 +1,208 @@
1
+ // L0 · infra —— egress.js(**唯一出网点**:出网与起进程全部收口在这里;0.5.47 加法)
2
+ //
3
+ // 为什么要有这个模块(上一批的教训,用户已复核):
4
+ // 上一批想用"给 `node:child_process` 的 ESM 命名空间打补丁 + 读 globalThis.__probeChildCalls"
5
+ // 来证明"测试没真出网、没真起进程"。两处都是**假守卫**:
6
+ // ① ESM 下 `import { execFile } from 'node:child_process'` 拿到的是**只读命名空间绑定**,
7
+ // `Object.defineProperty` 直接 `TypeError: Cannot redefine property: execFile` —— 这条路死了;
8
+ // ② 那个计数器是**测试文件里的局部变量**,而断言读的是 `globalThis.__probeChildCalls`
9
+ // ⇒ 断言恒真空,永远"0 次",守卫一点牙都没有。
10
+ //
11
+ // 本模块给的是**可注入单点**(唯一站得住的形态):
12
+ // · 生产代码的出网/起进程**只经这里**(http.js 的每条通道都换成这里 makeEgress 造出来的包装);
13
+ // · 单测 `__setEgressStub({ curlText, githubViaGh, ... })` 注入替身 ⇒ 计数、失败、超时都能被驱动,
14
+ // 且**一个真请求都发不出去**;
15
+ // · 计数器就在**执行的那一层**(真实实现前自增),不是测试文件里的局部变量、也不是 globalThis 空标志;
16
+ // · `__setEgressTripwire(true)` 是"漏了注入"的**当场抓手**:没被 stub 覆盖的出口**先抛**,不外发;
17
+ // · `__setEgressRealImpl({...})` 由生产模块在加载时登记真实现;`gaps` 记录"哪个出口被登记过",
18
+ // 于是"生产到底接上没有"是**可断言的**(不是靠读代码猜)。
19
+ // · `egressSelfCheckSpawn()` 是**计数器正控**:真的起一次 `node -e ""`,证明"0 次"不是恒绿。
20
+ //
21
+ // 为什么用 makeEgress(impl) 而不是让本模块 import http.js:**不起循环依赖**(infra → infra 也不行,
22
+ // egress ⇄ http 会成环)。真实现由 http.js 在加载时用 __setEgressRealImpl 登记,方向单向、可静态断言。
23
+ //
24
+ // 分层:L0 · infra(不得 import domain/routes/index —— tests/test-architecture-guard.mjs 断言②)。
25
+
26
+ import { execFile as nodeExecFile } from 'node:child_process'
27
+ import { request as nodeHttpsRequest } from 'node:https'
28
+ import { promisify } from 'node:util'
29
+
30
+ /** 真实出网/起进程的调用计数(只在真实现**之前**自增;stub 命中数另有 stubCalls 记)。 */
31
+ const stats = { realNetwork: 0, realSpawn: 0, stubCalls: 0, trips: 0, gaps: [] }
32
+ /** 单测注入的替身(缺哪个键 ⇒ 那个出口按"漏注入"处理,绝不悄悄走真实现)。 */
33
+ let stub = null
34
+ /** 生产登记的真实现(键 = 出口名)。 */
35
+ let realImpl = null
36
+
37
+ /** **测试进程判定**(与 routes/components.js#isTestProcess 同一判据,理由也同一条):
38
+ * 只看**自己进程的入口脚本**是不是本仓 `tests/**`;判据落在进程自身上 ⇒ 测试**无法自解**这层保护。
39
+ * 为什么网络侧也要它:测试若把替身还原成 null("还原生产实现")再走生产路径,真请求就会发出去 ——
40
+ * 与"测试把 spawn 桩摘掉 ⇒ 弹 explorer 窗口"是同一个漏洞形状。这里把它堵死:测试进程里
41
+ * **模块加载即开抓手**,真实现**必须显式 opt-in** 才可能被走到。 */
42
+ function isTestProcess() {
43
+ try {
44
+ if (process.env.DSH_TEST_PROCESS === '1') return true
45
+ } catch {}
46
+ try {
47
+ const entry = String(process.argv[1] ?? '').replace(/\\/gu, '/')
48
+ if (entry === '') return false
49
+ return /\/tests\/(?:test-[^/]*\.mjs|run-all\.mjs|probe-[^/]*\.[cm]js)$/u.test(entry)
50
+ } catch {
51
+ return false
52
+ }
53
+ }
54
+
55
+ /** 真实现(真实出网/起进程)的**显式 opt-in**:`DSH_EGRESS_ALLOW_REAL=1`。
56
+ * 生产不设它也不受影响(生产进程不是测试进程 ⇒ 不触发下面的加载即装)。 */
57
+ function realEgressOptIn() {
58
+ try {
59
+ return process.env.DSH_EGRESS_ALLOW_REAL === '1'
60
+ } catch {
61
+ return false
62
+ }
63
+ }
64
+
65
+ // ★ 模块加载即装(测试进程里无需任何 env、无需任何调用方配合):抓手默认开着,且把"我是测试进程"
66
+ // 写进环境让子进程继承 —— 于是"测试漏了注入"的结局是**当场红**,而不是真发出去一个请求。
67
+ let tripwire = isTestProcess() && !realEgressOptIn()
68
+ if (tripwire) {
69
+ try {
70
+ process.env.DSH_TEST_PROCESS = '1'
71
+ } catch {}
72
+ }
73
+
74
+ /** 单测注入替身:传对象 = 打桩(**一个真请求/真进程都不会发生**);传 null = 还原。 */
75
+ function __setEgressStub(next) {
76
+ stub = next !== null && typeof next === 'object' ? next : null
77
+ return stub !== null
78
+ }
79
+
80
+ /** 漏注入抓手开/关。开:未被 stub 覆盖的出口抛错并记一笔 trips;关:走真实现(生产默认)。 */
81
+ function __setEgressTripwire(on) {
82
+ tripwire = on === true
83
+ return tripwire
84
+ }
85
+
86
+ /** 生产模块加载时登记真实现(唯一一次;重复登记按合并处理)。返回已登记的出口名清单。 */
87
+ function __setEgressRealImpl(next) {
88
+ realImpl = { ...(realImpl ?? {}), ...(next ?? {}) }
89
+ return Object.keys(realImpl).sort()
90
+ }
91
+
92
+ /** 计数快照(浅拷贝,测试据此断言"0 真出网 / 0 真 spawn")。 */
93
+ function egressStatsNow() {
94
+ return { ...stats, gaps: [...stats.gaps] }
95
+ }
96
+
97
+ /** 清零计数(只给测试用;生产没有任何调用点)。 */
98
+ function __resetEgressStats() {
99
+ stats.realNetwork = 0
100
+ stats.realSpawn = 0
101
+ stats.stubCalls = 0
102
+ stats.trips = 0
103
+ stats.gaps = []
104
+ }
105
+
106
+ /** 请求目标(真话记录用):请求可能以 `request(url, opts, cb)` 或 `(opts, cb)` 形态调用。 */
107
+ function networkTargetOf(args) {
108
+ const first = args[0]
109
+ if (typeof first === 'string') return first
110
+ if (first !== null && typeof first === 'object') {
111
+ const host = first.hostname ?? first.host ?? ''
112
+ const path = first.path ?? ''
113
+ if (host !== '') return `${first.protocol ?? 'https:'}//${host}${path}`
114
+ }
115
+ return '(unknown)'
116
+ }
117
+
118
+ /** 目标是**回环地址**吗(127.0.0.1 / localhost / ::1)。
119
+ * 红线防的是**外网**请求与真实进程;而"传输层语义"这类测试(本仓既有做法:本地起坏代理复现
120
+ * 200 空 body / 垃圾页 / 404)本来就要真起 curl 打回环 —— 那是允许的,不应当成"漏注入"。
121
+ * 注意:只有**能看出目标**的出口(curl/jsDelivr/https 系列)才有这条豁免;`execFileAsync`
122
+ * 这种看不出目标的(pnpm/git/gh 二进制)一律拦,测试要真跑必须显式 opt-in。 */
123
+ function isLoopbackTarget(args) {
124
+ const target = networkTargetOf(args).toLowerCase()
125
+ return /^(?:https?|git\+https?):\/\/(?:127\.0\.0\.1|localhost|\[::1\])(?::\d+)?(?:[/?#]|$)/u.test(target)
126
+ }
127
+
128
+ /** 未注入出口的处理:tripwire 开着 ⇒ 抛(当场的抓手);关着 ⇒ 用自己的真实现(没有再登记就抛)。 */
129
+ function unwired(name, key, args) {
130
+ stats.gaps.push(name)
131
+ if (tripwire && !isLoopbackTarget(args)) {
132
+ stats.trips += 1
133
+ throw new Error(`测试红线:出口 ${name} 未被替身覆盖 —— 本套禁止任何真实出网/真实进程(请给 __setEgressStub 补上 ${key};只打回环的传输层测试可设 DSH_EGRESS_ALLOW_REAL=1)`)
134
+ }
135
+ const impl = realImpl?.[key]
136
+ if (typeof impl !== 'function') throw new Error(`出网出口 ${name} 没有真实现(生产模块没登记?)`)
137
+ return impl
138
+ }
139
+
140
+ /** 造一支出网包装:**stub 优先 → 记一笔 → 真实现**。顺序写在代码里,可读也可静态断言。
141
+ * `kind`:'network' 记出网计数,'spawn' 记起进程计数(正控自检据此断言计数器不是死的)。
142
+ * `fallback`:可选真实现(调用方直接给函数);给了就用它,否则查 realImpl 登记表。
143
+ * 为什么允许直接给:`node:https` 的请求原语也收在本模块里(见 egressHttpsRequestOnce),
144
+ * 于是"只有 egress.js 认识 node:https"是**静态可判**的,而不是靠一句注释约定。 */
145
+ function makeEgress(name, kind = 'network', fallback = null) {
146
+ const wrapper = async (...args) => {
147
+ if (stub !== null && typeof stub[name] === 'function') {
148
+ stats.stubCalls += 1
149
+ return await stub[name](...args)
150
+ }
151
+ const impl = typeof fallback === 'function' ? fallback : unwired(name, name, args)
152
+ if (kind === 'network') countNetwork(networkTargetOf(args))
153
+ else countSpawn()
154
+ return await impl(...args)
155
+ }
156
+ wrapper.egressName = name
157
+ return wrapper
158
+ }
159
+
160
+ /** 真话记录:**真的**要把一个请求发出去了(只被 makeEgress 与正控调用)。 */
161
+ function countNetwork(target) {
162
+ stats.realNetwork += 1
163
+ return target
164
+ }
165
+
166
+ /** 真话记录:**真的**要创建一个进程了(只被 makeEgress 与正控调用)。 */
167
+ function countSpawn() {
168
+ stats.realSpawn += 1
169
+ }
170
+
171
+ /** 正控用:不带任何注入,**真的**起一次进程(`node -e ""`,无窗口无副作用),并把计数交给调用方。
172
+ * 返回 `{ ok, counted }`:counted === 1 证明 spawn 计数不是死的(否则"0 次"就是假绿)。 */
173
+ async function egressSelfCheckSpawn({ spawnFn = null } = {}) {
174
+ const before = stats.realSpawn
175
+ const impl = typeof spawnFn === 'function' ? spawnFn : realImpl?.execFileAsync
176
+ if (typeof impl !== 'function') return { ok: false, counted: 0, reason: '没有登记 execFileAsync 真实现' }
177
+ countSpawn()
178
+ try {
179
+ await impl(process.execPath, ['-e', ''], { windowsHide: true, timeout: 20000 })
180
+ } catch {
181
+ return { ok: stats.realSpawn - before === 1, counted: stats.realSpawn - before }
182
+ }
183
+ return { ok: stats.realSpawn - before === 1, counted: stats.realSpawn - before }
184
+ }
185
+
186
+ /** 正控用:**真的**把一次出网记到(并可真的发出一次请求)。测试只给**回环地址**,
187
+ * 于是"计数器不是死的"与"零外网"可以同时成立。 */
188
+ function egressSelfCheckNetwork(target, { requestFn = null, send = false } = {}) {
189
+ const before = stats.realNetwork
190
+ countNetwork(networkTargetOf([target]))
191
+ const impl = typeof requestFn === 'function' ? requestFn : realImpl?.httpsRequestOnce
192
+ if (send === true) {
193
+ if (typeof impl !== 'function') return { counted: stats.realNetwork - before, sent: false, reason: '没有登记 httpsRequestOnce 真实现' }
194
+ return { counted: stats.realNetwork - before, sent: true, request: () => impl(target, { method: 'GET' }) }
195
+ }
196
+ return { counted: stats.realNetwork - before, sent: false }
197
+ }
198
+
199
+ /** **真的**发一次 https 请求(node:https 的原语收在本模块里:于是"只有 egress.js 认识
200
+ * node:https"是静态可判的)。http.js 的真实 `githubRequestOnce` 用它;单测注入替身后它不会被调用。 */
201
+ function egressHttpsRequestOnce(url, options) {
202
+ return nodeHttpsRequest(url, options)
203
+ }
204
+
205
+ export {
206
+ __setEgressStub, __setEgressTripwire, __setEgressRealImpl, __resetEgressStats, egressStatsNow,
207
+ makeEgress, egressHttpsRequestOnce, egressSelfCheckSpawn, egressSelfCheckNetwork,
208
+ }