@noob-stupid/dsh-plugin-console 0.5.46 → 0.5.48

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.
@@ -1,10 +1,18 @@
1
1
  // 由 Step 1 搬运工具从 lib/index.js 原样切出(只移动、未改逻辑)
2
2
  // 分层分组:L0 · infra(边界由 tests/test-architecture-guard.mjs 断言)
3
3
 
4
- import { execFile } from 'node:child_process'
4
+ // ★ 出网/起进程的唯一出口(0.5.47 接线):本文件的每条网络/进程通道都是 infra/egress.js
5
+ // makeEgress 造出来的**包装**(stub 优先 → 计数 → 真实现)。单测 `__setEgressStub({…})`
6
+ // 注入替身之后,`egressStatsNow()` 能如实证明"0 次真实出网、0 次真实 spawn"——上一批那种
7
+ // "给 node:child_process 命名空间打补丁 + 读 globalThis 空标志"的假守卫已废(ESM 下也打不上)。
8
+ // ★ 连 `node:https` 的请求原语也不在本文件(在 egress.js):于是"只有 egress.js 认识
9
+ // node:https / node:child_process"是**静态可判**的(tests/test-repo-manifest-channels.mjs 钉死),
10
+ // 而不是靠注释约定。本文件只留"怎么用"(协议/重试/竞速)。
11
+ import { execFile as nodeExecFile } from 'node:child_process'
12
+ import { promisify } from 'node:util'
5
13
  import { resolve } from 'node:path'
6
- import { request as httpsRequest } from 'node:https'
7
- import { GH_BIN_CANDIDATES, execFileAsync } from './exec.js'
14
+ import { GH_BIN_CANDIDATES } from './exec.js'
15
+ import { __setEgressRealImpl, egressHttpsRequestOnce, makeEgress } from './egress.js'
8
16
 
9
17
  /** 单次 https 请求;卡死的连接会在超时后被销毁。 */
10
18
 
@@ -38,9 +46,14 @@ import { GH_BIN_CANDIDATES, execFileAsync } from './exec.js'
38
46
 
39
47
  /** raw.githubusercontent.com 的镜像竞速前缀(与浏览器端四通道一致)。 */
40
48
 
