@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.
@@ -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 { removeDirVerifiedWithRetry } from '../infra/fsx.js'
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
- * 现在措辞如实:说清"仍有 git 占用(已尝试结束)",并给出可复制的手动删除命令。 */
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
- if (e.skipped === true) return `${e.url}(探活失败,已跳过)`
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) return `${e.url}(超时,进程已结束)`
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}。可手动删除后重试:Remove-Item -Recurse -Force '${stuck[0].dir}'`
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
- async function probeSourceAlive(url, timeoutMs = 4000) {
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 fetch(url, { method: 'HEAD', redirect: 'follow', signal: AbortSignal.timeout(timeoutMs) })
195
- return res.ok || res.status === 403 || res.status === 405 // 部分站点不支持 HEAD,按"活着"处理
196
- } catch { return false }
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
- async function gitCloneRepo(repo, dest, source = 'github', timeout = 180000, deps = {}) {
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 = probeSourceAlive,
213
- removeDir = removeDirVerifiedWithRetry,
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
- for (const [sourceIndex, cand] of candidates.entries()) {
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
- break
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} 轮),残留目录清不掉:${part}`
262
- : `克隆超时;git 进程已结束,但残留目录重试 ${cleared.rounds ?? cleared.attempts ?? 1} 轮后仍清不掉(可能被其它程序/杀软占用):${part}`)
263
- : `克隆失败,且残留目录重试 ${cleared.rounds ?? cleared.attempts ?? 1} 轮后仍清不掉(可能仍被 git 占用):${part}`,
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
- break // 清不掉的残留和这个源绑着,换下一个源(新目录不受影响)
449
+ return null // 清不掉的残留和这个源绑着,换下一个源(新目录不受影响)
271
450
  }
272
- if (res.timedOut === true && round === 0) {
273
- // ★ 同源、更长超时,重试一次(真机:ghproxy 卡死时短超时不够,长超时能过)
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({ url, message: res.timedOut === true ? `克隆超时(${url})` : (res.stderr !== '' ? res.stderr : `git clone 退出码 ${res.code}`), stderr: res.stderr, timedOut: res.timedOut === true, dir: part })
278
- break
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 }