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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -303,6 +303,9 @@ window.__ModuleLoader__.load({
303
303
  diagIntegrityNone: "来源无摘要,未校验",
304
304
  diagIntegrityMismatch: "摘要不匹配(已安装,未拦截)",
305
305
  diagIntegritySkip: "摘要未校验(读取失败,不影响安装)",
306
+ presetDot: "。预设:新建会话时选择",
307
+ presetInstalledMsg: "预设已装配",
308
+ diagChannelNotes: "通道说明",
306
309
  direct: "(浏览器直连 GitHub;若你的浏览器打不开 GitHub,将自动回退到服务端通道)",
307
310
  details: "详情",
308
311
  expandDesc: "展开",
@@ -834,6 +837,9 @@ window.__ModuleLoader__.load({
834
837
  diagIntegrityNone: "No digest from source; not verified",
835
838
  diagIntegrityMismatch: "Digest mismatch (installed, not blocked)",
836
839
  diagIntegritySkip: "Digest not verified (read failed; install unaffected)",
840
+ presetDot: ". Preset: pick it when starting a new session",
841
+ presetInstalledMsg: "Presets installed",
842
+ diagChannelNotes: "Channel notes",
837
843
  direct: "(The browser calls GitHub directly; the server channel is used as fallback.)",
838
844
  details: "Details",
839
845
  expandDesc: "Expand",
@@ -2881,6 +2887,11 @@ window.__ModuleLoader__.load({
2881
2887
  }
2882
2888
  } else if (data.ai === true) {
2883
2889
  setMessage(t("installed") + ":" + data.packageName + "。" + (data.aiNote ?? t("aiRepaired")) + diagMessage(data));
2890
+ } else if (typeof data.presetNote === "string" && data.presetNote !== "") {
2891
+ // 预设装配成功(2026-09-27 批次 D-③):落盘路径由服务端如实下发,这里补一句
2892
+ // 「预设:新建会话时选择」(点击安装的当下没有新会话,必须先说清楚下一步)。
2893
+ setMessage(t("presetInstalledMsg") + ":" + data.presetNote + t("presetDot") + diagMessage(data));
2894
+ setReloadHint(true);
2884
2895
  } else {
2885
2896
  setMessage(t("installed") + ":" + data.packageName + "(" + (data.entryId ?? "") + ")。" + (data.curlNote ?? (data.bundle ? t("bundleNote") : t("installNote"))) + (data.bundleNote ? "。" + data.bundleNote : "") + (data.lockNote ? "。⚠️ " + data.lockNote : "") + (data.depNote ? "。" + data.depNote : "") + diagMessage(data));
2886
2897
  }
@@ -2892,9 +2903,12 @@ window.__ModuleLoader__.load({
2892
2903
  }
2893
2904
  if (data.bundle) {
2894
2905
  setRestartHint(true);
2895
- } else if (data.suggestedAction !== null && data.suggestedAction !== undefined) {
2906
+ } else if ((data.suggestedAction !== null && data.suggestedAction !== undefined)
2907
+ || (typeof data.presetNote === "string" && data.presetNote !== "")) {
2896
2908
  // 有建议动作时不自动刷新页面(2026-09-27 加法):旧行为 2.5 秒后 reload,
2897
2909
  // 用户根本来不及点「执行」;这里改成给一个"请刷新"的可见提示。
2910
+ // 预设装配同理:只是往 ~/.dsh/.agent-presets 落盘,刷新页面没有意义,
2911
+ // 还会把「落盘路径 + 新建会话时选择」这句提示冲掉。
2898
2912
  setReloadHint(true);
2899
2913
  } else {
2900
2914
  // 安装成功:自动刷新页面,让插件立即出现在列表里(可见的成功反馈)
@@ -3012,6 +3026,18 @@ window.__ModuleLoader__.load({
3012
3026
  title: typeof diagnosis.hint === "string" ? diagnosis.hint : null,
3013
3027
  });
3014
3028
  }
3029
+ // ④ 通道备注(2026-09-27 批次 D-④):服务端一直在下发"为什么没试某个通道"(体积门禁、
3030
+ // 稀疏降级、release/git 预算…),但面板从未展示 → 用户只看到笼统的"失败"。
3031
+ // 失败时逐条显示(最多 6 条),成功时不显示(成功路径已有 presetNote/curlNote 等具体文案)。
3032
+ const channelNotes = Array.isArray(view.channelNotes) ? view.channelNotes.filter((n) => typeof n === "string" && n !== "") : [];
3033
+ if (view.status === "failed" && channelNotes.length > 0) {
3034
+ lines.push({
3035
+ key: "channelNotes",
3036
+ text: t("diagChannelNotes") + t("diagColon") + channelNotes.slice(0, 6).join(t("diagSemi")),
3037
+ level: "warn",
3038
+ title: channelNotes.join("\n"),
3039
+ });
3040
+ }
3015
3041
  return lines;
3016
3042
  };
3017
3043
  /** 诊断短句 → 消息栏后缀(空串 = 没问题):只在既有文案**末尾追加**,一个字都不替换。 */
@@ -3,6 +3,8 @@
3
3
  // 而"git 通道怎么用预算"本来就是一块自洽的策略,独立成模块后 install-job 只留一行调用。
4
4
  // 抽出只搬移 + 收窄形参,**语义一字未改**(原实现见 commit 8deb112 之前的 tryCandidateChannels 尾段)。
5
5
 
6
+ import { existsSync, readFileSync } from 'node:fs'
7
+ import { join } from 'node:path'
6
8
  import { gitCloneUrls } from './sources.js'
7
9
 
8
10
  /** git 通道的**独立预算**(2026-09-27 加法):默认 120 秒,可用环境变量
@@ -38,6 +40,19 @@ function formatBudgetMs(ms) {
38
40
  return n >= 10000 ? `${Math.round(n / 1000)} 秒` : `${Math.round(n)} 毫秒`
39
41
  }
40
42
 
43
+ /** 读回 node_modules 里那个包**真实的** package.json.name(读不到返回 null)。
44
+ * 用途见 tryGitChannel 里的核实:git 规格装出来的是**仓库根包**,名字未必等于候选包名。 */
45
+ function installedPackageName(profileDir, name) {
46
+ try {
47
+ const p = join(profileDir, 'node_modules', name, 'package.json')
48
+ if (!existsSync(p)) return null
49
+ const pkg = JSON.parse(readFileSync(p, 'utf8'))
50
+ return typeof pkg?.name === 'string' && pkg.name !== '' ? pkg.name : null
51
+ } catch {
52
+ return null
53
+ }
54
+ }
55
+
41
56
  /** git 通道(守卫③):只对**请求里那个仓库**试 git 规格,且只在"同一作业第一次"试。
42
57
  * 返回 { installedName, lastError }(与 tryCandidateChannels 的返回同形,便于原地替换)。
43
58
  * 预算规则(2026-09-27):
@@ -45,7 +60,7 @@ function formatBudgetMs(ms) {
45
60
  * · 单个规格的 pnpm 超时 = min(60 秒, 剩余);
46
61
  * · 预算耗尽就停手,并在 job 上留一条面板可见的原因(不静默跳过)。
47
62
  * 依赖注入:ch(通道实现)与 budget 由调用方传入 —— 与 install-job 的注入缝同一套语义。 */
48
- async function tryGitChannel({ job, ch, name, profileDir, repoChannelAllowed, expanded = false, budget = {}, deadline = null }) {
63
+ async function tryGitChannel({ job, ch, name, profileDir, repoChannelAllowed, expanded = false, budget = {}, deadline = null, verifyName = null }) {
49
64
  let installedName = null
50
65
  let lastError = null
51
66
  // gitChannelBlocked:作业层面已判定"这个仓库不该走 git"(根包未发布 / 首选候选来自市场索引)。
@@ -76,6 +91,22 @@ async function tryGitChannel({ job, ch, name, profileDir, repoChannelAllowed, ex
76
91
  try {
77
92
  // eslint-disable-next-line no-await-in-loop
78
93
  await ch.pnpmInstall(profileDir, spec, undefined, Math.min(GIT_SPEC_TIMEOUT_MS, remain))
94
+ // 装完**读回真实包名核实**(2026-09-27 批次 D-① 一并改掉的缺陷):git 规格装的是**仓库根包**,
95
+ // 候选是子包时名字根本不同(真机 dsh-routing-suite:根包 private、名字
96
+ // @dsh-external/dsh-super-injector,而候选是 dsh-router-standard)。旧代码无条件把 installedName
97
+ // 记成候选名 → 后面 appendInsert 写一条指向不存在模块的补丁行 → 下次启动服务直接崩。
98
+ // 读不到(null)时按"无法核实"放行(不误杀;桩实现/别名安装都可能读不到),但要留下备注。
99
+ const realName = typeof verifyName === 'function' ? verifyName(profileDir, name) : installedPackageName(profileDir, name)
100
+ if (realName === null) {
101
+ noteChannel(job, `git 规格装完后读不到 node_modules/${name}/package.json,无法核实包名(按成功处理,装后校验会再查)`)
102
+ installedName = name
103
+ break
104
+ }
105
+ if (realName !== name) {
106
+ lastError = new Error(`git 规格装成的是仓库根包 ${realName},不是候选 ${name}(git 只能装根包;子包请走 registry/release/源码装配通道)`)
107
+ noteChannel(job, lastError.message)
108
+ continue
109
+ }
79
110
  installedName = name
80
111
  break
81
112
  } catch (gitError) {
@@ -88,4 +119,4 @@ async function tryGitChannel({ job, ch, name, profileDir, repoChannelAllowed, ex
88
119
  return { installedName, lastError }
89
120
  }
90
121
 
91
- export { GIT_CHANNEL_BUDGET_MS, GIT_SPEC_TIMEOUT_MS, GIT_MIN_ATTEMPT_MS, gitChannelBudgetMs, noteChannel, formatBudgetMs, tryGitChannel }
122
+ export { GIT_CHANNEL_BUDGET_MS, GIT_SPEC_TIMEOUT_MS, GIT_MIN_ATTEMPT_MS, gitChannelBudgetMs, noteChannel, formatBudgetMs, installedPackageName, tryGitChannel }
@@ -13,8 +13,10 @@ import { GIT_CHANNEL_BUDGET_MS, GIT_MIN_ATTEMPT_MS, GIT_SPEC_TIMEOUT_MS, gitChan
13
13
  import { DEFAULT_BUNDLES, AI_CONSENT_TIMEOUT_MS, PNPM_INSTALL_TIMEOUT_MS, addBundleToManifest, backfillMissingDeps, curlManualInstall, detectBundleOnly, ensureBundlePatchIntegrity, githubReleaseInstall, pnpmInstall, raceInstallChannels, readBundlePatchRefNames, readGithubAuth, syncAggregateSubpackageVersions } from './install.js'
14
14
  import { classifyInstallFailure, retryPnpmOnce } from './install-diagnose.js'
15
15
  import { jobLog } from './jobs.js'
16
- import { verifyInstalledEntry } from './install-verify.js'
17
- import { expandSubpackageCandidates, fetchRepoPackage, fetchRepoPackageEx, fetchSubpackageNames, npmNameHintForRepo, packageProbeErrorText, subpackageCandidates } from './market.js'
16
+ import { gatePrivateRoot } from './repo-size.js'
17
+ import { tryPresetSourceChannel } from './preset-source.js'
18
+ import { alreadyInstalledTarget, verifyInstalledEntry } from './install-verify.js'
19
+ import { applyNpmNameHint, expandSubpackageCandidates, fetchRepoPackage, fetchRepoPackageEx, fetchSubpackageNames, npmNameHintForRepo, packageProbeErrorText, subpackageCandidates } from './market.js'
18
20
  import { declareProfileDependency } from './manifest.js'
19
21
  import { appendInsert, readPatchState, syncNameFromNote } from './patch.js'
20
22
  import { deriveEntryId, listEntries } from './runtime.js'
@@ -67,19 +69,15 @@ function channelImpls(ports) {
67
69
  * ③ git 通道:**保留 repoChannelAllowed**(它只 clone `job.repo`,候选是子包时 clone 根仓库装不出子包,
68
70
  * 属无意义尝试)**并保留 !expanded**(同一作业里对同一个 job.repo 反复 clone 纯属浪费时间,
69
71
  * 级联顺序也不该被破坏)。 */
70
- async function tryCandidateChannels({ job, ch, name, profileDir, registries, repoChannelAllowed, budget, baseUrl = null, expanded = false, deadline = null, expand = null }) {
72
+ async function tryCandidateChannels({ job, ch, name, profileDir, registries, repoChannelAllowed, budget, baseUrl = null, expanded = false, deadline = null, expand = null, sourceDeps = {} }) {
71
73
  let installedName = null
72
74
  let lastError = null
73
75
  // 加法优化:包已在 node_modules 且名字匹配时,不再重复安装/触发 EPERM,直接进入启用流程
74
- const existingTarget = join(profileDir, 'node_modules', name, 'package.json')
75
- if (existsSync(existingTarget)) {
76
- try {
77
- const existingPkg = JSON.parse(readFileSync(existingTarget, 'utf8'))
78
- if (existingPkg && existingPkg.name === name && job.update !== true) {
79
- job.curlNote = `已检测到本地已安装 ${name}@${existingPkg.version ?? '?'},跳过重复下载`
80
- return { installedName: name, lastError: null }
81
- }
82
- } catch {}
76
+ // (2026-09-27 搬进 install-verify.js#alreadyInstalledTarget:本文件贴着 600 行架构硬顶)
77
+ const already = alreadyInstalledTarget(profileDir, name, job)
78
+ if (already !== null) {
79
+ job.curlNote = already
80
+ return { installedName: name, lastError: null }
83
81
  }
84
82
  // 通道 0:并行竞速(pnpm 与 curl 同时启动,先成功者生效;失败方 abort,不影响后续串行通道)——守卫①
85
83
  {
@@ -165,6 +163,12 @@ async function tryCandidateChannels({ job, ch, name, profileDir, registries, rep
165
163
  noteChannel(job, `自动展开仓库子包失败(继续走后续通道):${error instanceof Error ? error.message : String(error)}`)
166
164
  }
167
165
  }
166
+ if (installedName === null) {
167
+ // 通道 n+1c(批次 D-③,2026-09-27 加法):**预设型子包的源码装配** —— 真机 dsh-routing-suite 的第三件
168
+ // dsh-router-standard 只存在于仓库源码里(npm 双 404、release 无资产),按包名的通道全试完才轮到它。
169
+ const preset = await tryPresetSourceChannel({ job, name, source: job.source, deps: sourceDeps })
170
+ if (preset.installed === true) { job.presetDone = true; job.presetNote = preset.note; installedName = name } else if (preset.error !== null && preset.error !== undefined) lastError = preset.error
171
+ }
168
172
  if (installedName === null) {
169
173
  // 通道 n+2:git 通道(GitHub 走加速代理+直连;Gitee 走对应平台)——守卫③
170
174
  // 2026-09-27:实现搬进 domain/git-channel.js(独立预算 + 不得超过作业剩余预算;
@@ -213,6 +217,8 @@ async function runInstallJob(job, ports, deps = {}) {
213
217
  probes.expandSubpackages = (opts = {}) => expandSubpackageCandidates({ ...opts, deps: { fetchSubpackageNames: probes.fetchSubpackageNames } })
214
218
  }
215
219
  const jobBudgetMs = Number.isFinite(deps.jobBudgetMs) && deps.jobBudgetMs > 0 ? deps.jobBudgetMs : 8 * 60 * 1000
220
+ // 源码通道(体积门禁 + 稀疏取源码 + 预设装配)的注入缝:与 marketProbes 同一风格,生产调用方不传
221
+ const sourceDeps = deps.sourceDeps ?? {}
216
222
  const aiConsentTimeoutMs = Number.isFinite(deps.aiConsentTimeoutMs) && deps.aiConsentTimeoutMs > 0 ? deps.aiConsentTimeoutMs : AI_CONSENT_TIMEOUT_MS
217
223
  /** 「安装即声明」的收尾(三个调用点共用一个出口):清单写回结果 → job 上的可见字段。
218
224
  * depNote / suggestedAction 也在这里落地:面板必须能看见"清单里**实际**写成了什么",以及"没写成时点哪个按钮"。 */
@@ -237,16 +243,8 @@ async function runInstallJob(job, ports, deps = {}) {
237
243
  // ★ 市场索引的首选候选(批次 C-⑪):索引带 npmName 时直接按包名走 registry,连"读根 package.json →
238
244
  // 展开子包"这一轮都省掉(本机 api.github.com 不可达时那条路根本走不通)。老索引无该字段 → 行为不变。
239
245
  if (candidates.length === 0 && job.repo) {
240
- const npmHint = probes.npmNameHint(job.repo)
241
- if (npmHint !== null) {
242
- candidates = [npmHint]
243
- job.npmNameHint = npmHint
244
- job.packageName = npmHint
245
- // git 通道只 clone 整个仓库(真机 dsh-web 429 MB / ghproxy 0 B/s),而且装不出这个子包 → 禁掉
246
- job.gitChannelBlocked = true
247
- noteChannel(job, `市场索引给出首选候选:${npmHint}(按包名直装,省掉"探测根包 + 展开子包")`)
248
- noteChannel(job, '已跳过 git 克隆通道:git 只 clone 整个仓库、装不出这个包(真机 dsh-web = 429 MB)')
249
- }
246
+ const hinted = applyNpmNameHint(job, probes.npmNameHint(job.repo), noteChannel)
247
+ if (hinted !== null) candidates = hinted
250
248
  }
251
249
  if (candidates.length === 0) {
252
250
  // 套装判定(0.5.19 改错,唯一入口在 suite.js 的 shouldRunSuiteInstall):**先看根包是否已发布**,
@@ -299,12 +297,10 @@ async function runInstallJob(job, ports, deps = {}) {
299
297
  if (pkg.private === true) {
300
298
  // 私有 monorepo 根:自动列出子包作为候选(把人工修复经验自动化),聚合包优先
301
299
  subpackageMode = true
302
- // 批次 A-③(2026-09-27):根包 private = **没发布到 npm** → registry 通道必然 404,
303
- // 而 git 通道会去 clone 整个仓库(真机:dsh-web 429 MB、ghproxy 下 0 B/s)。
304
- // 对该候选禁用 git,把时间让给"展开子包 → 按包名直装"这条真正能成功的路。
305
- job.privateRoot = true
306
- job.gitChannelBlocked = true
307
- noteChannel(job, '根包未发布到 npm(private: true),已跳过 git 克隆通道(避免白拉整个仓库)')
300
+ // 批次 D-①(2026-09-27 **改错**):根包 private = 没发布到 npm → registry 通道必然 404。
301
+ // 源码通道能不能走改由**仓库体积**判定(批次 A-③ 的一刀切把 dsh-routing-suite 的 preset
302
+ // 子包一起禁掉了,而它只有 1334 KB);note 由 gatePrivateRoot 写入(巨仓/尺寸未知都写清原因与出路)。
303
+ await gatePrivateRoot(job, noteChannel, sourceDeps)
308
304
  // 2026-09-20 事故(用户点装 zhu1090093659/dsh-web,报"未发现子包"):该仓库根包确实
309
305
  // private: true,但 main/dev 各有 22 个子包目录。失败原因是当时市场索引源全挂、网络受限,
310
306
  // subpackageCandidates() 读不到列表 —— 旧代码只有"有没有子包"一个出口,把**没读到**
@@ -343,10 +339,8 @@ async function runInstallJob(job, ports, deps = {}) {
343
339
  const rootPkg = await probes.fetchRepoPackage(job.repo, 'main')
344
340
  if (rootPkg !== null && rootPkg.private === true) {
345
341
  subpackageMode = true
346
- // 同上游分支:根包未发布 → 禁用 git 通道(批次 A-③)
347
- job.privateRoot = true
348
- job.gitChannelBlocked = true
349
- noteChannel(job, '根包未发布到 npm(private: true),已跳过 git 克隆通道(避免白拉整个仓库)')
342
+ // 上游分支同一道判据(批次 D-①):按仓库体积决定源码通道能不能走
343
+ await gatePrivateRoot(job, noteChannel, sourceDeps)
350
344
  const subs = await probes.subpackageCandidates(job.repo, 'main', readGithubAuth().token)
351
345
  if (subs.length > 0) {
352
346
  candidates = [...subs.filter((name) => !candidates.includes(name)), ...candidates]
@@ -404,13 +398,17 @@ async function runInstallJob(job, ports, deps = {}) {
404
398
  }
405
399
  const attempt = await tryCandidateChannels({
406
400
  job, ch, name, profileDir, registries, repoChannelAllowed, budget,
407
- baseUrl: ports.baseUrl ?? null, expanded, deadline,
401
+ baseUrl: ports.baseUrl ?? null, expanded, deadline, sourceDeps,
408
402
  expand: (expanded || job.npmNameHint !== undefined) ? null : expandSubpackages,
409
403
  })
410
404
  installedName = attempt.installedName
411
405
  lastError = attempt.lastError
412
406
  if (installedName !== null) break
413
407
  }
408
+ // 预设型子包走的是"取源码 → 装配到 ~/.dsh/.agent-presets":**没有 node_modules 包、也不该写补丁行/
409
+ // 声明依赖**(否则补丁行指向不存在的模块 → 启动崩溃,2026-09-06 事故同族)。candidateDone/packageName
410
+ // 与普通成功安装对齐,面板显示的才是真正装上的那一件。
411
+ if (job.presetDone === true) { job.status = 'done'; job.stage = 'done'; job.candidateDone = true; job.packageName = job.candidateName ?? job.packageName; return }
414
412
  if (installedName === null) {
415
413
  if (job.diagnosis == null) job.diagnosis = classifyInstallFailure(lastError?.message) // 分类提示兜底(加法)
416
414
  // 本地 AI 兜底会调用模型 API、产生费用:挂起等待用户明确同意后再执行
@@ -218,4 +218,21 @@ function verifyTarballDigest(tarballPath, dist = null) {
218
218
  }
219
219
  }
220
220
 
221
- export { verifyInstalledEntry, verifyTarballDigest, entryFieldOf, MIRROR_HINT }
221
+ /** 「目标包已经在 node_modules 里、且名字就是它」→ 返回面板要显示的短句;否则返回 null。
222
+ * 2026-09-27 从 install-job.js **原样搬出**(那边贴着 600 行架构硬顶):这套判断本来就是"装前只读校验",
223
+ * 与 verifyInstalledEntry 同族,只是发生在装之前。
224
+ * 语义一个字没改:只有 package.json 能解析、name 完全相等、且不是"更新"请求时才算命中
225
+ * (命中就不再重复下载,直接进入启用流程 —— 也避免再触发一次 Windows 上的 EPERM 原子替换)。 */
226
+ function alreadyInstalledTarget(profileDir, name, job = null) {
227
+ const target = join(profileDir, 'node_modules', name, 'package.json')
228
+ if (!existsSync(target)) return null
229
+ try {
230
+ const pkg = JSON.parse(readFileSync(target, 'utf8'))
231
+ if (pkg && pkg.name === name && job?.update !== true) {
232
+ return `已检测到本地已安装 ${name}@${pkg.version ?? '?'},跳过重复下载`
233
+ }
234
+ } catch {}
235
+ return null
236
+ }
237
+
238
+ export { verifyInstalledEntry, verifyTarballDigest, entryFieldOf, alreadyInstalledTarget, MIRROR_HINT }
@@ -544,7 +544,8 @@ function installJobView(job) {
544
544
  skillDir: job.skillDir ?? null,
545
545
  skillNote: job.skillNote ?? null,
546
546
  suiteReport: job.suiteReport ?? null,
547
- suiteNote: job.suiteNote ?? null,
547
+ // 预设装配结果(2026-09-27 批次 D-③ 加法):落盘路径 + "新建会话时选择" + 每个预设的合并/备份明细
548
+ suiteNote: job.suiteNote ?? null, presetNote: job.presetNote ?? null, presetInstalled: job.presetInstalled ?? null, presetSource: job.presetSource ?? null, repoSize: job.repoSize ?? null,
548
549
  hint: job.hint ?? null,
549
550
  // 子包级/套装级进度(2026-09-20 真装实测缺口:11 个子包的聚合仓库跑了 19 分钟,面板只显示
550
551
  // "安装中",用户不知道在装第几个、还剩几个)。子包通道来自 install-job.js 候选循环维护的
@@ -564,8 +565,7 @@ function installJobView(job) {
564
565
  }
565
566
  }
566
567
 
567
- /** 从 GitHub Release 下载预构建 tgz 装配到 node_modules/<pkgName> 已搬到 domain/release-source.js
568
- * (与 #3 的 release 源解析同族);本模块不再重复导出它(全仓库无导入点,导出面由 lib/index.js 决定)。 */
568
+ /** 「从 GitHub Release 下载预构建 tgz 装配」已搬到 domain/release-source.js(与 #3 的 release 源解析同族)。 */
569
569
 
570
570
  async function syncAggregateSubpackageVersions(profileDir, packageName, registries) {
571
571
  const pkgPath = join(profileDir, 'node_modules', packageName, 'package.json')
@@ -428,6 +428,23 @@ function npmNameHintForRepo(repo, deps = {}) {
428
428
  }
429
429
  }
430
430
 
431
+ /** 市场索引首选候选落地(批次 C-⑪,2026-09-27;批次 D-③ 从 install-job 搬进 market.js,
432
+ * 那边贴着 600 行架构硬顶)。索引带 npmName 时直接按包名走 registry,连"读根 package.json →
433
+ * 展开子包"这一轮都省掉(本机 api.github.com 不可达时那条路根本走不通);老索引无该字段 → null。
434
+ * 副作用与搬移前**一字不差**:写 job.npmNameHint / job.packageName / job.gitChannelBlocked + 两条备注。 */
435
+ function applyNpmNameHint(job, hint, note = null) {
436
+ if (typeof hint !== 'string' || hint === '') return null
437
+ job.npmNameHint = hint
438
+ job.packageName = hint
439
+ // git 通道只 clone 整个仓库(真机 dsh-web 429 MB / ghproxy 0 B/s),而且装不出这个子包 → 禁掉
440
+ job.gitChannelBlocked = true
441
+ if (typeof note === 'function') {
442
+ note(job, `市场索引给出首选候选:${hint}(按包名直装,省掉"探测根包 + 展开子包")`)
443
+ note(job, '已跳过 git 克隆通道:git 只 clone 整个仓库、装不出这个包(真机 dsh-web = 429 MB)')
444
+ }
445
+ return [hint]
446
+ }
447
+
431
448
  /** 懒惰展开(批次 A-③ 的实现;2026-09-27 从 install-job.js 搬进 market.js —— 那边触到 600 行硬顶):
432
449
  * 读仓库子包列表 → **聚合包优先**(名字以 all 结尾/-all- 形式)→ 去掉已有候选 → 限量返回。
433
450
  * 分支由调用方给(install-job 传 meta.default_branch),fetchSubpackageNames 自己会回退 main/master。 */
@@ -472,4 +489,4 @@ async function subpackageCandidates(repo, branch, auth) {
472
489
 
473
490
  const ENRICH_CACHE_TTL = 24 * 60 * 60 * 1000
474
491
 
475
- export { readEnrichCache, writeEnrichCache, enrichItemOne, enrichItems, normalizePlatformItems, githubRepoInfo, fetchRepoPackage, fetchRepoPackageEx, packageProbeErrorText, searchNpmPackages, searchSubpackageItems, parseRepoFromUrl, hasDirectNameHit, summarizeReadme, readPluginDetails, fetchSubpackageNames, fetchSubpackageNamesOnBranch, subpackageBranchOrder, subpackageCandidates, expandSubpackageCandidates, npmNameHintForRepo, ENRICH_CACHE_TTL }
492
+ export { readEnrichCache, writeEnrichCache, enrichItemOne, enrichItems, normalizePlatformItems, githubRepoInfo, fetchRepoPackage, fetchRepoPackageEx, packageProbeErrorText, searchNpmPackages, searchSubpackageItems, parseRepoFromUrl, hasDirectNameHit, summarizeReadme, readPluginDetails, fetchSubpackageNames, fetchSubpackageNamesOnBranch, subpackageBranchOrder, subpackageCandidates, expandSubpackageCandidates, npmNameHintForRepo, applyNpmNameHint, ENRICH_CACHE_TTL }
@@ -0,0 +1,339 @@
1
+ // L1 · domain —— preset-source.js(预设型子包的**源码装配**:稀疏取源码 → 定位 → 装配到 ~/.dsh/.agent-presets)
2
+ //
3
+ // 真机事故(2026-09-27,用户实测 yjh051108/dsh-routing-suite「三件套只装到两件」):
4
+ // 该仓库三件套 = `graded/`(release 有 tgz,已装)、`injector/`(release 有 tgz,已装)、
5
+ // **`preset/`(= dsh-router-standard,「思维模式路由预设」:npm 双 404、release 无资产 →
6
+ // 只存在于仓库源码里)**。旧流程对"根包 private"的仓库一刀切禁用源码通道(批次 A-③),
7
+ // 于是第三件连一次机会都没有;而"添加并启用"这条路上,按包名的通道(registry/curl/release)
8
+ // 对它必然是 404 —— 缺的就是"从仓库里把那一个目录取下来,按预设装配"这条能力。
9
+ //
10
+ // 本模块提供三件事(**唯一实现**,套装路径与候选路径共用,绝不复制粘贴第二套):
11
+ // ① findPresetDirs / isPresetDir —— 预设目录识别(从 suite.js **原样搬来**,suite.js 改为 re-export)
12
+ // ② assemblePreset —— 装配一个预设到 `<DSH_HOME>/.agent-presets/<name>`(**已存在同名预设时
13
+ // 绝不静默覆盖**:先整目录备份,再逐文件合并;同名不同内容的逐条报告)
14
+ // ③ fetchPresetSource / tryPresetSourceChannel —— 稀疏取源码(`--filter=blob:none --sparse`
15
+ // + `git sparse-checkout set <subdir>`;不支持时降级普通 clone)并装配
16
+ //
17
+ // 进程管理一律复用 repoland.js 的 runGitArgs / gitCloneRepo(kill-tree + wait-for-exit + `.tryN` +
18
+ // `.trash-*` 降级)—— 本模块**不新造任何进程管理**。
19
+
20
+ import { existsSync, readFileSync, readdirSync, mkdirSync, statSync, writeFileSync } from 'node:fs'
21
+ import { basename, dirname, join, relative, sep } from 'node:path'
22
+ import { tmpdir } from 'node:os'
23
+ import { gitCloneRepo, runGitArgs } from './repoland.js'
24
+ import { noteChannel } from './git-channel.js'
25
+ import { evaluateSourceChannel } from './repo-size.js'
26
+ import { copyTree, disposeDir } from '../infra/fsx.js'
27
+ import { dshHome } from '../infra/paths.js'
28
+
29
+ /** 预设的判别文件(框架发现预设看的就是这个:`~/.dsh/.agent-presets/<name>/agent.cordis.yml`)。 */
30
+ const PRESET_MARKER_FILES = ['agent.cordis.yml', 'agent.cordis.yaml']
31
+ /** 预设清单(可选,面板文案用)。 */
32
+ const PRESET_MANIFEST_FILE = 'preset.yml'
33
+ /** git 只读命令(ls-tree / show / sparse-checkout)的超时:都是元数据或单个 blob。 */
34
+ const GIT_READ_TIMEOUT_MS = 60000
35
+
36
+ /** 递归找含 preset.yml + agent.cordis.yml 的 agent 预设目录(深度 ≤ maxDepth)。
37
+ * 2026-09-27 从 suite.js **原样搬来**(只搬移未改逻辑):两个调用方(套装装配 / 预设子包装配)
38
+ * 必须用**同一份**判据,否则"套装装得下、候选装不下"这类漂移又会回来。 */
39
+ function findPresetDirs(root, maxDepth = 2) {
40
+ const out = []
41
+ const walk = (dir, depth) => {
42
+ if (depth > maxDepth) return
43
+ let entries = []
44
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
45
+ if (existsSync(join(dir, PRESET_MANIFEST_FILE)) && existsSync(join(dir, 'agent.cordis.yml'))) {
46
+ out.push(dir)
47
+ return
48
+ }
49
+ for (const e of entries) {
50
+ if (!e.isDirectory() || e.name.startsWith('.')) continue
51
+ walk(join(dir, e.name), depth + 1)
52
+ }
53
+ }
54
+ walk(root, 0)
55
+ return out
56
+ }
57
+
58
+ /** 这个目录自己就是一个 agent 预设吗(直接含 agent.cordis.yml/.yaml)。
59
+ * 与 findPresetDirs 的差别:findPresetDirs 还要求 preset.yml(列表展示用);这里更宽 ——
60
+ * 只要框架能挂载就算预设(有的预设不写 preset.yml)。 */
61
+ function isPresetDir(dir) {
62
+ return PRESET_MARKER_FILES.some((f) => existsSync(join(dir, f)))
63
+ }
64
+
65
+ /** 「预设型包名」的形状判据(**纯函数**,只是**便宜的初筛**,不作最终判据)。
66
+ * 最终判据永远是"目录里真的有 agent.cordis.yml"(见 tryPresetSourceChannel):
67
+ * 名字猜错只损失一次便宜的源码探测;而拿名字当判据会把普通插件误当预设装配。
68
+ * 覆盖:dsh-router-standard / dsh_router_standard / router-preset / @scope/dsh-preset-* 等。 */
69
+ function looksLikePresetPackageName(name) {
70
+ return /(?:^|[-_/])(?:router|preset)(?:[-_/]|$)/iu.test(String(name ?? '').trim())
71
+ }
72
+
73
+ /** 读预设清单(preset.yml 的 name/description/order;读不到返回 null,不抛)。 */
74
+ function readPresetManifest(dir) {
75
+ try {
76
+ const text = readFileSync(join(dir, PRESET_MANIFEST_FILE), 'utf8')
77
+ const pick = (key) => {
78
+ const m = text.match(new RegExp(`^\\s*${key}\\s*:\\s*(.+)$`, 'mu'))
79
+ return m === null ? null : m[1].trim().replace(/^["']|["']$/gu, '')
80
+ }
81
+ return { name: pick('name'), description: pick('description'), order: pick('order') }
82
+ } catch {
83
+ return null
84
+ }
85
+ }
86
+
87
+ /** 目录树里的文件清单(相对路径 + 字节数)——只读,best-effort,绝不抛。 */
88
+ function listTreeFiles(root, { maxDepth = 8 } = {}) {
89
+ const out = []
90
+ const walk = (dir, depth) => {
91
+ if (depth > maxDepth) return
92
+ let entries = []
93
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
94
+ for (const e of entries) {
95
+ const child = join(dir, e.name)
96
+ try {
97
+ if (e.isDirectory()) walk(child, depth + 1)
98
+ else if (e.isFile()) out.push({ rel: relative(root, child).split(sep).join('/'), bytes: statSync(child).size })
99
+ } catch {}
100
+ }
101
+ }
102
+ walk(String(root), 0)
103
+ return out
104
+ }
105
+
106
+ /** 装配一个预设到 `<presetsRoot>/<name>`(**唯一实现**)。
107
+ * 已存在同名预设时**绝不静默覆盖**:
108
+ * ① 先把原目录整份备份成 `<name>.bak-<ts>`(备份失败就**不合并**,如实报冲突);
109
+ * ② 再逐文件合并:源里有、目标里没有的算「新增」;两边都有且内容不同的算「覆盖」并逐条列出
110
+ * (内容相同的算「一致」,只在统计里体现,不刷屏)。
111
+ * 这是**加法**:旧代码(suite.js 里那三行)是 `rmSync(dest)` + `copyTree` —— 用户手上的同名预设
112
+ * 会被无声抹掉(真机 `.agent-presets` 下就有 router-standard / router-spec / router-react 三个在用)。
113
+ * 返回 { ok, name, dest, backup, added, overwritten, identical, conflicts, bytes, note }。 */
114
+ function assemblePreset(srcDir, name, { presetsRoot = null, now = Date.now } = {}) {
115
+ const root = presetsRoot ?? join(dshHome(), '.agent-presets')
116
+ const presetName = String(name ?? basename(srcDir)).trim()
117
+ if (presetName === '' || presetName === '.' || /[\\/]/u.test(presetName)) {
118
+ return { ok: false, name: presetName, dest: null, error: `预设目录名不合法:${JSON.stringify(name)}` }
119
+ }
120
+ const dest = join(root, presetName)
121
+ const srcFiles = listTreeFiles(srcDir)
122
+ if (srcFiles.length === 0) return { ok: false, name: presetName, dest, error: `源目录里没有任何文件:${srcDir}` }
123
+ const bytes = srcFiles.reduce((sum, f) => sum + f.bytes, 0)
124
+ let backup = null
125
+ const existed = existsSync(dest)
126
+ if (existed) {
127
+ backup = `${dest}.bak-${now()}`
128
+ try {
129
+ copyTree(dest, backup)
130
+ } catch (error) {
131
+ return {
132
+ ok: false,
133
+ name: presetName,
134
+ dest,
135
+ error: `已存在同名预设且备份失败(绝不在没备份的情况下覆盖):${error instanceof Error ? error.message : String(error)}`,
136
+ conflicts: srcFiles.map((f) => f.rel),
137
+ }
138
+ }
139
+ }
140
+ mkdirSync(root, { recursive: true })
141
+ const added = []
142
+ const overwritten = []
143
+ const identical = []
144
+ for (const file of srcFiles) {
145
+ const from = join(srcDir, ...file.rel.split('/'))
146
+ const target = join(dest, ...file.rel.split('/'))
147
+ const had = existsSync(target)
148
+ let same = false
149
+ if (had) {
150
+ try { same = readFileSync(target).equals(readFileSync(from)) } catch { same = false }
151
+ }
152
+ if (same) { identical.push(file.rel); continue }
153
+ try {
154
+ mkdirSync(dirname(target), { recursive: true })
155
+ writeFileSync(target, readFileSync(from))
156
+ } catch (error) {
157
+ return { ok: false, name: presetName, dest, backup, error: `合并 ${file.rel} 失败:${error instanceof Error ? error.message : String(error)}` }
158
+ }
159
+ if (had) overwritten.push(file.rel)
160
+ else added.push(file.rel)
161
+ }
162
+ const note = existed
163
+ ? `预设 ${presetName}:已存在同名预设,原目录已备份到 ${backup};本次合并新增 ${added.length} 个、覆盖 ${overwritten.length} 个同名文件、内容一致 ${identical.length} 个`
164
+ + (overwritten.length > 0 ? `(被覆盖的:${overwritten.slice(0, 6).join('、')}${overwritten.length > 6 ? ' 等' : ''})` : '')
165
+ : `预设 ${presetName} 已装配到 ${dest}(新增 ${added.length} 个文件)`
166
+ return { ok: true, name: presetName, dest, backup, added, overwritten, identical, conflicts: overwritten, bytes, files: srcFiles.map((f) => f.rel), note }
167
+ }
168
+
169
+ /** 跑一条 git 命令并取回 stdout(ls-tree/show 要的就是输出)。仍然走 repoland 的 runGitArgs:
170
+ * 那边已经把停滞判据 + kill-tree + wait-for-exit 做完了,这里只转发(`stdout` 由 runGitArgs 采集)。 */
171
+ function gitCapture(argv, { cwd, timeout, runGit = null } = {}) {
172
+ const run = typeof runGit === 'function' ? runGit : runGitArgs
173
+ return run(argv, { cwd, timeout })
174
+ }
175
+
176
+ /** 用 git 在**本地**定位候选对应的子包目录(零额外网络):
177
+ * `git ls-tree -r --name-only HEAD` 列出全部路径 → 只挑第 1/2 层的 package.json(与
178
+ * market.js#fetchSubpackageNamesOnBranch 的检索面一致)→ `git show HEAD:<path>` 核对 name
179
+ * (partial clone 会按需只拉这一个 blob)。
180
+ * 返回 { subdir, pkg, error };subdir 为 null 表示没找到。 */
181
+ async function locateSubpackageViaGit({ root, candidateName, runGit = null, timeout = GIT_READ_TIMEOUT_MS }) {
182
+ const want = String(candidateName ?? '').trim()
183
+ if (want === '') return { subdir: null, pkg: null, error: '候选包名为空' }
184
+ const listed = await gitCapture(['ls-tree', '-r', '--name-only', 'HEAD'], { cwd: root, timeout, runGit })
185
+ if (listed.code !== 0) return { subdir: null, pkg: null, error: `git ls-tree 失败:${String(listed.stderr ?? '').slice(0, 200)}` }
186
+ const paths = String(listed.stdout ?? '').split(/\r?\n/u)
187
+ .map((l) => l.trim())
188
+ .filter((l) => /^(?!node_modules\/)[^/]+(?:\/[^/]+)?\/package\.json$/u.test(l))
189
+ for (const path of paths) {
190
+ // eslint-disable-next-line no-await-in-loop
191
+ const shown = await gitCapture(['show', `HEAD:${path}`], { cwd: root, timeout, runGit })
192
+ if (shown.code !== 0) continue
193
+ try {
194
+ const pkg = JSON.parse(String(shown.stdout ?? ''))
195
+ if (pkg !== null && typeof pkg === 'object' && pkg.name === want) {
196
+ return { subdir: path.split('/').slice(0, -1).join('/'), pkg, error: null }
197
+ }
198
+ } catch {}
199
+ }
200
+ return { subdir: null, pkg: null, error: null }
201
+ }
202
+
203
+ /** 稀疏/定向取源码(**批次 D-② 的实现**):
204
+ * ① 骨架稀疏克隆 `git clone --depth 1 --filter=blob:none --sparse`(只取提交/树对象 + 根目录文件)
205
+ * —— 走 gitCloneRepo,因此自动带 `.tryN` / 探活 / kill-tree / wait-for-exit / `.trash-*` 降级 / archive 兜底;
206
+ * ② `git ls-tree` 在**本地**定位候选对应的子包目录(零额外网络);
207
+ * ③ `git sparse-checkout set <subdir>` 只把那一个目录的 blob 拉下来。
208
+ * 任一步"不被支持"(旧 git / 服务端不支持 partial clone)→ **降级到普通 clone** 并如实记 notes。
209
+ * 返回 { ok, root, subdir, sparse, downgraded, notes, error, missing }。 */
210
+ async function fetchPresetSource({ repo, candidateName, dest, source = 'github', timeout = undefined, deps = {} }) {
211
+ const gitClone = typeof deps.gitClone === 'function' ? deps.gitClone : gitCloneRepo
212
+ const runGit = typeof deps.runGit === 'function' ? deps.runGit : null
213
+ const cloneOpts = { ...(deps.cloneOpts ?? {}), sparse: [] }
214
+ const notes = []
215
+ let cloned = null
216
+ try {
217
+ cloned = await gitClone(repo, dest, source, timeout, cloneOpts)
218
+ } catch (error) {
219
+ return { ok: false, sparse: false, downgraded: false, notes, error: `稀疏取源码失败(clone):${error instanceof Error ? error.message : String(error)}` }
220
+ }
221
+ const sparse = cloned?.sparse === true
222
+ const downgradedFromSparse = cloned?.downgradedFromSparse === true
223
+ if (sparse) notes.push(`已用稀疏取源码(--filter=blob:none --sparse):只取提交/树对象 + 根目录文件${typeof cloned?.url === 'string' ? `(源:${cloned.url})` : ''}`)
224
+ if (downgradedFromSparse) notes.push('稀疏取源码不被支持,已降级到普通 clone')
225
+ const runTimeout = Number.isFinite(timeout) && timeout > 0 ? timeout : GIT_READ_TIMEOUT_MS
226
+ const locate = await locateSubpackageViaGit({ root: dest, candidateName, runGit, timeout: runTimeout })
227
+ if (locate.error !== null && locate.error !== undefined) {
228
+ return { ok: false, sparse, downgraded: downgradedFromSparse, notes, error: locate.error }
229
+ }
230
+ if (locate.subdir === null) {
231
+ return { ok: false, sparse, downgraded: downgradedFromSparse, notes, missing: true, error: `仓库里没有名为 ${candidateName} 的子包(第 1/2 层 package.json 都没匹配上)` }
232
+ }
233
+ if (!sparse) return { ok: true, root: dest, subdir: locate.subdir, pkg: locate.pkg, sparse: false, downgraded: downgradedFromSparse, notes }
234
+ const set = await gitCapture(['sparse-checkout', 'set', locate.subdir], { cwd: dest, timeout: runTimeout, runGit })
235
+ if (set.code === 0) {
236
+ notes.push(`已用 git sparse-checkout set ${locate.subdir} 定向拉取该目录`)
237
+ return { ok: true, root: dest, subdir: locate.subdir, pkg: locate.pkg, sparse: true, downgraded: false, notes }
238
+ }
239
+ // sparse-checkout 不可用(旧 git)→ 降级普通 clone 再定位一次(不把候选判死)
240
+ notes.push(`git sparse-checkout 不可用(${String(set.stderr ?? '').slice(0, 160)}),已降级到普通 clone`)
241
+ const plainDest = `${dest}-plain`
242
+ try { disposeDir(plainDest) } catch {}
243
+ try {
244
+ await gitClone(repo, plainDest, source, timeout, { ...(deps.cloneOpts ?? {}), sparse: null })
245
+ } catch (error) {
246
+ return { ok: false, sparse: true, downgraded: true, notes, error: `稀疏不被支持、降级普通 clone 也失败:${error instanceof Error ? error.message : String(error)}` }
247
+ }
248
+ const again = await locateSubpackageViaGit({ root: plainDest, candidateName, runGit, timeout: runTimeout })
249
+ if (again.subdir === null) {
250
+ return { ok: false, sparse: true, downgraded: true, notes, error: again.error ?? `普通 clone 后仍未找到子包 ${candidateName}` }
251
+ }
252
+ return { ok: true, root: plainDest, subdir: again.subdir, pkg: again.pkg, sparse: true, downgraded: true, notes }
253
+ }
254
+
255
+ /** 候选级「源码通道」:预设型子包的源码装配(install-job 在所有按包名的通道失败后调用)。
256
+ * 判据链(每一条都写进 job.channelNotes,用户能看到"试了什么、为什么没试"):
257
+ * ① 名字不像预设型(looksLikePresetPackageName)→ 直接返回,**不联网、不取源码**;
258
+ * ② 源码通道体积门禁(repo-size 的缓存判据)→ 不允许就带 note 返回;
259
+ * ③ 稀疏取源码 → 本地定位子包目录 → 目录里有 agent.cordis.yml 才算预设(最终判据);
260
+ * ④ 逐个 assemblePreset 到 `<DSH_HOME>/.agent-presets/<name>`。
261
+ * 返回 { handled, installed, name, presets, note, error }。
262
+ * handled=false → "这个候选与预设无关",上层照旧走后续通道;
263
+ * handled=true 且 installed=false → "确实是预设型但没装成",error 是**具体原因**。 */
264
+ async function tryPresetSourceChannel({ job, name, source = 'github', limit = 4, deps = {} } = {}) {
265
+ const candidateName = String(name ?? '').trim()
266
+ if (!looksLikePresetPackageName(candidateName)) return { handled: false, installed: false, error: null }
267
+ const repo = String(job?.repo ?? '').trim()
268
+ if (repo === '') return { handled: false, installed: false, error: null }
269
+ // 同一作业里同一个候选只试一次(防止上游重入把一次源码取下来了两次)
270
+ const tried = Array.isArray(job?.presetTried) ? job.presetTried : []
271
+ if (tried.includes(candidateName)) return { handled: true, installed: false, error: null }
272
+ if (job !== null && job !== undefined) job.presetTried = [...tried, candidateName]
273
+ const gate = await evaluateSourceChannel(repo, deps.sizeDeps ?? {})
274
+ if (gate.allow !== true) {
275
+ noteChannel(job, `预设型候选 ${candidateName} 的源码装配被跳过:${gate.note}`)
276
+ return { handled: true, installed: false, error: new Error(`预设型子包 ${candidateName} 需要从仓库源码装配,但源码通道不可用:${gate.note}`) }
277
+ }
278
+ const tmpRoot = deps.tmpRoot ?? join(tmpdir(), `dsh-preset-${job?.id ?? 'job'}-${Date.now()}`)
279
+ const dest = join(tmpRoot, 'repo')
280
+ try { mkdirSync(tmpRoot, { recursive: true }) } catch {}
281
+ try {
282
+ const fetched = await fetchPresetSource({ repo, candidateName, dest, source, timeout: deps.cloneTimeoutMs, deps: deps.fetchDeps ?? {} })
283
+ for (const n of fetched.notes ?? []) noteChannel(job, n)
284
+ if (fetched.ok !== true) {
285
+ noteChannel(job, `预设型子包 ${candidateName} 的源码装配失败:${fetched.error}`)
286
+ return { handled: true, installed: false, error: new Error(`预设型子包 ${candidateName}:${fetched.error}`), presets: [] }
287
+ }
288
+ const subDir = join(fetched.root, ...String(fetched.subdir).split('/'))
289
+ const presets = findPresetDirs(subDir, 2)
290
+ // 兜底:子包目录自己就是预设(有的仓库直接把 agent.cordis.yml 放在子包根)
291
+ if (presets.length === 0 && isPresetDir(subDir)) presets.push(subDir)
292
+ if (presets.length === 0) {
293
+ const why = `${candidateName} 的子包目录(${fetched.subdir})里没有 agent.cordis.yml —— 它不是预设型子包,不能按预设装配`
294
+ noteChannel(job, why)
295
+ return { handled: true, installed: false, error: new Error(`预设型子包 ${candidateName}:${why}`), presets: [] }
296
+ }
297
+ const presetsRoot = deps.presetsRoot ?? join(dshHome(), '.agent-presets')
298
+ const reports = []
299
+ const skipped = Math.max(0, presets.length - limit)
300
+ for (const p of presets.slice(0, limit)) {
301
+ // eslint-disable-next-line no-await-in-loop
302
+ const report = assemblePreset(p, basename(p), { presetsRoot, ...(deps.now === undefined ? {} : { now: deps.now }) })
303
+ reports.push({ ...report, manifest: readPresetManifest(p), sparse: fetched.sparse === true, subdir: fetched.subdir })
304
+ }
305
+ const failed = reports.filter((r) => r.ok !== true)
306
+ if (job !== null && job !== undefined) {
307
+ job.presetInstalled = reports.map((r) => ({
308
+ name: r.name, dest: r.dest, ok: r.ok === true, added: r.added?.length ?? 0, overwritten: r.overwritten?.length ?? 0,
309
+ identical: r.identical?.length ?? 0, backup: r.backup ?? null, bytes: r.bytes ?? 0, error: r.error ?? null,
310
+ marker: r.ok === true ? 'agent.cordis.yml' : null, subdir: r.subdir ?? null, sparse: r.sparse === true,
311
+ }))
312
+ job.presetSource = { repo, subdir: fetched.subdir, sparse: fetched.sparse === true, downgraded: fetched.downgraded === true, root: fetched.root }
313
+ }
314
+ if (failed.length > 0 && failed.length === reports.length) {
315
+ return { handled: true, installed: false, error: new Error(`预设装配失败:${failed[0].error}`), presets: reports }
316
+ }
317
+ const okNames = reports.filter((r) => r.ok === true).map((r) => r.name)
318
+ // 只要**建过备份**就必须说(哪怕本次一个文件都没覆盖)——否则用户不知道多了一个 .bak 目录,
319
+ // 也不知道"这次其实什么都没改"。
320
+ const merged = reports.filter((r) => r.ok === true && typeof r.backup === 'string' && r.backup !== '')
321
+ const note = `预设已装配:${okNames.join('、')}(落盘 ${presetsRoot};**新建会话时选择**)`
322
+ + (fetched.sparse === true ? ';源码用稀疏取源码(--filter=blob:none --sparse + sparse-checkout set),只取该子包目录' : '')
323
+ + (skipped > 0 ? `;该仓库还有 ${skipped} 个预设本次未装配(单次上限 ${limit} 个)` : '')
324
+ + (merged.length > 0
325
+ ? `;已存在同名预设 ${merged.map((r) => r.name).join('、')}:原目录已备份(${merged.map((r) => r.backup).join('、')}),本次按文件合并(覆盖 ${merged.reduce((s, r) => s + r.overwritten.length, 0)} 个、内容一致 ${merged.reduce((s, r) => s + r.identical.length, 0)} 个)`
326
+ : '')
327
+ + (failed.length > 0 ? `;${failed.length} 个预设装配失败:${failed.map((r) => `${r.name}(${r.error})`).join('、')}` : '')
328
+ return { handled: true, installed: true, name: candidateName, presets: reports, note }
329
+ } finally {
330
+ // 临时目录清理复用既有降级(删不掉就改名成 .trash-*),绝不静默留下垃圾
331
+ try { disposeDir(tmpRoot) } catch {}
332
+ }
333
+ }
334
+
335
+ export {
336
+ PRESET_MARKER_FILES, PRESET_MANIFEST_FILE, GIT_READ_TIMEOUT_MS,
337
+ findPresetDirs, isPresetDir, looksLikePresetPackageName, readPresetManifest, listTreeFiles,
338
+ assemblePreset, locateSubpackageViaGit, fetchPresetSource, gitCapture, tryPresetSourceChannel,
339
+ }
@@ -0,0 +1,136 @@
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({ state = 'unknown', sizeKb = null } = {}, thresholdMb = GIT_MAX_REPO_MB) {
41
+ const limit = Number.isFinite(Number(thresholdMb)) && Number(thresholdMb) >= 1 ? Number(thresholdMb) : GIT_MAX_REPO_MB
42
+ const kb = Number(sizeKb)
43
+ const known = state === 'known' && Number.isFinite(kb) && kb >= 0
44
+ if (!known) {
45
+ return {
46
+ allow: false,
47
+ state: 'unknown',
48
+ sizeKb: null,
49
+ limitMb: limit,
50
+ note: '仓库体积未知(GitHub 元数据探测失败/超时/404):已**保守跳过**源码通道(git / archive / 稀疏取源码),避免白拉一个可能几百 MB 的仓库。请重试;或改用「仓库落地」把仓库克隆到本地目录后安装。',
51
+ }
52
+ }
53
+ if (kb <= limit * 1024) {
54
+ return {
55
+ allow: true,
56
+ state: 'known',
57
+ sizeKb: kb,
58
+ limitMb: limit,
59
+ note: `仓库体积 ${formatRepoSize(kb)}(上限 ${limit} MB):已放行源码通道(git / archive / 稀疏取源码)`,
60
+ }
61
+ }
62
+ return {
63
+ allow: false,
64
+ state: 'known',
65
+ sizeKb: kb,
66
+ limitMb: limit,
67
+ // 同时给 MB 与 KB:刚好卡在阈值附近时(20481 KB vs 20480 KB)MB 会四舍五入成同一个数,
68
+ // 只写 MB 会让用户看不出"到底超了多少"。
69
+ note: `仓库过于庞大(${formatRepoSize(kb)}/${Math.round(kb)} KB,上限 ${limit} MB):已跳过源码通道(git / archive / 稀疏取源码)——请用「仓库落地」把仓库克隆到本地目录,或改用本地镜像/子包(已发布到 npm 的聚合包优先)。`,
70
+ }
71
+ }
72
+
73
+ /** 探测结果缓存(默认 6 小时):同一个作业里 git 通道与预设源码通道会各问一次,
74
+ * 跨作业也会反复问(市场卡片重试)。缓存键是 owner/repo(大小写不敏感)。
75
+ * 只缓存**成功**结果:探测失败不落缓存,下次重试有机会拿到尺寸(免得一次网络抖动把仓库
76
+ * 永久钉在"尺寸未知"上)。 */
77
+ const SIZE_CACHE_TTL_MS = 6 * 60 * 60 * 1000
78
+ const sizeCache = new Map()
79
+
80
+ /** 仓库体积探测:`GET /repos/{owner}/{repo}` 的 `size`(KB)。
81
+ * 走既有 http 多通道(githubJson 官方+镜像竞速,且带 gh CLI 兜底;curlJson 走系统网络栈),
82
+ * 与 META_BUDGET_MS 同一个外层预算(黑洞期不卡 40 秒)。
83
+ * 返回 { state: 'known'|'unknown', sizeKb, note, cached }。**绝不抛**(探测失败是正常结局)。 */
84
+ async function fetchRepoSize(repo, deps = {}) {
85
+ const full = String(repo ?? '').trim().replace(/\.git$/u, '')
86
+ if (full === '' || !full.includes('/')) return { state: 'unknown', sizeKb: null, note: '仓库名无效', cached: false }
87
+ const key = full.toLowerCase()
88
+ const now = deps.now ?? Date.now
89
+ const cache = deps.cache ?? sizeCache
90
+ const ttl = Number.isFinite(deps.ttlMs) ? deps.ttlMs : SIZE_CACHE_TTL_MS
91
+ const hit = cache.get(key)
92
+ if (hit !== undefined && now() - hit.at < ttl) return { ...hit.value, cached: true }
93
+ const race = deps.race ?? (async () => Promise.race([
94
+ Promise.any([
95
+ githubJson(`${GITHUB_API}/repos/${full}`, undefined, deps.token ?? null),
96
+ curlJson(`${GITHUB_API}/repos/${full}`, 12000, {}, { ipv4: true }),
97
+ ]),
98
+ new Promise((resolve) => setTimeout(() => resolve(null), Number.isFinite(deps.budgetMs) ? deps.budgetMs : META_BUDGET_MS)),
99
+ ]).catch(() => null))
100
+ let meta = null
101
+ try { meta = await race() } catch { meta = null }
102
+ const sizeKb = Number(meta?.size)
103
+ if (meta !== null && meta !== undefined && Number.isFinite(sizeKb) && sizeKb >= 0) {
104
+ const value = { state: 'known', sizeKb, note: '', cached: false }
105
+ try { cache.set(key, { at: now(), value }) } catch {}
106
+ return value
107
+ }
108
+ return { state: 'unknown', sizeKb: null, note: '未能读到仓库体积(元数据探测失败/超时/404)', cached: false }
109
+ }
110
+
111
+ /** 一次算完「这个仓库的源码通道能不能走」:探测 → 纯判据。deps.probe 只为单测注入。 */
112
+ async function evaluateSourceChannel(repo, deps = {}) {
113
+ const probe = typeof deps.probe === 'function' ? deps.probe : fetchRepoSize
114
+ let result = null
115
+ try { result = await probe(repo, deps) } catch { result = null }
116
+ const state = result?.state === 'known' ? 'known' : 'unknown'
117
+ const gate = sourceChannelGate({ state, sizeKb: result?.sizeKb ?? null }, deps.thresholdMb ?? gitMaxRepoMb(deps.env ?? process.env))
118
+ return { ...gate, cached: result?.cached === true }
119
+ }
120
+
121
+ /** 把判据落到 job 上(唯一入口,install-job 的两处 private 根分支共用):
122
+ * 写 job.privateRoot / job.repoSize / job.gitChannelBlocked / job.sourceChannelBlocked,并把 note 交给调用方记档。
123
+ * 返回值同 evaluateSourceChannel(调用方若要给更细的下一步文案可直接用 note)。 */
124
+ async function gatePrivateRoot(job, note, deps = {}) {
125
+ const gate = await evaluateSourceChannel(job?.repo, deps)
126
+ if (job !== null && job !== undefined) {
127
+ job.privateRoot = true
128
+ job.repoSize = { state: gate.state, sizeKb: gate.sizeKb, limitMb: gate.limitMb }
129
+ job.gitChannelBlocked = gate.allow !== true
130
+ job.sourceChannelBlocked = gate.allow !== true
131
+ }
132
+ if (typeof note === 'function') note(job, gate.note)
133
+ return gate
134
+ }
135
+
136
+ 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') {
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.25",
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",