41
- function githubRequestOnce(url, { signal, accept, token, timeout = 20000 } = {}) {
49
+ // ── 出网单点的**真实现**(0.5.47)────────────────────────────────────────────────────
50
+ // 下面这一块是本文件里**唯一**碰 node 进程/网络原语的地方(另一处是 egress.js)。对外导出的
51
+ // 同名函数则是 egress 包装:stub 优先 → 记一笔 → 回到这里。`__setEgressRealImpl` 把真实现登记给
52
+ // egress,于是"生产到底接上没有"是**可断言的**(egress 的 gaps 会列出被登记过的出口名),
53
+ // 不靠读代码猜 —— 上一批的教训正是"加了文件但没人 import"。
54
+ function githubRequestOnceReal(url, { signal, accept, token, timeout = 20000 } = {}) {
42
55
  return new Promise((resolve, reject) => {
43
- const req = httpsRequest(url, {
56
+ const req = egressHttpsRequestOnce(url, {
44
57
  method: 'GET',
45
58
  headers: {
46
59
  'user-agent': GITHUB_UA,
@@ -59,6 +72,84 @@ function githubRequestOnce(url, { signal, accept, token, timeout = 20000 } = {})
59
72
  }
60
73
  })
61
74
  }
75
+ function httpsRequestReal(url, options) {
76
+ return egressHttpsRequestOnce(url, options)
77
+ }
78
+ const execFileAsyncReal = promisify(nodeExecFile)
79
+ async function githubViaGhReal(apiPath, token = null) {
80
+ // 环境变量而不是命令行参数:token 不能出现在进程命令行(同机其他进程可见)
81
+ const env = token === null ? undefined : { ...process.env, GH_TOKEN: token, GITHUB_TOKEN: token }
82
+ let lastError = null
83
+ for (const bin of GH_BIN_CANDIDATES) {
84
+ try {
85
+ // eslint-disable-next-line no-await-in-loop
86
+ const { stdout } = await execFileAsyncReal(bin, ['api', apiPath, '--jq', '.'], { windowsHide: true, timeout: 45000, maxBuffer: 8 * 1024 * 1024, ...(env === undefined ? {} : { env }) })
87
+ try {
88
+ return JSON.parse(stdout)
89
+ } catch {
90
+ throw new Error('gh api 兜底返回的不是合法 JSON')
91
+ }
92
+ } catch (error) {
93
+ lastError = error
94
+ // ENOENT = 该路径不存在,继续下一个候选;其他错误(网络/认证)直接失败
95
+ if (error?.code === 'ENOENT') continue
96
+ throw new Error(`gh api 兜底失败:${error instanceof Error ? error.message : String(error)}`)
97
+ }
98
+ }
99
+ throw new Error(lastError?.code === 'ENOENT'
100
+ ? 'gh api 兜底失败:未找到 gh CLI'
101
+ : `gh api 兜底失败:${lastError instanceof Error ? lastError.message : String(lastError)}`)
102
+ }
103
+ async function curlJsonReal(url, timeout = 15000, headers = {}, { ipv4 = false } = {}) {
104
+ const bin = process.platform === 'win32' ? 'curl.exe' : 'curl'
105
+ const args = ['-s', '-m', String(Math.max(5, Math.ceil(timeout / 1000))), '-H', 'accept: application/json', '-w', '\n__HTTP__%{http_code}']
106
+ // ipv4:只给 GitHub 域名用——「解析出 IPv6 但没有 IPv6 路由」的环境里 curl 默认要先空等 ~5.2s 才回退
107
+ // IPv4(2026-09-20 另一位用户实测:默认 5473ms vs `-4` 的 461ms);双栈正常的环境无副作用。
108
+ if (ipv4) args.push('-4')
109
+ for (const [key, value] of Object.entries(headers)) args.push('-H', `${key}: ${value}`)
110
+ args.push(url)
111
+ const { stdout } = await execFileAsyncReal(bin, args, { timeout: timeout + 3000, windowsHide: true, maxBuffer: 8 * 1024 * 1024 })
112
+ const m = String(stdout).match(/\n__HTTP__(\d+)\s*$/u)
113
+ const code = m ? Number(m[1]) : 0
114
+ const body = m ? String(stdout).slice(0, m.index) : String(stdout)
115
+ if (code !== 0 && (code < 200 || code >= 300)) throw new Error(`请求失败 (HTTP ${code})`)
116
+ return JSON.parse(body)
117
+ }
118
+ async function curlTextReal(url, timeout = 10000, { ipv4 = false } = {}) {
119
+ const bin = process.platform === 'win32' ? 'curl.exe' : 'curl'
120
+ const args = ['-s', '-m', String(Math.max(3, Math.ceil(timeout / 1000))), '-H', 'accept: text/plain', '-w', '\n__HTTP__%{http_code}']
121
+ // 见 curlJson 注释:GitHub 域名走 IPv4 快通道(IPv6 无路由的环境下默认要白等 ~5.2s)
122
+ if (ipv4) args.push('-4')
123
+ args.push(url)
124
+ const { stdout } = await execFileAsyncReal(
125
+ bin,
126
+ args,
127
+ { timeout: timeout + 3000, windowsHide: true, maxBuffer: 8 * 1024 * 1024 },
128
+ )
129
+ const m = String(stdout).match(/\n__HTTP__(\d+)\s*$/u)
130
+ const code = m ? Number(m[1]) : 0
131
+ const body = m ? String(stdout).slice(0, m.index) : String(stdout)
132
+ if (code !== 0 && (code < 200 || code >= 300)) throw new Error(`HTTP ${code}`)
133
+ return body
134
+ }
135
+
136
+ // ── 对外导出的出网出口 = egress 包装(stub 优先 → 计数 → 真实现)──────────────────────
137
+ const githubRequestOnce = makeEgress('httpsRequestOnce', 'network')
138
+ const httpsRequest = makeEgress('httpsRequest', 'network')
139
+ const githubViaGh = makeEgress('githubViaGh', 'spawn')
140
+ const curlJson = makeEgress('curlJson', 'spawn')
141
+ const curlText = makeEgress('curlText', 'spawn')
142
+
143
+ /** 登记真实现(**方向单向**:egress 不 import 本文件,避免 infra 内部成环)。 */
144
+ __setEgressRealImpl({
145
+ httpsRequestOnce: githubRequestOnceReal,
146
+ httpsRequest: httpsRequestReal,
147
+ execFileAsync: execFileAsyncReal,
148
+ githubViaGh: githubViaGhReal,
149
+ curlJson: curlJsonReal,
150
+ curlText: curlTextReal,
151
+ })
152
+
62
153
  function raceFirst2xx(promises) {
63
154
  return new Promise((resolve, reject) => {
64
155
  let pending = promises.length
@@ -127,36 +218,8 @@ function collectBody(res) {
127
218
  }
128
219
  /** gh CLI 通道。传 token 时用 GH_TOKEN/GITHUB_TOKEN 环境变量**覆盖 gh 的 keyring 凭据** ——
129
220
  * 这是"校验某个 token 是否有效"时唯一安全的用法:否则 gh 会拿本机已登录的身份作答,
130
- * 一个填错的 token 也会"验证成功"(详见 routes/github-login.js 的说明)。 */
131
- function githubViaGh(apiPath, token = null) {
132
- // 环境变量而不是命令行参数:token 不能出现在进程命令行(同机其他进程可见)
133
- const env = token === null ? undefined : { ...process.env, GH_TOKEN: token, GITHUB_TOKEN: token }
134
- return new Promise((resolve, reject) => {
135
- const attempt = (index) => {
136
- if (index >= GH_BIN_CANDIDATES.length) {
137
- reject(new Error('gh api 兜底失败:未找到 gh CLI'))
138
- return
139
- }
140
- execFile(GH_BIN_CANDIDATES[index], ['api', apiPath, '--jq', '.'], { windowsHide: true, timeout: 45000, maxBuffer: 8 * 1024 * 1024, ...(env === undefined ? {} : { env }) }, (error, stdout) => {
141
- if (error) {
142
- // ENOENT = 该路径不存在,继续下一个候选;其他错误(网络/认证)直接失败
143
- if (error.code === 'ENOENT') {
144
- attempt(index + 1)
145
- return
146
- }
147
- reject(new Error(`gh api 兜底失败:${error.message}`))
148
- return
149
- }
150
- try {
151
- resolve(JSON.parse(stdout))
152
- } catch {
153
- reject(new Error('gh api 兜底返回的不是合法 JSON'))
154
- }
155
- })
156
- }
157
- attempt(0)
158
- })
159
- }
221
+ * 一个填错的 token 也会"验证成功"(详见 routes/github-login.js 的说明)。
222
+ * 真实现见上面的 githubViaGhReal;这里往下都是**组合逻辑**(竞速/兜底),不再直接碰进程。 */
160
223
  async function githubJson(url, signal, token = null) {
161
224
  const apiPath = url.startsWith(GITHUB_API) ? url.slice(GITHUB_API.length) : null
162
225
  // https 主通道(官方重试+镜像并行)与 gh CLI **并行竞速**:黑洞期 gh 秒回,不再等 https 超时
@@ -193,38 +256,6 @@ async function githubText(url, signal, token = null) {
193
256
  if (status < 200 || status >= 300) return null
194
257
  return body
195
258
  }
196
- async function curlJson(url, timeout = 15000, headers = {}, { ipv4 = false } = {}) {
197
- const bin = process.platform === 'win32' ? 'curl.exe' : 'curl'
198
- const args = ['-s', '-m', String(Math.max(5, Math.ceil(timeout / 1000))), '-H', 'accept: application/json', '-w', '\n__HTTP__%{http_code}']
199
- // ipv4:只给 GitHub 域名用——「解析出 IPv6 但没有 IPv6 路由」的环境里 curl 默认要先空等 ~5.2s 才回退
200
- // IPv4(2026-09-20 另一位用户实测:默认 5473ms vs `-4` 的 461ms);双栈正常的环境无副作用。
201
- if (ipv4) args.push('-4')
202
- for (const [key, value] of Object.entries(headers)) args.push('-H', `${key}: ${value}`)
203
- args.push(url)
204
- const { stdout } = await execFileAsync(bin, args, { timeout: timeout + 3000, windowsHide: true, maxBuffer: 8 * 1024 * 1024 })
205
- const m = String(stdout).match(/\n__HTTP__(\d+)\s*$/u)
206
- const code = m ? Number(m[1]) : 0
207
- const body = m ? String(stdout).slice(0, m.index) : String(stdout)
208
- if (code !== 0 && (code < 200 || code >= 300)) throw new Error(`请求失败 (HTTP ${code})`)
209
- return JSON.parse(body)
210
- }
211
- async function curlText(url, timeout = 10000, { ipv4 = false } = {}) {
212
- const bin = process.platform === 'win32' ? 'curl.exe' : 'curl'
213
- const args = ['-s', '-m', String(Math.max(3, Math.ceil(timeout / 1000))), '-H', 'accept: text/plain', '-w', '\n__HTTP__%{http_code}']
214
- // 见 curlJson 注释:GitHub 域名走 IPv4 快通道(IPv6 无路由的环境下默认要白等 ~5.2s)
215
- if (ipv4) args.push('-4')
216
- args.push(url)
217
- const { stdout } = await execFileAsync(
218
- bin,
219
- args,
220
- { timeout: timeout + 3000, windowsHide: true, maxBuffer: 8 * 1024 * 1024 },
221
- )
222
- const m = String(stdout).match(/\n__HTTP__(\d+)\s*$/u)
223
- const code = m ? Number(m[1]) : 0
224
- const body = m ? String(stdout).slice(0, m.index) : String(stdout)
225
- if (code !== 0 && (code < 200 || code >= 300)) throw new Error(`HTTP ${code}`)
226
- return body
227
- }
228
259
  async function fetchJsonUrl(url, timeout = 15000, headers = {}) {
229
260
  let lastError
230
261
  const isHttps = /^https:\/\//u.test(url)
@@ -241,7 +272,8 @@ async function fetchJsonUrl(url, timeout = 15000, headers = {}) {
241
272
  // 否则会抛出 "Protocol http: not supported" 掩盖 curl 的真实连接错误
242
273
  if (!isHttps) continue
243
274
  try {
244
- const res = await githubRequest(url, { headers })
275
+ // 走 egress 包装(不是上面的真实现):单测注入替身后这条兜底也发不出去
276
+ const res = await httpsRequest(url, { headers })
245
277
  const status = res.statusCode ?? 0
246
278
  const body = await collectBody(res)
247
279
  if (status < 200 || status >= 300) throw new Error(`请求失败 (HTTP ${status})`)
@@ -279,61 +311,14 @@ const FETCH_OK = 'ok'
279
311
  const FETCH_NOT_FOUND = 'not-found'
280
312
  const FETCH_UNREACHABLE = 'unreachable'
281
313
 
282
- /** 竞速组合(纯逻辑,导出供单测):通道结果 + 预算 → { state, body }。
283
- * · anyPromise 以 null 成功 = 某通道确定性 404 → not-found
284
- * · anyPromise 以字符串成功 = 读到内容 → ok
285
- * · anyPromise 全部失败(AggregateError)或超出预算 → unreachable(**不再与 404 混为一谈**) */
286
- function raceFetchOutcome(anyPromise, budgetMs) {
287
- const settled = Promise.resolve(anyPromise)
288
- .then((body) => (body === null ? { state: FETCH_NOT_FOUND, body: null } : { state: FETCH_OK, body }))
289
- .catch(() => ({ state: FETCH_UNREACHABLE, body: null }))
290
- return Promise.race([
291
- settled,
292
- new Promise((resolve) => setTimeout(() => resolve({ state: FETCH_UNREACHABLE, body: null }), budgetMs)),
293
- ])
294
- }
314
+ // 旧的 raceFetchOutcome(anyPromise, budgetMs) 已被下面的通道版取代(胜出通道 + 逐条失败原因)。
295
315
 
296
- /** raw 抓取(带状态):返回 { state, body }。state 区分「确定性 404」与「超时/全部通道不可达」——
297
- * 二者都以"没内容"结束,但给用户看的文案必须不同(否则永远误报"文件不存在",误导排查方向)。 */
298
- async function rawTextFetch(repo, branch, path, budgetMs = FETCH_BUDGET_MS) {
299
- const url = `${GITHUB_RAW}/${repo}/${encodeURIComponent(branch)}/${path}`
300
- // 404/4xx:确定性"不存在",立即以 null 胜出(不等慢分支)
301
- const notFound = (promise) => promise
302
- .catch((error) => {
303
- if (error instanceof Error && /HTTP 404|HTTP 400|Not Found|not found|非 2xx|HTTP 非 2xx/u.test(error.message)) return null
304
- throw error
305
- })
306
- // 各通道统一口径:状态 2xx **且** body 非空白,才算读到文件(否则抛"非 2xx"→ 归一为 null)
307
- const real = (promise) => promise.then((body) => {
308
- const text = readBodyOrNull(body)
309
- if (text === null) throw new Error('HTTP 非 2xx')
310
- return text
311
- })
312
- const https = notFound(real(githubText(url)))
313
- const gh = notFound(githubViaGh(`repos/${repo}/contents/${path}?ref=${encodeURIComponent(branch)}`)
314
- .then((data) => {
315
- if (data && typeof data.content === 'string') return Buffer.from(data.content, 'base64').toString('utf8')
316
- throw new Error('contents 无内容')
317
- })
318
- .then((text) => {
319
- const body = readBodyOrNull(text)
320
- if (body === null) throw new Error('contents 无内容')
321
- return body
322
- }))
323
- const curl = notFound(real(curlText(url, 9000, { ipv4: true })))
324
- const cdn = notFound(real(curlText(`https://cdn.jsdelivr.net/gh/${repo}@${encodeURIComponent(branch)}/${path}`, 9000, { ipv4: true })))
325
- return await raceFetchOutcome(Promise.any([https, gh, curl, cdn]), budgetMs)
326
- }
316
+ // 旧的 rawTextFetch/rawTextWithFallback 已被下面的通道版取代(四条既有通道一条不删)。
327
317
 
328
- /** 兼容入口:调用方只关心"有没有内容"(null = 没有)。需要区分超时/404 的场景用 rawTextFetch。 */
329
- async function rawTextWithFallback(repo, branch, path) {
330
- const { state, body } = await rawTextFetch(repo, branch, path)
331
- return state === FETCH_OK ? body : null
332
- }
333
318
  function postJsonUrl(url, formBody) {
334
319
  return new Promise((resolve, reject) => {
335
320
  const body = new URLSearchParams(formBody).toString()
336
- const req = httpsRequest(url, {
321
+ const req = httpsRequestReal(url, {
337
322
  method: 'POST',
338
323
  headers: {
339
324
  'content-type': 'application/x-www-form-urlencoded',
@@ -370,4 +355,163 @@ const RAW_MIRROR_PREFIXES = [
370
355
  'https://ghproxy.net/https://raw.githubusercontent.com',
371
356
  'https://ghfast.top/https://raw.githubusercontent.com',
372
357
  ]
373
- export { githubRequestOnce, raceFirst2xx, githubRequest, collectBody, githubViaGh, githubJson, githubText, curlJson, curlText, fetchJsonUrl, rawTextWithFallback, rawTextFetch, raceFetchOutcome, readBodyOrNull, looksLikeGitmodules, postJsonUrl, GITHUB_API, GITHUB_RAW, GITHUB_UA, API_MIRROR_PREFIXES, RAW_MIRROR_PREFIXES, FETCH_BUDGET_MS, META_BUDGET_MS, FETCH_OK, FETCH_NOT_FOUND, FETCH_UNREACHABLE }
358
+ // ── 通道身份 + 逐通道失败原因(2026-10-13 加法)─────────────────────────────────
359
+ // 缺口:Promise.any 只把**胜出者**当返回值,胜出的是哪条通道被丢弃;全失败时 AggregateError
360
+ // 被 catch 成一句 unreachable —— 用户看到的只有"抓取超时",**看不到哪几条通道试过、各自为什么没成**,
361
+ // 于是无从判断该修网络、该换源,还是该等一会儿重试。下面把两样都带出来。
362
+ //
363
+ // 关键性能约束(与旧行为逐字一致):**成功必须早返回** —— 旧代码 curl 400ms 赢时不会去等
364
+ // node:https 的 20s 超时;所以这里用"首个成功即解析"的 race,只有**全部失败**时才收齐全量原因
365
+ // (那时本来就要等到预算耗尽)。
366
+ const CHANNEL_LABELS = {
367
+ local: '本地落地',
368
+ https: 'https(GitHub 主通道/镜像竞速)',
369
+ gh: 'gh CLI',
370
+ curl: 'curl 直连',
371
+ jsdelivr: 'jsDelivr CDN',
372
+ 'npm-mirror': 'npm 镜像(npmmirror)',
373
+ 'npm-official': 'npm 官方源(registry.npmjs.org)',
374
+ budget: '总预算',
375
+ }
376
+ /** 通道 id → 用户可读名(未知 id 原样返回,绝不显示空标签)。 */
377
+ function channelLabel(id) {
378
+ return CHANNEL_LABELS[String(id ?? '')] ?? String(id ?? '未知通道')
379
+ }
380
+ /** 失败原因归一:超时/HTTP 状态/网络原话,取最短且可读的一句(保留原始 error.message 关键片段)。 */
381
+ function compactFailReason(error) {
382
+ const message = error instanceof Error ? error.message : String(error ?? '失败')
383
+ const text = message.replace(/\s+/gu, ' ').trim()
384
+ if (text === '') return '失败(无原因)'
385
+ if (/timed out|timeout|超时|ETIMEDOUT|too slow/iu.test(text)) return `超时(${text.slice(0, 80)})`
386
+ if (/^(HTTP \d+|请求失败 \(HTTP \d+\))/u.test(text)) return text
387
+ return text.slice(0, 120)
388
+ }
389
+ /** 各通道结果 → 逐条「通道名:原因」清单(供用户可读报错;顺序即探测顺序,可追溯)。 */
390
+ function channelFailureLines(failures) {
391
+ const list = Array.isArray(failures) ? failures : []
392
+ return list.filter((f) => f !== null && typeof f === 'object').map((f) => {
393
+ const reason = typeof f.reason === 'string' && f.reason !== '' ? f.reason : '失败(无原因)'
394
+ return `· ${channelLabel(f.id)}:${typeof f.detail === 'string' && f.detail !== '' ? `${reason} —— ${f.detail}` : reason}`
395
+ })
396
+ }
397
+ /** 逐通道原因清单文本(空列表 → 空串,调用方据此决定要不要加这段)。 */
398
+ function channelFailureSummary(failures) {
399
+ const lines = channelFailureLines(failures)
400
+ return lines.length === 0 ? '' : lines.join('\n')
401
+ }
402
+
403
+ /** 竞速组合(纯逻辑,导出供单测):通道描述数组 + 预算 → `{ state, body, channel, failures }`。
404
+ * · 某通道返回 null(确定性 404)→ 立刻 not-found(旧语义:不等慢分支)
405
+ * · 某通道返回字符串(2xx 且内容非空白)→ 立刻 ok,并带出**胜出通道 id**
406
+ * · 全部失败 / 超出预算 → unreachable,且 `failures` **逐条**列出每条通道各自的原因
407
+ * 成败的判定权仍在通道自己(它们已按 notFound/real 归一成 null/string/throw)。
408
+ * **向后兼容**:旧形态 `raceFetchOutcome(promise, budgetMs)`(单个 Promise 竞速)仍然可用 ——
409
+ * 单测与老调用点一个字都不用改,只是它现在也会带上 channel/failures。 */
410
+ function raceFetchOutcome(input, legacyBudgetMs) {
411
+ const isObjectForm = input !== null && typeof input === 'object' && !Array.isArray(input)
412
+ && (Array.isArray(input.channels) || typeof input.channels !== 'undefined')
413
+ const channels = isObjectForm ? (input.channels ?? []) : [input]
414
+ const deps = isObjectForm ? (input.deps ?? {}) : {}
415
+ const timeoutMs = Number.isFinite(isObjectForm ? input.timeoutMs : legacyBudgetMs) ? (isObjectForm ? input.timeoutMs : legacyBudgetMs) : FETCH_BUDGET_MS
416
+ const setTimer = deps.setTimeout ?? setTimeout
417
+ const list = (Array.isArray(channels) ? channels : [channels])
418
+ // 旧形态:单个 Promise / 值,包成一条通道(默认 id 沿用最老的那条 'https',语义不变)
419
+ .map((ch, index) => (ch !== null && typeof ch === 'object' && typeof ch?.run === 'function'
420
+ ? ch
421
+ : { id: `channel-${index}`, label: channelLabel(`channel-${index}`), run: () => (typeof ch?.then === 'function' ? ch : Promise.resolve(ch)) }))
422
+ if (list.length === 0) return Promise.resolve({ state: FETCH_UNREACHABLE, body: null, channel: null, failures: [] })
423
+ // 每条通道**只启动一次**:raw[index] 同时是"结果"与"该通道已了结"的标记(null = 还在等)。
424
+ const raw = new Array(list.length).fill(null)
425
+ let settled = false
426
+ let pending = list.length
427
+ return new Promise((resolve) => {
428
+ const done = (outcome) => { if (!settled) { settled = true; resolve(outcome) } }
429
+ const failuresOf = () => raw.map((entry, index) => (entry === null
430
+ ? { id: list[index].id ?? `channel-${index}`, label: channelLabel(list[index].id), reason: '预算内未返回(超时/仍在等待)', state: FETCH_UNREACHABLE, detail: '' }
431
+ : { id: list[index].id ?? `channel-${index}`, label: entry.label, reason: entry.reason, state: entry.state, detail: entry.detail ?? '' }))
432
+ const settleOne = (index, body) => {
433
+ const label = list[index].label ?? channelLabel(list[index].id)
434
+ raw[index] = { state: body === null ? FETCH_NOT_FOUND : FETCH_OK, reason: '', detail: '', label }
435
+ pending -= 1
436
+ // ★ 胜出通道与"当时已知的其余通道失败原因"一起带出(确定性 404 同样立即胜出,不等慢分支)
437
+ done(body === null
438
+ ? { state: FETCH_NOT_FOUND, body: null, channel: list[index].id ?? `channel-${index}`, failures: failuresOf() }
439
+ : { state: FETCH_OK, body, channel: list[index].id ?? `channel-${index}`, failures: failuresOf() })
440
+ }
441
+ const failOne = (index, error) => {
442
+ raw[index] = { state: FETCH_UNREACHABLE, reason: compactFailReason(error), detail: '', label: list[index].label ?? channelLabel(list[index].id) }
443
+ pending -= 1
444
+ // 全部失败 → unreachable + **逐条**原因(这时本来就要等到预算耗尽,收齐不影响速度)
445
+ if (pending === 0) done({ state: FETCH_UNREACHABLE, body: null, channel: null, failures: failuresOf() })
446
+ }
447
+ for (let index = 0; index < list.length; index += 1) {
448
+ // 同步抛出也算这条通道失败(不能让它把整个竞速掀掉)
449
+ try {
450
+ Promise.resolve().then(list[index].run).then(
451
+ (body) => settleOne(index, body),
452
+ (error) => failOne(index, error),
453
+ )
454
+ } catch (error) {
455
+ failOne(index, error)
456
+ }
457
+ }
458
+ // 外层预算兜底(与旧实现同为 FETCH_BUDGET_MS)
459
+ setTimer(() => done({ state: FETCH_UNREACHABLE, body: null, channel: null, failures: failuresOf() }), timeoutMs)
460
+ })
461
+ }
462
+
463
+ /** raw 抓取(带状态 + **通道身份** + 逐通道失败原因):返回 `{ state, body, channel, failures }`。
464
+ * 通道 = 既有 4 条竞速(node:https 官方+镜像 / gh CLI / curl 直连 / curl jsDelivr,一条不删)
465
+ * **加上**调用方注入的额外通道(本地落地、npm registry 包清单 —— 见 domain/landed-source.js、
466
+ * domain/registry-manifest.js)。额外通道排在既有 4 条**之后**:优先级按可达性,
467
+ * 本地 > 既有网络通道 > registry。 */
468
+ async function rawTextFetch(repo, branch, path, budgetMs = FETCH_BUDGET_MS, deps = {}) {
469
+ const url = `${GITHUB_RAW}/${repo}/${encodeURIComponent(branch)}/${path}`
470
+ // 404/4xx:确定性"不存在",立即以 null 胜出(不等慢分支)
471
+ const notFound = (promise) => promise
472
+ .catch((error) => {
473
+ if (error instanceof Error && /HTTP 404|HTTP 400|Not Found|not found|非 2xx|HTTP 非 2xx/u.test(error.message)) return null
474
+ throw error
475
+ })
476
+ // 各通道统一口径:状态 2xx **且** body 非空白,才算读到文件(否则抛"非 2xx"→ 归一为 null)
477
+ const real = (promise) => promise.then((body) => {
478
+ const text = readBodyOrNull(body)
479
+ if (text === null) throw new Error('HTTP 非 2xx')
480
+ return text
481
+ })
482
+ const https = notFound(real(githubText(url)))
483
+ const gh = notFound(real(githubViaGh(`repos/${repo}/contents/${path}?ref=${encodeURIComponent(branch)}`)
484
+ .then((data) => {
485
+ if (data && typeof data.content === 'string') return Buffer.from(data.content, 'base64').toString('utf8')
486
+ throw new Error('contents 无内容')
487
+ })
488
+ .then((text) => {
489
+ const body = readBodyOrNull(text)
490
+ if (body === null) throw new Error('contents 无内容')
491
+ return body
492
+ })))
493
+ const curl = notFound(real(curlText(url, 9000, { ipv4: true })))
494
+ const cdn = notFound(real(curlText(`https://cdn.jsdelivr.net/gh/${repo}@${encodeURIComponent(branch)}/${path}`, 9000, { ipv4: true })))
495
+ // 额外通道(本地落地 / npm registry 包清单):按注入顺序追加,**排在既有 4 条之后** ——
496
+ // 既有 4 条仍是主力(能力不许删),额外通道只增加可达性。
497
+ const extra = (Array.isArray(deps.extraChannels) ? deps.extraChannels : [])
498
+ .filter((ch) => ch !== null && typeof ch?.run === 'function')
499
+ // 口径与既有 4 条完全一致:确定性 404 → null(立即胜出);空内容 → 当作"没读到"
500
+ .map((ch) => ({ id: ch.id, label: ch.label, run: () => notFound(real(Promise.resolve().then(ch.run))) }))
501
+ const channels = [
502
+ { id: 'https', run: () => https },
503
+ { id: 'gh', run: () => gh },
504
+ { id: 'curl', run: () => curl },
505
+ { id: 'jsdelivr', run: () => cdn },
506
+ ...extra,
507
+ ]
508
+ const race = deps.race ?? raceFetchOutcome
509
+ return await race({ channels, timeoutMs: budgetMs, deps })
510
+ }
511
+
512
+ /** 兼容入口:调用方只关心"有没有内容"(null = 没有)。需要区分超时/404 的场景用 rawTextFetch。 */
513
+ async function rawTextWithFallback(repo, branch, path) {
514
+ const { state, body } = await rawTextFetch(repo, branch, path)
515
+ return state === FETCH_OK ? body : null
516
+ }
517
+ export { githubRequestOnce, raceFirst2xx, githubRequest, collectBody, githubViaGh, githubJson, githubText, curlJson, curlText, fetchJsonUrl, rawTextWithFallback, rawTextFetch, raceFetchOutcome, readBodyOrNull, looksLikeGitmodules, postJsonUrl, GITHUB_API, GITHUB_RAW, GITHUB_UA, API_MIRROR_PREFIXES, RAW_MIRROR_PREFIXES, FETCH_BUDGET_MS, META_BUDGET_MS, FETCH_OK, FETCH_NOT_FOUND, FETCH_UNREACHABLE, CHANNEL_LABELS, channelLabel, compactFailReason, channelFailureLines, channelFailureSummary }
@@ -52,9 +52,65 @@ function baseDirOf(baseUrl) {
52
52
  * require.resolve('pkg/package.json') 对声明了 exports 且不含 './package.json' 的包会抛错
53
53
  * (2026-09-04 教训:@openviking/dsh-memory-plugin 因此被 heal 误判模块缺失并自动禁用)——
54
54
  * fallback 直接查 node_modules 物理路径。
55
+ *
56
+ * ★ 2026-10-13(0.5.48)改错:**对 exports 子路径的假阳性**。
57
+ * 现场:装 ds-bot 时 `verifyInstalledEntry` 拿 `ds-bot/web-search` 来核实,旧代码把引用名拼成
58
+ * `ds-bot/web-search/package.json` ⇒ ds-bot 的 exports 里没有这个子路径 ⇒
59
+ * `ERR_PACKAGE_PATH_NOT_EXPORTED` ⇒ 退物理路径 `node_modules/ds-bot/web-search/package.json`
60
+ * ⇒ **也不存在**(包的清单在 `node_modules/ds-bot/package.json`,不在子目录里)⇒ 返回 null
61
+ * ⇒ 误报"缺模块会崩/会被自动禁用"。
62
+ * 但按**真实解析语义**这个引用是好的:本机实测 `require.resolve('ds-bot/web-search')` **成功**
63
+ * (`包/子路径` 是生态既有约定,官方 patch 里也有这种 entry),`import.meta.resolve` 同理,
64
+ * 模块也能真 `import()`。
65
+ * ⇒ 现在先用**真实解析语义**解析「引用名本身」(不再拿字符串拼 `/package.json`),
66
+ * 成功后从解析结果**向上找最近的 package.json** 作为该包的清单。
67
+ * ⇒ 负控不变:真不存在的子路径,三条路都失败 ⇒ 仍然返回 null(**绝不放松成"一律放行"**)。
55
68
  */
69
+ function nearestPackageJson(fromPath) {
70
+ try {
71
+ let dir = dirname(fromPath)
72
+ for (let depth = 0; depth < 40; depth += 1) {
73
+ const candidate = join(dir, 'package.json')
74
+ if (existsSync(candidate)) return candidate
75
+ const parent = dirname(dir)
76
+ if (parent === dir) break
77
+ dir = parent
78
+ }
79
+ } catch {}
80
+ return null
81
+ }
82
+
83
+ /** 该引用是不是"包 + 子路径"形态(`pkg/sub`、`@scope/pkg/sub`)。
84
+ * 判据与 packageNameOf 同源:剥掉包名后还剩东西 ⇒ 是子路径引用。 */
85
+ function isSubpathRef(refName) {
86
+ if (typeof refName !== 'string' || refName === '') return false
87
+ return refName !== packageNameOf(refName)
88
+ }
89
+
90
+ /** 用真实解析语义解析「包/子路径」引用的清单路径:先解析引用本身,再向上找最近的清单。 */
91
+ function resolveViaRealSemantics(refName, base) {
92
+ if (typeof base !== 'string' || base === '') return null
93
+ try {
94
+ const entry = createRequire(join(base, 'package.json')).resolve(refName)
95
+ return nearestPackageJson(entry)
96
+ } catch {
97
+ return null
98
+ }
99
+ }
100
+
56
101
  function resolvePackageJson(pkgName, baseDir, fallbackBase) {
57
102
  const name = packageNameOf(pkgName)
103
+ // ① 真实解析语义(认 exports 子路径)—— 优先,且**不**拼 `/package.json`
104
+ const real = resolveViaRealSemantics(pkgName, baseDir)
105
+ if (real !== null) return real
106
+ // ★ 子路径引用(`包/子路径`)到此为止:**不许**再退到"按包名找清单"的那两条 fallback。
107
+ // 为什么:那两条是按**包名**(packageNameOf 剥掉子路径后的 name)找 `node_modules/<包名>/package.json`,
108
+ // 对不存在的子路径也会命中**包根**那份清单 ⇒ 把一个坏引用判成好的
109
+ // —— 正是"校验被放松成一律放行",任务红线明令不许。
110
+ // (本机实测:`@scope/tool/nope` 在 @scope/tool 存在时会从 fallback 拿到包根清单。)
111
+ // 而真实解析语义对这一类已经给出权威答案:能解析就是好,不能解析就是坏 ⇒ 直接返回 null。
112
+ if (isSubpathRef(pkgName)) return null
113
+ // ② 既有两条(顺序与行为不变):`<name>/package.json` 解析 → node_modules 物理路径
58
114
  try {
59
115
  return createRequire(join(baseDir, 'package.json')).resolve(`${name}/package.json`)
60
116
  } catch {}
@@ -65,6 +121,8 @@ function resolvePackageJson(pkgName, baseDir, fallbackBase) {
65
121
  // 官方 @deepseek-ai/* 恰好可见、第三方插件全部解析失败 → 详情/版本/仓库全空。
66
122
  // 回退到 profile 目录(fallbackBase)再试一次。
67
123
  if (typeof fallbackBase === 'string' && fallbackBase !== '' && fallbackBase !== baseDir) {
124
+ const realFallback = resolveViaRealSemantics(pkgName, fallbackBase)
125
+ if (realFallback !== null) return realFallback
68
126
  try {
69
127
  return createRequire(join(fallbackBase, 'package.json')).resolve(`${name}/package.json`)
70
128
  } catch {}
@@ -0,0 +1,74 @@
1
+ // L0 · infra —— pnpm 供应链闸门(minimumReleaseAge)的**判据与错误翻译**(0.5.48 加法)
2
+ //
3
+ // 为什么单开一个模块:这两个函数的归属是自洽的("把 pnpm 的闸门原文翻译成用户能照着做的错误"),
4
+ // 而 infra/exec.js 贴着 tests/test-architecture-guard.mjs 的 **单文件 ≤ 600 行**硬顶(0.5.48 实测
5
+ // 723 行被棘轮拦下)⇒ 抽出来是**只搬移与新增**,exec.js 只留调用点。
6
+ //
7
+ // 现场(用户实测,装 ds-bot 时撞出来):
8
+ // pnpm 的 minimumReleaseAge 闸门拦下了太新的版本,而它在**可交互**时会弹提问等人回答;
9
+ // 桌面壳/服务进程给的是**永不关闭的 stdin 管道** ⇒ 不抛错、不超时,**永久挂住**
10
+ // (实测两次分别挂到 90s / 180s 被杀)。
11
+ //
12
+ // 闸门实现(本机 pnpm 11.7.0 `dist/pnpm.mjs`,只读反编译取证):
13
+ // L194277 const inCi = opts3.ci ?? isCI
14
+ // L194278 const canPrompt = !inCi && Boolean(process.stdin.isTTY)
15
+ // L194305 if (canPrompt) { await promptForApproval(immature) }
16
+ // L194307 else { throw failOnImmature(immature) } ← 我们要走的分支
17
+ // L194330 confirmed = await dist_default5({ message, default: false }) ← prompt 库读 stdin 到 EOF
18
+ // L63849 MINIMUM_RELEASE_AGE_VIOLATION_CODE = 'MINIMUM_RELEASE_AGE_VIOLATION'
19
+ //
20
+ // ★ 红线:本模块只做"识别 + 翻译成可操作错误",**绝不**提供任何绕过闸门的手段
21
+ // (不使用忽略锁文件校验 / 强制安装一类开关)。闸门该拦还是拦,只是把"等人回答"换成"立刻报错并给解法"。
22
+ //
23
+ // 分层:L0 · infra(不得 import domain/routes/index —— tests/test-architecture-guard.mjs 断言②)。
24
+
25
+ /** 命中 pnpm 供应链闸门的**判据**(纯函数,单测覆盖)。
26
+ * 两代真实文案都要认(照 exec.js#unknownPnpmOption 的教训:缺一条就等于"闸门命中被当成普通失败",
27
+ * 用户拿不到"怎么加白名单"的解法):
28
+ * · pnpm 11.7.0 的违规码 `MINIMUM_RELEASE_AGE_VIOLATION` / `ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION`;
29
+ * · 严格模式不可交互时 pnpm 抛 `NO_MATURE_MATCHING_VERSION`(同文件 194308 的 failOnImmature);
30
+ * · 用户在提问处拒绝时是 `MINIMUM_RELEASE_AGE_DENIED`(同文件 194340)。 */
31
+ function releaseAgeGateHit(message) {
32
+ const text = String(message ?? '')
33
+ return /MINIMUM_RELEASE_AGE_VIOLATION|MINIMUM_RELEASE_AGE_DENIED|NO_MATURE_MATCHING_VERSION|ERR_PNPM_MINIMUM_RELEASE_AGE/iu.test(text)
34
+ }
35
+
36
+ /** 从 pnpm 原始输出里抠出"被闸门拦下的 `包@版本`"清单(尽力而为,抠不到就返回 [])。
37
+ * 两种真实排版:` <name>@<version> <reason>`(failOnImmature)与裸 `<name>@<version>`。 */
38
+ function releaseAgeBlockedSpecs(message) {
39
+ const found = new Set()
40
+ for (const line of String(message ?? '').split(/\r?\n/u)) {
41
+ const m = line.match(/(@?[A-Za-z0-9][\w.@/-]*?)@(\d+\.\d+\.\d+(?:-[\w.]+)?)/u)
42
+ if (m) found.add(`${m[1]}@${m[2]}`)
43
+ }
44
+ return [...found]
45
+ }
46
+
47
+ /** 把"命中供应链闸门"翻译成**用户能照着做**的错误(纯函数,单测覆盖)。
48
+ * 需求红线:**必须快速如实失败并给出解法**。所以这里只说三件事:
49
+ * ① 哪个包被拦下 ② 命中哪条闸 ③ 怎么加白名单。 */
50
+ function releaseAgeGateError({ spec, profileDir, message } = {}) {
51
+ const blocked = releaseAgeBlockedSpecs(message)
52
+ const who = blocked.length > 0 ? blocked.join('、') : String(spec ?? '(未能从 pnpm 输出解析出包名)')
53
+ const lines = [
54
+ `pnpm 供应链闸门(minimumReleaseAge)拦下了 ${who}:该版本发布时间还没满 pnpm 配置的最短"冷静期"。`,
55
+ '这不是网络故障。闸门是安全特性,本控制台**不会绕过它**(既不忽略锁文件校验,也不强制装),',
56
+ '所以这里选择**立刻如实失败**,并把解法交给你:',
57
+ '解法(任选其一):',
58
+ ` ① 加白名单:在 profile 的 pnpm-workspace.yaml 里把该版本追加进 minimumReleaseAgeExclude,例如`,
59
+ ` minimumReleaseAgeExclude:\n - '${blocked[0] ?? '<包名>@<版本>'}'`,
60
+ ' ★ 该清单是**逐版本**匹配的(pnpm 只认 `包名@精确版本`,不支持范围)⇒ 每发一个新版本都要加一行。',
61
+ ' ② 等:过了冷静期后重试同一操作即可,无需改配置。',
62
+ ' ③ 调低/关闭该策略:不推荐,等于放弃这道供应链保护。',
63
+ ]
64
+ if (typeof profileDir === 'string' && profileDir !== '') lines.push(`目标 profile:${profileDir}`)
65
+ const error = new Error(lines.join('\n'))
66
+ error.name = 'PnpmSupplyChainGateError'
67
+ error.code = 'DSH_PNPM_RELEASE_AGE_GATE'
68
+ error.gate = 'minimumReleaseAge'
69
+ error.blocked = blocked
70
+ error.pnpmMessage = String(message ?? '')
71
+ return error
72
+ }
73
+
74
+ export { releaseAgeGateHit, releaseAgeBlockedSpecs, releaseAgeGateError }