@noob-stupid/dsh-plugin-console 0.5.16 → 0.5.18
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/README.md +16 -0
- package/README.zh.md +12 -0
- package/lib/client.js +49 -1
- package/lib/index.js +3 -3
- package/lib/server/domain/archive-source.js +209 -0
- package/lib/server/domain/git-channel.js +91 -0
- package/lib/server/domain/install-cleanup.js +62 -0
- package/lib/server/domain/install-job.js +137 -129
- package/lib/server/domain/install.js +2 -0
- package/lib/server/domain/market.js +72 -6
- package/lib/server/domain/repoland.js +289 -40
- package/lib/server/domain/sources.js +26 -2
- package/lib/server/infra/exec.js +93 -18
- package/lib/server/infra/fsx.js +194 -8
- package/lib/server/routes/components.js +11 -6
- package/lib/server/routes/skills.js +9 -5
- package/lib/server/routes/sources.js +80 -1
- package/package.json +1 -1
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// L1 · domain —— repoland.js(仓库落地:落地目录配置 / 已落地列表 / 克隆;分层 Step 4 从 lib/index.js 搬出,只搬移未改逻辑)
|
|
2
2
|
// 分组见 D:\dsh\dsh-plugin-hub-plan\architecture.zh.md 三
|
|
3
3
|
|
|
4
|
-
import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, renameSync } from 'node:fs'
|
|
4
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, renameSync, statSync } from 'node:fs'
|
|
5
5
|
import { spawn } from 'node:child_process'
|
|
6
|
-
import { dirname, join } from 'node:path'
|
|
6
|
+
import { dirname, join, resolve } from 'node:path'
|
|
7
7
|
import { homedir } from 'node:os'
|
|
8
8
|
import { gitCloneCandidates } from './sources.js'
|
|
9
|
+
import { archiveRepo } from './archive-source.js'
|
|
9
10
|
import { execFileAsync, gitEnv, killProcessTree } from '../infra/exec.js'
|
|
10
|
-
import {
|
|
11
|
+
import { copyTree, disposeDir, disposeNote } from '../infra/fsx.js'
|
|
11
12
|
import { dshHome, repoLandConfFile } from '../infra/paths.js'
|
|
12
13
|
|
|
13
14
|
/** 仓库落地根目录(可配置,默认 ~/.dsh/repos)。 */
|
|
@@ -113,23 +114,36 @@ function orderGitCandidates(candidates, preferredTemplate) {
|
|
|
113
114
|
* 2026-09-26 真机(官方桌面端里装 git 源插件):ghproxy 卡死 → 我们的超时到了但**没杀 git 进程**,
|
|
114
115
|
* 于是 `git clone` / `git remote-https` / `index-pack --shallow-file …\.git\shallow.lock` 常驻,
|
|
115
116
|
* Windows 不允许删除被打开的文件 → 目标目录清不掉 → 旧代码把它写成"环境禁止删除"并**放弃后续源**。
|
|
116
|
-
*
|
|
117
|
+
* 2026-09-26(本次改错):措辞与处置都跟着 `disposeDir` 走 —— 残留目录删不掉时不再让用户去命令行
|
|
118
|
+
* (旧文案是「可手动删除后重试:Remove-Item -Recurse -Force …」):能改名降级就**已经在后台让开了**,
|
|
119
|
+
* 真的连改名都失败才如实说"控制台会稍后自动重试清理"。 */
|
|
117
120
|
function summarizeCloneErrors(errors) {
|
|
118
121
|
const first = errors[0]
|
|
119
122
|
const tried = errors.map((e) => {
|
|
120
123
|
if (e.unclean === true) return `${e.url}(残留目录被占用,已跳过重试)`
|
|
121
|
-
|
|
124
|
+
// archive 通道(批次 C-⑨):把自己的失败摘要(含"下载到多少字节")原样带出来
|
|
125
|
+
if (e.archive === true) return `${e.url}(${String(e.message ?? 'archive 通道失败').slice(0, 160)})`
|
|
126
|
+
// 批次 B-⑦(2026-09-27):探活失败的源现在会在**最后一轮**再试一次,文案要说清"它并没有被永久跳过"
|
|
127
|
+
if (e.deferred === true && e.timedOut !== true) return `${e.url}(探活失败的源,已在本轮末尾重试)`
|
|
128
|
+
if (e.skipped === true) return `${e.url}(探活失败,已降级到本轮末尾重试)`
|
|
122
129
|
if (e.retrying === true) return `${e.url}(超时,已改用更长超时重试)`
|
|
123
|
-
if (e.timedOut === true)
|
|
130
|
+
if (e.timedOut === true) {
|
|
131
|
+
// 批次 B-⑧(2026-09-27):超时到底"收到多少字节"必须写出来 —— 0 B 就是镜像只连不传(
|
|
132
|
+
// 换源即可),有字节则是真在传、只是慢(值得再等)。旧文案只有"超时"两个字,用户无法判断。
|
|
133
|
+
const bytes = Number.isFinite(Number(e.bytesReceived)) ? Number(e.bytesReceived) : null
|
|
134
|
+
return `${e.url}(超时,进程已结束${bytes === null ? '' : (bytes > 0 ? `;本次已收到 ${bytes} B` : ';本次仅收到 0 B')})`
|
|
135
|
+
}
|
|
124
136
|
return /already exists and is not an empty directory/u.test(e.message) ? `${e.url}(目录非空)` : e.url
|
|
125
137
|
}).join(';')
|
|
126
138
|
const detail = gitErrorDetail(first)
|
|
127
139
|
const stuck = errors.filter((e) => e.unclean === true)
|
|
128
140
|
const stuckNote = stuck.length === 0
|
|
129
141
|
? ''
|
|
130
|
-
: `;注意:${stuck[0].message}
|
|
142
|
+
: `;注意:${stuck[0].message}。控制台会在后台自动重试清理,无需手动处理`
|
|
143
|
+
const trashedRecord = errors.find((e) => typeof e.trashNote === 'string' && e.trashNote !== '')
|
|
144
|
+
const trashedNote = trashedRecord === undefined ? '' : `;${trashedRecord.trashNote}`
|
|
131
145
|
const sourceCount = new Set(errors.map((e) => e.url)).size
|
|
132
|
-
return `git clone 失败(首个错误:${first?.message ?? '未知'}${detail !== '' ? `;git 说:${detail}` : ''});已尝试 ${sourceCount} 个源(共 ${errors.length} 次尝试):${tried}${stuckNote}`
|
|
146
|
+
return `git clone 失败(首个错误:${first?.message ?? '未知'}${detail !== '' ? `;git 说:${detail}` : ''});已尝试 ${sourceCount} 个源(共 ${errors.length} 次尝试):${tried}${trashedNote}${stuckNote}`
|
|
133
147
|
}
|
|
134
148
|
|
|
135
149
|
/** 结束**整棵**进程树。超时/中断后必须做:git 会派生 remote-https / index-pack 子进程,
|
|
@@ -152,6 +166,72 @@ function waitChildExit(child, timeoutMs, pollMs = 60) {
|
|
|
152
166
|
})
|
|
153
167
|
}
|
|
154
168
|
|
|
169
|
+
/** git 停滞判据(2026-09-27 加法,真机实测:ghproxy 下 **git 协议 0 B/s** 却能挂满整个超时)。
|
|
170
|
+
* 交给 git 自己判:连续 20 秒平均速率 < 1 B/s 即中止传输并报 `Operation too slow`。
|
|
171
|
+
* 这是 `-c` 全局选项,必须排在子命令 `clone` **之前**(`git -c k=v clone …`)。
|
|
172
|
+
* 效果:一个"只连不传"的源从"每个源白等 60/180 秒"变成"≈20 秒判死 → 立刻换下一个源"。 */
|
|
173
|
+
const GIT_STALL_ARGS = ['-c', 'http.lowSpeedLimit=1', '-c', 'http.lowSpeedTime=20']
|
|
174
|
+
|
|
175
|
+
/** 已落地仓库复用(批次 C-⑩,2026-09-27 加法):克隆前先看 `<reposDir>/<owner>/<repo>`。
|
|
176
|
+
* 为什么:用户在「仓库落地」里已经把仓库拉到本地(reposDir,默认 ~/.dsh/repos),
|
|
177
|
+
* 安装/套装装配时再去网上拉一遍纯属浪费——本机直连不通时甚至是"白等一场空"。
|
|
178
|
+
* 命中条件:目录存在、看起来是仓库(有 .git 或 .gitmodules)、且**含 package.json**
|
|
179
|
+
* (没有 package.json 的多半是半成品/技能仓库,交给正常通道更稳)。
|
|
180
|
+
* 返回 `{ hit, path }`;deps 只为单测注入。 */
|
|
181
|
+
function findLandedRepo(repo, deps = {}) {
|
|
182
|
+
const exists = deps.exists ?? existsSync
|
|
183
|
+
const list = deps.listLanded ?? listLandedRepos
|
|
184
|
+
const full = String(repo ?? '').trim().replace(/\.git$/u, '')
|
|
185
|
+
if (full === '' || !full.includes('/')) return { hit: false, path: null }
|
|
186
|
+
let landed = []
|
|
187
|
+
try { landed = list() ?? [] } catch { landed = [] }
|
|
188
|
+
const match = landed.find((r) => String(r.repo).toLowerCase() === full.toLowerCase())
|
|
189
|
+
if (match === undefined) return { hit: false, path: null }
|
|
190
|
+
try {
|
|
191
|
+
if (!exists(join(match.path, 'package.json'))) return { hit: false, path: match.path }
|
|
192
|
+
} catch {
|
|
193
|
+
return { hit: false, path: match.path }
|
|
194
|
+
}
|
|
195
|
+
return { hit: true, path: match.path }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** git 克隆的默认首轮超时(2026-09-27 下调 180 秒 → 60 秒)。
|
|
199
|
+
* 为什么能降:停滞判据(GIT_STALL_ARGS)已经把"连得上但不传"这一类提前到 ≈20 秒判死,
|
|
200
|
+
* 剩下的真实传输有进度就继续跑;真正慢但**有进度**的源由 gitCloneRepo 的"同源更长超时重试"接手
|
|
201
|
+
* (只在有进度时才重试,见 measureProgressBytes)。 */
|
|
202
|
+
const GIT_CLONE_TIMEOUT_MS = 60000
|
|
203
|
+
|
|
204
|
+
/** 统计目录树里的文件字节数(best-effort:任何一层读不到就跳过,绝不抛)。 */
|
|
205
|
+
function measureDirBytes(dir, { readdir = readdirSync, stat = statSync } = {}) {
|
|
206
|
+
let total = 0
|
|
207
|
+
const walk = (p, depth) => {
|
|
208
|
+
if (depth > 8) return
|
|
209
|
+
let entries = []
|
|
210
|
+
try { entries = readdir(p, { withFileTypes: true }) } catch { return }
|
|
211
|
+
for (const entry of entries) {
|
|
212
|
+
const child = join(p, entry.name)
|
|
213
|
+
try {
|
|
214
|
+
if (entry.isDirectory()) walk(child, depth + 1)
|
|
215
|
+
else if (entry.isFile()) total += stat(child).size
|
|
216
|
+
} catch {}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
walk(String(dir), 0)
|
|
220
|
+
return total
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** 本次尝试"收到了多少字节"(2026-09-27 加法)——决定**这个源配不配用更长超时再试一次**。
|
|
224
|
+
* 真机证据:ghproxy 卡死是 0 B/s(一点进度都没有),再用 1.75 倍超时重试只是把白等拉长;
|
|
225
|
+
* 而"慢但在长"的源(比如大仓库首包)值得再给一次机会。
|
|
226
|
+
* 先量 `.git/objects`(git 边下边写 pack/tmp_pack,这里就是进度条),没有就退化成量整个目标目录。 */
|
|
227
|
+
function measureProgressBytes(part, deps = {}) {
|
|
228
|
+
const exists = deps.exists ?? existsSync
|
|
229
|
+
const measure = deps.measureDir ?? measureDirBytes
|
|
230
|
+
const objects = join(String(part), '.git', 'objects')
|
|
231
|
+
const bytes = exists(objects) ? measure(objects) : 0
|
|
232
|
+
return bytes > 0 ? bytes : measure(part)
|
|
233
|
+
}
|
|
234
|
+
|
|
155
235
|
/** 跑一次 git clone:支持超时,且**超时即杀掉整棵树并等它真的退出**。返回 { code, stderr, timedOut, pid, exited }。 */
|
|
156
236
|
function runGitClone(url, dest, timeout, { spawnFn = spawn, killTree = killProcessTree, exitWaitMs = 800, exitWaitMs2 = 300 } = {}) {
|
|
157
237
|
return new Promise((resolve) => {
|
|
@@ -161,7 +241,7 @@ function runGitClone(url, dest, timeout, { spawnFn = spawn, killTree = killProce
|
|
|
161
241
|
let child = null
|
|
162
242
|
let stderr = ''
|
|
163
243
|
try {
|
|
164
|
-
child = spawnFn('git', ['clone', '--depth', '1', '--quiet', url, dest], {
|
|
244
|
+
child = spawnFn('git', [...GIT_STALL_ARGS, 'clone', '--depth', '1', '--quiet', url, dest], {
|
|
165
245
|
windowsHide: true,
|
|
166
246
|
env: gitEnv(),
|
|
167
247
|
detached: process.platform !== 'win32', // POSIX:自成进程组,便于 -pid 整体杀
|
|
@@ -188,12 +268,64 @@ function runGitClone(url, dest, timeout, { spawnFn = spawn, killTree = killProce
|
|
|
188
268
|
})
|
|
189
269
|
}
|
|
190
270
|
|
|
191
|
-
/**
|
|
192
|
-
|
|
271
|
+
/** git 智能 HTTP 的探活地址(批次 B-⑦②,2026-09-27 改错):从 `HEAD /` 改成
|
|
272
|
+
* `GET <url>/info/refs?service=git-upload-pack` —— 后者是 git 协议的**真实入口**,
|
|
273
|
+
* 响应首行必须是以 4 位十六进制长度开头的 pkt-line(如 `001e# service=git-upload-pack`),
|
|
274
|
+
* 这样至少能把"根本不是 git 服务的镜像/错误页/登录页"提前滤掉(旧 HEAD 判据只验可达性)。 */
|
|
275
|
+
function gitInfoRefsUrl(url) {
|
|
276
|
+
const base = String(url ?? '')
|
|
277
|
+
if (base === '') return ''
|
|
278
|
+
return `${base.replace(/\/+$/u, '')}/info/refs?service=git-upload-pack`
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** 探活失败的归因(纯函数,单测覆盖):把"网络不可达"与"本地代理/证书拦截"分开。
|
|
282
|
+
* 为什么必须分开(批次 B-⑦③):本机装了 Steam++ 这类加速器后会改 hosts / 装自签根证书,
|
|
283
|
+
* 表现是 `unable to get local issuer certificate` / `self signed certificate` —— 这种情况让用户
|
|
284
|
+
* "重试"是没用的,必须提示他关掉加速器/代理。 */
|
|
285
|
+
function classifyProbeFailure(error) {
|
|
286
|
+
const text = String(error?.cause?.message ?? error?.message ?? error ?? '')
|
|
287
|
+
if (/certificate|CERT_|self[- ]signed|UNABLE_TO_VERIFY|SSL|TLS|proxy|ECONNREFUSED|ERR_PROXY/iu.test(text)) {
|
|
288
|
+
return { kind: 'intercepted', note: `本地代理/证书拦截(${text.slice(0, 90)})—— 检测到本机加速器/代理,建议关闭后重试` }
|
|
289
|
+
}
|
|
290
|
+
return { kind: 'unreachable', note: `网络不可达(${text.slice(0, 90) || '连接失败'})` }
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** 源探活(带归因):返回 { alive, kind, status, note }。
|
|
294
|
+
* 判据(沿用旧语义 + 新增 pkt-line 校验):
|
|
295
|
+
* · `file://` 本地裸仓库:直接算活着(旧代码对 file:// 一律判死 → 完全离线/内网共享盘场景永远用不上)
|
|
296
|
+
* · 403 / 405:部分镜像不支持该探测,按活着处理(老判据保留,不误杀镜像)
|
|
297
|
+
* · 其他非 2xx:不存活(域名在但仓库/路径没了)
|
|
298
|
+
* · 2xx:校验响应首行是 pkt-line;响应体读不到时保守地按活着处理(别误杀)
|
|
299
|
+
* `deps.fetch` 只为单测注入,生产调用不传。 */
|
|
300
|
+
async function probeSourceAliveDetail(url, timeoutMs = 4000, deps = {}) {
|
|
301
|
+
const fetchFn = deps.fetch ?? fetch
|
|
302
|
+
const target = String(url ?? '')
|
|
303
|
+
if (/^file:/iu.test(target)) return { alive: true, kind: 'local', status: null, note: '本地裸仓库' }
|
|
193
304
|
try {
|
|
194
|
-
const res = await
|
|
195
|
-
|
|
196
|
-
|
|
305
|
+
const res = await fetchFn(gitInfoRefsUrl(target), {
|
|
306
|
+
method: 'GET',
|
|
307
|
+
redirect: 'follow',
|
|
308
|
+
headers: { accept: '*/*' },
|
|
309
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
310
|
+
})
|
|
311
|
+
if (res.status === 403 || res.status === 405) return { alive: true, kind: 'http', status: res.status, note: `HTTP ${res.status}(该镜像不支持智能 HTTP 探测,按活着处理)` }
|
|
312
|
+
if (res.ok !== true) return { alive: false, kind: 'http', status: res.status, note: `HTTP ${res.status}` }
|
|
313
|
+
let head = ''
|
|
314
|
+
try { if (typeof res.text === 'function') head = String(await res.text()).slice(0, 64) } catch {}
|
|
315
|
+
if (head === '') return { alive: true, kind: 'http', status: res.status, note: '响应体不可读,按活着处理' }
|
|
316
|
+
return /^[0-9a-f]{4}# service=git-upload-pack/u.test(head)
|
|
317
|
+
? { alive: true, kind: 'git', status: res.status, note: '' }
|
|
318
|
+
: { alive: false, kind: 'not-git', status: res.status, note: `响应不像 git 服务(首行:${head.split(/\r?\n/u)[0].slice(0, 40)})` }
|
|
319
|
+
} catch (error) {
|
|
320
|
+
const reason = classifyProbeFailure(error)
|
|
321
|
+
return { alive: false, kind: reason.kind, status: null, note: reason.note }
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** 源探活:镜像站"连得上但传不动"探不出来,但**域名挂掉/被墙/根本不是 git 服务**能提前识别,
|
|
326
|
+
* 省掉一整个克隆超时的白等。返回布尔(老签名不变;需要归因文案请用 probeSourceAliveDetail)。 */
|
|
327
|
+
async function probeSourceAlive(url, timeoutMs = 4000, deps = {}) {
|
|
328
|
+
return (await probeSourceAliveDetail(url, timeoutMs, deps)).alive === true
|
|
197
329
|
}
|
|
198
330
|
|
|
199
331
|
/** git clone(镜像→直连;gitee 直连),返回 { url, attempt, dir, source, retried } 或抛错。
|
|
@@ -204,33 +336,70 @@ async function probeSourceAlive(url, timeoutMs = 4000) {
|
|
|
204
336
|
* 杀树成功但 Windows 句柄晚一拍释放时,不再"一次定生死"、也不再误报"残留被占用";
|
|
205
337
|
* ③ 源策略:**同一个源超时后用更长超时(默认 1.75 倍)重试一次**,
|
|
206
338
|
* 并把**上次成功过的源**提到最前来试(本机直连 github 不通、唯一可用源就是 ghproxy,
|
|
207
|
-
* 一旦它超时就"整次安装彻底失败"——现在先重试它一次,再谈别的源)。
|
|
208
|
-
|
|
339
|
+
* 一旦它超时就"整次安装彻底失败"——现在先重试它一次,再谈别的源)。
|
|
340
|
+
* 2026-09-26(本次改错):清理默认实现由 removeDirVerifiedWithRetry 换成 **disposeDir**
|
|
341
|
+
* (删不掉就改名降级成同父目录的 `.trash-<ts>-<rand>`,见 infra/fsx.js)——残留目录不再需要用户
|
|
342
|
+
* 手动删除,也不再因为"清不掉"而放弃后面本来可用的源。`deps.removeDir` 这个注入口名字没变。
|
|
343
|
+
* 2026-09-27(本次改错):① 首轮超时 180 秒 → 60 秒(停滞判据已把"只连不传"提前到 ≈20 秒判死);
|
|
344
|
+
* ② "同源 1.75 倍长超时重试"从**无条件**改成**只对"有进度"的源**——
|
|
345
|
+
* 每个失败记录都带上 `bytesReceived`(.git/objects 落盘字节数),0 B 就说明
|
|
346
|
+
* 这个源一点都没传,再用更长超时重试只是把白等拉长(真机 ghproxy 实测 0 B/s)。
|
|
347
|
+
* 2026-09-27(批次 B-⑦):③ 探活判据换成 `GET <url>/info/refs?service=git-upload-pack` + pkt-line 校验,
|
|
348
|
+
* 并把失败归因(网络不可达 / 本地代理·证书拦截)写进错误清单;
|
|
349
|
+
* ④ **探活失败的源不再"一次定生死"**:降级到**最后一轮**再试一次
|
|
350
|
+
* (探活本身可能只是瞬时抖动;真正挂掉的源也只有一次克隆的代价)。 */
|
|
351
|
+
async function gitCloneRepo(repo, dest, source = 'github', timeout = GIT_CLONE_TIMEOUT_MS, deps = {}) {
|
|
209
352
|
const {
|
|
210
353
|
spawnFn = spawn,
|
|
211
354
|
killTree = killProcessTree,
|
|
212
|
-
probe =
|
|
213
|
-
|
|
355
|
+
probe = null,
|
|
356
|
+
probeDetail = null,
|
|
357
|
+
removeDir = disposeDir,
|
|
214
358
|
renameDir = renameSync,
|
|
215
359
|
exitWaitMs = 800,
|
|
216
360
|
retryFactor = 1.75,
|
|
217
361
|
readMemo = readGitSourceMemo,
|
|
218
362
|
writeMemo = rememberGitSource,
|
|
363
|
+
measureBytes = measureProgressBytes,
|
|
364
|
+
archive = archiveRepo,
|
|
365
|
+
archiveBranch = 'main',
|
|
366
|
+
reuseLanded = true,
|
|
367
|
+
findLanded = findLandedRepo,
|
|
368
|
+
copyLanded = copyTree,
|
|
219
369
|
} = deps
|
|
370
|
+
// ★ 已落地仓库优先复用(批次 C-⑩):命中就直接把本地那份搬到 dest,一个网络请求都不发。
|
|
371
|
+
// 跳过 .git 是有意的(copyTree 的既有语义):上层要的是仓库**内容**(package.json/.gitmodules),
|
|
372
|
+
// 本机已有完整仓库,没必要把几百 MB 的 .git 再复制一份。
|
|
373
|
+
if (reuseLanded === true) {
|
|
374
|
+
try {
|
|
375
|
+
const landed = findLanded(repo)
|
|
376
|
+
if (landed.hit === true && landed.path !== null) {
|
|
377
|
+
if (resolve(landed.path) === resolve(dest)) {
|
|
378
|
+
return { url: `file://${landed.path}`, attempt: 0, tries: 0, dir: dest, source: 'landed', retried: false, reused: true }
|
|
379
|
+
}
|
|
380
|
+
try { removeDir(dest) } catch {}
|
|
381
|
+
copyLanded(landed.path, dest)
|
|
382
|
+
return { url: `file://${landed.path}`, attempt: 0, tries: 0, dir: dest, source: 'landed', retried: false, reused: true, from: landed.path }
|
|
383
|
+
}
|
|
384
|
+
} catch {}
|
|
385
|
+
}
|
|
386
|
+
// 兼容旧的 `deps.probe`(单测/调用方注入的布尔探活):它优先于新的带归因探活
|
|
387
|
+
const probeOne = typeof probeDetail === 'function'
|
|
388
|
+
? probeDetail
|
|
389
|
+
: (typeof probe === 'function'
|
|
390
|
+
? async (url) => ({ alive: (await probe(url)) === true, kind: 'probe', status: null, note: '连不上(探活失败)' })
|
|
391
|
+
: (url) => probeSourceAliveDetail(url))
|
|
220
392
|
let preferred = ''
|
|
221
393
|
try { preferred = readMemo() } catch { preferred = '' }
|
|
222
394
|
const candidates = orderGitCandidates(gitCloneCandidates(repo, source), preferred)
|
|
223
395
|
const retryTimeout = Math.max(timeout + 1, Math.round(timeout * retryFactor))
|
|
224
396
|
const errors = []
|
|
225
397
|
let partSeq = 0
|
|
226
|
-
|
|
398
|
+
const deferred = []
|
|
399
|
+
/** 试一个源(最多两轮:正常超时 → 有进度才用更长超时重试)。
|
|
400
|
+
* 返回 `{ result }` 表示克隆成功;返回 null 表示这个源用完了,继续下一个源。 */
|
|
401
|
+
const attemptSource = async (cand, sourceIndex, isDeferred) => {
|
|
227
402
|
const url = cand.url
|
|
228
|
-
const alive = await probe(url)
|
|
229
|
-
if (!alive) {
|
|
230
|
-
partSeq += 1
|
|
231
|
-
errors.push({ url, message: `源探活失败(连不上):${url}`, skipped: true, dir: `${dest}.try${partSeq}` })
|
|
232
|
-
continue
|
|
233
|
-
}
|
|
234
403
|
// 同一个源最多两次:正常超时 → 更长超时重试一次
|
|
235
404
|
for (let round = 0; round < 2; round += 1) {
|
|
236
405
|
partSeq += 1
|
|
@@ -244,41 +413,121 @@ async function gitCloneRepo(repo, dest, source = 'github', timeout = 180000, dep
|
|
|
244
413
|
removeDir(dest)
|
|
245
414
|
renameDir(part, dest)
|
|
246
415
|
try { writeMemo(cand.urlTemplate) } catch {}
|
|
247
|
-
return { url, attempt: sourceIndex + 1, tries: partSeq, dir: dest, source: cand.id, retried: round > 0 }
|
|
416
|
+
return { result: { url, attempt: sourceIndex + 1, tries: partSeq, dir: dest, source: cand.id, retried: round > 0, deferred: isDeferred } }
|
|
248
417
|
} catch (error) {
|
|
249
|
-
errors.push({ url, message: `克隆成功但落地失败(${error instanceof Error ? error.message : String(error)})`, dir: part })
|
|
250
|
-
|
|
418
|
+
errors.push({ url, message: `克隆成功但落地失败(${error instanceof Error ? error.message : String(error)})`, dir: part, deferred: isDeferred })
|
|
419
|
+
return null
|
|
251
420
|
}
|
|
252
421
|
}
|
|
253
|
-
//
|
|
254
|
-
//
|
|
422
|
+
// 失败:先量**本次到底收到了多少字节**(决定"配不配长超时重试",也是给用户看的事实),
|
|
423
|
+
// 再尽力清掉半成品目录(带重试的核实删除 → 删不掉则**改名降级**成 .trash-*);
|
|
424
|
+
// 只有连改名都失败才如实说明(不再谎报"环境禁止删除",也不再让用户去命令行手动删)
|
|
425
|
+
let bytesReceived = 0
|
|
426
|
+
try { bytesReceived = measureBytes(part) } catch { bytesReceived = 0 }
|
|
427
|
+
const hasProgress = bytesReceived > 0
|
|
255
428
|
const cleared = removeDir(part)
|
|
429
|
+
const trashNote = cleared !== null && cleared !== undefined && typeof cleared.trashPath === 'string' && cleared.trashPath !== ''
|
|
430
|
+
? disposeNote(cleared)
|
|
431
|
+
: ''
|
|
432
|
+
const trashField = trashNote === '' ? {} : { trashed: cleared.trashPath, trashNote }
|
|
256
433
|
if (cleared && cleared.ok === false) {
|
|
257
434
|
errors.push({
|
|
258
435
|
url,
|
|
259
436
|
message: res.timedOut === true
|
|
260
437
|
? (res.exited === false
|
|
261
|
-
? `克隆超时;git 进程在等待后仍未退出(清理已重试 ${cleared.rounds ?? cleared.attempts ?? 1}
|
|
262
|
-
: `克隆超时;git 进程已结束,但残留目录重试 ${cleared.rounds ?? cleared.attempts ?? 1}
|
|
263
|
-
: `克隆失败,且残留目录重试 ${cleared.rounds ?? cleared.attempts ?? 1}
|
|
438
|
+
? `克隆超时;git 进程在等待后仍未退出(清理已重试 ${cleared.rounds ?? cleared.attempts ?? 1} 轮、改名降级也失败),残留目录清不掉:${part}`
|
|
439
|
+
: `克隆超时;git 进程已结束,但残留目录重试 ${cleared.rounds ?? cleared.attempts ?? 1} 轮后仍清不掉(改名降级也失败,多为杀软/其它程序占用):${part}`)
|
|
440
|
+
: `克隆失败,且残留目录重试 ${cleared.rounds ?? cleared.attempts ?? 1} 轮后仍清不掉(改名降级也失败):${part}`,
|
|
264
441
|
stderr: res.stderr,
|
|
265
442
|
unclean: true,
|
|
266
443
|
timedOut: res.timedOut === true,
|
|
267
444
|
exited: res.exited !== false,
|
|
445
|
+
bytesReceived,
|
|
268
446
|
dir: part,
|
|
447
|
+
deferred: isDeferred,
|
|
269
448
|
})
|
|
270
|
-
|
|
449
|
+
return null // 清不掉的残留和这个源绑着,换下一个源(新目录不受影响)
|
|
271
450
|
}
|
|
272
|
-
if (res.timedOut === true && round === 0) {
|
|
273
|
-
// ★
|
|
274
|
-
errors.push({ url, message: `克隆超时(${useTimeout}ms),改用 ${retryTimeout}ms 同源重试`, stderr: res.stderr, timedOut: true, retrying: true, dir: part })
|
|
451
|
+
if (res.timedOut === true && round === 0 && hasProgress) {
|
|
452
|
+
// ★ 同源、更长超时,重试一次 —— **只对真有进度的源**(0 B 的源再等一次只是把白等拉长)
|
|
453
|
+
errors.push({ url, message: `克隆超时(${useTimeout}ms,本次已收到 ${bytesReceived} B),改用 ${retryTimeout}ms 同源重试`, stderr: res.stderr, timedOut: true, retrying: true, bytesReceived, dir: part, deferred: isDeferred, ...trashField })
|
|
275
454
|
continue
|
|
276
455
|
}
|
|
277
|
-
errors.push({
|
|
278
|
-
|
|
456
|
+
errors.push({
|
|
457
|
+
url,
|
|
458
|
+
message: res.timedOut === true
|
|
459
|
+
? `克隆超时(${url}${hasProgress ? `,本次已收到 ${bytesReceived} B` : ',本次收到 0 B(无进度,不再用更长超时重试)'})`
|
|
460
|
+
: (res.stderr !== '' ? `${res.stderr}(本次收到 ${bytesReceived} B)` : `git clone 退出码 ${res.code}(本次收到 ${bytesReceived} B)`),
|
|
461
|
+
stderr: res.stderr,
|
|
462
|
+
timedOut: res.timedOut === true,
|
|
463
|
+
bytesReceived,
|
|
464
|
+
noProgress: !hasProgress,
|
|
465
|
+
dir: part,
|
|
466
|
+
deferred: isDeferred,
|
|
467
|
+
...trashField,
|
|
468
|
+
})
|
|
469
|
+
return null
|
|
470
|
+
}
|
|
471
|
+
return null
|
|
472
|
+
}
|
|
473
|
+
for (const [sourceIndex, cand] of candidates.entries()) {
|
|
474
|
+
// eslint-disable-next-line no-await-in-loop
|
|
475
|
+
const detail = await probeOne(cand.url)
|
|
476
|
+
if (detail.alive !== true) {
|
|
477
|
+
partSeq += 1
|
|
478
|
+
errors.push({
|
|
479
|
+
url: cand.url,
|
|
480
|
+
message: `源探活失败(${detail.note ?? '连不上'}):${cand.url}`,
|
|
481
|
+
skipped: true,
|
|
482
|
+
probeKind: detail.kind ?? null,
|
|
483
|
+
probeNote: detail.note ?? '',
|
|
484
|
+
dir: `${dest}.try${partSeq}`,
|
|
485
|
+
})
|
|
486
|
+
deferred.push({ cand, sourceIndex })
|
|
487
|
+
continue
|
|
488
|
+
}
|
|
489
|
+
// eslint-disable-next-line no-await-in-loop
|
|
490
|
+
const done = await attemptSource(cand, sourceIndex, false)
|
|
491
|
+
if (done !== null) return done.result
|
|
492
|
+
}
|
|
493
|
+
// ★ archive 通道(批次 C-⑨,2026-09-27 加法):git 源全部失败后,改用**普通 HTTP 下载压缩包**。
|
|
494
|
+
// 真机依据:同一个 ghproxy 域名下 archive GET 4 MB/s、git 协议 0 B/s —— 少了这一步,
|
|
495
|
+
// "git 协议被镜像掐死"就等于"这个仓库装不上"。顺序放在**探活失败的降级轮之前**:
|
|
496
|
+
// archive 是已知能跑满带宽的传输,而降级轮里的源大多是探测时就不可达的(希望更小)。
|
|
497
|
+
if (typeof archive === 'function') {
|
|
498
|
+
try {
|
|
499
|
+
const got = await archive(repo, dest, { branch: archiveBranch, ...(deps.archiveOptions ?? {}), deps: deps.archiveDeps ?? {} })
|
|
500
|
+
return {
|
|
501
|
+
url: got.url,
|
|
502
|
+
attempt: candidates.length + 1,
|
|
503
|
+
tries: partSeq + (got.tries ?? 1),
|
|
504
|
+
dir: got.dir ?? dest,
|
|
505
|
+
source: got.sourceId !== undefined && got.sourceId !== '' ? `archive:${got.sourceId}` : 'archive',
|
|
506
|
+
retried: false,
|
|
507
|
+
archive: true,
|
|
508
|
+
branch: got.branch ?? archiveBranch,
|
|
509
|
+
bytes: got.bytes ?? null,
|
|
510
|
+
gitNote: got.gitNote ?? null,
|
|
511
|
+
}
|
|
512
|
+
} catch (error) {
|
|
513
|
+
const raw = String(error?.message ?? error).replace(/\s+/gu, ' ')
|
|
514
|
+
errors.push({
|
|
515
|
+
url: `archive://${repo}`,
|
|
516
|
+
archive: true,
|
|
517
|
+
message: `archive 通道失败:${raw.slice(0, 400)}`,
|
|
518
|
+
timedOut: /超时/u.test(raw),
|
|
519
|
+
bytesReceived: 0,
|
|
520
|
+
})
|
|
279
521
|
}
|
|
280
522
|
}
|
|
523
|
+
// ★ 最后一轮(批次 B-⑦④):探活失败的源降级到这里**再试一次**(不再永久跳过)。
|
|
524
|
+
// 对真挂掉的源,代价只是一次克隆(有停滞判据 + 预算封顶);对瞬时抖动的源,这是唯一的机会。
|
|
525
|
+
for (const { cand, sourceIndex } of deferred) {
|
|
526
|
+
// eslint-disable-next-line no-await-in-loop
|
|
527
|
+
const done = await attemptSource(cand, sourceIndex, true)
|
|
528
|
+
if (done !== null) return done.result
|
|
529
|
+
}
|
|
281
530
|
throw new Error(summarizeCloneErrors(errors))
|
|
282
531
|
}
|
|
283
532
|
|
|
284
|
-
export { reposDirCache, getReposDir, setReposDir, listLandedRepos, gitCloneRepo, summarizeCloneErrors, orderGitCandidates, readGitSourceMemo, rememberGitSource, killProcessTree, probeSourceAlive }
|
|
533
|
+
export { reposDirCache, getReposDir, setReposDir, listLandedRepos, findLandedRepo, gitCloneRepo, summarizeCloneErrors, orderGitCandidates, readGitSourceMemo, rememberGitSource, killProcessTree, probeSourceAlive, probeSourceAliveDetail, gitInfoRefsUrl, classifyProbeFailure, GIT_STALL_ARGS, GIT_CLONE_TIMEOUT_MS, measureDirBytes, measureProgressBytes }
|
|
@@ -80,6 +80,22 @@ function readSources() {
|
|
|
80
80
|
return gitSources
|
|
81
81
|
})()
|
|
82
82
|
: defaults.gitSources
|
|
83
|
+
// archive 通道源(批次 C-⑨,2026-09-27 加法):形状与 gitSources 一致({owner}/{repo} 占位符,
|
|
84
|
+
// 可选 {branch}),主→备依次尝试;老配置没有该字段 → 用默认(行为与改动前一致)。
|
|
85
|
+
const archiveSources = (Array.isArray(data.archiveSources) ? data.archiveSources : [])
|
|
86
|
+
.filter((s) => s && typeof s.urlTemplate === 'string' && s.urlTemplate.includes('{owner}') && s.urlTemplate.includes('{repo}') && isAllowedGitSourceUrl(s.urlTemplate))
|
|
87
|
+
.map((s) => ({
|
|
88
|
+
id: String(s.id ?? '').slice(0, 40) || `arc-${Math.random().toString(36).slice(2, 8)}`,
|
|
89
|
+
name: String(s.name ?? s.urlTemplate).slice(0, 60) || s.urlTemplate,
|
|
90
|
+
urlTemplate: s.urlTemplate,
|
|
91
|
+
primary: s.primary === true,
|
|
92
|
+
}))
|
|
93
|
+
const archiveFinal = archiveSources.length > 0
|
|
94
|
+
? (() => {
|
|
95
|
+
if (!archiveSources.some((s) => s.primary)) archiveSources[0].primary = true
|
|
96
|
+
return archiveSources
|
|
97
|
+
})()
|
|
98
|
+
: defaults.archiveSources
|
|
83
99
|
const gitee = {
|
|
84
100
|
...giteeBase,
|
|
85
101
|
clientSecret: secrets.gitee?.clientSecret ?? giteeBase.clientSecret,
|
|
@@ -87,7 +103,7 @@ function readSources() {
|
|
|
87
103
|
}
|
|
88
104
|
if (registries.length > 0) {
|
|
89
105
|
if (!registries.some((r) => r.primary)) registries[0].primary = true
|
|
90
|
-
return { registries, searchSources, indexSources: indexFinal, gitSources: gitFinal, indexMerge: data.indexMerge === true, gitee }
|
|
106
|
+
return { registries, searchSources, indexSources: indexFinal, gitSources: gitFinal, archiveSources: archiveFinal, indexMerge: data.indexMerge === true, gitee }
|
|
91
107
|
}
|
|
92
108
|
} catch {}
|
|
93
109
|
return defaults
|
|
@@ -132,6 +148,7 @@ function maskSources(sources) {
|
|
|
132
148
|
indexSources: sources.indexSources ?? DEFAULT_SOURCES.indexSources,
|
|
133
149
|
indexMerge: sources.indexMerge === true,
|
|
134
150
|
gitSources: sources.gitSources ?? DEFAULT_SOURCES.gitSources,
|
|
151
|
+
archiveSources: sources.archiveSources ?? DEFAULT_SOURCES.archiveSources,
|
|
135
152
|
searchSources: (sources.searchSources ?? []).map((s) => {
|
|
136
153
|
if (s && typeof s.headers === 'object' && Object.keys(s.headers).length > 0) {
|
|
137
154
|
const masked = {}
|
|
@@ -262,6 +279,13 @@ const DEFAULT_SOURCES = {
|
|
|
262
279
|
{ id: 'ghproxy-git', name: 'ghproxy 镜像', urlTemplate: 'https://ghproxy.net/https://github.com/{owner}/{repo}.git', primary: true },
|
|
263
280
|
{ id: 'github-git', name: 'GitHub 直连', urlTemplate: 'https://github.com/{owner}/{repo}.git', primary: false },
|
|
264
281
|
],
|
|
282
|
+
// archive 通道源(批次 C-⑨,2026-09-27 加法):git 协议拉不动时的等价替代 —— 直接 HTTP 下载
|
|
283
|
+
// 仓库压缩包。真机实测:同一个 ghproxy 域名下 archive 4 MB/s、git 协议 0 B/s。
|
|
284
|
+
// {branch} 占位符可选;模板里没有它时该 URL 对任何分支都成立(由站点自己决定)。
|
|
285
|
+
archiveSources: [
|
|
286
|
+
{ id: 'ghproxy-archive', name: 'ghproxy 镜像(archive)', urlTemplate: 'https://ghproxy.net/https://github.com/{owner}/{repo}/archive/refs/heads/{branch}.tar.gz', primary: true },
|
|
287
|
+
{ id: 'codeload-archive', name: 'GitHub codeload', urlTemplate: 'https://codeload.github.com/{owner}/{repo}/tar.gz/refs/heads/{branch}', primary: false },
|
|
288
|
+
],
|
|
265
289
|
// 索引合并模式:true = 所有索引源结果合并去重(公共索引 + 内网私有索引同时可见);
|
|
266
290
|
// false = 主→备只用一个(内网优先,更快)
|
|
267
291
|
indexMerge: false,
|
|
@@ -322,4 +346,4 @@ const GITEE_AUTH_URL = 'https://gitee.com/oauth/authorize'
|
|
|
322
346
|
|
|
323
347
|
const GITEE_TOKEN_URL = 'https://gitee.com/oauth/token'
|
|
324
348
|
const DEFAULT_SEARCH = 'dsh-plugin'
|
|
325
|
-
export { readSources, writeSources, maskSources, readSourceSecrets, writeSourceSecrets, isAllowedSourceUrl, isAllowedGitSourceUrl, readGiteeConfig, giteeStatusView, orderedRegistries, createGiteeOAuthState, consumeGiteeOAuthState, DEFAULT_SOURCES, GITEE_OAUTH_STATES, gitCloneUrls, gitCloneCandidates, GITEE_AUTH_URL, GITEE_TOKEN_URL, DEFAULT_SEARCH }
|
|
349
|
+
export { readSources, writeSources, maskSources, readSourceSecrets, writeSourceSecrets, isAllowedSourceUrl, isAllowedGitSourceUrl, readGiteeConfig, giteeStatusView, orderedRegistries, createGiteeOAuthState, consumeGiteeOAuthState, DEFAULT_SOURCES, GITEE_OAUTH_STATES, gitCloneUrls, gitCloneCandidates, GITEE_AUTH_URL, GITEE_TOKEN_URL, DEFAULT_SEARCH }
|