@noob-stupid/dsh-plugin-console 0.5.17 → 0.5.19

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 { disposeDir, disposeNote } 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)。 */
@@ -120,9 +121,18 @@ function summarizeCloneErrors(errors) {
120
121
  const first = errors[0]
121
122
  const tried = errors.map((e) => {
122
123
  if (e.unclean === true) return `${e.url}(残留目录被占用,已跳过重试)`
123
- 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}(探活失败,已降级到本轮末尾重试)`
124
129
  if (e.retrying === true) return `${e.url}(超时,已改用更长超时重试)`
125
- 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
+ }
126
136
  return /already exists and is not an empty directory/u.test(e.message) ? `${e.url}(目录非空)` : e.url
127
137
  }).join(';')
128
138
  const detail = gitErrorDetail(first)
@@ -156,6 +166,72 @@ function waitChildExit(child, timeoutMs, pollMs = 60) {
156
166
  })
157
167
  }
158
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
+
159
235
  /** 跑一次 git clone:支持超时,且**超时即杀掉整棵树并等它真的退出**。返回 { code, stderr, timedOut, pid, exited }。 */
160
236
  function runGitClone(url, dest, timeout, { spawnFn = spawn, killTree = killProcessTree, exitWaitMs = 800, exitWaitMs2 = 300 } = {}) {
161
237
  return new Promise((resolve) => {
@@ -165,7 +241,7 @@ function runGitClone(url, dest, timeout, { spawnFn = spawn, killTree = killProce
165
241
  let child = null
166
242
  let stderr = ''
167
243
  try {
168
- child = spawnFn('git', ['clone', '--depth', '1', '--quiet', url, dest], {
244
+ child = spawnFn('git', [...GIT_STALL_ARGS, 'clone', '--depth', '1', '--quiet', url, dest], {
169
245
  windowsHide: true,
170
246
  env: gitEnv(),
171
247
  detached: process.platform !== 'win32', // POSIX:自成进程组,便于 -pid 整体杀
@@ -192,12 +268,64 @@ function runGitClone(url, dest, timeout, { spawnFn = spawn, killTree = killProce
192
268
  })
193
269
  }
194
270
 
195
- /** 源探活:镜像站"连得上但传不动"探不出来,但**域名挂掉/被墙**能提前识别,省掉一整个克隆超时的白等。 */
196
- 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: '本地裸仓库' }
197
304
  try {
198
- const res = await fetch(url, { method: 'HEAD', redirect: 'follow', signal: AbortSignal.timeout(timeoutMs) })
199
- return res.ok || res.status === 403 || res.status === 405 // 部分站点不支持 HEAD,按"活着"处理
200
- } 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
201
329
  }
202
330
 
203
331
  /** git clone(镜像→直连;gitee 直连),返回 { url, attempt, dir, source, retried } 或抛错。
@@ -211,33 +339,67 @@ async function probeSourceAlive(url, timeoutMs = 4000) {
211
339
  * 一旦它超时就"整次安装彻底失败"——现在先重试它一次,再谈别的源)。
212
340
  * 2026-09-26(本次改错):清理默认实现由 removeDirVerifiedWithRetry 换成 **disposeDir**
213
341
  * (删不掉就改名降级成同父目录的 `.trash-<ts>-<rand>`,见 infra/fsx.js)——残留目录不再需要用户
214
- * 手动删除,也不再因为"清不掉"而放弃后面本来可用的源。`deps.removeDir` 这个注入口名字没变。 */
215
- async function gitCloneRepo(repo, dest, source = 'github', timeout = 180000, deps = {}) {
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 = {}) {
216
352
  const {
217
353
  spawnFn = spawn,
218
354
  killTree = killProcessTree,
219
- probe = probeSourceAlive,
355
+ probe = null,
356
+ probeDetail = null,
220
357
  removeDir = disposeDir,
221
358
  renameDir = renameSync,
222
359
  exitWaitMs = 800,
223
360
  retryFactor = 1.75,
224
361
  readMemo = readGitSourceMemo,
225
362
  writeMemo = rememberGitSource,
363
+ measureBytes = measureProgressBytes,
364
+ archive = archiveRepo,
365
+ archiveBranch = 'main',
366
+ reuseLanded = true,
367
+ findLanded = findLandedRepo,
368
+ copyLanded = copyTree,
226
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))
227
392
  let preferred = ''
228
393
  try { preferred = readMemo() } catch { preferred = '' }
229
394
  const candidates = orderGitCandidates(gitCloneCandidates(repo, source), preferred)
230
395
  const retryTimeout = Math.max(timeout + 1, Math.round(timeout * retryFactor))
231
396
  const errors = []
232
397
  let partSeq = 0
233
- for (const [sourceIndex, cand] of candidates.entries()) {
398
+ const deferred = []
399
+ /** 试一个源(最多两轮:正常超时 → 有进度才用更长超时重试)。
400
+ * 返回 `{ result }` 表示克隆成功;返回 null 表示这个源用完了,继续下一个源。 */
401
+ const attemptSource = async (cand, sourceIndex, isDeferred) => {
234
402
  const url = cand.url
235
- const alive = await probe(url)
236
- if (!alive) {
237
- partSeq += 1
238
- errors.push({ url, message: `源探活失败(连不上):${url}`, skipped: true, dir: `${dest}.try${partSeq}` })
239
- continue
240
- }
241
403
  // 同一个源最多两次:正常超时 → 更长超时重试一次
242
404
  for (let round = 0; round < 2; round += 1) {
243
405
  partSeq += 1
@@ -251,14 +413,18 @@ async function gitCloneRepo(repo, dest, source = 'github', timeout = 180000, dep
251
413
  removeDir(dest)
252
414
  renameDir(part, dest)
253
415
  try { writeMemo(cand.urlTemplate) } catch {}
254
- 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 } }
255
417
  } catch (error) {
256
- errors.push({ url, message: `克隆成功但落地失败(${error instanceof Error ? error.message : String(error)})`, dir: part })
257
- break
418
+ errors.push({ url, message: `克隆成功但落地失败(${error instanceof Error ? error.message : String(error)})`, dir: part, deferred: isDeferred })
419
+ return null
258
420
  }
259
421
  }
260
- // 失败:先尽力清掉半成品目录(带重试的核实删除 → 删不掉则**改名降级**成 .trash-*);
422
+ // 失败:先量**本次到底收到了多少字节**(决定"配不配长超时重试",也是给用户看的事实),
423
+ // 再尽力清掉半成品目录(带重试的核实删除 → 删不掉则**改名降级**成 .trash-*);
261
424
  // 只有连改名都失败才如实说明(不再谎报"环境禁止删除",也不再让用户去命令行手动删)
425
+ let bytesReceived = 0
426
+ try { bytesReceived = measureBytes(part) } catch { bytesReceived = 0 }
427
+ const hasProgress = bytesReceived > 0
262
428
  const cleared = removeDir(part)
263
429
  const trashNote = cleared !== null && cleared !== undefined && typeof cleared.trashPath === 'string' && cleared.trashPath !== ''
264
430
  ? disposeNote(cleared)
@@ -276,20 +442,92 @@ async function gitCloneRepo(repo, dest, source = 'github', timeout = 180000, dep
276
442
  unclean: true,
277
443
  timedOut: res.timedOut === true,
278
444
  exited: res.exited !== false,
445
+ bytesReceived,
279
446
  dir: part,
447
+ deferred: isDeferred,
280
448
  })
281
- break // 清不掉的残留和这个源绑着,换下一个源(新目录不受影响)
449
+ return null // 清不掉的残留和这个源绑着,换下一个源(新目录不受影响)
282
450
  }
283
- if (res.timedOut === true && round === 0) {
284
- // ★ 同源、更长超时,重试一次(真机:ghproxy 卡死时短超时不够,长超时能过)
285
- errors.push({ url, message: `克隆超时(${useTimeout}ms),改用 ${retryTimeout}ms 同源重试`, stderr: res.stderr, timedOut: true, retrying: true, dir: part, ...trashField })
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 })
286
454
  continue
287
455
  }
288
- 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, ...trashField })
289
- 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
+ })
290
521
  }
291
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
+ }
292
530
  throw new Error(summarizeCloneErrors(errors))
293
531
  }
294
532
 
295
- 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 }
@@ -7,8 +7,10 @@ import { tmpdir } from 'node:os'
7
7
  import { appendInsert } from './patch.js'
8
8
  import { gitCloneRepo } from './repoland.js'
9
9
  import { deriveEntryId, listEntries } from './runtime.js'
10
+ import { noteChannel } from './git-channel.js'
11
+ import { orderedRegistries, readSources } from './sources.js'
10
12
  import { copyTree } from '../infra/fsx.js'
11
- import { looksLikeGitmodules, rawTextWithFallback } from '../infra/http.js'
13
+ import { GITHUB_API, META_BUDGET_MS, curlJson, fetchJsonUrl, githubJson, looksLikeGitmodules, rawTextWithFallback } from '../infra/http.js'
12
14
  import { dshHome, findPatchPath } from '../infra/paths.js'
13
15
 
14
16
  /** 解析 .gitmodules:返回 [{name, path, url}](submodule 套装识别用)。 */
@@ -92,16 +94,140 @@ function resolveInstallKind(requestKind, probeText) {
92
94
  return looksLikeGitmodules(probeText) ? 'suite' : 'plugin'
93
95
  }
94
96
 
97
+ /** 仓库元数据(默认分支)探测:githubJson 与 curlJson 竞速 + META_BUDGET_MS 降级(黑洞期不卡 40s)。
98
+ * 0.5.19 从 install-job.js 原样搬来(**只搬移,表达式一字未改**):套装判定前要先读根包名
99
+ * (见 shouldRunSuiteInstall),这次探测的结果缓存在 job.repoMeta 上供下游复用,同一个作业不重复联网。
100
+ * 8s 而非旧值 3s:IPv6 无路由的环境里单条通道就要 5.4s,3s 预算必输 → branch 恒为 main,
101
+ * 默认分支为 dev 的仓库会取错分支(2026-09-20 另一位用户实测)。 */
102
+ async function fetchRepoMeta(repo) {
103
+ const meta = await Promise.race([
104
+ Promise.any([
105
+ githubJson(`${GITHUB_API}/repos/${repo}`),
106
+ curlJson(`${GITHUB_API}/repos/${repo}`, 12000, {}, { ipv4: true }),
107
+ ]),
108
+ new Promise((resolve) => setTimeout(() => resolve(null), META_BUDGET_MS)),
109
+ ]).catch(() => null)
110
+ return meta ?? null
111
+ }
112
+
113
+ /** 这个包名在 registry 上**确实存在**吗?只有确定性命中才算"存在":404 / 超时 / 不可达一律当"查不到"。
114
+ * 口径必须这么窄的理由:查不到不能推翻 .gitmodules 判据 —— 网络问题不该把套装安装变成插件安装。
115
+ * 用既有 npm 元数据能力(与 market.js 读 packument 同一套:配置里的软件源顺序 + fetchJsonUrl),
116
+ * 最多看前 2 个源、每源 6 秒封顶;测试用 probes.namePublished 替换(见 test-suite-fallback.mjs)。 */
117
+ async function namePublishedOnRegistry(name) {
118
+ const encoded = name.startsWith('@')
119
+ ? `@${encodeURIComponent(name.slice(1).split('/')[0])}%2f${encodeURIComponent(name.split('/').slice(1).join('/'))}`
120
+ : encodeURIComponent(name)
121
+ for (const reg of orderedRegistries(readSources()).slice(0, 2)) {
122
+ try {
123
+ // eslint-disable-next-line no-await-in-loop
124
+ const data = await fetchJsonUrl(`${reg}/${encoded}`, 6000)
125
+ const hasLatest = typeof data?.['dist-tags']?.latest === 'string'
126
+ const hasVersions = data !== null && typeof data === 'object' && data.versions !== null && typeof data.versions === 'object' && Object.keys(data.versions).length > 0
127
+ if (hasLatest || hasVersions) return true
128
+ } catch {}
129
+ }
130
+ return false
131
+ }
132
+
133
+ /** 套装判定(唯一入口,0.5.19 改错):**先看根包是否已发布,再决定要不要按 .gitmodules 走套装**。
134
+ * 真机事故(2026-09-27,用户点装 zhu1090093659/dsh-web 全家桶):市场卡片点「添加到本地」只带 owner/repo、
135
+ * 不带包名 → candidates=[] → 旧流程直接探 .gitmodules(该仓库根目录确实有一份)→ 第一步就 clone 整个仓库
136
+ * (429 MB),而真正能装上的子包 @linxin666/dsh-web-all(5.97 MB)根本没机会被尝试。
137
+ * 判据顺序(每一步都只做"确认",不做"猜测"):
138
+ * ① 读根 package.json(下游本来也要读,结果缓存进 job 复用)→ 有 name 且 registry 上存在 → **插件通道**;
139
+ * ② 否则按 .gitmodules **内容**判(内容不像 gitmodules 就不是套装 —— 2026-09-19 事故的口径不变);
140
+ * ③ 判成套装后再问一句 registry:根包没发布、但**子包**已发布(真机 dsh-web 就是这种)→ 插件通道。
141
+ * 理由:仓库里有已发布的可安装单元时,按包名装才是"装得上 + 能随 lock 更新"的那条路;
142
+ * 子包也都没发布(子模块是纯 git 组件)→ 照旧走套装装配,能力一点没少。
143
+ * 返回 true = 走套装安装。副作用:job.repoMeta / job.defaultBranch / job.rootPkgProbe / job.subpackageProbe
144
+ * 缓存本次探测结果,下游候选循环直接复用(install-job.js 里带 `??` 的那几行)。 */
145
+ async function shouldRunSuiteInstall(job, probes = {}) {
146
+ // 同一作业只判一次:缓存下来后 install-job(判定套装分支)与套装作业入口(显式「安装套装」)
147
+ // 共用同一个结论,第二次调用零联网 —— 见 runSuiteInstallJob 顶部的同一道判据。
148
+ if (job.suiteDecision !== undefined) return job.suiteDecision
149
+ const decide = (value, reason) => {
150
+ job.suiteDecision = value
151
+ job.suiteDecisionReason = reason
152
+ return value
153
+ }
154
+ if (job.kind === 'skill') return decide(false, '技能请求不走套装通道')
155
+ const gmProbe = typeof probes.probeGitmodules === 'function' ? probes.probeGitmodules : probeGitmodules
156
+ const meta = await fetchRepoMeta(job.repo).catch(() => null)
157
+ job.repoMeta = meta
158
+ const branch = meta?.default_branch ?? 'main'
159
+ job.defaultBranch = branch
160
+ let root = null
161
+ if (typeof probes.fetchRepoPackageEx === 'function') {
162
+ // 探测异常不改变结论:回落成"按 .gitmodules 判"(旧行为),不让一次探测把安装打挂
163
+ try { root = await probes.fetchRepoPackageEx(job.repo, branch) } catch { root = null }
164
+ job.rootPkgProbe = root
165
+ }
166
+ const rootPkg = root !== null && root !== undefined && root.pkg !== null && typeof root.pkg === 'object' ? root.pkg : null
167
+ const name = rootPkg !== null && typeof rootPkg.name === 'string' ? rootPkg.name : ''
168
+ const published = async (pkgName) => (typeof probes.namePublished === 'function'
169
+ ? (await probes.namePublished(pkgName)) === true
170
+ : namePublishedOnRegistry(pkgName))
171
+ if (name !== '' && (await published(name)) === true) {
172
+ job.rootPublished = name
173
+ noteChannel(job, `根包 ${name} 已发布到 npm:优先插件通道(不判套装、不克隆仓库)`)
174
+ return decide(false, `根包 ${name} 已发布到 npm`)
175
+ }
176
+ if (resolveInstallKind(job.kind, await gmProbe(job.repo)) !== 'suite') return decide(false, '仓库根目录没有有效的 .gitmodules(不是 submodule 套装仓库)')
177
+ if (name !== '' && typeof probes.subpackageCandidates === 'function') {
178
+ let subs = []
179
+ try { subs = await probes.subpackageCandidates(job.repo, branch) } catch { subs = [] }
180
+ if (Array.isArray(subs) && subs.length > 0) {
181
+ job.subpackageProbe = subs
182
+ for (const cand of subs.slice(0, 3)) {
183
+ // eslint-disable-next-line no-await-in-loop
184
+ if ((await published(cand)) === true) {
185
+ job.subpackagePreferred = cand
186
+ noteChannel(job, `根包未发布到 npm,但子包 ${cand} 已发布:优先插件通道(跳过套装克隆,避免白拉整个仓库)`)
187
+ return decide(false, `根包未发布到 npm,但子包 ${cand} 已发布到 npm`)
188
+ }
189
+ }
190
+ }
191
+ }
192
+ return decide(true, '')
193
+ }
194
+
95
195
  /** 套装安装(submodule 聚合仓库):照仓库 install.ps1/README 语义——
96
196
  * clone 套装 → 手动镜像拉取子模块 → 按类型装配(bundle 插件含 Release tgz 兜底 / 普通插件 / 技能 / agent 预设)。
97
197
  * 不执行第三方脚本本体(安全护栏:脚本型只读语义不运行)。 */
98
- async function runSuiteInstallJob(job, ports) {
198
+ async function runSuiteInstallJob(job, ports, deps = {}) {
199
+ // 根克隆的实现可注入(测试注入缝,沿用 runInstallJob 的 deps 风格:生产调用方不传第三个参数)
200
+ const gitClone = typeof deps.gitClone === 'function' ? deps.gitClone : gitCloneRepo
201
+ // 0.5.19:套装作业入口的**同一道判据** —— 前端 hasSuite=true 的卡片点「添加到本地」时发的是
202
+ // kind=suite,路由会直接调到这里(routes/install.js 的 runSuiteThenFallback),根本不经过 install-job
203
+ // 的候选循环;真机 dsh-web 的 429 MB 就是这条路拉起来的。判定结论缓存在 job.suiteDecision 上:
204
+ // install-job 已经判过(并注入过探测桩)时这里零联网直接复用;路由调用时由它把真实探测传进来。
205
+ const decided = job.suiteDecision === undefined
206
+ ? await shouldRunSuiteInstall(job, deps.probes ?? {})
207
+ : job.suiteDecision
208
+ if (decided === false) {
209
+ job.stage = 'detecting'
210
+ return { notASuite: true, reason: `${job.suiteDecisionReason ?? '该仓库有已发布到 npm 的可安装包'},已自动回落普通插件安装` }
211
+ }
99
212
  const tmpDir = join(tmpdir(), `dsh-suite-${job.id}-${Date.now()}`)
100
213
  const report = []
101
214
  try {
102
215
  job.stage = 'preparing'
103
216
  mkdirSync(tmpDir, { recursive: true })
104
- await gitCloneRepo(job.repo, tmpDir, job.source)
217
+ try {
218
+ await gitClone(job.repo, tmpDir, job.source)
219
+ } catch (cloneError) {
220
+ // 0.5.19(改错):套装仓库**克隆失败**不再把作业判 failed。
221
+ // 旧代码整个函数只有一个 try/catch → 根克隆一失败就 job.status='failed'(真机 dsh-web:429 MB 巨仓、
222
+ // ghproxy 的 git 协议 0 B/s,必然失败),而 notASuite(把决定权交回普通通道)**只在"克隆成功但
223
+ // .gitmodules 为空"时才返回** → 克隆失败等于没有任何回落,用户只看到一个失败的任务。
224
+ // 克隆失败只证明"这条路走不通",不证明"装不上":回落普通通道(npm → Release → git,按包名施工)。
225
+ job.stage = 'detecting'
226
+ return {
227
+ notASuite: true,
228
+ reason: `套装仓库克隆失败(${cloneError instanceof Error ? cloneError.message : String(cloneError)}),已自动回落普通插件安装`,
229
+ }
230
+ }
105
231
  const subs = readGitmodules(tmpDir)
106
232
  if (subs.length === 0) {
107
233
  // 探测与仓库实际内容不符(假阳性 / 仓库已重构):**不报失败**,把决定权交回调用方
@@ -217,4 +343,4 @@ async function runSuiteInstallJob(job, ports) {
217
343
  job.finishedAt = Date.now()
218
344
  }
219
345
  }
220
- export { readGitmodules, findPresetDirs, packageEntryExists, runSuiteInstallJob, probeGitmodules, resolveInstallKind }
346
+ export { readGitmodules, findPresetDirs, packageEntryExists, runSuiteInstallJob, probeGitmodules, resolveInstallKind, fetchRepoMeta, shouldRunSuiteInstall }
@@ -90,9 +90,18 @@ async function runPnpmWithFallback(args, { execOpts = {}, runners = resolvePnpmR
90
90
 
91
91
  /** git 非交互环境:禁止任何登录/凭据窗口弹出(私有仓库或不可达源直接失败,不做交互式重试)。 */
92
92
 
93
+ /** git 停滞判据的 **env 形式**(2026-09-27 加法):与 domain/repoland.js 的
94
+ * `-c http.lowSpeedLimit=1 -c http.lowSpeedTime=20` 同一语义(连续 20 秒 <1 B/s 就让 git 自己退出),
95
+ * 用途是覆盖**我们没有直接 spawn git** 的路径 —— 主要是 `pnpm add git+https://…`(pnpm 内部自己跑
96
+ * git,我们管不到它的命令行),以及 ai-run / skills / components 里其它 git 调用点。
97
+ * 实测(本机"只连不传"的 TCP 桩源):只带这两个 env、**不带** `-c`,git 同样在 20.4 秒退出并报
98
+ * `Operation too slow. Less than 1 bytes/sec transferred the last 20 seconds`(见 tests/test-git-stall-guard.mjs)。 */
99
+ const GIT_LOW_SPEED_ENV = { GIT_HTTP_LOW_SPEED_LIMIT: '1', GIT_HTTP_LOW_SPEED_TIME: '20' }
100
+
93
101
  function gitEnv() {
94
102
  return {
95
103
  ...process.env,
104
+ ...GIT_LOW_SPEED_ENV,
96
105
  GIT_TERMINAL_PROMPT: '0',
97
106
  GCM_INTERACTIVE: 'never',
98
107
  GIT_ASKPASS: 'echo',
@@ -355,6 +364,9 @@ function buildPnpmEnv(registry, base = process.env) {
355
364
  // git 通道禁止交互式凭据(与 gitEnv 同一语义):避免 Git Credential Manager 弹登录窗
356
365
  GIT_TERMINAL_PROMPT: '0',
357
366
  GCM_INTERACTIVE: 'never',
367
+ // pnpm 的 git 依赖(`git+https://…`)由 pnpm 自己 spawn git,我们传不了命令行选项 →
368
+ // 用 env 给它同一套停滞判据(否则一个 0 B/s 的镜像能挂满 pnpm 的 fetch 超时)
369
+ ...GIT_LOW_SPEED_ENV,
358
370
  ...pnpmEnvOverrides(registry),
359
371
  }
360
372
  }
@@ -422,4 +434,4 @@ const GH_BIN_CANDIDATES = [
422
434
  join(homedir(), 'scoop', 'shims', 'gh.exe'),
423
435
  ]
424
436
 
425
- export { GH_BIN_CANDIDATES, gitEnv, gitBin, processAlive, execFileAsync, resolvePnpmRunners, runPnpmWithFallback, killProcessTree, posixDescendants, execFileWithKillTree, KILL_WAIT_MS, KILL_WAIT_POLL_MS, pnpmEnvOverrides, buildPnpmEnv, pnpmFetchArgs, pnpmAddArgs, unknownPnpmOption, runPnpmAdd }
437
+ export { GH_BIN_CANDIDATES, gitEnv, gitBin, processAlive, execFileAsync, resolvePnpmRunners, runPnpmWithFallback, killProcessTree, posixDescendants, execFileWithKillTree, KILL_WAIT_MS, KILL_WAIT_POLL_MS, GIT_LOW_SPEED_ENV, pnpmEnvOverrides, buildPnpmEnv, pnpmFetchArgs, pnpmAddArgs, unknownPnpmOption, runPnpmAdd }