@noob-stupid/dsh-plugin-console 0.5.17 → 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 { 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 }
@@ -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 }
@@ -401,6 +401,85 @@ async function routeSources(req, res, rc) {
401
401
  sendJson(res, 200, { ok: true, sources: maskSources(sources) })
402
402
  return
403
403
  }
404
+ if (action === 'add-archive') {
405
+ // archive 通道源(批次 C-⑨):模板必须含 {owner}/{repo}({branch} 可选)
406
+ const name = typeof body.name === 'string' ? body.name.trim() : ''
407
+ const urlTemplate = typeof body.urlTemplate === 'string' ? body.urlTemplate.trim() : ''
408
+ if (!urlTemplate.includes('{owner}') || !urlTemplate.includes('{repo}')) {
409
+ sendError(res, 400, 'archive 源模板必须同时包含 {owner} 与 {repo} 占位符({branch} 可选)')
410
+ return
411
+ }
412
+ if (!isAllowedGitSourceUrl(urlTemplate)) {
413
+ sendError(res, 400, 'archive 源地址必须是 https://(或本机/私网 http://、file://)的合法 URL')
414
+ return
415
+ }
416
+ if ((sources.archiveSources ?? []).some((s) => s.urlTemplate === urlTemplate)) {
417
+ sendError(res, 400, '该 archive 源已存在')
418
+ return
419
+ }
420
+ sources.archiveSources = [...(sources.archiveSources ?? []), {
421
+ id: `arc-${Date.now().toString(36)}`,
422
+ name: name || urlTemplate,
423
+ urlTemplate,
424
+ primary: (sources.archiveSources ?? []).length === 0,
425
+ }]
426
+ await writeSources(sources)
427
+ sendJson(res, 200, { ok: true, sources: maskSources(sources) })
428
+ return
429
+ }
430
+ if (action === 'edit-archive') {
431
+ const id = typeof body.id === 'string' ? body.id : ''
432
+ const name = typeof body.name === 'string' ? body.name.trim() : ''
433
+ const urlTemplate = typeof body.urlTemplate === 'string' ? body.urlTemplate.trim() : ''
434
+ if (!urlTemplate.includes('{owner}') || !urlTemplate.includes('{repo}')) {
435
+ sendError(res, 400, 'archive 源模板必须同时包含 {owner} 与 {repo} 占位符({branch} 可选)')
436
+ return
437
+ }
438
+ if (!isAllowedGitSourceUrl(urlTemplate)) {
439
+ sendError(res, 400, 'archive 源地址必须是 https://(或本机/私网 http://、file://)的合法 URL')
440
+ return
441
+ }
442
+ const target = (sources.archiveSources ?? []).find((s) => s.id === id)
443
+ if (!target) {
444
+ sendError(res, 404, '没有这个 archive 源')
445
+ return
446
+ }
447
+ target.name = name || urlTemplate
448
+ target.urlTemplate = urlTemplate
449
+ await writeSources(sources)
450
+ sendJson(res, 200, { ok: true, sources: maskSources(sources) })
451
+ return
452
+ }
453
+ if (action === 'set-archive-primary') {
454
+ const id = typeof body.id === 'string' ? body.id : ''
455
+ const list = sources.archiveSources ?? []
456
+ if (!list.some((s) => s.id === id)) {
457
+ sendError(res, 404, '没有这个 archive 源')
458
+ return
459
+ }
460
+ for (const s of list) s.primary = s.id === id
461
+ await writeSources(sources)
462
+ sendJson(res, 200, { ok: true, sources: maskSources(sources) })
463
+ return
464
+ }
465
+ if (action === 'remove-archive') {
466
+ const id = typeof body.id === 'string' ? body.id : ''
467
+ const list = sources.archiveSources ?? []
468
+ if (!list.some((s) => s.id === id)) {
469
+ sendError(res, 404, '没有这个 archive 源')
470
+ return
471
+ }
472
+ const rest = list.filter((s) => s.id !== id)
473
+ if (rest.length === 0) {
474
+ sendError(res, 400, '至少保留一个 archive 源(可先添加自建镜像再删除默认源)')
475
+ return
476
+ }
477
+ if (!rest.some((s) => s.primary)) rest[0].primary = true
478
+ sources.archiveSources = rest
479
+ await writeSources(sources)
480
+ sendJson(res, 200, { ok: true, sources: maskSources(sources) })
481
+ return
482
+ }
404
483
  if (action === 'reset') {
405
484
  const defaults = JSON.parse(JSON.stringify(DEFAULT_SOURCES))
406
485
  setMarketIndexCache(null)
@@ -430,7 +509,7 @@ async function routeSources(req, res, rc) {
430
509
  sendJson(res, 200, { ok: true, sources: maskSources(sources) })
431
510
  return
432
511
  }
433
- sendError(res, 400, '未知操作(add / edit / set-primary / remove / add-search / remove-search / add-index / edit-index / set-index-primary / remove-index / set-index-merge / add-git / edit-git / set-git-primary / remove-git / gitee-setup / gitee-clear / reset)')
512
+ sendError(res, 400, '未知操作(add / edit / set-primary / remove / add-search / remove-search / add-index / edit-index / set-index-primary / remove-index / set-index-merge / add-git / edit-git / set-git-primary / remove-git / add-archive / edit-archive / set-archive-primary / remove-archive / gitee-setup / gitee-clear / reset)')
434
513
  return
435
514
  }
436
515
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noob-stupid/dsh-plugin-console",
3
- "version": "0.5.17",
3
+ "version": "0.5.18",
4
4
  "description": "DSH 框架升级安全与插件升级门控:一键升级、失败自动回滚、升级后回滚上版、旧插件不适配自动禁用;内置多源插件市场为发现层,插件源全部可自定义,可指向公司内网私有源 / 私有索引 / 本地 Git 仓库,纯内网离线可用 | Framework upgrade safety & plugin version gating for DSH, with a customizable multi-source plugin market: point every source at internal mirrors or a local file:// repo for intranet-only, offline installs.",
5
5
  "repository": {
6
6
  "type": "git",