@noob-stupid/dsh-plugin-console 0.3.67 → 0.4.0
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 +431 -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 +154 -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,504 @@
|
|
|
1
|
+
// L1 · domain —— release-source.js(GitHub Release 源解析,issue #3)
|
|
2
|
+
//
|
|
3
|
+
// 背景(用户 issue,附逐条实测):安装 yjh051108/dsh-routing-suite(根包 @dsh-external/dsh-super-injector,
|
|
4
|
+
// private: true)时 npm registry 404 → 直接掉进 AI 兜底(约 4 分钟)。真正能装上的产物在**另一个仓库**
|
|
5
|
+
// yjh051108/dsh-super-injector 的 release 里(asset 形如 dsh-external-dsh-super-injector-0.3.5.tgz)。
|
|
6
|
+
// 旧 githubReleaseInstall() 只用 job.repo 找仓库、只看 releases/latest、且对同一 release 下多个 asset
|
|
7
|
+
// 不按包名匹配 —— 于是"产物在别的仓库"这一整类包永远装不上。
|
|
8
|
+
//
|
|
9
|
+
// 本模块把这条链路的**选源与挑选**独立出来(纯逻辑 + 只读网络探测;下载/落盘仍由 install.js 做):
|
|
10
|
+
// ① 候选仓库集合按优先级:显式 repo → 已装包 package.json.repository → npm registry 元数据 → GitHub 搜索包名
|
|
11
|
+
// ② 遍历候选仓库的最近 ≤10 条 release,把每条 release 的 assets **全部**列出,按包名匹配挑选
|
|
12
|
+
// ③ 选不中时给出"尝试过的仓库 + asset 清单"的清单式错误(排查用),
|
|
13
|
+
// 并且**任何单个来源探测失败都只是"这个来源没有"**,绝不让探测异常冒泡成未捕获异常。
|
|
14
|
+
//
|
|
15
|
+
// 为什么不抛:这条通道是安装兜底链的最后一环,探测失败(限流/未登录/仓库不存在/没有 release)是常态,
|
|
16
|
+
// 该做的是换下一个候选来源并如实汇报,而不是把整条作业打断。
|
|
17
|
+
|
|
18
|
+
import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs'
|
|
19
|
+
import { basename, dirname, join } from 'node:path'
|
|
20
|
+
import { tmpdir } from 'node:os'
|
|
21
|
+
import { GITHUB_API, fetchJsonUrl, githubJson } from '../infra/http.js'
|
|
22
|
+
import { GH_BIN_CANDIDATES, execFileAsync } from '../infra/exec.js'
|
|
23
|
+
import { copyTree } from '../infra/fsx.js'
|
|
24
|
+
import { baseDirOf, entryPkgMeta, pluginRoot } from '../infra/paths.js'
|
|
25
|
+
import { githubRepoInfo, parseRepoFromUrl } from './market.js'
|
|
26
|
+
import { orderedRegistries, readSources } from './sources.js'
|
|
27
|
+
import { compareSemverText, parseSemverText } from '../infra/semver.js'
|
|
28
|
+
|
|
29
|
+
/** 每个候选仓库最多看多少条 release:翻页对收益极小(产物一般在最近几条),却可能把作业拖到超时。 */
|
|
30
|
+
const RELEASE_LIST_LIMIT = 10
|
|
31
|
+
/** 候选仓库上限:每个仓库至少 1 次 releases 接口调用,候选太多会把时间预算吃光。 */
|
|
32
|
+
const MAX_RELEASE_CANDIDATE_REPOS = 5
|
|
33
|
+
/** 真正去扫 release 的候选仓库上限(issue #3 的硬预算之一):候选列表可以长,但只对排在前面的少数几个
|
|
34
|
+
* 花"列 release"的钱——后面的候选要么是搜索出来的同名无关仓库,要么命中率极低。 */
|
|
35
|
+
const RELEASE_SCAN_MAX_REPOS = 3
|
|
36
|
+
/** **整条反查链路的总时间预算**(issue #3 明确要求):反查候选仓库 + 逐仓库列 release + 挑 asset 全算在内。
|
|
37
|
+
* 到点即放弃、把控制权交回安装主链的下一条通道——这条通道是兜底链的最后一环,
|
|
38
|
+
* 任何情况下都不允许它把一次安装在"没有产物的候选"上拖住(现场:私有聚合根展开出 3 个候选,
|
|
39
|
+
* 每个候选都要重打一遍 registry + 搜索接口才算"没有")。 */
|
|
40
|
+
const RELEASE_CHANNEL_BUDGET_MS = 20000
|
|
41
|
+
/** GitHub 搜索命中的、仓库名与包名逐字对上的候选最多取几个(同名仓库可能有多个,按星数排)。 */
|
|
42
|
+
const RELEASE_SEARCH_REPOS = 2
|
|
43
|
+
/** 搜索接口单页条数。 */
|
|
44
|
+
const RELEASE_SEARCH_LIMIT = 5
|
|
45
|
+
/** 元数据探测超时(registry packument):404 是确定性结论,超时即换下一个来源。 */
|
|
46
|
+
const RELEASE_META_TIMEOUT_MS = 12000
|
|
47
|
+
/** release 产物体积上限:asset 可以是任何东西(安装包/镜像/视频),DSH 插件本体都在几 MB 内;
|
|
48
|
+
* 超限即失败换下一个候选,避免把大文件拉进临时目录(下载本身仍受 curl -m 60 的时间上限约束)。 */
|
|
49
|
+
const MAX_RELEASE_ASSET_BYTES = 128 * 1024 * 1024
|
|
50
|
+
/** release 产物下载的镜像前缀(与 raw/api 用的是同一批加速器)。
|
|
51
|
+
* ★ 为什么必须有(2026-09-22 实测):本机 curl 直连 `https://github.com/<owner>/<repo>/releases/download/…`
|
|
52
|
+
* 返回 exit 35(SSL connect error;node https 也报 unable to verify the first certificate),
|
|
53
|
+
* 而**同一个 URL 经 ghproxy.net 是 200 / 358KB** —— 只试直连会让"反查命中 + asset 挑对"之后
|
|
54
|
+
* 仍然装不上(issue #3 的现场正是这样被拖进 AI 兜底 4 分钟)。顺序 = 信任顺序:直连优先,镜像兜底。 */
|
|
55
|
+
const RELEASE_DOWNLOAD_MIRROR_PREFIXES = [
|
|
56
|
+
'https://ghproxy.net/',
|
|
57
|
+
'https://ghfast.top/',
|
|
58
|
+
]
|
|
59
|
+
/** 包名→仓库的反查结果缓存(10 分钟):同一个作业里多个候选包、同一包多次重试都不必重打搜索接口
|
|
60
|
+
* (GitHub 搜索接口限额 30 次/分,是最容易被自己打满的一条)。 */
|
|
61
|
+
const releaseRepoCache = new Map()
|
|
62
|
+
const RELEASE_REPO_CACHE_TTL = 10 * 60 * 1000
|
|
63
|
+
|
|
64
|
+
/** 清空反查缓存(单测用:避免用例间互相污染)。 */
|
|
65
|
+
function clearReleaseSourceCache() {
|
|
66
|
+
releaseRepoCache.clear()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 这条链路的总预算文案(失败时如实告诉用户"为什么后面没试")。 */
|
|
70
|
+
function releaseBudgetText() {
|
|
71
|
+
return `release 反查总预算 ${Math.round(RELEASE_CHANNEL_BUDGET_MS / 1000)} 秒`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 距 deadline 还剩多少毫秒;deadline 非有限值(Infinity)= 不限制。 */
|
|
75
|
+
function remainingMs(deadline) {
|
|
76
|
+
return Number.isFinite(deadline) ? Math.max(0, deadline - Date.now()) : Number.POSITIVE_INFINITY
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 把"剩余预算"变成可中断的 AbortSignal(githubJson 支持 signal,超时会真的中断 https 请求与镜像竞速)。
|
|
80
|
+
* 拿不到就返回 undefined —— 此时仍由调用方的时间判断 + githubJson 自带超时兜底。 */
|
|
81
|
+
function budgetSignal(ms) {
|
|
82
|
+
if (!Number.isFinite(ms) || ms <= 0) return undefined
|
|
83
|
+
if (typeof AbortSignal === 'undefined' || typeof AbortSignal.timeout !== 'function') return undefined
|
|
84
|
+
return AbortSignal.timeout(Math.max(1, Math.ceil(ms)))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** asset 文件名归一:大小写不敏感 + 下划线/短横线互换(issue #3 明确要求容忍这两种变体)。 */
|
|
88
|
+
function normalizeAssetName(name) {
|
|
89
|
+
return String(name ?? '').toLowerCase().replace(/_/gu, '-')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 包名 → 可接受的 asset 文件名主干(去版本号后应与其中之一相等)。
|
|
93
|
+
* `@scope/pkg` → `scope-pkg`(精确形式)与 `pkg`(裸名形式);非 scoped 包只有一种形式。
|
|
94
|
+
* 顺序即优先级:**精确形式优先**(少一次"同名不同 scope"的误判机会)。 */
|
|
95
|
+
function releaseAssetStems(packageName) {
|
|
96
|
+
const raw = normalizeAssetName(String(packageName ?? '').trim())
|
|
97
|
+
if (raw === '') return []
|
|
98
|
+
const m = raw.match(/^@([^/]+)\/(.+)$/u)
|
|
99
|
+
if (m) return [`${m[1]}-${m[2]}`, m[2]]
|
|
100
|
+
return [raw]
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** asset 名 → 包名匹配信息(纯函数,单测覆盖)。返回 null = 不是这个包的产物。
|
|
104
|
+
* 容忍:`scope-pkg-<version>.tgz`、`scope-pkg.tgz`、`pkg-<version>.tgz`、`pkg.tgz`(大小写/下划线变体)。
|
|
105
|
+
* 只认 tarball(.tgz / .tar.gz):zip/exe/源码包没有安装路径,当它们不存在比"装了再说"安全。 */
|
|
106
|
+
function assetMatchInfo(assetName, packageName) {
|
|
107
|
+
const file = normalizeAssetName(assetName)
|
|
108
|
+
const base = file.replace(/\.tar\.gz$/u, '').replace(/\.tgz$/u, '')
|
|
109
|
+
if (base === file || base === '') return null
|
|
110
|
+
const stems = releaseAssetStems(packageName)
|
|
111
|
+
for (let i = 0; i < stems.length; i += 1) {
|
|
112
|
+
const stem = stems[i]
|
|
113
|
+
if (base === stem) return { file, exact: i === 0, version: null }
|
|
114
|
+
if (!base.startsWith(`${stem}-`)) continue
|
|
115
|
+
// 版本号必须紧跟主干:`scope-pkg-other-1.0.0` 这种"别的包名以本包名开头"不能算命中
|
|
116
|
+
const rest = base.slice(stem.length + 1)
|
|
117
|
+
if (!/^v?\d/u.test(rest)) continue
|
|
118
|
+
return { file, exact: i === 0, version: parseSemverText(rest) === null ? null : rest.replace(/^v/u, '') }
|
|
119
|
+
}
|
|
120
|
+
return null
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 候选产物的排序(纯函数,单测覆盖):① 包名精确匹配优先 ② 版本更高优先(带版本 > 不带版本)
|
|
124
|
+
* ③ release 更新优先 ④ 文件名兜底(保证结果确定,不受输入顺序影响)。 */
|
|
125
|
+
function compareAssetMatch(a, b) {
|
|
126
|
+
if (a.exact !== b.exact) return a.exact ? -1 : 1
|
|
127
|
+
const av = a.version === null ? null : parseSemverText(a.version)
|
|
128
|
+
const bv = b.version === null ? null : parseSemverText(b.version)
|
|
129
|
+
if (av !== null && bv !== null) {
|
|
130
|
+
const d = compareSemverText(bv, av)
|
|
131
|
+
if (d !== 0) return d
|
|
132
|
+
} else if (av !== null || bv !== null) {
|
|
133
|
+
return av !== null ? -1 : 1
|
|
134
|
+
}
|
|
135
|
+
const byTime = (b.publishedAt ?? 0) - (a.publishedAt ?? 0)
|
|
136
|
+
if (byTime !== 0) return byTime
|
|
137
|
+
return String(a.file).localeCompare(String(b.file))
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 一条 release 的 assets → 与包名匹配的候选(已排序)。 */
|
|
141
|
+
function rankReleaseAssets(assets, packageName, publishedAt = 0) {
|
|
142
|
+
const out = []
|
|
143
|
+
for (const asset of (Array.isArray(assets) ? assets : [])) {
|
|
144
|
+
const name = typeof asset?.name === 'string' ? asset.name : ''
|
|
145
|
+
const info = assetMatchInfo(name, packageName)
|
|
146
|
+
if (info === null) continue
|
|
147
|
+
out.push({ asset, ...info, publishedAt })
|
|
148
|
+
}
|
|
149
|
+
return out.sort(compareAssetMatch)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** 遍历的分组(每个候选仓库 + 它的 release 列表)→ 选定结果(纯函数,单测覆盖)。
|
|
153
|
+
* 命中:{ ok:true, repo, release, asset, file, version, tried }
|
|
154
|
+
* 未命中:{ ok:false, tried, message } ← message 是清单式排查文案,**不是抛出的异常**
|
|
155
|
+
* 语义:候选仓库按优先级**依次**尝试,第一个找到匹配 asset 的仓库胜出(不再跨仓库比版本——
|
|
156
|
+
* 否则"最可疑的仓库"会被"更晚反查到的仓库"顶掉,来源就不可预期了)。 */
|
|
157
|
+
function planReleaseInstall(packageName, groups) {
|
|
158
|
+
const tried = []
|
|
159
|
+
for (const group of (Array.isArray(groups) ? groups : [])) {
|
|
160
|
+
const repo = group?.repo ?? null
|
|
161
|
+
if (group?.error) {
|
|
162
|
+
tried.push({ repo, error: String(group.error), releases: [] })
|
|
163
|
+
continue
|
|
164
|
+
}
|
|
165
|
+
const matches = []
|
|
166
|
+
const rows = []
|
|
167
|
+
for (const release of (Array.isArray(group?.releases) ? group.releases : [])) {
|
|
168
|
+
const at = Date.parse(release?.published_at ?? release?.created_at ?? '') || 0
|
|
169
|
+
const ranked = rankReleaseAssets(release?.assets, packageName, at)
|
|
170
|
+
rows.push({
|
|
171
|
+
tag: typeof release?.tag_name === 'string' ? release.tag_name : null,
|
|
172
|
+
assets: (Array.isArray(release?.assets) ? release.assets : []).map((a) => (typeof a?.name === 'string' ? a.name : '')),
|
|
173
|
+
matched: ranked.map((r) => r.file),
|
|
174
|
+
})
|
|
175
|
+
for (const r of ranked) matches.push({ ...r, release })
|
|
176
|
+
}
|
|
177
|
+
if (matches.length > 0) {
|
|
178
|
+
matches.sort(compareAssetMatch)
|
|
179
|
+
const best = matches[0]
|
|
180
|
+
const tag = typeof best.release?.tag_name === 'string' ? best.release.tag_name.replace(/^v/iu, '') : null
|
|
181
|
+
return { ok: true, repo, release: best.release, asset: best.asset, file: best.file, version: best.version ?? tag, tried }
|
|
182
|
+
}
|
|
183
|
+
tried.push({ repo, releases: rows })
|
|
184
|
+
}
|
|
185
|
+
return { ok: false, tried, message: releaseChannelFailureText(packageName, tried) }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** 失败时的清单式文案(纯函数,单测覆盖):把"尝试过哪些仓库、每个仓库有哪些 release/asset"**如实**摊开——
|
|
189
|
+
* 排查这类问题全靠这份清单(旧文案只有一句"仓库没有 latest release",用户根本不知道还试过谁)。 */
|
|
190
|
+
function releaseChannelFailureText(packageName, tried) {
|
|
191
|
+
const name = String(packageName ?? '(未知名)')
|
|
192
|
+
const head = `GitHub release 通道:没能找到与包名 ${name} 匹配的发布产物`
|
|
193
|
+
if (!Array.isArray(tried) || tried.length === 0) {
|
|
194
|
+
return `${head}(也没能反查到候选仓库:显式仓库为空、本机没有已安装的该包、npm registry 元数据与 GitHub 搜索都没能给出仓库)。`
|
|
195
|
+
}
|
|
196
|
+
const lines = tried.map((t) => {
|
|
197
|
+
const repo = t?.repo ?? '(未知仓库)'
|
|
198
|
+
if (t?.error) return `· ${repo}:读取 releases 失败(${t.error})`
|
|
199
|
+
const releases = Array.isArray(t?.releases) ? t.releases : []
|
|
200
|
+
if (releases.length === 0) return `· ${repo}:没有任何 release`
|
|
201
|
+
const rows = releases.map((r) => `${r.tag ?? '(无 tag)'} → ${r.assets.length > 0 ? r.assets.join('、') : '(无 asset)'}`)
|
|
202
|
+
return `· ${repo}:${rows.join(';')}`
|
|
203
|
+
})
|
|
204
|
+
return `${head}。已尝试的仓库与资产清单:\n${lines.join('\n')}`
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 仓库标识归一(`owner/name`、完整 URL、`git+https://…`):非法输入返回 null 而不是抛。
|
|
208
|
+
* 复用 market.js 的 githubRepoInfo(仓库名格式的唯一权威),它抛错就说明用户给的不是仓库。 */
|
|
209
|
+
function normalizeRepoSpec(value) {
|
|
210
|
+
const raw = String(value ?? '').trim().replace(/^git\+/u, '')
|
|
211
|
+
if (raw === '') return null
|
|
212
|
+
try {
|
|
213
|
+
return githubRepoInfo(raw)
|
|
214
|
+
} catch {
|
|
215
|
+
return null
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** npm 包名 → packument URL 段(与 curlManualInstall 同一口径:scope 的 `/` 编成 %2f)。 */
|
|
220
|
+
function encodeNpmName(packageName) {
|
|
221
|
+
const name = String(packageName)
|
|
222
|
+
return name.startsWith('@')
|
|
223
|
+
? `@${encodeURIComponent(name.slice(1).split('/')[0])}%2f${encodeURIComponent(name.split('/').slice(1).join('/'))}`
|
|
224
|
+
: encodeURIComponent(name)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** npm registry 元数据反查仓库:多源依次尝试(镜像/官方),命中 repository 即返回。
|
|
228
|
+
* 注:包根本没发布到 registry(issue 里的 @dsh-external/* 正是如此,npmjs/npmmirror 双 404)时这里就是空手,
|
|
229
|
+
* 必须靠后面的 GitHub 搜索兜底——所以这一段的失败绝不能当成"没有可用产物"。
|
|
230
|
+
* 预算:每个 registry 的单次超时是 min(RELEASE_META_TIMEOUT_MS, 剩余预算),预算耗尽即整体放弃。 */
|
|
231
|
+
async function repoFromNpmMetadata(packageName, registries, fetchJson, deadline = Number.POSITIVE_INFINITY) {
|
|
232
|
+
for (const reg of (registries ?? []).slice(0, 3)) {
|
|
233
|
+
const left = remainingMs(deadline)
|
|
234
|
+
if (left <= 0) break
|
|
235
|
+
try {
|
|
236
|
+
const meta = await fetchJson(`${reg}/${encodeNpmName(packageName)}`, Math.max(1000, Math.min(RELEASE_META_TIMEOUT_MS, left)))
|
|
237
|
+
const repo = parseRepoFromUrl(meta?.repository?.url ?? meta?.repository ?? '')
|
|
238
|
+
if (repo !== null) return { repo, from: `npm 元数据(${reg})` }
|
|
239
|
+
} catch {}
|
|
240
|
+
}
|
|
241
|
+
return null
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** 从包名推导仓库:GitHub 仓库搜索,按"仓库名与包名逐字对上 → 星数"挑。
|
|
245
|
+
* 实测(2026-09-22,issue #3 验收):`@scope/name` 的 `scope name` 查询**常常 0 条**——scope 不在仓库检索面里
|
|
246
|
+
* (dsh-external dsh-super-injector → 0 条,而 dsh-super-injector → 5 条且首位就是正确仓库)。
|
|
247
|
+
* 所以先按 scope+name 试一次,没有再退回裸包名;未登录/限流/网络失败一律跳过,不抛。
|
|
248
|
+
* 预算:每次搜索都带剩余预算的 AbortSignal,到点即停(搜索接口限额 30 次/分,也不该多打)。 */
|
|
249
|
+
async function reposFromGithubSearch(packageName, token, ghJson, deadline = Number.POSITIVE_INFINITY) {
|
|
250
|
+
const raw = normalizeAssetName(String(packageName ?? '').trim())
|
|
251
|
+
if (raw === '') return []
|
|
252
|
+
const m = raw.match(/^@([^/]+)\/(.+)$/u)
|
|
253
|
+
const base = m ? m[2] : raw
|
|
254
|
+
const queries = m ? [`${m[1]} ${base}`, base] : [base]
|
|
255
|
+
for (const q of queries) {
|
|
256
|
+
const left = remainingMs(deadline)
|
|
257
|
+
if (left <= 0) break
|
|
258
|
+
let items = []
|
|
259
|
+
try {
|
|
260
|
+
const data = await ghJson(`${GITHUB_API}/search/repositories?q=${encodeURIComponent(q)}&per_page=${RELEASE_SEARCH_LIMIT}`, budgetSignal(left), token)
|
|
261
|
+
items = Array.isArray(data?.items) ? data.items : []
|
|
262
|
+
} catch {
|
|
263
|
+
continue
|
|
264
|
+
}
|
|
265
|
+
const hitName = (it) => normalizeAssetName(String(it?.name ?? ''))
|
|
266
|
+
const named = items.filter((it) => hitName(it) === base)
|
|
267
|
+
const picked = (named.length > 0 ? named : items.filter((it) => hitName(it).includes(base)))
|
|
268
|
+
.slice()
|
|
269
|
+
.sort((a, b) => (b?.stargazers_count ?? 0) - (a?.stargazers_count ?? 0))
|
|
270
|
+
.slice(0, named.length > 0 ? RELEASE_SEARCH_REPOS : 1)
|
|
271
|
+
const out = picked
|
|
272
|
+
.map((it) => ({ repo: normalizeRepoSpec(it?.full_name), from: `GitHub 搜索「${q}」` }))
|
|
273
|
+
.filter((c) => c.repo !== null)
|
|
274
|
+
if (out.length > 0) return out
|
|
275
|
+
}
|
|
276
|
+
return []
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** 网络侧的两条反查(带 10 分钟缓存)。deadline 是整条 release 链路的总预算终点。 */
|
|
280
|
+
async function networkCandidateRepos(packageName, { registries, token, fetchers, deadline = Number.POSITIVE_INFINITY }) {
|
|
281
|
+
const key = String(packageName ?? '')
|
|
282
|
+
const hit = releaseRepoCache.get(key)
|
|
283
|
+
if (hit !== undefined && Date.now() - hit.at < RELEASE_REPO_CACHE_TTL) return hit.repos
|
|
284
|
+
const repos = []
|
|
285
|
+
const npm = await repoFromNpmMetadata(packageName, registries, fetchers.fetchJson, deadline)
|
|
286
|
+
if (npm !== null) repos.push(npm)
|
|
287
|
+
if (repos.length < MAX_RELEASE_CANDIDATE_REPOS && remainingMs(deadline) > 0) {
|
|
288
|
+
repos.push(...await reposFromGithubSearch(packageName, token, fetchers.githubJson, deadline))
|
|
289
|
+
}
|
|
290
|
+
releaseRepoCache.set(key, { at: Date.now(), repos })
|
|
291
|
+
return repos
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** 候选仓库集合(按优先级,去重,上限 MAX_RELEASE_CANDIDATE_REPOS):
|
|
295
|
+
* ① 显式给的 repo(现有行为,优先级最高——调用方说哪个仓库就是哪个)
|
|
296
|
+
* ② name 已安装/可解析时读其 package.json 的 repository(复用 entryPkgMeta,本地零网络成本)
|
|
297
|
+
* ③ npm registry 元数据的 repository
|
|
298
|
+
* ④ 从包名推导:GitHub 搜索 scope/name(失败即跳过)
|
|
299
|
+
* 每条都带 from(来源),最终写进用户可见的"来源"说明里。 */
|
|
300
|
+
async function resolveReleaseCandidateRepos(options = {}) {
|
|
301
|
+
const {
|
|
302
|
+
repo = null, packageName = null, baseUrl = null, profileDir = null,
|
|
303
|
+
registries = null, token = null, fetchers = {},
|
|
304
|
+
deadline = Date.now() + RELEASE_CHANNEL_BUDGET_MS,
|
|
305
|
+
} = options
|
|
306
|
+
const fetch = { fetchJson: fetchers.fetchJson ?? fetchJsonUrl, githubJson: fetchers.githubJson ?? githubJson }
|
|
307
|
+
const out = []
|
|
308
|
+
const push = (candidate) => {
|
|
309
|
+
if (candidate?.repo == null) return
|
|
310
|
+
if (out.some((c) => c.repo.toLowerCase() === candidate.repo.toLowerCase())) return
|
|
311
|
+
if (out.length >= MAX_RELEASE_CANDIDATE_REPOS) return
|
|
312
|
+
out.push(candidate)
|
|
313
|
+
}
|
|
314
|
+
push({ repo: normalizeRepoSpec(repo), from: '调用方显式指定' })
|
|
315
|
+
if (typeof packageName === 'string' && packageName !== '') {
|
|
316
|
+
try {
|
|
317
|
+
const meta = entryPkgMeta(packageName, baseUrl ?? 'file:///', profileDir ?? null)
|
|
318
|
+
push({ repo: parseRepoFromUrl(meta?.repository ?? ''), from: '本机已装包的 package.json.repository' })
|
|
319
|
+
} catch {}
|
|
320
|
+
for (const c of await networkCandidateRepos(packageName, {
|
|
321
|
+
registries: Array.isArray(registries) && registries.length > 0 ? registries : orderedRegistries(readSources()),
|
|
322
|
+
token,
|
|
323
|
+
fetchers: fetch,
|
|
324
|
+
deadline,
|
|
325
|
+
})) push(c)
|
|
326
|
+
}
|
|
327
|
+
return out
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** 取一个仓库的最近若干条 release(**一次**接口调用拿到 release 及其 assets,不翻页)。
|
|
331
|
+
* 失败不抛:返回 { releases: [], error },由清单式文案如实汇报"这个仓库没读成"。
|
|
332
|
+
* budgetMs 是这条链路剩余的预算:≤0 时直接返回"超出预算"(不发起请求),正数则作为本次调用的硬上限。 */
|
|
333
|
+
async function fetchReleaseList(repo, token = null, ghJson = githubJson, limit = RELEASE_LIST_LIMIT, budgetMs = RELEASE_CHANNEL_BUDGET_MS) {
|
|
334
|
+
const left = Number.isFinite(budgetMs) ? Math.min(budgetMs, RELEASE_CHANNEL_BUDGET_MS) : RELEASE_CHANNEL_BUDGET_MS
|
|
335
|
+
if (!(left > 0)) return { releases: [], error: `${releaseBudgetText()}已用尽,未再请求该仓库` }
|
|
336
|
+
try {
|
|
337
|
+
const data = await ghJson(`${GITHUB_API}/repos/${repo}/releases?per_page=${limit}`, budgetSignal(left), token)
|
|
338
|
+
const releases = (Array.isArray(data) ? data : []).filter((r) => r !== null && typeof r === 'object')
|
|
339
|
+
// 新→旧:接口默认按创建时间倒序,这里显式排序,保证"逐条尝试"的顺序与"版本更高优先"的输入确定
|
|
340
|
+
releases.sort((a, b) => (Date.parse(b.published_at ?? b.created_at ?? '') || 0) - (Date.parse(a.published_at ?? a.created_at ?? '') || 0))
|
|
341
|
+
return { releases, error: null }
|
|
342
|
+
} catch (error) {
|
|
343
|
+
return { releases: [], error: error instanceof Error ? error.message : String(error) }
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** 选源主入口:反查候选仓库 → 逐仓库取 release 列表 → 第一个匹配上的仓库胜出。
|
|
348
|
+
* 返回 planReleaseInstall 的结果,外加:
|
|
349
|
+
* · repos/froms:反查到的候选仓库(如实写进用户可见来源/排查文案)
|
|
350
|
+
* · sourceFallback:全部候选都没有匹配 asset 时,仍可用的"最新 tag 源码 tarball"(老行为兜底)
|
|
351
|
+
* · expired:本次是否因为总预算用尽而提前收工(失败文案要把这件事说清楚)
|
|
352
|
+
* 任何探测失败都不抛——未命中时由调用方决定是抛清单式错误还是走兜底。
|
|
353
|
+
* ★ 硬预算(issue #3):整个过程被 RELEASE_CHANNEL_BUDGET_MS 封顶,且只对前 RELEASE_SCAN_MAX_REPOS 个
|
|
354
|
+
* 候选仓库"列 release";到点即返回未命中,绝不阻塞安装主链。 */
|
|
355
|
+
async function selectReleaseInstall(options = {}) {
|
|
356
|
+
const {
|
|
357
|
+
repo = null, packageName = null, baseUrl = null, profileDir = null,
|
|
358
|
+
registries = null, token = null, fetchers = {},
|
|
359
|
+
budgetMs = RELEASE_CHANNEL_BUDGET_MS,
|
|
360
|
+
} = options
|
|
361
|
+
const deadline = Date.now() + (Number.isFinite(budgetMs) && budgetMs > 0 ? budgetMs : RELEASE_CHANNEL_BUDGET_MS)
|
|
362
|
+
const ghJson = fetchers.githubJson ?? githubJson
|
|
363
|
+
const candidates = await resolveReleaseCandidateRepos({ repo, packageName, baseUrl, profileDir, registries, token, fetchers, deadline })
|
|
364
|
+
const groups = []
|
|
365
|
+
let expired = false
|
|
366
|
+
for (const candidate of candidates.slice(0, RELEASE_SCAN_MAX_REPOS)) {
|
|
367
|
+
const left = remainingMs(deadline)
|
|
368
|
+
if (left <= 0) {
|
|
369
|
+
expired = true
|
|
370
|
+
groups.push({ repo: candidate.repo, from: candidate.from, releases: [], error: `${releaseBudgetText()}已用尽,未再扫描该仓库` })
|
|
371
|
+
continue
|
|
372
|
+
}
|
|
373
|
+
// 逐仓库串行:拿到第一个有匹配 asset 的仓库就停(后面的候选连 releases 都不必读)
|
|
374
|
+
const fetched = await fetchReleaseList(candidate.repo, token, ghJson, RELEASE_LIST_LIMIT, left)
|
|
375
|
+
if (fetched.error !== null && remainingMs(deadline) <= 0) expired = true
|
|
376
|
+
groups.push({ repo: candidate.repo, from: candidate.from, error: fetched.error, releases: fetched.releases })
|
|
377
|
+
const plan = planReleaseInstall(packageName, groups)
|
|
378
|
+
if (plan.ok) return { ...plan, repos: candidates.map((c) => ({ ...c })), groups, expired }
|
|
379
|
+
}
|
|
380
|
+
if (candidates.length > RELEASE_SCAN_MAX_REPOS) {
|
|
381
|
+
groups.push({
|
|
382
|
+
repo: `(另有 ${candidates.length - RELEASE_SCAN_MAX_REPOS} 个候选仓库)`, from: '预算裁剪',
|
|
383
|
+
releases: [], error: `候选仓库扫描上限为 ${RELEASE_SCAN_MAX_REPOS} 个(预算裁剪),未再扫描`,
|
|
384
|
+
})
|
|
385
|
+
}
|
|
386
|
+
const plan = planReleaseInstall(packageName, groups)
|
|
387
|
+
if (expired) plan.message = `${plan.message}\n(注:${releaseBudgetText()}已用尽,剩余候选仓库与资产未再扫描——这是时间预算,不代表它们没有产物)`
|
|
388
|
+
return { ...plan, repos: candidates.map((c) => ({ ...c })), groups, sourceFallback: sourceTarballFallback(groups), expired }
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** 老行为兜底(**保留**,不是新增能力):候选仓库的 release 里一个匹配 asset 都没有时,仍按
|
|
392
|
+
* "第一条 release 的 tag + codeload 源码 tarball"装——很多插件仓库就是只打 tag 不发 asset 的,
|
|
393
|
+
* 删掉这条路会让它们从"能装"变成"装不上"。盒子验证照旧把关包名,装错包名一律被拒绝。 */
|
|
394
|
+
function sourceTarballFallback(groups) {
|
|
395
|
+
for (const group of (Array.isArray(groups) ? groups : [])) {
|
|
396
|
+
const first = (Array.isArray(group?.releases) ? group.releases : [])[0]
|
|
397
|
+
const tag = typeof first?.tag_name === 'string' ? first.tag_name : null
|
|
398
|
+
if (tag !== null) return { repo: group.repo, tag }
|
|
399
|
+
}
|
|
400
|
+
return null
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** release 产物的下载候选地址(纯函数,单测覆盖):直连优先 + 镜像兜底。
|
|
404
|
+
* 空 url 返回空数组(调用方按"没有下载地址"报错,不去打无意义的请求)。 */
|
|
405
|
+
function releaseDownloadUrls(url) {
|
|
406
|
+
const raw = String(url ?? '').trim()
|
|
407
|
+
if (raw === '') return []
|
|
408
|
+
return [raw, ...RELEASE_DOWNLOAD_MIRROR_PREFIXES.map((prefix) => `${prefix}${raw}`)]
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** curl 下载 release 产物到 dest(带体积上下限与超时):asset 是仓库里的任意文件,
|
|
412
|
+
* 太小=没下成(黑洞期常见 0 字节/错误页),太大=不该拉进临时目录。runner 可注入(单测)。
|
|
413
|
+
* 下载地址按 releaseDownloadUrls 顺序依次尝试,**总时长被 timeoutMs 封顶**(每次尝试只拿到剩余预算,
|
|
414
|
+
* 所以镜像再多也不会把兜底通道拖长);第一个下成并通过体积校验的即胜出。 */
|
|
415
|
+
async function downloadReleaseArtifact(url, dest, options = {}) {
|
|
416
|
+
const { bin = null, maxBytes = MAX_RELEASE_ASSET_BYTES, timeoutMs = 70000, runner = execFileAsync, mirrors = true } = options
|
|
417
|
+
const curlBin = bin ?? (process.platform === 'win32' ? 'curl.exe' : 'curl')
|
|
418
|
+
const urls = mirrors ? releaseDownloadUrls(url) : [String(url ?? '')].filter((u) => u !== '')
|
|
419
|
+
if (urls.length === 0) throw new Error('GitHub 通道:没有下载地址')
|
|
420
|
+
const deadline = Date.now() + timeoutMs
|
|
421
|
+
let lastError = null
|
|
422
|
+
for (let i = 0; i < urls.length; i += 1) {
|
|
423
|
+
const left = deadline - Date.now()
|
|
424
|
+
if (left < 5000) { lastError = lastError ?? new Error(`下载总预算 ${Math.round(timeoutMs / 1000)} 秒已用尽`); break }
|
|
425
|
+
const attempt = urls[i]
|
|
426
|
+
try {
|
|
427
|
+
await runner(curlBin, ['-s', '-L', '-m', String(Math.max(5, Math.min(60, Math.floor(left / 1000)))), '-o', dest, attempt], { timeout: Math.min(timeoutMs, left) + 3000, windowsHide: true })
|
|
428
|
+
if (!existsSync(dest)) throw new Error(`下载没有落盘(${attempt})`)
|
|
429
|
+
const size = statSync(dest).size
|
|
430
|
+
if (size < 100) throw new Error(`下载内容过小(${size} 字节,${attempt})`)
|
|
431
|
+
if (size > maxBytes) throw new Error(`产物超过体积上限(${(size / 1048576).toFixed(1)}MB > ${Math.round(maxBytes / 1048576)}MB,${attempt})`)
|
|
432
|
+
return size
|
|
433
|
+
} catch (error) {
|
|
434
|
+
lastError = error
|
|
435
|
+
try { rmSync(dest, { force: true }) } catch {}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
const tried = urls.map((u) => (u === urls[0] ? `${u}(直连)` : u)).join('、')
|
|
439
|
+
throw new Error(`GitHub 通道:下载失败(已尝试 ${urls.length} 条地址:${tried}):${lastError?.message ?? '未知'}`)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** 安装目标根(宿主插件特判):本面板部署在宿主根层 node_modules,更新时覆盖根层而非 web profile
|
|
443
|
+
* node_modules(包根本身由 paths.js 的 pluginRoot() 解析——全仓库只有那一处算包根)。
|
|
444
|
+
* 返回 `<root>/<packageName>`。
|
|
445
|
+
* ★ 特判的判据必须包含"自身确实住在某个 node_modules 里"(dirname 的 basename 为 node_modules):
|
|
446
|
+
* 旧判据只有 `existsSync(<pkg>/package.json)`,而任何**开发检出**(D:\dsh\dsh-plugin-hub-refactor 这种
|
|
447
|
+
* 不在 node_modules 下的目录)都满足它 → 目标会被算成检出的**父目录**,release 通道装一次插件就往
|
|
448
|
+
* `D:\dsh\<包名>` 写一份。生产布局不变:宿主根层 `<host>/node_modules/<pkg>` 仍然命中特判。 */
|
|
449
|
+
function releaseInstallTarget(profileDir, packageName) {
|
|
450
|
+
let targetRoot = join(profileDir, 'node_modules')
|
|
451
|
+
try {
|
|
452
|
+
const selfDir = pluginRoot()
|
|
453
|
+
const selfRoot = dirname(selfDir)
|
|
454
|
+
if (selfRoot !== targetRoot && basename(selfRoot) === 'node_modules' && existsSync(join(selfDir, 'package.json'))) targetRoot = selfRoot
|
|
455
|
+
} catch {}
|
|
456
|
+
return join(targetRoot, packageName)
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** 从 GitHub Release 下载预构建 tgz 装配到 node_modules/<pkgName>(gh CLI 通道,含绝对路径候选)。
|
|
460
|
+
* 这里保持"取第一个 tgz"的老语义(子包装配的 manifest 已指明 subRepo),不在本次 issue 范围内。 */
|
|
461
|
+
async function installBundleFromRelease(subRepo, pkgName, target) {
|
|
462
|
+
const dlDir = join(tmpdir(), `dsh-rel-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`)
|
|
463
|
+
mkdirSync(dlDir, { recursive: true })
|
|
464
|
+
try {
|
|
465
|
+
const args = ['release', 'download', '-R', subRepo, '-p', '*.tgz', '-D', dlDir]
|
|
466
|
+
let downloaded = false
|
|
467
|
+
let lastError = null
|
|
468
|
+
for (const bin of GH_BIN_CANDIDATES) {
|
|
469
|
+
try {
|
|
470
|
+
await execFileAsync(bin, args, { timeout: 180000, windowsHide: true, maxBuffer: 8 * 1024 * 1024 })
|
|
471
|
+
downloaded = true
|
|
472
|
+
break
|
|
473
|
+
} catch (error) {
|
|
474
|
+
lastError = error
|
|
475
|
+
if (error.code !== 'ENOENT') break
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (!downloaded) throw new Error(lastError?.message ?? 'gh release download 失败')
|
|
479
|
+
const tgz = readdirSync(dlDir).find((f) => f.endsWith('.tgz'))
|
|
480
|
+
if (!tgz) throw new Error('Release 无 tgz 资产')
|
|
481
|
+
const extractDir = join(dlDir, 'x')
|
|
482
|
+
mkdirSync(extractDir, { recursive: true })
|
|
483
|
+
await execFileAsync('tar', ['-xzf', join(dlDir, tgz), '-C', extractDir], { timeout: 60000, windowsHide: true })
|
|
484
|
+
const pkgDir = join(extractDir, 'package')
|
|
485
|
+
if (!existsSync(join(pkgDir, 'package.json'))) throw new Error('tgz 内无 package/package.json')
|
|
486
|
+
if (existsSync(target)) rmSync(target, { recursive: true, force: true })
|
|
487
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
488
|
+
copyTree(pkgDir, target)
|
|
489
|
+
} finally {
|
|
490
|
+
rmSync(dlDir, { recursive: true, force: true })
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
export {
|
|
495
|
+
RELEASE_LIST_LIMIT, MAX_RELEASE_CANDIDATE_REPOS, RELEASE_SCAN_MAX_REPOS, RELEASE_CHANNEL_BUDGET_MS,
|
|
496
|
+
RELEASE_SEARCH_REPOS, RELEASE_META_TIMEOUT_MS,
|
|
497
|
+
MAX_RELEASE_ASSET_BYTES, RELEASE_REPO_CACHE_TTL, RELEASE_DOWNLOAD_MIRROR_PREFIXES, releaseRepoCache, clearReleaseSourceCache,
|
|
498
|
+
remainingMs, budgetSignal,
|
|
499
|
+
normalizeAssetName, releaseAssetStems, assetMatchInfo, compareAssetMatch, rankReleaseAssets,
|
|
500
|
+
planReleaseInstall, releaseChannelFailureText, normalizeRepoSpec, encodeNpmName,
|
|
501
|
+
repoFromNpmMetadata, reposFromGithubSearch, resolveReleaseCandidateRepos, fetchReleaseList,
|
|
502
|
+
selectReleaseInstall, sourceTarballFallback, releaseDownloadUrls, downloadReleaseArtifact, releaseInstallTarget,
|
|
503
|
+
installBundleFromRelease,
|
|
504
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// L1 · domain —— repoland.js(仓库落地:落地目录配置 / 已落地列表 / 克隆;分层 Step 4 从 lib/index.js 搬出,只搬移未改逻辑)
|
|
2
|
+
// 分组见 D:\dsh\dsh-plugin-hub-plan\architecture.zh.md 三
|
|
3
|
+
|
|
4
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync } from 'node:fs'
|
|
5
|
+
import { dirname, join } from 'node:path'
|
|
6
|
+
import { homedir } from 'node:os'
|
|
7
|
+
import { gitCloneUrls } from './sources.js'
|
|
8
|
+
import { execFileAsync, gitEnv } from '../infra/exec.js'
|
|
9
|
+
import { removeDirVerified } from '../infra/fsx.js'
|
|
10
|
+
import { repoLandConfFile } from '../infra/paths.js'
|
|
11
|
+
|
|
12
|
+
/** 仓库落地根目录(可配置,默认 ~/.dsh/repos)。 */
|
|
13
|
+
let reposDirCache = null
|
|
14
|
+
|
|
15
|
+
function getReposDir() {
|
|
16
|
+
if (reposDirCache !== null) return reposDirCache
|
|
17
|
+
try {
|
|
18
|
+
if (existsSync(repoLandConfFile())) {
|
|
19
|
+
const conf = JSON.parse(readFileSync(repoLandConfFile(), 'utf8'))
|
|
20
|
+
if (typeof conf.dir === 'string' && conf.dir.trim() !== '') {
|
|
21
|
+
reposDirCache = conf.dir.trim()
|
|
22
|
+
return reposDirCache
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
} catch {}
|
|
26
|
+
reposDirCache = join(homedir(), '.dsh', 'repos')
|
|
27
|
+
return reposDirCache
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function setReposDir(dir) {
|
|
31
|
+
reposDirCache = String(dir ?? '').trim()
|
|
32
|
+
mkdirSync(dirname(repoLandConfFile()), { recursive: true })
|
|
33
|
+
writeFileSync(repoLandConfFile(), JSON.stringify({ dir: reposDirCache }, null, 2), 'utf8')
|
|
34
|
+
return reposDirCache
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 已落地仓库列表:扫描 dir 下两级目录(owner/name 下含 .git)。 */
|
|
38
|
+
function listLandedRepos() {
|
|
39
|
+
const dir = getReposDir()
|
|
40
|
+
const out = []
|
|
41
|
+
try {
|
|
42
|
+
if (!existsSync(dir)) return out
|
|
43
|
+
for (const owner of readdirSync(dir, { withFileTypes: true })) {
|
|
44
|
+
if (!owner.isDirectory() || owner.name.startsWith('.')) continue
|
|
45
|
+
const ownerDir = join(dir, owner.name)
|
|
46
|
+
for (const entry of readdirSync(ownerDir, { withFileTypes: true })) {
|
|
47
|
+
if (!entry.isDirectory() || entry.name.startsWith('.')) continue
|
|
48
|
+
const repoDir = join(ownerDir, entry.name)
|
|
49
|
+
if (existsSync(join(repoDir, '.git'))) {
|
|
50
|
+
out.push({ owner: owner.name, name: entry.name, repo: `${owner.name}/${entry.name}`, path: repoDir })
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
} catch {}
|
|
55
|
+
return out
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 从 execFile 错误里取 git 自己说的话(stderr 末两行)。`--quiet` 只静音进度,
|
|
59
|
+
* 真实原因(HTTP 502 / 无法解析主机 / 认证失败)仍在 stderr 里;只报 `Command failed: …`
|
|
60
|
+
* 等于没告诉用户任何信息(2026-09-20 演练:套装子模块失败只看到 Command failed)。 */
|
|
61
|
+
function gitErrorDetail(error) {
|
|
62
|
+
const raw = typeof error?.stderr === 'string' && error.stderr.trim() !== ''
|
|
63
|
+
? error.stderr
|
|
64
|
+
: (typeof error?.message === 'string' ? error.message : '')
|
|
65
|
+
return raw
|
|
66
|
+
.split(/\r?\n/u)
|
|
67
|
+
.map((line) => line.trim())
|
|
68
|
+
.filter((line) => line !== '' && !/^Command failed/u.test(line))
|
|
69
|
+
.slice(-2)
|
|
70
|
+
.join(' | ')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 逐条尝试的错误汇总(纯函数,单测覆盖):报**第一个**错误(真实原因)+ 尝试清单。
|
|
74
|
+
* 为什么要这样:多源重试时若第一次失败留下半成品目录,第二次会以
|
|
75
|
+
* `fatal: destination path '…' already exists and is not an empty directory` 失败,
|
|
76
|
+
* 旧代码把它当 lastError 抛出去 → 用户只看到"目录非空",真实原因(镜像/网络不可达)被完全掩盖。
|
|
77
|
+
* 2026-09-20 另一位用户实测报的就是这句。 */
|
|
78
|
+
function summarizeCloneErrors(errors) {
|
|
79
|
+
const first = errors[0]
|
|
80
|
+
const tried = errors.map((e) => (e.unclean === true
|
|
81
|
+
? `${e.url}(目标目录清不掉,未重试)`
|
|
82
|
+
: (/already exists and is not an empty directory/u.test(e.message) ? `${e.url}(目录非空)` : e.url))).join(';')
|
|
83
|
+
const detail = gitErrorDetail(first)
|
|
84
|
+
const uncleanNote = errors.some((e) => e.unclean === true)
|
|
85
|
+
? ';注意:上一次失败留下的半成品目录无法清理(当前环境禁止删除),多源重试因此无效——请手动删除该目录后重试'
|
|
86
|
+
: ''
|
|
87
|
+
return `git clone 失败(首个错误:${first?.message ?? '未知'}${detail !== '' ? `;git 说:${detail}` : ''});已尝试 ${errors.length} 个源:${tried}${uncleanNote}`
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** git clone(镜像→直连;gitee 直连),返回 { url } 或抛错。 */
|
|
91
|
+
async function gitCloneRepo(repo, dest, source = 'github', timeout = 180000) {
|
|
92
|
+
const urls = gitCloneUrls(repo, source)
|
|
93
|
+
const errors = []
|
|
94
|
+
for (const [attempt, url] of urls.entries()) {
|
|
95
|
+
// 每次尝试前都清掉目标目录:上一次可能留下半成品(git 会先建目录再传输),
|
|
96
|
+
// 不清就会让第二次以"目录非空"失败并掩盖真实原因(见 summarizeCloneErrors 注释)。
|
|
97
|
+
// 关键是**核实**清理结果:清不掉就别再试下一个源——那只会多出一条"目录非空",
|
|
98
|
+
// 把第一个源的真实错误一起搅浑(本机 %TEMP% 下删除被静默忽略时就是这样,2026-09-20 实测)。
|
|
99
|
+
const cleared = removeDirVerified(dest)
|
|
100
|
+
if (!cleared.ok) {
|
|
101
|
+
errors.push({ url, message: `克隆目标目录无法清理(环境禁止删除):${dest}`, unclean: true })
|
|
102
|
+
break
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
// eslint-disable-next-line no-await-in-loop
|
|
106
|
+
await execFileAsync('git', ['clone', '--depth', '1', '--quiet', url, dest], {
|
|
107
|
+
timeout,
|
|
108
|
+
windowsHide: true,
|
|
109
|
+
env: gitEnv(),
|
|
110
|
+
})
|
|
111
|
+
return { url, attempt: attempt + 1 }
|
|
112
|
+
} catch (error) {
|
|
113
|
+
errors.push({ url, message: error?.message ?? String(error), stderr: error?.stderr })
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
throw new Error(summarizeCloneErrors(errors))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export { reposDirCache, getReposDir, setReposDir, listLandedRepos, gitCloneRepo, summarizeCloneErrors }
|