@noob-stupid/dsh-plugin-console 0.5.24 → 0.5.26

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.
@@ -0,0 +1,155 @@
1
+ // L1 · domain —— repo-size.js(仓库体积探测 + 「源码通道」放行判据;2026-09-27 加法,批次 D-①)
2
+ //
3
+ // 为什么要这个模块(真机事故,2026-09-27,用户实测 yjh051108/dsh-routing-suite「三件套只装到两件」):
4
+ // 0.5.18 的批次 A-③ 为了治 zhu1090093659/dsh-web(429 MB)被白拉,写成
5
+ // 「**根包未发布(private)→ 禁用 git/archive 通道**」——判据挂在"根包 private"这一个布尔量上。
6
+ // 而 dsh-routing-suite 的根包恰好也是 private,于是它的源码通道被一并跳过:
7
+ // `graded/`、`injector/` 有 release 产物(装上了),**`preset/`(= dsh-router-standard 预设)
8
+ // 只存在于仓库源码里** → npm 双 404、release 无资产 → 第三件永远装不上。
9
+ // 一刀切的错在于:真正的成本是**仓库体积**,不是"根包是否 private"。
10
+ // 本模块把判据换成按体积分档(小仓库放行 / 巨仓仍禁 / 探测不到时保守但不沉默)。
11
+ //
12
+ // 探测复用既有 http 多通道(githubJson 的官方+镜像+gh 竞速、curlJson 的 curl 快通道),
13
+ // 与 suite.js#fetchRepoMeta 同一套写法与同一个预算常量;结果按仓库缓存(默认 6 小时)。
14
+
15
+ import { GITHUB_API, META_BUDGET_MS, curlJson, githubJson } from '../infra/http.js'
16
+
17
+ /** 源码通道的体积上限(MB,可用 `DSH_GIT_MAX_REPO_MB` 配置,夹在 1 MB ~ 4096 MB)。
18
+ * 20 MB 的取法:真机能装的最小聚合子包 @linxin666/dsh-web-all 是 5.97 MB,
19
+ * 社区插件仓库实测中位数远小于 20 MB;而 dsh-web 的 429 MB 是它的 21 倍 —— 阈值落在两簇之间,
20
+ * 不会因为"多几 MB 的文档/截图"把正常插件误杀,也不会放过巨仓。 */
21
+ const GIT_MAX_REPO_MB = 20
22
+ function gitMaxRepoMb(env = process.env) {
23
+ const raw = Number.parseInt(String(env?.DSH_GIT_MAX_REPO_MB ?? ''), 10)
24
+ if (!Number.isFinite(raw)) return GIT_MAX_REPO_MB
25
+ return Math.min(4096, Math.max(1, raw))
26
+ }
27
+
28
+ /** KB → MB 可读写法(保留 1 位小数;只服务面板文案)。 */
29
+ function formatRepoSize(sizeKb) {
30
+ const kb = Number(sizeKb)
31
+ if (!Number.isFinite(kb) || kb < 0) return '未知'
32
+ return `${(kb / 1024).toFixed(1)} MB`
33
+ }
34
+
35
+ /** 源码通道判据(**纯函数**,边界矩阵单测覆盖):探测结果 × 阈值 → { allow, note }。
36
+ * · state='known' 且 sizeKb ≤ 阈值 → 放行(note 说清体积与阈值,用户知道"为什么这次允许")
37
+ * · state='known' 且 sizeKb > 阈值 → **仍禁**,note 必须含体积、原因与出路(仓库落地 / 本地镜像)
38
+ * · state='unknown'(探测失败/超时/404)→ **保守但不沉默**:保持禁令 + note 说明"尺寸未知,已保守跳过"
39
+ * 返回的 note 一律非空 —— 静默跳过正是这次事故的另一半(用户什么都看不到)。 */
40
+ function sourceChannelGate(options = {}, thresholdMb = GIT_MAX_REPO_MB) {
41
+ // ⚠️ 这里**故意不用解构默认值**(旧代码是 `{ state = 'unknown', sizeKb = null } = {}`):
42
+ // 解构的 `= null` 会在"键存在但值是 undefined"时也填上 null,于是**原始值被抹掉**——
43
+ // 而本次改错要判的恰恰是"读到的到底是什么",note 里必须分得出 null / undefined / 空串 / false。
44
+ const raw = options !== null && typeof options === 'object' ? options : {}
45
+ const provided = Object.prototype.hasOwnProperty.call(raw, 'sizeKb')
46
+ const state = raw.state === undefined ? 'unknown' : raw.state
47
+ const rawSize = provided ? raw.sizeKb : null
48
+ const sizeKb = rawSize
49
+ const limit = Number.isFinite(Number(thresholdMb)) && Number(thresholdMb) >= 1 ? Number(thresholdMb) : GIT_MAX_REPO_MB
50
+ const kb = Number(sizeKb)
51
+ // 判据必须落在**原始值**上:旧代码写的是 `Number(sizeKb)` 之后再判有限性,于是
52
+ // `sizeKb: null` → `Number(null) === 0` → 有限且 ≥ 0 → **被当成 0 MB 放行**(`undefined` 同理 → NaN→
53
+ // 有限性为假,那条反倒是禁的;真正漏的是 null / '' / false / [] 这些"能转成 0"的值)。
54
+ // 0 KB 只有在**真的是数字 0**时才是合法尺寸(空仓库;见边界断言),"读不到尺寸"绝不能伪装成 0。
55
+ const numericSize = typeof rawSize === 'number' && Number.isFinite(rawSize) && rawSize >= 0
56
+ const known = state === 'known' && numericSize
57
+ if (!known) {
58
+ // state='known' 但尺寸不是有限数 = 上游给了自相矛盾的结论(探测层不会这样产出)。
59
+ // 这时必须**如实说清**并保守跳过;旧文案会把它说成"探测失败/超时/404",与真实原因不符。
60
+ const contradiction = state === 'known'
61
+ const shown = provided ? String(rawSize) : 'undefined(未提供)'
62
+ return {
63
+ allow: false,
64
+ state: 'unknown',
65
+ sizeKb: null,
66
+ limitMb: limit,
67
+ note: contradiction
68
+ ? `仓库体积读到的不是有效数字(state=known 但 sizeKb=${shown}):已**保守跳过**源码通道(git / archive / 稀疏取源码)—— 尺寸读不出来时绝不能当成 0 MB 放行。请重试;或改用「仓库落地」把仓库克隆到本地目录后安装。`
69
+ : '仓库体积未知(GitHub 元数据探测失败/超时/404):已**保守跳过**源码通道(git / archive / 稀疏取源码),避免白拉一个可能几百 MB 的仓库。请重试;或改用「仓库落地」把仓库克隆到本地目录后安装。',
70
+ }
71
+ }
72
+ if (kb <= limit * 1024) {
73
+ return {
74
+ allow: true,
75
+ state: 'known',
76
+ sizeKb: kb,
77
+ limitMb: limit,
78
+ note: `仓库体积 ${formatRepoSize(kb)}(上限 ${limit} MB):已放行源码通道(git / archive / 稀疏取源码)`,
79
+ }
80
+ }
81
+ return {
82
+ allow: false,
83
+ state: 'known',
84
+ sizeKb: kb,
85
+ limitMb: limit,
86
+ // 同时给 MB 与 KB:刚好卡在阈值附近时(20481 KB vs 20480 KB)MB 会四舍五入成同一个数,
87
+ // 只写 MB 会让用户看不出"到底超了多少"。
88
+ note: `仓库过于庞大(${formatRepoSize(kb)}/${Math.round(kb)} KB,上限 ${limit} MB):已跳过源码通道(git / archive / 稀疏取源码)——请用「仓库落地」把仓库克隆到本地目录,或改用本地镜像/子包(已发布到 npm 的聚合包优先)。`,
89
+ }
90
+ }
91
+
92
+ /** 探测结果缓存(默认 6 小时):同一个作业里 git 通道与预设源码通道会各问一次,
93
+ * 跨作业也会反复问(市场卡片重试)。缓存键是 owner/repo(大小写不敏感)。
94
+ * 只缓存**成功**结果:探测失败不落缓存,下次重试有机会拿到尺寸(免得一次网络抖动把仓库
95
+ * 永久钉在"尺寸未知"上)。 */
96
+ const SIZE_CACHE_TTL_MS = 6 * 60 * 60 * 1000
97
+ const sizeCache = new Map()
98
+
99
+ /** 仓库体积探测:`GET /repos/{owner}/{repo}` 的 `size`(KB)。
100
+ * 走既有 http 多通道(githubJson 官方+镜像竞速,且带 gh CLI 兜底;curlJson 走系统网络栈),
101
+ * 与 META_BUDGET_MS 同一个外层预算(黑洞期不卡 40 秒)。
102
+ * 返回 { state: 'known'|'unknown', sizeKb, note, cached }。**绝不抛**(探测失败是正常结局)。 */
103
+ async function fetchRepoSize(repo, deps = {}) {
104
+ const full = String(repo ?? '').trim().replace(/\.git$/u, '')
105
+ if (full === '' || !full.includes('/')) return { state: 'unknown', sizeKb: null, note: '仓库名无效', cached: false }
106
+ const key = full.toLowerCase()
107
+ const now = deps.now ?? Date.now
108
+ const cache = deps.cache ?? sizeCache
109
+ const ttl = Number.isFinite(deps.ttlMs) ? deps.ttlMs : SIZE_CACHE_TTL_MS
110
+ const hit = cache.get(key)
111
+ if (hit !== undefined && now() - hit.at < ttl) return { ...hit.value, cached: true }
112
+ const race = deps.race ?? (async () => Promise.race([
113
+ Promise.any([
114
+ githubJson(`${GITHUB_API}/repos/${full}`, undefined, deps.token ?? null),
115
+ curlJson(`${GITHUB_API}/repos/${full}`, 12000, {}, { ipv4: true }),
116
+ ]),
117
+ new Promise((resolve) => setTimeout(() => resolve(null), Number.isFinite(deps.budgetMs) ? deps.budgetMs : META_BUDGET_MS)),
118
+ ]).catch(() => null))
119
+ let meta = null
120
+ try { meta = await race() } catch { meta = null }
121
+ const sizeKb = Number(meta?.size)
122
+ if (meta !== null && meta !== undefined && Number.isFinite(sizeKb) && sizeKb >= 0) {
123
+ const value = { state: 'known', sizeKb, note: '', cached: false }
124
+ try { cache.set(key, { at: now(), value }) } catch {}
125
+ return value
126
+ }
127
+ return { state: 'unknown', sizeKb: null, note: '未能读到仓库体积(元数据探测失败/超时/404)', cached: false }
128
+ }
129
+
130
+ /** 一次算完「这个仓库的源码通道能不能走」:探测 → 纯判据。deps.probe 只为单测注入。 */
131
+ async function evaluateSourceChannel(repo, deps = {}) {
132
+ const probe = typeof deps.probe === 'function' ? deps.probe : fetchRepoSize
133
+ let result = null
134
+ try { result = await probe(repo, deps) } catch { result = null }
135
+ const state = result?.state === 'known' ? 'known' : 'unknown'
136
+ const gate = sourceChannelGate({ state, sizeKb: result?.sizeKb ?? null }, deps.thresholdMb ?? gitMaxRepoMb(deps.env ?? process.env))
137
+ return { ...gate, cached: result?.cached === true }
138
+ }
139
+
140
+ /** 把判据落到 job 上(唯一入口,install-job 的两处 private 根分支共用):
141
+ * 写 job.privateRoot / job.repoSize / job.gitChannelBlocked / job.sourceChannelBlocked,并把 note 交给调用方记档。
142
+ * 返回值同 evaluateSourceChannel(调用方若要给更细的下一步文案可直接用 note)。 */
143
+ async function gatePrivateRoot(job, note, deps = {}) {
144
+ const gate = await evaluateSourceChannel(job?.repo, deps)
145
+ if (job !== null && job !== undefined) {
146
+ job.privateRoot = true
147
+ job.repoSize = { state: gate.state, sizeKb: gate.sizeKb, limitMb: gate.limitMb }
148
+ job.gitChannelBlocked = gate.allow !== true
149
+ job.sourceChannelBlocked = gate.allow !== true
150
+ }
151
+ if (typeof note === 'function') note(job, gate.note)
152
+ return gate
153
+ }
154
+
155
+ export { GIT_MAX_REPO_MB, SIZE_CACHE_TTL_MS, sizeCache, gitMaxRepoMb, formatRepoSize, sourceChannelGate, fetchRepoSize, evaluateSourceChannel, gatePrivateRoot }
@@ -127,6 +127,8 @@ function summarizeCloneErrors(errors) {
127
127
  if (e.deferred === true && e.timedOut !== true) return `${e.url}(探活失败的源,已在本轮末尾重试)`
128
128
  if (e.skipped === true) return `${e.url}(探活失败,已降级到本轮末尾重试)`
129
129
  if (e.retrying === true) return `${e.url}(超时,已改用更长超时重试)`
130
+ // 批次 D-②(2026-09-27):稀疏不被支持 → 已降级普通 clone,原因要留在清单里(不再只报"失败")
131
+ if (typeof e.sparseUnsupported === 'string') return `${e.url}(${e.message})`
130
132
  if (e.timedOut === true) {
131
133
  // 批次 B-⑧(2026-09-27):超时到底"收到多少字节"必须写出来 —— 0 B 就是镜像只连不传(
132
134
  // 换源即可),有字节则是真在传、只是慢(值得再等)。旧文案只有"超时"两个字,用户无法判断。
@@ -232,18 +234,32 @@ function measureProgressBytes(part, deps = {}) {
232
234
  return bytes > 0 ? bytes : measure(part)
233
235
  }
234
236
 
235
- /** 跑一次 git clone:支持超时,且**超时即杀掉整棵树并等它真的退出**。返回 { code, stderr, timedOut, pid, exited }。 */
236
- function runGitClone(url, dest, timeout, { spawnFn = spawn, killTree = killProcessTree, exitWaitMs = 800, exitWaitMs2 = 300 } = {}) {
237
+ /** git 稀疏/定向取源码的参数(2026-09-27 加法,批次 D-②):`--filter=blob:none` 只取提交/树对象
238
+ * (不取文件内容),`--sparse` 让工作区只落地根目录文件;需要哪个目录再用
239
+ * `git sparse-checkout set <dir>` 单独拉下来。真机实测(yjh051108/dsh-routing-suite,1334 KB):
240
+ * 骨架 92 KB / 3.9 秒 → `sparse-checkout set preset` 后总共 1.0 MB;而整仓 clone 是 2.34 MB / 17.3 秒。
241
+ * 意义不在这个仓库省了多少,而在**巨仓里的小目录**:整仓 clone 会白拉几百 MB,稀疏只拉那一个目录。 */
242
+ const GIT_SPARSE_FLAGS = ['--filter=blob:none', '--sparse']
243
+
244
+ /** 跑一条 git 命令:支持超时,且**超时即杀掉整棵树并等它真的退出**。
245
+ * 返回 { code, stdout, stderr, timedOut, pid, exited }(stdout 供 ls-tree/show 这类只读命令用)。
246
+ * 2026-09-27 从 runGitClone **原样抽出**(只把 argv 变成形参、cwd 变成选项):稀疏取源码的
247
+ * `sparse-checkout set` / `ls-tree` / `show` 与 clone 共用同一套进程管理(停滞判据 + kill-tree +
248
+ * wait-for-exit),**不新造第二套**。 */
249
+ function runGitArgs(argv, { timeout = GIT_CLONE_TIMEOUT_MS, cwd = null, spawnFn = spawn, killTree = killProcessTree, exitWaitMs = 800, exitWaitMs2 = 300, stallGuard = true } = {}) {
237
250
  return new Promise((resolve) => {
238
251
  let settled = false
239
252
  let timedOut = false
240
- const finish = (payload) => { if (!settled) { settled = true; resolve(payload) } }
253
+ const finish = (payload) => { if (!settled) { settled = true; resolve({ ...payload, stdout: stdout.trim() }) } }
241
254
  let child = null
242
255
  let stderr = ''
256
+ let stdout = ''
257
+ const args = [...(stallGuard ? GIT_STALL_ARGS : []), ...argv]
243
258
  try {
244
- child = spawnFn('git', [...GIT_STALL_ARGS, 'clone', '--depth', '1', '--quiet', url, dest], {
259
+ child = spawnFn('git', args, {
245
260
  windowsHide: true,
246
261
  env: gitEnv(),
262
+ ...(cwd === null ? {} : { cwd }),
247
263
  detached: process.platform !== 'win32', // POSIX:自成进程组,便于 -pid 整体杀
248
264
  })
249
265
  } catch (error) {
@@ -262,12 +278,49 @@ function runGitClone(url, dest, timeout, { spawnFn = spawn, killTree = killProce
262
278
  finish({ code: -1, stderr: stderr.trim(), timedOut: true, pid: child.pid, exited })
263
279
  }, timeout)
264
280
  child.stderr?.on?.('data', (chunk) => { stderr += String(chunk) })
281
+ child.stdout?.on?.('data', (chunk) => { stdout += String(chunk) })
265
282
  child.on('error', (error) => { clearTimeout(timer); if (timedOut) return; finish({ code: -1, stderr: String(error?.message ?? error), timedOut: false, pid: child.pid, exited: true }) })
266
283
  // 超时分支自己收尾(要先把退出等完),这里的 close 不能再抢答
267
284
  child.on('close', (code) => { clearTimeout(timer); if (timedOut) return; finish({ code: typeof code === 'number' ? code : -1, stderr: stderr.trim(), timedOut: false, pid: child.pid, exited: true }) })
268
285
  })
269
286
  }
270
287
 
288
+ /** git 报"我不认识这个选项/子命令"的判据(纯函数,单测覆盖):稀疏取源码不被支持时要能识别,
289
+ * 才能**降级到普通 clone**(而不是把整个候选判死)。真机可能的三类:
290
+ * · 旧 git 不认 `--filter` / `--sparse`(`unknown option` / `error: unknown option`)
291
+ * · 服务端不支持 partial clone(git 只警告,不算失败 → 这里不误判)
292
+ * · `sparse-checkout` 子命令不存在(`git: 'sparse-checkout' is not a git command`) */
293
+ function sparseUnsupportedReason(text) {
294
+ const s = String(text ?? '')
295
+ if (/is not a git command|unknown subcommand|no such command/iu.test(s)) return 'git 不认识 sparse-checkout 子命令(版本过旧)'
296
+ if (/unknown option|unrecognized option|invalid option|usage: git clone/iu.test(s)) return 'git 不认识 --filter/--sparse 选项(版本过旧)'
297
+ return null
298
+ }
299
+
300
+ /** 跑一次 git clone:`sparse` 传数组则走稀疏取源码(`--filter=blob:none --sparse`;非空时再
301
+ * `sparse-checkout set` 把那几个目录拉下来,"不支持"由 sparseUnsupportedReason 识别后交给上层降级)。
302
+ * 返回 { code, stderr, timedOut, pid, exited, sparse, sparseUnsupported }。 */
303
+ async function runGitClone(url, dest, timeout, { sparse = null, spawnFn = spawn, killTree = killProcessTree, exitWaitMs = 800, exitWaitMs2 = 300 } = {}) {
304
+ const useSparse = Array.isArray(sparse)
305
+ const args = ['clone', '--depth', '1', ...(useSparse ? GIT_SPARSE_FLAGS : []), '--quiet', url, dest]
306
+ const res = await runGitArgs(args, { timeout, spawnFn, killTree, exitWaitMs, exitWaitMs2 })
307
+ if (res.code !== 0 || !useSparse || sparse.length === 0) {
308
+ return { ...res, sparse: useSparse, sparseUnsupported: sparseUnsupportedReason(res.stderr) }
309
+ }
310
+ // 稀疏克隆成功 → 再把这几个目录单独拉下来(同一个 runGitArgs:超时照样杀树 + 等退出)
311
+ const set = await runGitArgs(['sparse-checkout', 'set', ...sparse.map((d) => String(d))], { timeout, cwd: dest, spawnFn, killTree, exitWaitMs, exitWaitMs2 })
312
+ if (set.code === 0) return { ...res, sparse: true, sparseUnsupported: null }
313
+ return {
314
+ code: -1,
315
+ stderr: `${set.stderr || `git sparse-checkout set 退出码 ${set.code}`}`,
316
+ timedOut: set.timedOut === true,
317
+ pid: set.pid ?? null,
318
+ exited: set.exited !== false,
319
+ sparse: true,
320
+ sparseUnsupported: sparseUnsupportedReason(set.stderr),
321
+ }
322
+ }
323
+
271
324
  /** git 智能 HTTP 的探活地址(批次 B-⑦②,2026-09-27 改错):从 `HEAD /` 改成
272
325
  * `GET <url>/info/refs?service=git-upload-pack` —— 后者是 git 协议的**真实入口**,
273
326
  * 响应首行必须是以 4 位十六进制长度开头的 pkt-line(如 `001e# service=git-upload-pack`),
@@ -332,22 +385,17 @@ async function probeSourceAlive(url, timeoutMs = 4000, deps = {}) {
332
385
  * 每次尝试都用**全新唯一目录** `.tryN`,成功后才 rename 到 dest —— 这样即使上一轮残留目录被占用,
333
386
  * 也不会再出现"一个源失败 → 后面所有源都因目录非空而无效"的连锁失效。
334
387
  * 2026-09-26 真机加法:① 超时杀树后**等进程真的退出**(runGitClone 内两轮 kill+wait);
335
- * ② 失败后的清理走 removeDirVerifiedWithRetry(3 轮 × 250ms)——
336
- * 杀树成功但 Windows 句柄晚一拍释放时,不再"一次定生死"、也不再误报"残留被占用";
337
- * ③ 源策略:**同一个源超时后用更长超时(默认 1.75 倍)重试一次**,
338
- * 并把**上次成功过的源**提到最前来试(本机直连 github 不通、唯一可用源就是 ghproxy,
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)。
388
+ * ② 失败后的清理走 disposeDir(删不掉就改名降级成 `.trash-<ts>-<rand>`,见 infra/fsx.js)
389
+ * —— 残留目录不再需要用户手动删除,也不因"清不掉"放弃后面本来可用的源;
390
+ * ③ 源策略:**同一个源超时后用更长超时(默认 1.75 倍)重试一次**,并把**上次
391
+ * 成功过的源**提到最前来试(本机直连 github 不通、唯一可用源就是 ghproxy)。
392
+ * 2026-09-27(改错):① 首轮超时 180 秒 → 60 秒(停滞判据已把"只连不传"提前到 ≈20 秒判死);
393
+ * ② 长超时重试从**无条件**改成**只对"有进度"的源**(失败记录带 `bytesReceived`,
394
+ * 0 B 就是一点没传,再等只是把白等拉长 —— 真机 ghproxy 实测 0 B/s);
347
395
  * 2026-09-27(批次 B-⑦):③ 探活判据换成 `GET <url>/info/refs?service=git-upload-pack` + pkt-line 校验,
348
- * 并把失败归因(网络不可达 / 本地代理·证书拦截)写进错误清单;
349
- * ④ **探活失败的源不再"一次定生死"**:降级到**最后一轮**再试一次
350
- * (探活本身可能只是瞬时抖动;真正挂掉的源也只有一次克隆的代价)。 */
396
+ * 失败归因(网络不可达 / 本地代理·证书拦截)写进错误清单;
397
+ * ④ **探活失败的源不再"一次定生死"**:降级到**最后一轮**再试一次。
398
+ * 2026-09-27(批次 D-②):⑤ `deps.sparse` 走稀疏取源码;不被支持时**降级到普通 clone**(下一轮)。 */
351
399
  async function gitCloneRepo(repo, dest, source = 'github', timeout = GIT_CLONE_TIMEOUT_MS, deps = {}) {
352
400
  const {
353
401
  spawnFn = spawn,
@@ -366,7 +414,13 @@ async function gitCloneRepo(repo, dest, source = 'github', timeout = GIT_CLONE_T
366
414
  reuseLanded = true,
367
415
  findLanded = findLandedRepo,
368
416
  copyLanded = copyTree,
417
+ sparse = null,
369
418
  } = deps
419
+ // 批次 D-②(2026-09-27 加法):`sparse` 传数组即走稀疏取源码(`--filter=blob:none --sparse`,
420
+ // 非空时再 `sparse-checkout set` 拉那几个目录)。**稀疏不被支持时降级到普通 clone**:
421
+ // 同一个源的第二轮(更长超时)改用普通 clone,并把原因如实写进错误清单。
422
+ const wantSparse = Array.isArray(sparse)
423
+ let sparseDowngraded = false
370
424
  // ★ 已落地仓库优先复用(批次 C-⑩):命中就直接把本地那份搬到 dest,一个网络请求都不发。
371
425
  // 跳过 .git 是有意的(copyTree 的既有语义):上层要的是仓库**内容**(package.json/.gitmodules),
372
426
  // 本机已有完整仓库,没必要把几百 MB 的 .git 再复制一份。
@@ -405,15 +459,17 @@ async function gitCloneRepo(repo, dest, source = 'github', timeout = GIT_CLONE_T
405
459
  partSeq += 1
406
460
  const part = `${dest}.try${partSeq}`
407
461
  const useTimeout = round === 0 ? timeout : retryTimeout
462
+ // 稀疏只在第一轮试(round 0);"不被支持"或退到第二轮时一律普通 clone(降级语义)。
463
+ const useSparse = wantSparse && round === 0 ? sparse : null
408
464
  try { removeDir(part) } catch {}
409
465
  // eslint-disable-next-line no-await-in-loop
410
- const res = await runGitClone(url, part, useTimeout, { spawnFn, killTree, exitWaitMs })
466
+ const res = await runGitClone(url, part, useTimeout, { spawnFn, killTree, exitWaitMs, sparse: useSparse })
411
467
  if (res.code === 0) {
412
468
  try {
413
469
  removeDir(dest)
414
470
  renameDir(part, dest)
415
471
  try { writeMemo(cand.urlTemplate) } catch {}
416
- return { result: { url, attempt: sourceIndex + 1, tries: partSeq, dir: dest, source: cand.id, retried: round > 0, deferred: isDeferred } }
472
+ return { result: { url, attempt: sourceIndex + 1, tries: partSeq, dir: dest, source: cand.id, retried: round > 0, deferred: isDeferred, sparse: res.sparse === true, downgradedFromSparse: sparseDowngraded } }
417
473
  } catch (error) {
418
474
  errors.push({ url, message: `克隆成功但落地失败(${error instanceof Error ? error.message : String(error)})`, dir: part, deferred: isDeferred })
419
475
  return null
@@ -448,6 +504,14 @@ async function gitCloneRepo(repo, dest, source = 'github', timeout = GIT_CLONE_T
448
504
  })
449
505
  return null // 清不掉的残留和这个源绑着,换下一个源(新目录不受影响)
450
506
  }
507
+ // ★ 批次 D-②(2026-09-27):稀疏取源码**不被支持**(旧 git 不认 --filter/--sparse,或没有
508
+ // sparse-checkout 子命令)→ **降级到普通 clone**:同一轮的下一轮(更长超时)改用普通 clone,
509
+ // 并把原因如实写进错误清单(不静默降级,用户能在失败/备注里看到"为什么走了整仓 clone")。
510
+ if (round === 0 && typeof res.sparseUnsupported === 'string') {
511
+ sparseDowngraded = true
512
+ errors.push({ url, message: `稀疏取源码不被支持(${res.sparseUnsupported}),已降级到普通 clone 重试`, stderr: res.stderr, sparseUnsupported: res.sparseUnsupported, sparse: true, bytesReceived, dir: part, deferred: isDeferred, ...trashField })
513
+ continue
514
+ }
451
515
  if (res.timedOut === true && round === 0 && hasProgress) {
452
516
  // ★ 同源、更长超时,重试一次 —— **只对真有进度的源**(0 B 的源再等一次只是把白等拉长)
453
517
  errors.push({ url, message: `克隆超时(${useTimeout}ms,本次已收到 ${bytesReceived} B),改用 ${retryTimeout}ms 同源重试`, stderr: res.stderr, timedOut: true, retrying: true, bytesReceived, dir: part, deferred: isDeferred, ...trashField })
@@ -530,4 +594,4 @@ async function gitCloneRepo(repo, dest, source = 'github', timeout = GIT_CLONE_T
530
594
  throw new Error(summarizeCloneErrors(errors))
531
595
  }
532
596
 
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 }
597
+ export { reposDirCache, getReposDir, setReposDir, listLandedRepos, findLandedRepo, gitCloneRepo, summarizeCloneErrors, orderGitCandidates, readGitSourceMemo, rememberGitSource, killProcessTree, probeSourceAlive, probeSourceAliveDetail, gitInfoRefsUrl, classifyProbeFailure, runGitArgs, runGitClone, sparseUnsupportedReason, GIT_STALL_ARGS, GIT_SPARSE_FLAGS, waitChildExit, GIT_CLONE_TIMEOUT_MS, measureDirBytes, measureProgressBytes }
@@ -6,6 +6,7 @@ import { dirname, join, basename } from 'node:path'
6
6
  import { tmpdir } from 'node:os'
7
7
  import { appendInsert } from './patch.js'
8
8
  import { gitCloneRepo } from './repoland.js'
9
+ import { findPresetDirs, assemblePreset } from './preset-source.js'
9
10
  import { deriveEntryId, listEntries } from './runtime.js'
10
11
  import { noteChannel } from './git-channel.js'
11
12
  import { orderedRegistries, readSources } from './sources.js'
@@ -36,26 +37,6 @@ function readGitmodules(dir) {
36
37
  return subs.filter((s) => s.path !== '' && s.url !== '')
37
38
  }
38
39
 
39
- /** 递归找含 preset.yml + agent.cordis.yml 的 agent 预设目录(深度 ≤ maxDepth)。 */
40
- function findPresetDirs(root, maxDepth = 2) {
41
- const out = []
42
- const walk = (dir, depth) => {
43
- if (depth > maxDepth) return
44
- let entries = []
45
- try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
46
- if (existsSync(join(dir, 'preset.yml')) && existsSync(join(dir, 'agent.cordis.yml'))) {
47
- out.push(dir)
48
- return
49
- }
50
- for (const e of entries) {
51
- if (!e.isDirectory() || e.name.startsWith('.')) continue
52
- walk(join(dir, e.name), depth + 1)
53
- }
54
- }
55
- walk(root, 0)
56
- return out
57
- }
58
-
59
40
  /** 包是否有构建产物(main/module/exports/bin 或 lib/index.js 任一存在)。
60
41
  * 排除类型声明(.d.ts)与 package.json 自身——exports 的 "./package.json" 是合法导出但不是运行时入口。 */
61
42
  function packageEntryExists(dir, pkg) {
@@ -296,16 +277,21 @@ async function runSuiteInstallJob(job, ports, deps = {}) {
296
277
  }
297
278
  // c. agent 预设(含 preset.yml + agent.cordis.yml 的目录;预设优先于普通 npm 包——
298
279
  // 如 dsh-router-standard 既是 npm 包又带预设目录,install.ps1 意图是复制预设)
280
+ // 2026-09-27(批次 D-③ 改错):装配走 preset-source.js#assemblePreset(**唯一实现**),
281
+ // 与"预设型子包"候选路径共用。旧代码这里是 `rmSync(dest)` + `copyTree` —— **静默覆盖**
282
+ // 用户手上的同名预设;现在改为"先整目录备份、再逐文件合并",并把备份路径与覆盖清单写进报告。
299
283
  const presets = findPresetDirs(subDir, 2)
300
284
  for (const p of presets) {
301
- const pname = basename(p)
302
- const presetsRoot = join(dshHome(), '.agent-presets')
303
- mkdirSync(presetsRoot, { recursive: true })
304
- const dest = join(presetsRoot, pname)
305
- if (existsSync(dest)) rmSync(dest, { recursive: true, force: true })
306
- copyTree(p, dest)
307
- report.push({ component: sub.name, type: 'preset', ok: true, note: `已安装预设 ${pname}(新建会话可选)` })
308
- handled = true
285
+ const assembled = assemblePreset(p, basename(p))
286
+ report.push({
287
+ component: sub.name,
288
+ type: 'preset',
289
+ ok: assembled.ok === true,
290
+ note: assembled.ok === true ? `${assembled.note}(新建会话时选择)` : `预设装配失败:${assembled.error}`,
291
+ presetPath: assembled.dest ?? null,
292
+ backup: assembled.backup ?? null,
293
+ })
294
+ if (assembled.ok === true) handled = true
309
295
  }
310
296
  // d. 普通 npm 插件(无 bundle 且无预设/技能)
311
297
  if (!handled && pkg && typeof pkg.name === 'string') {
@@ -119,12 +119,34 @@ function waitGone(dir, timeoutMs) {
119
119
  })
120
120
  }
121
121
 
122
+ /** 外部删除兜底该用哪条命令(**判据唯一真源**,异步/同步两个兜底共用)。
123
+ *
124
+ * 真机缺陷(0.5.25 及以前,2026-09-27 定位):`clean-residuals` 清的陈旧
125
+ * `fw-quarantine.json.applied-*` 是**文件**,而 shell 兜底一律用 `rmdir /s /q <path>` ——
126
+ * `rmdir` 在 Windows 上只能删**目录**,对文件返回 `exit=267 The directory name is invalid`,
127
+ * 于是"`rmSync` 一次瞬时失败"被兜底放大成**用户可见的「有 1 项没能删除」误报**(文件其实好好的)。
128
+ * 判据:文件 → `del /f /q`;目录 → `rmdir /s /q`;已经不在了 → 视为已达成(**不是失败**)。
129
+ * `lstatSync` 而不是 `statSync`:悬空符号链接本身也要能被删掉。
130
+ * 读不出类型(竞态/权限)→ 走目录形态:目录树是这套兜底原本唯一服务的对象,保持旧行为不倒退。
131
+ * 注意:`del` / `rmdir` 都是 cmd.exe 的**内建命令**,只能经 `cmd.exe /c` 调用,没有对应的可执行文件。 */
132
+ function shellDeleteCommand(dir) {
133
+ if (!existsSync(dir)) return { kind: 'gone', argv: null, method: 'already-gone' }
134
+ let isDirectory = true
135
+ try { isDirectory = lstatSync(dir).isDirectory() } catch { isDirectory = true }
136
+ return isDirectory
137
+ ? { kind: 'dir', argv: ['rmdir', '/s', '/q', dir], method: 'rmdir' }
138
+ : { kind: 'file', argv: ['del', '/f', '/q', dir], method: 'del' }
139
+ }
140
+
122
141
  /** 外部删除兜底:实测本机 PowerShell/.NET 能删掉 Node `rmSync` 静默删不掉的树。 */
123
142
  async function removeViaShell(dir, timeoutMs = 120000) {
124
143
  try {
125
144
  if (process.platform === 'win32') {
126
- await execFileAsync('cmd.exe', ['/c', 'rmdir', '/s', '/q', dir], { windowsHide: true, timeout: timeoutMs })
127
- return { ok: true, method: 'rmdir' }
145
+ const target = shellDeleteCommand(dir)
146
+ if (target.kind === 'gone') return { ok: true, method: target.method }
147
+ await execFileAsync('cmd.exe', ['/c', ...target.argv], { windowsHide: true, timeout: timeoutMs })
148
+ // `del` 对"文件已被别处删掉"会返回 0 又不报错,这里不再多判;调用方仍会核实目标是否消失
149
+ return { ok: true, method: target.method }
128
150
  }
129
151
  await execFileAsync('rm', ['-rf', '--', dir], { timeout: timeoutMs })
130
152
  return { ok: true, method: 'rm' }
@@ -179,12 +201,16 @@ function sleepSync(ms) {
179
201
  } catch {}
180
202
  }
181
203
 
182
- /** 外部删除兜底(同步版):实测本机 `cmd /c rmdir /s /q` 能删掉 Node `rmSync` 静默删不掉的树。 */
204
+ /** 外部删除兜底(同步版):实测本机 `cmd /c rmdir /s /q` 能删掉 Node `rmSync` 静默删不掉的树。
205
+ * 目标类型判据与异步版**同一份**(shellDeleteCommand):文件用 `del /f /q`、目录用 `rmdir /s /q` ——
206
+ * 旧实现对文件用 `rmdir` 只会拿到 exit=267,把"其实删得掉"的文件报成删不掉(见 shellDeleteCommand 注释)。 */
183
207
  function removeViaShellSync(dir, timeoutMs = 120000) {
184
208
  try {
185
209
  if (process.platform === 'win32') {
186
- execFileSync('cmd.exe', ['/c', 'rmdir', '/s', '/q', dir], { windowsHide: true, timeout: timeoutMs, stdio: 'ignore' })
187
- return { ok: true, method: 'rmdir' }
210
+ const target = shellDeleteCommand(dir)
211
+ if (target.kind === 'gone') return { ok: true, method: target.method }
212
+ execFileSync('cmd.exe', ['/c', ...target.argv], { windowsHide: true, timeout: timeoutMs, stdio: 'ignore' })
213
+ return { ok: true, method: target.method }
188
214
  }
189
215
  execFileSync('rm', ['-rf', '--', dir], { timeout: timeoutMs, stdio: 'ignore' })
190
216
  return { ok: true, method: 'rm' }
@@ -395,4 +421,4 @@ function startTrashCleanup(opts = {}) {
395
421
  }
396
422
  }
397
423
 
398
- export { copyTree, queuedWrite, cleanupStalePackageDir, removeDirVerified, removeDirVerifiedAsync, removeDirVerifiedWithRetry, removeViaShellSync, sleepSync, clearReadonly, waitGone, removeViaShell, writeQueue, isLockFailure, trashPathFor, disposeDir, disposeNote, trashScanRoots, findTrashDirs, cleanupTrashDirs, startTrashCleanup, TRASH_PREFIX, TRASH_RE, TRASH_CLEAN_LIMIT, TRASH_CLEAN_ITEM_MS }
424
+ export { copyTree, queuedWrite, cleanupStalePackageDir, removeDirVerified, removeDirVerifiedAsync, removeDirVerifiedWithRetry, removeViaShellSync, sleepSync, clearReadonly, waitGone, removeViaShell, shellDeleteCommand, writeQueue, isLockFailure, trashPathFor, disposeDir, disposeNote, trashScanRoots, findTrashDirs, cleanupTrashDirs, startTrashCleanup, TRASH_PREFIX, TRASH_RE, TRASH_CLEAN_LIMIT, TRASH_CLEAN_ITEM_MS }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noob-stupid/dsh-plugin-console",
3
- "version": "0.5.24",
3
+ "version": "0.5.26",
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",