@noob-stupid/dsh-plugin-console 0.5.46 → 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.
- package/lib/client.js +93 -9
- package/lib/server/domain/install-job.js +26 -12
- package/lib/server/domain/install-verify.js +59 -1
- package/lib/server/domain/install.js +7 -3
- package/lib/server/domain/landed-source.js +79 -0
- package/lib/server/domain/manifest-probe.js +68 -0
- package/lib/server/domain/market.js +64 -20
- package/lib/server/domain/presence-check.js +121 -0
- package/lib/server/domain/probe-outcome.js +58 -0
- package/lib/server/domain/registry-manifest.js +102 -0
- package/lib/server/domain/repo-package-match.js +135 -0
- package/lib/server/domain/repo-url.js +23 -0
- package/lib/server/domain/subdir-detect.js +36 -7
- package/lib/server/domain/subdir-install.js +368 -0
- package/lib/server/domain/suite.js +4 -1
- package/lib/server/infra/egress.js +208 -0
- package/lib/server/infra/http.js +263 -119
- package/lib/server/routes/components.js +85 -8
- package/lib/server/routes/install.js +27 -0
- package/lib/server/routes/state.js +7 -1
- package/package.json +1 -1
package/lib/server/infra/http.js
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
// 由 Step 1 搬运工具从 lib/index.js 原样切出(只移动、未改逻辑)
|
|
2
2
|
// 分层分组:L0 · infra(边界由 tests/test-architecture-guard.mjs 断言)
|
|
3
3
|
|
|
4
|
-
|
|
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 {
|
|
7
|
-
import {
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 }
|
|
@@ -157,24 +157,78 @@ function openFolderBin() {
|
|
|
157
157
|
return process.platform === 'darwin' ? 'open' : 'xdg-open'
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
-
// ── 唯一进程创建点(可注入 + 默认安全;2026-10-13
|
|
160
|
+
// ── 唯一进程创建点(可注入 + 默认安全;2026-10-13 三次事故记录)──────────────────────────
|
|
161
161
|
// 事故一(0.5.45):上一批测试为了覆盖"成功路径"直接调真路由 ⇒ 真的起了 explorer.exe,而夹具路径
|
|
162
162
|
// 当时不存在 ⇒ 用户桌面弹出 Windows「位置不可用」对话框。
|
|
163
163
|
// 事故二(0.5.46 施工中):又弹了一次 —— 根因是**默认不安全**:那时"默认真 spawn,只靠外部设
|
|
164
164
|
// DSH_OPEN_FOLDER_NO_SPAWN=1 才拦得住",任何人/任何 CI 不带那个变量跑测试就会重新踩到。
|
|
165
|
-
//
|
|
166
|
-
//
|
|
165
|
+
// 事故三(2026-10-13,用户第三次截图):**又弹了一次**,指到夹具
|
|
166
|
+
// `tests\.testdir\open-folder-home\repos\probe-org\landed-re…`("位置不可用"= 启动时目标已不存在)。
|
|
167
|
+
// 根因不是"少设了一个变量",而是**两道门都只保护"不传 allowRealSpawn 的调用方"**:
|
|
168
|
+
// · opt-in 门:`routeRepoOpen` **永远**传 allowRealSpawn:true(它代表用户点了按钮)⇒ 测试里
|
|
169
|
+
// 调一次真路由 = 一次真 opt-in;
|
|
170
|
+
// · 注入门:测试里一句 `__setOpenFolderSpawn(null)`(原语义"还原生产实现")就把桩摘掉 ⇒
|
|
171
|
+
// 两个条件同时成立,真 explorer.exe 就在测试进程里起来了。
|
|
172
|
+
// 现在把语义收成**测试进程不可自解**的两层(不需要任何环境变量、任何调用方配合):
|
|
173
|
+
// ⓪ **测试进程判定**(isTestProcess):只看自己进程的入口脚本是不是本仓 `tests/**`(run-all 的子进程也算)
|
|
174
|
+
// ⇒ 是 ⇒ 模块**加载即装**测试桩(injectedSpawn = tripwire),且 `__setOpenFolderSpawn(null)`
|
|
175
|
+
// 还原到的仍然只能是这个测试桩 —— 安全网拆不掉;
|
|
167
176
|
// ① 注入缝:单测 `__setOpenFolderSpawn(stub)` 换成桩(一次进程都不建);
|
|
168
177
|
// ② 保险丝:`DSH_OPEN_FOLDER_NO_SPAWN=1` 时**绝不**走到 spawn —— 如实返回结构化拒绝
|
|
169
178
|
// (不是静默跳过:调用方照旧把这条原因报给用户);
|
|
170
179
|
// ③ **默认安全(opt-in)**:回落到真 `defaultSpawn` 需要 `allowRealSpawn:true` 或
|
|
171
180
|
// `DSH_OPEN_FOLDER_ALLOW_REAL_SPAWN=1`;两者都没有 ⇒ 结构化拒绝。生产的 routeRepoOpen 是
|
|
172
181
|
// 唯一的 opt-in 调用方 —— 于是"测试漏了注入"的默认结局是**红/被拒**,而不是弹用户一个窗口;
|
|
173
|
-
// ④
|
|
174
|
-
//
|
|
182
|
+
// ④ **运行期断言**:测试进程里"即将真 spawn"(无注入桩可选)⇒ **当场抛错**(见 openFolderVerified 顶部)
|
|
183
|
+
// —— 这一条专治"测试调真路由 ⇒ 真 opt-in"那条路(事故三);
|
|
184
|
+
// ⑤ 计数:真实 spawn 调用次数可读,测试断言恒为 0(自检那一次除外);`__openFolderSelfCheck()` 是
|
|
185
|
+
// 计数器的正控(真的起一次**不弹任何窗口**的进程,证明计数不是死的)。
|
|
175
186
|
let injectedSpawn = null
|
|
176
187
|
let realSpawnCalls = 0
|
|
177
188
|
|
|
189
|
+
/** **测试进程判定**(2026-10-13 第三次事故的根修;刻意**不依赖任何环境变量**)。
|
|
190
|
+
*
|
|
191
|
+
* 为什么必须有它:opt-in 那道门只保护"没传 allowRealSpawn 的调用方",而 **routeRepoOpen 永远传
|
|
192
|
+
* allowRealSpawn:true**(它代表"用户点了按钮")。于是"测试里调了一次真路由"= 一次真 opt-in
|
|
193
|
+
* ⇒ explorer.exe 照样弹窗;再叠上测试里一句 `__setOpenFolderSpawn(null)`("还原生产实现"),
|
|
194
|
+
* 安全网就被彻底拆掉。前两次事故(0.5.45 / 0.5.46 施工中)就是这么来的,第三次(2026-10-13
|
|
195
|
+
* 用户截图 `tests\.testdir\open-folder-home\repos\probe-org\landed-re…` 位置不可用)仍是这条路。
|
|
196
|
+
*
|
|
197
|
+
* 判据只看**自己进程的入口脚本**:`<repo>/tests/test-*.mjs`(含 run-all.mjs 的子进程)⇒ 测试进程。
|
|
198
|
+
* 生产入口(dsh / bin.js / 桌面端运行时)不匹配 ⇒ 行为与生产完全一致。绝不猜别的进程、绝不看环境变量。 */
|
|
199
|
+
function isTestProcess() {
|
|
200
|
+
// 子进程同样受保护:测试进程在加载本模块时会把标记写进环境(见文件下方"模块加载即装"),
|
|
201
|
+
// 于是 `node -e` / 临时脚本 / 任何由测试拉起的 node 子进程都被判成测试进程 —— 这一条**不是**
|
|
202
|
+
// "靠用户设环境变量才安全":标记是代码自己写、自己继承的,用户什么都不用做。
|
|
203
|
+
try {
|
|
204
|
+
if (process.env.DSH_TEST_PROCESS === '1') return true
|
|
205
|
+
} catch {}
|
|
206
|
+
try {
|
|
207
|
+
const entry = String(process.argv[1] ?? '').replace(/\\/gu, '/')
|
|
208
|
+
if (entry === '') return false
|
|
209
|
+
return /\/tests\/(?:test-[^/]*\.mjs|run-all\.mjs|probe-[^/]*\.cjs)$/u.test(entry)
|
|
210
|
+
} catch {
|
|
211
|
+
return false
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** 测试进程里的默认桩:**在创建进程之前**抛错 —— 这就是"即将真 spawn ⇒ 当场红"的运行期断言
|
|
216
|
+
* (不是放行、也不是结构化拒绝:漏注入的结局必须是红)。 */
|
|
217
|
+
function testProcessTripwire() {
|
|
218
|
+
throw new Error('测试红线:测试进程里绝不允许真的启动文件管理器(explorer.exe)—— 请在测试里注入 spawnFn / __setOpenFolderSpawn(stub),或用 rc.deps.openFolderLauncher')
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ★ 模块加载即装(无需任何 env、无需任何调用方配合):测试进程里 `injectedSpawn` 永远是一个桩,
|
|
222
|
+
// 连生产的 opt-in 调用方(routeRepoOpen)也绕不过它 —— 测试**永远**不 opt-in。
|
|
223
|
+
// 同时把"我是测试进程"写进环境,让子进程(node -e / 临时脚本 / 任何 node 子进程)继承同一道保护:
|
|
224
|
+
// 光看 argv[1] 只能认出"入口就是测试文件"的那一层,而测试拉起的子进程完全可能调真路由(事故三的形状)。
|
|
225
|
+
if (isTestProcess()) {
|
|
226
|
+
injectedSpawn = testProcessTripwire
|
|
227
|
+
try {
|
|
228
|
+
process.env.DSH_TEST_PROCESS = '1'
|
|
229
|
+
} catch {}
|
|
230
|
+
}
|
|
231
|
+
|
|
178
232
|
/** 真 spawn 的**显式 opt-in**(默认拒绝;见 openFolderVerified 第 ③ 条)。 */
|
|
179
233
|
function realSpawnOptIn(allowRealSpawn) {
|
|
180
234
|
return allowRealSpawn === true || process.env.DSH_OPEN_FOLDER_ALLOW_REAL_SPAWN === '1'
|
|
@@ -185,9 +239,12 @@ function defaultSpawn(bin, args, options) {
|
|
|
185
239
|
return spawn(bin, args, options)
|
|
186
240
|
}
|
|
187
241
|
|
|
188
|
-
/** 单测注入启动桩:传函数 = 打桩(**不创建任何进程**);传 null =
|
|
242
|
+
/** 单测注入启动桩:传函数 = 打桩(**不创建任何进程**);传 null = 还原生产实现 ——
|
|
243
|
+
* **但在测试进程里"还原"到的只能还是测试桩**(见 isTestProcess 顶部注释:一句
|
|
244
|
+
* `__setOpenFolderSpawn(null)` 就把真实 explorer 重新武装起来,正是第三次弹窗的根因)。
|
|
245
|
+
* 返回 `injectedSpawn !== null`(测试进程里恒为 true:安全网不可被测试自己拆掉)。 */
|
|
189
246
|
function __setOpenFolderSpawn(fn) {
|
|
190
|
-
injectedSpawn = typeof fn === 'function' ? fn : null
|
|
247
|
+
injectedSpawn = typeof fn === 'function' ? fn : (isTestProcess() ? testProcessTripwire : null)
|
|
191
248
|
return injectedSpawn !== null
|
|
192
249
|
}
|
|
193
250
|
|
|
@@ -196,6 +253,19 @@ function __openFolderRealSpawnCalls() {
|
|
|
196
253
|
return realSpawnCalls
|
|
197
254
|
}
|
|
198
255
|
|
|
256
|
+
/** 安全网现状(只读;测试用来断言"测试进程里桩摘不掉"这件事本身)。 */
|
|
257
|
+
function __openFolderSafetyState() {
|
|
258
|
+
return { testProcess: isTestProcess(), injected: injectedSpawn !== null, realSpawnCalls }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** **仅测试用**:故意把注入桩摘掉,用来证明"即将真 spawn ⇒ 当场抛错给红"这条运行期断言是活的
|
|
262
|
+
* (不是一句没人走的空话)。正常路径下测试进程**永远**摘不掉桩 —— 本函数只服务变异验证/负控,
|
|
263
|
+
* 生产代码不得调用(它在测试进程里只会让下一次真实启动请求当场抛错)。 */
|
|
264
|
+
function __forceUnsafeSpawnForTest() {
|
|
265
|
+
injectedSpawn = null
|
|
266
|
+
return injectedSpawn === null
|
|
267
|
+
}
|
|
268
|
+
|
|
199
269
|
/** 计数器正控(桩自校验):用**生产同一个** defaultSpawn 起一次 node -e ""(无窗口、无副作用),
|
|
200
270
|
* 返回它是否被计数到。返回 false ⇒ 计数桩坏了,测试必须据此响亮失败,而不是拿 0 当"没启动过"。 */
|
|
201
271
|
async function __openFolderSelfCheck() {
|
|
@@ -237,6 +307,13 @@ function openFolderPathIssue(target) {
|
|
|
237
307
|
* 判据(本仓口径):宁可红也不报假绿;静默一律不许。 */
|
|
238
308
|
function openFolderVerified({ target, bin = null, spawnFn = null, launchWaitMs = 1200, allowRealSpawn = false } = {}) {
|
|
239
309
|
const started = Date.now()
|
|
310
|
+
// ★ 运行期断言(2026-10-13 第三次事故的根修):**测试进程里"即将真 spawn"一律当场抛错**。
|
|
311
|
+
// 判定发生在任何进程创建之前、且**先于** opt-in:生产的 routeRepoOpen 会传 allowRealSpawn:true,
|
|
312
|
+
// 测试里调真路由 = 真 opt-in ⇒ 只有这一道断言能拦住它("测试永远不 opt-in"就是这么落实的)。
|
|
313
|
+
const injectedNow = typeof spawnFn === 'function' ? spawnFn : injectedSpawn
|
|
314
|
+
if (injectedNow === null && isTestProcess()) {
|
|
315
|
+
throw new Error('测试红线:测试进程里即将真的启动文件管理器(explorer.exe)—— 已当场拒绝并给红(请注入 spawnFn / __setOpenFolderSpawn(stub) / rc.deps.openFolderLauncher)')
|
|
316
|
+
}
|
|
240
317
|
return new Promise((resolve) => {
|
|
241
318
|
// ② 保险丝:测试红线。**先于任何进程创建**判定,且如实说明被拦下的原因。
|
|
242
319
|
if (process.env.DSH_OPEN_FOLDER_NO_SPAWN === '1') {
|
|
@@ -433,4 +510,4 @@ async function routeComponentStatus(req, res, rc) {
|
|
|
433
510
|
return
|
|
434
511
|
}
|
|
435
512
|
|
|
436
|
-
export { routeComponents, routeRepoClone, routeRepoList, routeRepoLandConfig, routeRepoRemove, routeRepoOpen, routeComponentAutostart, routeComponentStart, routeComponentStop, routeComponentStatus, openFolderBin, openFolderVerified, __setOpenFolderSpawn, __openFolderRealSpawnCalls, __openFolderSelfCheck }
|
|
513
|
+
export { routeComponents, routeRepoClone, routeRepoList, routeRepoLandConfig, routeRepoRemove, routeRepoOpen, routeComponentAutostart, routeComponentStart, routeComponentStop, routeComponentStatus, openFolderBin, openFolderVerified, isTestProcess, __setOpenFolderSpawn, __openFolderRealSpawnCalls, __openFolderSelfCheck, __openFolderSafetyState, __forceUnsafeSpawnForTest }
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
// L2 · routes —— 安装(POST /install · POST /install-status)
|
|
2
2
|
// 分层 Step 8b:从 lib/index.js 的 handle() 原样搬出(只搬移未改逻辑;缩进保持原样)
|
|
3
3
|
|
|
4
|
+
import { dirname } from 'node:path'
|
|
4
5
|
import { installJobView } from '../domain/install.js'
|
|
5
6
|
import { runInstallJob } from '../domain/install-job.js'
|
|
7
|
+
import { failedJobEvidence } from '../domain/install-verify.js'
|
|
6
8
|
import { fetchRepoPackageEx, githubRepoInfo, subpackageCandidates } from '../domain/market.js'
|
|
7
9
|
import { runSkillInstallJob } from '../domain/skills.js'
|
|
10
|
+
import { normalizeSubdir } from '../domain/subdir-install.js'
|
|
8
11
|
import { probeGitmodules, resolveInstallKind, runSuiteInstallJob } from '../domain/suite.js'
|
|
9
12
|
import { sendError, sendJson } from '../infra/httpd.js'
|
|
13
|
+
import { findPatchPath } from '../infra/paths.js'
|
|
10
14
|
import { installJobs, nextInstallJobSeq } from '../state.js'
|
|
11
15
|
|
|
12
16
|
async function routeInstall(req, res, rc) {
|
|
@@ -38,6 +42,20 @@ async function routeInstall(req, res, rc) {
|
|
|
38
42
|
return
|
|
39
43
|
}
|
|
40
44
|
const source = body.source === 'gitee' ? body.source : 'github'
|
|
45
|
+
// 子目录插件的一键安装(2026-10-13 加法,第二轮):面板在「插件在 <子目录>/ 下」那张卡片上
|
|
46
|
+
// 直接带 `subdir` 过来。**格式先在这里拦住**(与 packageName 同一套"来源校验一视同仁"的写法),
|
|
47
|
+
// 判据的权威仍是 domain/subdir-install.js#normalizeSubdir —— 两边同一把尺子。
|
|
48
|
+
const rawSubdir = typeof body.subdir === 'string' ? body.subdir.trim() : ''
|
|
49
|
+
const subdir = rawSubdir === '' ? null : normalizeSubdir(rawSubdir)
|
|
50
|
+
if (rawSubdir !== '' && subdir === null) {
|
|
51
|
+
sendError(res, 400, 'subdir 不合法:只接受仓库里第 1~2 层的相对目录(如 ds-bot 或 packages/foo),不接受绝对路径、盘符、反斜杠与 . / ..')
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
if (subdir !== null && repo === '') {
|
|
55
|
+
sendError(res, 400, '从子目录安装必须提供仓库(owner/name)')
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
const branch = typeof body.branch === 'string' && body.branch.trim() !== '' ? body.branch.trim() : null
|
|
41
59
|
const npmNamePattern = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/u
|
|
42
60
|
if (givenName !== '' && (!npmNamePattern.test(givenName) || givenName.length > 214)) {
|
|
43
61
|
sendError(res, 400, 'packageName 不是合法的 npm 包名')
|
|
@@ -77,6 +95,9 @@ async function routeInstall(req, res, rc) {
|
|
|
77
95
|
lastError: null,
|
|
78
96
|
update: body.update === true,
|
|
79
97
|
kind,
|
|
98
|
+
// 子目录插件安装(纯加法:不带 subdir 时这两项就是 null,作业形状与改动前逐字相同)
|
|
99
|
+
subdir,
|
|
100
|
+
branch,
|
|
80
101
|
}
|
|
81
102
|
if (suiteNote !== null) job.suiteNote = suiteNote
|
|
82
103
|
installJobs.set(job.id, job)
|
|
@@ -117,6 +138,12 @@ async function routeInstallStatus(req, res, rc) {
|
|
|
117
138
|
sendJson(res, 200, {
|
|
118
139
|
ok: true,
|
|
119
140
|
...installJobView(job),
|
|
141
|
+
// ★「其实已经装上了」核实(0.5.47 改错,用户被误导过):**报失败/给重试之前**先查三处
|
|
142
|
+
// (依赖清单 / dsh.profile.bundles / node_modules)——已装 ⇒ 面板显示「已安装 vX(无需重装)」,
|
|
143
|
+
// 历史失败降级为次要信息。作业自己的那份(例如子目录安装早退时算的)在没有失败时照旧下发。
|
|
144
|
+
installedEvidence: job.status === 'failed'
|
|
145
|
+
? (failedJobEvidence(dirname(findPatchPath(ctx)), job) ?? job.installedEvidence ?? null)
|
|
146
|
+
: (job.installedEvidence ?? null),
|
|
120
147
|
// 结构化建议动作(2026-09-27 加法,向后兼容):老客户端忽略该字段;
|
|
121
148
|
// 新客户端在安装结果卡片里渲染「执行 / 复制」小框(POST /plugin-console/run-suggested)。
|
|
122
149
|
// 放这里而不是 install.js#installJobView:那个模块已贴着 600 行架构棘轮(见 test-architecture-guard.mjs)。
|