@noob-stupid/dsh-plugin-console 0.3.67 → 0.4.1
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 +126 -31
- package/lib/index.js +60 -9687
- package/lib/server/domain/ai-run.js +479 -0
- package/lib/server/domain/ai.js +246 -0
- package/lib/server/domain/compat.js +474 -0
- package/lib/server/domain/components.js +108 -0
- package/lib/server/domain/dep-source.js +122 -0
- package/lib/server/domain/format-contract.js +265 -0
- package/lib/server/domain/format-scan.js +434 -0
- package/lib/server/domain/framework.js +393 -0
- package/lib/server/domain/install-job.js +561 -0
- package/lib/server/domain/install.js +599 -0
- package/lib/server/domain/jobs.js +28 -0
- package/lib/server/domain/market.js +409 -0
- package/lib/server/domain/patch.js +203 -0
- package/lib/server/domain/presets.js +93 -0
- package/lib/server/domain/quarantine.js +224 -0
- package/lib/server/domain/release-source.js +504 -0
- package/lib/server/domain/repoland.js +119 -0
- package/lib/server/domain/revoke.js +184 -0
- package/lib/server/domain/runtime.js +118 -0
- package/lib/server/domain/selfupdate.js +319 -0
- package/lib/server/domain/skills.js +234 -0
- package/lib/server/domain/sources.js +297 -0
- package/lib/server/domain/suite.js +220 -0
- package/lib/server/infra/exec.js +98 -0
- package/lib/server/infra/fsx.js +163 -0
- package/lib/server/infra/fw-integrity-check.js +37 -0
- package/lib/server/infra/http.js +373 -0
- package/lib/server/infra/httpd.js +51 -0
- package/lib/server/infra/mask.js +19 -0
- package/lib/server/infra/paths.js +177 -0
- package/lib/server/infra/semver.js +168 -0
- package/lib/server/routes/ai.js +172 -0
- package/lib/server/routes/components.js +254 -0
- package/lib/server/routes/framework-preflight.js +160 -0
- package/lib/server/routes/framework-upgrade.js +679 -0
- package/lib/server/routes/framework.js +544 -0
- package/lib/server/routes/github-login.js +198 -0
- package/lib/server/routes/index.js +128 -0
- package/lib/server/routes/install.js +116 -0
- package/lib/server/routes/market.js +415 -0
- package/lib/server/routes/plugins.js +562 -0
- package/lib/server/routes/skills.js +107 -0
- package/lib/server/routes/sources.js +437 -0
- package/lib/server/routes/state.js +125 -0
- package/lib/server/state.js +22 -0
- package/package.json +1 -1
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
// 由 Step 1 搬运工具从 lib/index.js 原样切出(只移动、未改逻辑)
|
|
2
|
+
// 分组见 D:\dsh\dsh-plugin-hub-plan\architecture.zh.md §三 L0 · infra
|
|
3
|
+
|
|
4
|
+
import { execFile } from 'node:child_process'
|
|
5
|
+
import { resolve } from 'node:path'
|
|
6
|
+
import { request as httpsRequest } from 'node:https'
|
|
7
|
+
import { GH_BIN_CANDIDATES, execFileAsync } from './exec.js'
|
|
8
|
+
|
|
9
|
+
/** 单次 https 请求;卡死的连接会在超时后被销毁。 */
|
|
10
|
+
|
|
11
|
+
/** 并行竞速:任一分支拿到 2xx 即胜出;非 2xx 与错误都视为失败,全部失败则拒绝。 */
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* GitHub 公开元数据请求。用 node:https 而非全局 fetch,并跳过证书校验:
|
|
15
|
+
* 国内网络环境的中间设备会注入不可信证书,全局 fetch 因此直接失败;
|
|
16
|
+
* 本插件只经此通道拉取公开的仓库/包元数据,npm 安装本身仍走 registry 的
|
|
17
|
+
* 完整 TLS 校验,所以这里放宽校验不会让安装环节失去 TLS 保护。
|
|
18
|
+
*
|
|
19
|
+
* 原逻辑保持不动:官方通道两次尝试(每次 20 秒,间隔 1.5 秒)。
|
|
20
|
+
* 在此基础上新增并行分支:镜像(api/raw 各自前缀)同时竞速,15 秒封顶,
|
|
21
|
+
* 任一分支先拿到 2xx 即胜出;全部失败时回退官方通道的最终错误。
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** 用 curl 子进程请求 JSON(绕过本机中间设备对 node:https TLS 指纹的拦截;curl 走系统网络栈)。 */
|
|
25
|
+
|
|
26
|
+
/** 用 curl 拉取文本(raw 文件用:本机 curl 直连 raw.githubusercontent.com 秒回,绕开 node:https 黑洞)。 */
|
|
27
|
+
|
|
28
|
+
/** 任意 https JSON 接口(Gitee/自定义源用):curl 优先、node:https 兜底;4xx/5xx 确定性失败直接抛出。 */
|
|
29
|
+
|
|
30
|
+
/** raw 文件读取:https 主通道(含镜像)、gh CLI、curl 直连、curl jsDelivr CDN **四通道并行竞速**。
|
|
31
|
+
* 黑洞期 https/ghproxy 全挂、gh 可能不在服务进程 PATH——jsDelivr CDN 走系统网络且国内可达,是最后兜底。
|
|
32
|
+
* 关键语义:**404 是确定性结果**(文件不存在,探测 .gitmodules/SKILL.md 等时的正常答案),
|
|
33
|
+
* 必须立即返回 null,不能等其它分支(黑洞期 https 通道 20s+ 才失败会拖死整个请求)。 */
|
|
34
|
+
|
|
35
|
+
/** 简单 https POST 表单(Gitee token 交换用),返回 {status, body}。 */
|
|
36
|
+
|
|
37
|
+
/** api.github.com 的镜像竞速前缀(主通道黑洞期可用的兜底)。 */
|
|
38
|
+
|
|
39
|
+
/** raw.githubusercontent.com 的镜像竞速前缀(与浏览器端四通道一致)。 */
|
|
40
|
+
|
|
41
|
+
function githubRequestOnce(url, { signal, accept, token, timeout = 20000 } = {}) {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
const req = httpsRequest(url, {
|
|
44
|
+
method: 'GET',
|
|
45
|
+
headers: {
|
|
46
|
+
'user-agent': GITHUB_UA,
|
|
47
|
+
accept: accept ?? 'application/vnd.github+json',
|
|
48
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
49
|
+
},
|
|
50
|
+
}, resolve)
|
|
51
|
+
req.on('error', reject)
|
|
52
|
+
req.setTimeout(timeout, () => req.destroy(new Error('GitHub 请求超时')))
|
|
53
|
+
if (signal !== undefined) {
|
|
54
|
+
if (signal.aborted) {
|
|
55
|
+
req.destroy(new Error('请求已取消'))
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
signal.addEventListener('abort', () => req.destroy(new Error('请求已取消')), { once: true })
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
function raceFirst2xx(promises) {
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
let pending = promises.length
|
|
65
|
+
let done = false
|
|
66
|
+
for (const promise of promises) {
|
|
67
|
+
promise.then(
|
|
68
|
+
(res) => {
|
|
69
|
+
if (done) return
|
|
70
|
+
const ok = res.statusCode === undefined || (res.statusCode >= 200 && res.statusCode < 300)
|
|
71
|
+
if (ok) {
|
|
72
|
+
done = true
|
|
73
|
+
resolve(res)
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
if (--pending === 0) reject(new Error(`HTTP ${res.statusCode}`))
|
|
77
|
+
},
|
|
78
|
+
() => {
|
|
79
|
+
if (done) return
|
|
80
|
+
if (--pending === 0) reject(new Error('GitHub 请求失败'))
|
|
81
|
+
},
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
async function githubRequest(url, options) {
|
|
87
|
+
const isApi = url.startsWith(GITHUB_API)
|
|
88
|
+
const isRaw = url.startsWith(GITHUB_RAW)
|
|
89
|
+
// ── 原逻辑:官方通道两次尝试 ──
|
|
90
|
+
const official = (async () => {
|
|
91
|
+
let lastError
|
|
92
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
93
|
+
if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 1500))
|
|
94
|
+
try {
|
|
95
|
+
return await githubRequestOnce(url, options)
|
|
96
|
+
} catch (error) {
|
|
97
|
+
lastError = error
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
throw lastError ?? new Error('GitHub 请求失败')
|
|
101
|
+
})()
|
|
102
|
+
// ── 新增并行分支:镜像竞速(15 秒封顶;非 2xx 视为失败)──
|
|
103
|
+
const prefixes = isApi ? API_MIRROR_PREFIXES : isRaw ? RAW_MIRROR_PREFIXES : []
|
|
104
|
+
const mirrors = prefixes.map((prefix) => (async () => {
|
|
105
|
+
const res = await githubRequestOnce(`${prefix}${url.slice(isApi ? GITHUB_API.length : GITHUB_RAW.length)}`, { ...options, timeout: 15000 })
|
|
106
|
+
if (res.statusCode !== undefined && (res.statusCode < 200 || res.statusCode >= 300)) throw new Error(`HTTP ${res.statusCode}`)
|
|
107
|
+
return res
|
|
108
|
+
})())
|
|
109
|
+
try {
|
|
110
|
+
return await raceFirst2xx([official, ...mirrors])
|
|
111
|
+
} catch {
|
|
112
|
+
// 全灭:回退官方通道的最终错误(保留原始报错语义)
|
|
113
|
+
try {
|
|
114
|
+
return await official
|
|
115
|
+
} catch (error) {
|
|
116
|
+
throw error
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function collectBody(res) {
|
|
121
|
+
return new Promise((resolve, reject) => {
|
|
122
|
+
const chunks = []
|
|
123
|
+
res.on('data', (chunk) => chunks.push(chunk))
|
|
124
|
+
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
|
125
|
+
res.on('error', reject)
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
/** gh CLI 通道。传 token 时用 GH_TOKEN/GITHUB_TOKEN 环境变量**覆盖 gh 的 keyring 凭据** ——
|
|
129
|
+
* 这是"校验某个 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
|
+
}
|
|
160
|
+
async function githubJson(url, signal, token = null) {
|
|
161
|
+
const apiPath = url.startsWith(GITHUB_API) ? url.slice(GITHUB_API.length) : null
|
|
162
|
+
// https 主通道(官方重试+镜像并行)与 gh CLI **并行竞速**:黑洞期 gh 秒回,不再等 https 超时
|
|
163
|
+
if (apiPath !== null) {
|
|
164
|
+
const https = (async () => {
|
|
165
|
+
const res = await githubRequest(url, { signal, token })
|
|
166
|
+
const status = res.statusCode ?? 0
|
|
167
|
+
if (status === 403 && Number(res.headers['x-ratelimit-remaining'] ?? '1') === 0) {
|
|
168
|
+
throw new Error('GitHub 接口限流已用尽,请稍后再试')
|
|
169
|
+
}
|
|
170
|
+
const body = await collectBody(res)
|
|
171
|
+
if (status < 200 || status >= 300) throw new Error(`GitHub 请求失败 (HTTP ${status})`)
|
|
172
|
+
return JSON.parse(body)
|
|
173
|
+
})()
|
|
174
|
+
try {
|
|
175
|
+
return await raceFirst2xx([https, githubViaGh(apiPath)])
|
|
176
|
+
} catch (error) {
|
|
177
|
+
try { return await https } catch (httpsError) { throw httpsError }
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const res = await githubRequest(url, { signal, token })
|
|
181
|
+
const status = res.statusCode ?? 0
|
|
182
|
+
if (status === 403 && Number(res.headers['x-ratelimit-remaining'] ?? '1') === 0) {
|
|
183
|
+
throw new Error('GitHub 接口限流已用尽,请稍后再试')
|
|
184
|
+
}
|
|
185
|
+
const body = await collectBody(res)
|
|
186
|
+
if (status < 200 || status >= 300) throw new Error(`GitHub 请求失败 (HTTP ${status})`)
|
|
187
|
+
return JSON.parse(body)
|
|
188
|
+
}
|
|
189
|
+
async function githubText(url, signal, token = null) {
|
|
190
|
+
const res = await githubRequest(url, { signal, accept: 'application/json', token })
|
|
191
|
+
const status = res.statusCode ?? 0
|
|
192
|
+
const body = await collectBody(res)
|
|
193
|
+
if (status < 200 || status >= 300) return null
|
|
194
|
+
return body
|
|
195
|
+
}
|
|
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
|
+
async function fetchJsonUrl(url, timeout = 15000, headers = {}) {
|
|
229
|
+
let lastError
|
|
230
|
+
const isHttps = /^https:\/\//u.test(url)
|
|
231
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
232
|
+
if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
233
|
+
try {
|
|
234
|
+
return await curlJson(url, timeout, headers)
|
|
235
|
+
} catch (error) {
|
|
236
|
+
// HTTP 状态错误(4xx/5xx)是确定性失败:直接抛出,不重试、不走 node 兜底
|
|
237
|
+
if (error instanceof Error && /^请求失败 \(HTTP \d+\)$/u.test(error.message)) throw error
|
|
238
|
+
lastError = error
|
|
239
|
+
}
|
|
240
|
+
// node:https 兜底仅对 https 有效:私网 http 镜像只走 curl 通道,
|
|
241
|
+
// 否则会抛出 "Protocol http: not supported" 掩盖 curl 的真实连接错误
|
|
242
|
+
if (!isHttps) continue
|
|
243
|
+
try {
|
|
244
|
+
const res = await githubRequest(url, { headers })
|
|
245
|
+
const status = res.statusCode ?? 0
|
|
246
|
+
const body = await collectBody(res)
|
|
247
|
+
if (status < 200 || status >= 300) throw new Error(`请求失败 (HTTP ${status})`)
|
|
248
|
+
return JSON.parse(body)
|
|
249
|
+
} catch (error) {
|
|
250
|
+
lastError = error
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
throw lastError ?? new Error('请求失败')
|
|
254
|
+
}
|
|
255
|
+
/** 「真的读到了文件内容」判定:null / 空串 / 纯空白都不算。
|
|
256
|
+
* 真实事故(2026-09-19,用户反馈装 dsh-whale-widget 报「未找到 .gitmodules」):
|
|
257
|
+
* 本机加速器/代理(Watt Toolkit 之类,劫持 raw.githubusercontent.com 到 127.0.0.1)
|
|
258
|
+
* 会对**不存在的文件**回 2xx + 空 body;旧代码只判 `body === null`,
|
|
259
|
+
* 于是空串被当成"文件存在" → 普通插件被判定成 submodule 套装置仓库 → 安装必然失败。 */
|
|
260
|
+
function readBodyOrNull(body) {
|
|
261
|
+
return typeof body === 'string' && body.trim() !== '' ? body : null
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** .gitmodules 内容校验:必须是真 gitmodules(含 [submodule "x"] 段)才算套装仓库。
|
|
265
|
+
* 只判"探测非 null"会把代理/CDN 的垃圾响应也算成套装;判定权交给内容,不交给状态码。 */
|
|
266
|
+
function looksLikeGitmodules(text) {
|
|
267
|
+
return typeof text === 'string' && /^\s*\[submodule\s+"/mu.test(text)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** raw 抓取外层预算(毫秒)。事件(2026-09-20,另一位用户:Android + proot Ubuntu 容器,域名解析出
|
|
271
|
+
* IPv6 但容器无 IPv6 路由):4 条通道最快也要 5.4s(curl 直连被 -m 6 掐死、node:https 5435ms、
|
|
272
|
+
* jsDelivr 5564ms),旧的 5000ms 预算**必然先超时** → 抓取失败被当成"文件不存在",
|
|
273
|
+
* 报成「仓库没有 package.json」。放宽到 10s:真 404 仍是毫秒级返回,只有全网慢时才多等。 */
|
|
274
|
+
const FETCH_BUDGET_MS = 10000
|
|
275
|
+
/** 仓库元数据(默认分支)探测预算:旧值 3s 在同一环境下同样必输 → branch 恒为 'main',
|
|
276
|
+
* 默认分支为 dev 的仓库会取错分支。 */
|
|
277
|
+
const META_BUDGET_MS = 8000
|
|
278
|
+
const FETCH_OK = 'ok'
|
|
279
|
+
const FETCH_NOT_FOUND = 'not-found'
|
|
280
|
+
const FETCH_UNREACHABLE = 'unreachable'
|
|
281
|
+
|
|
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
|
+
}
|
|
295
|
+
|
|
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
|
+
}
|
|
327
|
+
|
|
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
|
+
function postJsonUrl(url, formBody) {
|
|
334
|
+
return new Promise((resolve, reject) => {
|
|
335
|
+
const body = new URLSearchParams(formBody).toString()
|
|
336
|
+
const req = httpsRequest(url, {
|
|
337
|
+
method: 'POST',
|
|
338
|
+
headers: {
|
|
339
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
340
|
+
'user-agent': GITHUB_UA,
|
|
341
|
+
accept: 'application/json',
|
|
342
|
+
},
|
|
343
|
+
timeout: 20000,
|
|
344
|
+
}, (res) => {
|
|
345
|
+
const chunks = []
|
|
346
|
+
res.on('data', (chunk) => chunks.push(chunk))
|
|
347
|
+
res.on('end', () => {
|
|
348
|
+
try {
|
|
349
|
+
resolve({ status: res.statusCode ?? 0, body: JSON.parse(Buffer.concat(chunks).toString('utf8')) })
|
|
350
|
+
} catch {
|
|
351
|
+
resolve({ status: res.statusCode ?? 0, body: null })
|
|
352
|
+
}
|
|
353
|
+
})
|
|
354
|
+
})
|
|
355
|
+
req.on('error', reject)
|
|
356
|
+
req.write(body)
|
|
357
|
+
req.end()
|
|
358
|
+
})
|
|
359
|
+
}
|
|
360
|
+
const GITHUB_API = 'https://api.github.com'
|
|
361
|
+
const GITHUB_RAW = 'https://raw.githubusercontent.com'
|
|
362
|
+
const GITHUB_UA = 'dsh-plugin-console/0.1 (local dsh web instance)'
|
|
363
|
+
const API_MIRROR_PREFIXES = [
|
|
364
|
+
'https://ghproxy.net/https://api.github.com',
|
|
365
|
+
'https://ghfast.top/https://api.github.com',
|
|
366
|
+
]
|
|
367
|
+
// 2026-09-19 移除 mirror.ghproxy.com:该域名早已失效(实测连接超时;失效域名被停放页接管时
|
|
368
|
+
// 会回 2xx HTML —— 正是"普通插件被误判成 submodule 套装"的假阳性来源之一)。
|
|
369
|
+
const RAW_MIRROR_PREFIXES = [
|
|
370
|
+
'https://ghproxy.net/https://raw.githubusercontent.com',
|
|
371
|
+
'https://ghfast.top/https://raw.githubusercontent.com',
|
|
372
|
+
]
|
|
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 }
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// 由 Step 1 搬运工具从 lib/index.js 原样切出(只移动、未改逻辑)
|
|
2
|
+
// 分组见 D:\dsh\dsh-plugin-hub-plan\architecture.zh.md §三 L0 · infra
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
/** 写请求跨站防护:浏览器同源策略不阻止跨站发送,必须校验 Origin / Sec-Fetch-Site。
|
|
6
|
+
* 非浏览器客户端(无 Origin/Sec-Fetch-Site 头)仍允许,避免 curl/脚本/测试被误伤。 */
|
|
7
|
+
|
|
8
|
+
function isLoopback(address) {
|
|
9
|
+
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'
|
|
10
|
+
}
|
|
11
|
+
function isAllowedWriteOrigin(req, port) {
|
|
12
|
+
const origin = typeof req.headers?.origin === 'string' ? req.headers.origin : null
|
|
13
|
+
const site = typeof req.headers?.['sec-fetch-site'] === 'string' ? req.headers['sec-fetch-site'] : null
|
|
14
|
+
if (origin !== null && origin !== '' && !ALLOWED_LOCAL_ORIGINS(port).has(origin)) return false
|
|
15
|
+
if (site !== null && site !== 'same-origin' && site !== 'none') return false
|
|
16
|
+
return true
|
|
17
|
+
}
|
|
18
|
+
function ALLOWED_LOCAL_ORIGINS(port) {
|
|
19
|
+
return new Set([
|
|
20
|
+
`http://127.0.0.1:${port}`,
|
|
21
|
+
`http://localhost:${port}`,
|
|
22
|
+
`http://[::1]:${port}`,
|
|
23
|
+
])
|
|
24
|
+
}
|
|
25
|
+
function sendJson(res, status, body) {
|
|
26
|
+
const payload = JSON.stringify(body)
|
|
27
|
+
res.writeHead(status, {
|
|
28
|
+
'content-type': 'application/json; charset=utf-8',
|
|
29
|
+
'cache-control': 'no-store',
|
|
30
|
+
})
|
|
31
|
+
res.end(payload)
|
|
32
|
+
}
|
|
33
|
+
function sendError(res, status, message, details) {
|
|
34
|
+
sendJson(res, status, { ok: false, error: message, ...(details === undefined ? {} : { details }) })
|
|
35
|
+
}
|
|
36
|
+
async function readBody(req, maxBytes = 64 * 1024) {
|
|
37
|
+
const chunks = []
|
|
38
|
+
let total = 0
|
|
39
|
+
for await (const chunk of req) {
|
|
40
|
+
total += chunk.length
|
|
41
|
+
if (total > maxBytes) throw new Error('请求体过大')
|
|
42
|
+
chunks.push(chunk)
|
|
43
|
+
}
|
|
44
|
+
if (chunks.length === 0) return {}
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
47
|
+
} catch {
|
|
48
|
+
throw new Error('请求体不是合法 JSON')
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export { isLoopback, isAllowedWriteOrigin, ALLOWED_LOCAL_ORIGINS, sendJson, sendError, readBody }
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// 由 Step 1 搬运工具从 lib/index.js 原样切出(只移动、未改逻辑)
|
|
2
|
+
// 分组见 D:\dsh\dsh-plugin-hub-plan\architecture.zh.md §三 L0 · infra
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
function maskUrl(url) {
|
|
6
|
+
try {
|
|
7
|
+
const u = new URL(url)
|
|
8
|
+
if (u.password !== '') u.password = '***'
|
|
9
|
+
if (u.username !== '') u.username = '***'
|
|
10
|
+
if (u.search !== '') u.search = ''
|
|
11
|
+
return u.toString()
|
|
12
|
+
} catch {
|
|
13
|
+
return String(url).split(/[?#]/u)[0]
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function escapeRegExp(value) {
|
|
17
|
+
return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')
|
|
18
|
+
}
|
|
19
|
+
export { maskUrl, escapeRegExp }
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// L0 · infra(paths.js)—— 分层 Step 从 lib/index.js 搬出,只搬移未改逻辑
|
|
2
|
+
// 分组见 D:\dsh\dsh-plugin-hub-plan\architecture.zh.md §三
|
|
3
|
+
|
|
4
|
+
import { readFileSync, existsSync, statSync } from 'node:fs'
|
|
5
|
+
import { dirname, join, resolve } from 'node:path'
|
|
6
|
+
import { homedir } from 'node:os'
|
|
7
|
+
import { fileURLToPath } from 'node:url'
|
|
8
|
+
import { createRequire } from 'node:module'
|
|
9
|
+
|
|
10
|
+
/** DSH 数据根目录(与 dsh-github-login 工具共享令牌文件位置)。 */
|
|
11
|
+
function dshHome() {
|
|
12
|
+
return process.env.DSH_HOME?.trim() || join(homedir(), '.dsh')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** 默认 profile 用户补丁层路径(无 include 条目可推导时的兜底)。 */
|
|
16
|
+
function defaultPatchPath() {
|
|
17
|
+
const home = dshHome()
|
|
18
|
+
return join(home, 'profiles', 'web', 'cordis.patch.yml')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 从 loader 树推导 profile 的 cordis.patch.yml 绝对路径。 */
|
|
22
|
+
function findPatchPath(ctx) {
|
|
23
|
+
for (const entry of ctx.loader.entries()) {
|
|
24
|
+
const cfg = entry.options?.config
|
|
25
|
+
if (entry.options?.name !== 'cordis:include' || cfg == null || typeof cfg.path !== 'string') continue
|
|
26
|
+
if (!cfg.path.includes('cordis.yml')) continue
|
|
27
|
+
const configPath = fileURLToPath(new URL(cfg.path))
|
|
28
|
+
return configPath.replace(/cordis\.yml$/u, 'cordis.patch.yml')
|
|
29
|
+
}
|
|
30
|
+
return defaultPatchPath()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** profile 根目录(resolvePackageJson 的 fallbackBase 用;失败返回 null)。 */
|
|
34
|
+
function profileDirOf(ctx) {
|
|
35
|
+
try {
|
|
36
|
+
return dirname(findPatchPath(ctx))
|
|
37
|
+
} catch {
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 把 file:// URL(如 ctx.baseUrl)转成目录路径(resolve 用)。 */
|
|
43
|
+
function baseDirOf(baseUrl) {
|
|
44
|
+
try {
|
|
45
|
+
if (typeof baseUrl === 'string' && baseUrl.startsWith('file:')) return dirname(fileURLToPath(baseUrl))
|
|
46
|
+
} catch {}
|
|
47
|
+
return typeof baseUrl === 'string' && baseUrl !== '' ? baseUrl : '.'
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 解析包 package.json 的绝对路径(exports 限制包 fallback):
|
|
52
|
+
* require.resolve('pkg/package.json') 对声明了 exports 且不含 './package.json' 的包会抛错
|
|
53
|
+
* (2026-09-04 教训:@openviking/dsh-memory-plugin 因此被 heal 误判模块缺失并自动禁用)——
|
|
54
|
+
* fallback 直接查 node_modules 物理路径。
|
|
55
|
+
*/
|
|
56
|
+
function resolvePackageJson(pkgName, baseDir, fallbackBase) {
|
|
57
|
+
const name = packageNameOf(pkgName)
|
|
58
|
+
try {
|
|
59
|
+
return createRequire(join(baseDir, 'package.json')).resolve(`${name}/package.json`)
|
|
60
|
+
} catch {}
|
|
61
|
+
const parts = String(name).split('/')
|
|
62
|
+
const candidate = join(baseDir, 'node_modules', ...parts, 'package.json')
|
|
63
|
+
if (existsSync(candidate)) return candidate
|
|
64
|
+
// issue #15:npm 全局安装 dsh 时 ctx.baseUrl 落在框架安装树(而非 profile node_modules),
|
|
65
|
+
// 官方 @deepseek-ai/* 恰好可见、第三方插件全部解析失败 → 详情/版本/仓库全空。
|
|
66
|
+
// 回退到 profile 目录(fallbackBase)再试一次。
|
|
67
|
+
if (typeof fallbackBase === 'string' && fallbackBase !== '' && fallbackBase !== baseDir) {
|
|
68
|
+
try {
|
|
69
|
+
return createRequire(join(fallbackBase, 'package.json')).resolve(`${name}/package.json`)
|
|
70
|
+
} catch {}
|
|
71
|
+
const candidate2 = join(fallbackBase, 'node_modules', ...parts, 'package.json')
|
|
72
|
+
if (existsSync(candidate2)) return candidate2
|
|
73
|
+
}
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function entryPkgMeta(moduleName, baseUrl, profileDir) {
|
|
78
|
+
if (typeof moduleName !== 'string' || moduleName.startsWith('cordis:')) return null
|
|
79
|
+
const hit = pkgMetaCache.get(moduleName)
|
|
80
|
+
if (hit !== undefined && Date.now() - hit.at < PKG_META_TTL) return hit
|
|
81
|
+
const meta = { at: Date.now(), installDate: null, version: null, repository: null }
|
|
82
|
+
try {
|
|
83
|
+
const pkgPath = resolvePackageJson(moduleName, baseDirOf(baseUrl), profileDir ?? null)
|
|
84
|
+
if (pkgPath === null) throw new Error('not found')
|
|
85
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
|
86
|
+
// 安装日期:优先 curl 通道写入的 .dsh-installed-at 标记(真实安装时刻);
|
|
87
|
+
// 否则用 package.json 的 mtime,但 npm tarball 会把文件时间固定为 1985-10-26
|
|
88
|
+
// (可复现构建),此时回退到目录创建时间(birthtime,解压时刻,Windows 上可靠)。
|
|
89
|
+
try {
|
|
90
|
+
const marker = join(dirname(pkgPath), '.dsh-installed-at')
|
|
91
|
+
if (existsSync(marker)) {
|
|
92
|
+
const ts = Number(readFileSync(marker, 'utf8').trim())
|
|
93
|
+
if (Number.isFinite(ts) && ts > 0) {
|
|
94
|
+
const d = new Date(ts)
|
|
95
|
+
meta.installDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (meta.installDate === null) {
|
|
99
|
+
const st = statSync(pkgPath)
|
|
100
|
+
const yearOk = (t) => t >= 946684800000 // 2000-01-01:早于它都是打包器固定时间戳等伪日期
|
|
101
|
+
const candidates = [st.mtimeMs, st.birthtimeMs ?? NaN].filter((t) => Number.isFinite(t) && yearOk(t))
|
|
102
|
+
if (candidates.length > 0) {
|
|
103
|
+
const d = new Date(Math.min(...candidates))
|
|
104
|
+
meta.installDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
} catch {}
|
|
108
|
+
meta.version = typeof pkg.version === 'string' ? pkg.version : null
|
|
109
|
+
const rawRepo = typeof pkg.repository === 'string' ? pkg.repository : (pkg.repository?.url ?? null)
|
|
110
|
+
if (typeof rawRepo === 'string') meta.repository = rawRepo.replace(/^git\+/u, '').replace(/\.git$/u, '').toLowerCase()
|
|
111
|
+
} catch {}
|
|
112
|
+
pkgMetaCache.set(moduleName, meta)
|
|
113
|
+
return meta
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 包名归一:支持子路径导出形式(@linxin666/dsh-web-all/settings → @linxin666/dsh-web-all)。 */
|
|
117
|
+
function packageNameOf(moduleName) {
|
|
118
|
+
if (typeof moduleName !== 'string') return moduleName
|
|
119
|
+
const m = moduleName.match(/^(@[^/]+\/[^/]+)(?:\/.*)?$/u)
|
|
120
|
+
return m ? m[1] : moduleName
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** include 前缀(加载器条目 id 形如 include:schedule,补丁行 id 为 schedule)。 */
|
|
124
|
+
function includePrefix(ctx) {
|
|
125
|
+
for (const entry of ctx.loader.entries()) {
|
|
126
|
+
if (entry.options?.name === 'cordis:include') return `${entry.id}:`
|
|
127
|
+
}
|
|
128
|
+
return ''
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 接受加载器条目 id 或行 id,返回补丁行 id。 */
|
|
132
|
+
function rowIdOf(ctx, entryId) {
|
|
133
|
+
const prefix = includePrefix(ctx)
|
|
134
|
+
if (prefix.length > 0 && entryId.startsWith(prefix)) return entryId.slice(prefix.length)
|
|
135
|
+
return entryId
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 已加载插件的包元信息缓存(安装日期/版本/仓库),60 秒 TTL。 */
|
|
139
|
+
const pkgMetaCache = new Map()
|
|
140
|
+
|
|
141
|
+
const PKG_META_TTL = 60000
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 本插件包根目录(含 package.json 与 lib/)。
|
|
145
|
+
* ★ 为什么必须有它:函数搬进 lib/server/** 之后,`dirname(import.meta.url)` 指的是
|
|
146
|
+
* `lib/server/infra` 而不是包根 —— 直接拿它拼 '..' 会得到错误路径。
|
|
147
|
+
* 升级/回滚/重启脚本生成器里的 pluginDir 也走这里,错了会让生成的脚本找不到插件目录。
|
|
148
|
+
* **全仓库只有这一处允许用 import.meta.url 计算包根。**
|
|
149
|
+
*/
|
|
150
|
+
export function pluginRoot() {
|
|
151
|
+
return resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 软件源配置文件(registry 列表,安装链按主→备依次尝试)。 */
|
|
155
|
+
const sourcesFile = () => join(dshHome(), 'plugin-console-sources.json')
|
|
156
|
+
|
|
157
|
+
/** 敏感凭据单独落盘(避免与可分享配置混存):自定义搜索源 headers + Gitee secret/token。 */
|
|
158
|
+
const sourcesSecretsFile = () => join(dshHome(), 'plugin-console-sources.secrets.json')
|
|
159
|
+
|
|
160
|
+
/** 市场索引落盘缓存(网络不可达时降级展示上次成功结果,避免内网/断网下市场空白)。 */
|
|
161
|
+
const marketIndexCacheFile = () => join(dshHome(), 'plugin-console-market-index-cache.json')
|
|
162
|
+
|
|
163
|
+
/** 组件注册表(服务型组件:启动/停止/状态控制按钮的数据源)。 */
|
|
164
|
+
const componentsFile = () => join(dshHome(), 'plugin-console', 'components.json')
|
|
165
|
+
|
|
166
|
+
/** AI 赋能任务持久化文件(DSH 重启不丢计划/结果;running 任务重启后标记中断)。 */
|
|
167
|
+
const aiJobsFile = () => join(dshHome(), 'plugin-console', 'ai-jobs.json')
|
|
168
|
+
|
|
169
|
+
const repoLandConfFile = () => join(dshHome(), 'plugin-console', 'repo-land.json')
|
|
170
|
+
|
|
171
|
+
/** 批量识别搜索结果类型(官方 bundle / 聚合仓库 / 普通项目 / 技能仓库)。
|
|
172
|
+
* 全部条目并发 + raw 主站与镜像双通道竞速,单条 4 秒封顶。
|
|
173
|
+
* 聚合仓库(根包 private+workspaces)额外检查子包是否有 dsh.bundle 清单:
|
|
174
|
+
* 任一子包可 `dsh plugin add` 直装 → aggregateInstallable = true(★ 筛选会包含它)。 */
|
|
175
|
+
const ENRICH_CACHE_FILE = () => join(dshHome(), 'plugin-console', 'enrich-cache.json')
|
|
176
|
+
|
|
177
|
+
export { dshHome, defaultPatchPath, findPatchPath, profileDirOf, baseDirOf, resolvePackageJson, entryPkgMeta, packageNameOf, includePrefix, rowIdOf, pkgMetaCache, PKG_META_TTL, sourcesFile, sourcesSecretsFile, marketIndexCacheFile, componentsFile, aiJobsFile, repoLandConfFile, ENRICH_CACHE_FILE }
|