@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,373 @@
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
+ // ② fetchPresetSource / tryPresetSourceChannel —— 稀疏取源码(`--filter=blob:none --sparse`
13
+ // + `git sparse-checkout set <subdir>`;不支持时降级普通 clone)并装配
14
+ // ③ 装配本身(assemblePreset / 校验 / 备份 / 读回核实)**已搬到 domain/preset-install.js**
15
+ // (0.5.26 改错 F2+F3+F4:默认改为"只补不覆盖"、写前校验、备份路径唯一)。
16
+ // 本模块只做 re-export,既有调用方与测试一个字都不用改 —— 写盘逻辑全项目只有一份。
17
+ //
18
+ // 进程管理一律复用 repoland.js 的 runGitArgs / gitCloneRepo(kill-tree + wait-for-exit + `.tryN` +
19
+ // `.trash-*` 降级)—— 本模块**不新造任何进程管理**。
20
+
21
+ import { existsSync, readFileSync, readdirSync, mkdirSync } from 'node:fs'
22
+ import { basename, join } from 'node:path'
23
+ import { tmpdir } from 'node:os'
24
+ import { gitCloneRepo, runGitArgs } from './repoland.js'
25
+ import { noteChannel } from './git-channel.js'
26
+ import { evaluateSourceChannel } from './repo-size.js'
27
+ import { disposeDir } from '../infra/fsx.js'
28
+ import { dshHome } from '../infra/paths.js'
29
+ import {
30
+ PRESET_MARKER_FILES, PRESET_MANIFEST_FILE, assemblePreset, listTreeFiles,
31
+ presetSourceOf, recordPresetSource, suggestedOverwritePresetAction,
32
+ } from './preset-install.js'
33
+
34
+ /** git 只读命令(ls-tree / show / sparse-checkout)的超时:都是元数据或单个 blob。 */
35
+ const GIT_READ_TIMEOUT_MS = 60000
36
+
37
+ /** 递归找含 preset.yml + agent.cordis.yml 的 agent 预设目录(深度 ≤ maxDepth)。
38
+ * 2026-09-27 从 suite.js **原样搬来**(只搬移未改逻辑):两个调用方(套装装配 / 预设子包装配)
39
+ * 必须用**同一份**判据,否则"套装装得下、候选装不下"这类漂移又会回来。 */
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_MANIFEST_FILE)) && 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
+ /** 这个目录自己就是一个 agent 预设吗(直接含 agent.cordis.yml/.yaml)。
60
+ * 与 findPresetDirs 的差别:findPresetDirs 还要求 preset.yml(列表展示用);这里更宽 ——
61
+ * 只要框架能挂载就算预设(有的预设不写 preset.yml)。 */
62
+ function isPresetDir(dir) {
63
+ return PRESET_MARKER_FILES.some((f) => existsSync(join(dir, f)))
64
+ }
65
+
66
+ /** 「预设型包名」的形状判据(**纯函数**,只是**便宜的初筛**,不作最终判据)。
67
+ * 最终判据永远是"目录里真的有 agent.cordis.yml"(见 tryPresetSourceChannel):
68
+ * 名字猜错只损失一次便宜的源码探测;而拿名字当判据会把普通插件误当预设装配。
69
+ * 覆盖:dsh-router-standard / dsh_router_standard / router-preset / @scope/dsh-preset-* 等。 */
70
+ function looksLikePresetPackageName(name) {
71
+ return /(?:^|[-_/])(?:router|preset)(?:[-_/]|$)/iu.test(String(name ?? '').trim())
72
+ }
73
+
74
+ /** 读预设清单(preset.yml 的 name/description/order;读不到返回 null,不抛)。 */
75
+ function readPresetManifest(dir) {
76
+ try {
77
+ const text = readFileSync(join(dir, PRESET_MANIFEST_FILE), 'utf8')
78
+ const pick = (key) => {
79
+ const m = text.match(new RegExp(`^\\s*${key}\\s*:\\s*(.+)$`, 'mu'))
80
+ return m === null ? null : m[1].trim().replace(/^["']|["']$/gu, '')
81
+ }
82
+ return { name: pick('name'), description: pick('description'), order: pick('order') }
83
+ } catch {
84
+ return null
85
+ }
86
+ }
87
+
88
+ /** 跑一条 git 命令并取回 stdout(ls-tree/show 要的就是输出)。仍然走 repoland 的 runGitArgs:
89
+ * 那边已经把停滞判据 + kill-tree + wait-for-exit 做完了,这里只转发(`stdout` 由 runGitArgs 采集)。 */
90
+ function gitCapture(argv, { cwd, timeout, runGit = null } = {}) {
91
+ const run = typeof runGit === 'function' ? runGit : runGitArgs
92
+ return run(argv, { cwd, timeout })
93
+ }
94
+
95
+ /** 用 git 在**本地**定位候选对应的子包目录(零额外网络):
96
+ * `git ls-tree -r --name-only HEAD` 列出全部路径 → 只挑第 1/2 层的 package.json(与
97
+ * market.js#fetchSubpackageNamesOnBranch 的检索面一致)→ `git show HEAD:<path>` 核对 name
98
+ * (partial clone 会按需只拉这一个 blob)。
99
+ * 返回 { subdir, pkg, error };subdir 为 null 表示没找到。 */
100
+ async function locateSubpackageViaGit({ root, candidateName, runGit = null, timeout = GIT_READ_TIMEOUT_MS }) {
101
+ const want = String(candidateName ?? '').trim()
102
+ if (want === '') return { subdir: null, pkg: null, error: '候选包名为空' }
103
+ const listed = await gitCapture(['ls-tree', '-r', '--name-only', 'HEAD'], { cwd: root, timeout, runGit })
104
+ if (listed.code !== 0) return { subdir: null, pkg: null, error: `git ls-tree 失败:${String(listed.stderr ?? '').slice(0, 200)}` }
105
+ const paths = String(listed.stdout ?? '').split(/\r?\n/u)
106
+ .map((l) => l.trim())
107
+ .filter((l) => /^(?!node_modules\/)[^/]+(?:\/[^/]+)?\/package\.json$/u.test(l))
108
+ for (const path of paths) {
109
+ // eslint-disable-next-line no-await-in-loop
110
+ const shown = await gitCapture(['show', `HEAD:${path}`], { cwd: root, timeout, runGit })
111
+ if (shown.code !== 0) continue
112
+ try {
113
+ const pkg = JSON.parse(String(shown.stdout ?? ''))
114
+ if (pkg !== null && typeof pkg === 'object' && pkg.name === want) {
115
+ return { subdir: path.split('/').slice(0, -1).join('/'), pkg, error: null }
116
+ }
117
+ } catch {}
118
+ }
119
+ return { subdir: null, pkg: null, error: null }
120
+ }
121
+
122
+ /** 稀疏/定向取源码(**批次 D-② 的实现**):
123
+ * ① 骨架稀疏克隆 `git clone --depth 1 --filter=blob:none --sparse`(只取提交/树对象 + 根目录文件)
124
+ * —— 走 gitCloneRepo,因此自动带 `.tryN` / 探活 / kill-tree / wait-for-exit / `.trash-*` 降级 / archive 兜底;
125
+ * ② `git ls-tree` 在**本地**定位候选对应的子包目录(零额外网络);
126
+ * ③ `git sparse-checkout set <subdir>` 只把那一个目录的 blob 拉下来。
127
+ * 任一步"不被支持"(旧 git / 服务端不支持 partial clone)→ **降级到普通 clone** 并如实记 notes。
128
+ * 返回 { ok, root, subdir, sparse, downgraded, notes, error, missing }。 */
129
+ async function fetchPresetSource({ repo, candidateName, dest, source = 'github', timeout = undefined, deps = {} }) {
130
+ const gitClone = typeof deps.gitClone === 'function' ? deps.gitClone : gitCloneRepo
131
+ const runGit = typeof deps.runGit === 'function' ? deps.runGit : null
132
+ const cloneOpts = { ...(deps.cloneOpts ?? {}), sparse: [] }
133
+ const notes = []
134
+ let cloned = null
135
+ try {
136
+ cloned = await gitClone(repo, dest, source, timeout, cloneOpts)
137
+ } catch (error) {
138
+ return { ok: false, sparse: false, downgraded: false, notes, error: `稀疏取源码失败(clone):${error instanceof Error ? error.message : String(error)}` }
139
+ }
140
+ const sparse = cloned?.sparse === true
141
+ const downgradedFromSparse = cloned?.downgradedFromSparse === true
142
+ if (sparse) notes.push(`已用稀疏取源码(--filter=blob:none --sparse):只取提交/树对象 + 根目录文件${typeof cloned?.url === 'string' ? `(源:${cloned.url})` : ''}`)
143
+ if (downgradedFromSparse) notes.push('稀疏取源码不被支持,已降级到普通 clone')
144
+ const runTimeout = Number.isFinite(timeout) && timeout > 0 ? timeout : GIT_READ_TIMEOUT_MS
145
+ const locate = await locateSubpackageViaGit({ root: dest, candidateName, runGit, timeout: runTimeout })
146
+ if (locate.error !== null && locate.error !== undefined) {
147
+ return { ok: false, sparse, downgraded: downgradedFromSparse, notes, error: locate.error }
148
+ }
149
+ if (locate.subdir === null) {
150
+ return { ok: false, sparse, downgraded: downgradedFromSparse, notes, missing: true, error: `仓库里没有名为 ${candidateName} 的子包(第 1/2 层 package.json 都没匹配上)` }
151
+ }
152
+ if (!sparse) return { ok: true, root: dest, subdir: locate.subdir, pkg: locate.pkg, sparse: false, downgraded: downgradedFromSparse, notes }
153
+ const set = await gitCapture(['sparse-checkout', 'set', locate.subdir], { cwd: dest, timeout: runTimeout, runGit })
154
+ if (set.code === 0) {
155
+ notes.push(`已用 git sparse-checkout set ${locate.subdir} 定向拉取该目录`)
156
+ return { ok: true, root: dest, subdir: locate.subdir, pkg: locate.pkg, sparse: true, downgraded: false, notes }
157
+ }
158
+ // sparse-checkout 不可用(旧 git)→ 降级普通 clone 再定位一次(不把候选判死)
159
+ notes.push(`git sparse-checkout 不可用(${String(set.stderr ?? '').slice(0, 160)}),已降级到普通 clone`)
160
+ const plainDest = `${dest}-plain`
161
+ try { disposeDir(plainDest) } catch {}
162
+ try {
163
+ await gitClone(repo, plainDest, source, timeout, { ...(deps.cloneOpts ?? {}), sparse: null })
164
+ } catch (error) {
165
+ return { ok: false, sparse: true, downgraded: true, notes, error: `稀疏不被支持、降级普通 clone 也失败:${error instanceof Error ? error.message : String(error)}` }
166
+ }
167
+ const again = await locateSubpackageViaGit({ root: plainDest, candidateName, runGit, timeout: runTimeout })
168
+ if (again.subdir === null) {
169
+ return { ok: false, sparse: true, downgraded: true, notes, error: again.error ?? `普通 clone 后仍未找到子包 ${candidateName}` }
170
+ }
171
+ return { ok: true, root: plainDest, subdir: again.subdir, pkg: again.pkg, sparse: true, downgraded: true, notes }
172
+ }
173
+
174
+ /** 候选级「源码通道」:预设型子包的源码装配(install-job 在所有按包名的通道失败后调用)。
175
+ * 判据链(每一条都写进 job.channelNotes,用户能看到"试了什么、为什么没试"):
176
+ * ① 名字不像预设型(looksLikePresetPackageName)→ 直接返回,**不联网、不取源码**;
177
+ * ② 源码通道体积门禁(repo-size 的缓存判据)→ 不允许就带 note 返回;
178
+ * ③ 稀疏取源码 → 本地定位子包目录 → 目录里有 agent.cordis.yml 才算预设(最终判据);
179
+ * ④ 逐个 assemblePreset 到 `<DSH_HOME>/.agent-presets/<name>`。
180
+ * 返回 { handled, installed, name, presets, note, error }。
181
+ * handled=false → "这个候选与预设无关",上层照旧走后续通道;
182
+ * handled=true 且 installed=false → "确实是预设型但没装成",error 是**具体原因**。 */
183
+ async function tryPresetSourceChannel({ job, name, source = 'github', limit = 4, deps = {} } = {}) {
184
+ const candidateName = String(name ?? '').trim()
185
+ if (!looksLikePresetPackageName(candidateName)) return { handled: false, installed: false, error: null }
186
+ const repo = String(job?.repo ?? '').trim()
187
+ if (repo === '') return { handled: false, installed: false, error: null }
188
+ // 同一作业里同一个候选只试一次(防止上游重入把一次源码取下来了两次)
189
+ const tried = Array.isArray(job?.presetTried) ? job.presetTried : []
190
+ if (tried.includes(candidateName)) return { handled: true, installed: false, error: null }
191
+ if (job !== null && job !== undefined) job.presetTried = [...tried, candidateName]
192
+ const gate = await evaluateSourceChannel(repo, deps.sizeDeps ?? {})
193
+ if (gate.allow !== true) {
194
+ noteChannel(job, `预设型候选 ${candidateName} 的源码装配被跳过:${gate.note}`)
195
+ return { handled: true, installed: false, error: new Error(`预设型子包 ${candidateName} 需要从仓库源码装配,但源码通道不可用:${gate.note}`) }
196
+ }
197
+ const tmpRoot = deps.tmpRoot ?? join(tmpdir(), `dsh-preset-${job?.id ?? 'job'}-${Date.now()}`)
198
+ const dest = join(tmpRoot, 'repo')
199
+ try { mkdirSync(tmpRoot, { recursive: true }) } catch {}
200
+ try {
201
+ const fetched = await fetchPresetSource({ repo, candidateName, dest, source, timeout: deps.cloneTimeoutMs, deps: deps.fetchDeps ?? {} })
202
+ for (const n of fetched.notes ?? []) noteChannel(job, n)
203
+ if (fetched.ok !== true) {
204
+ noteChannel(job, `预设型子包 ${candidateName} 的源码装配失败:${fetched.error}`)
205
+ return { handled: true, installed: false, error: new Error(`预设型子包 ${candidateName}:${fetched.error}`), presets: [] }
206
+ }
207
+ const subDir = join(fetched.root, ...String(fetched.subdir).split('/'))
208
+ const presets = findPresetDirs(subDir, 2)
209
+ // 兜底:子包目录自己就是预设(有的仓库直接把 agent.cordis.yml 放在子包根)
210
+ if (presets.length === 0 && isPresetDir(subDir)) presets.push(subDir)
211
+ if (presets.length === 0) {
212
+ const why = `${candidateName} 的子包目录(${fetched.subdir})里没有 agent.cordis.yml —— 它不是预设型子包,不能按预设装配`
213
+ noteChannel(job, why)
214
+ return { handled: true, installed: false, error: new Error(`预设型子包 ${candidateName}:${why}`), presets: [] }
215
+ }
216
+ const presetsRoot = deps.presetsRoot ?? join(dshHome(), '.agent-presets')
217
+ const reports = []
218
+ const skipped = Math.max(0, presets.length - limit)
219
+ for (const p of presets.slice(0, limit)) {
220
+ // eslint-disable-next-line no-await-in-loop
221
+ // overwrite **永不**在这里为真:安装路径必须走"只补不覆盖"的默认语义。
222
+ // 覆盖只能由用户显式点「覆盖该预设」→ 白名单动作 overwrite-preset 触发(见 plugin-actions.js)。
223
+ const report = assemblePreset(p, basename(p), { presetsRoot, ...(deps.now === undefined ? {} : { now: deps.now }) })
224
+ reports.push({ ...report, manifest: readPresetManifest(p), sparse: fetched.sparse === true, subdir: fetched.subdir })
225
+ }
226
+ const failed = reports.filter((r) => r.ok !== true)
227
+ if (job !== null && job !== undefined) {
228
+ // 面板可读的结构化结果。0.5.26 加法:mode / skipped / skippedInvalid / verified ——
229
+ // 「默认只补不覆盖」必须**在数据里看得见**(跳过了哪几个同名文件、为什么),不能只在 note 里叙述。
230
+ job.presetInstalled = reports.map((r) => ({
231
+ name: r.name, dest: r.dest, ok: r.ok === true, added: r.added?.length ?? 0, overwritten: r.overwritten?.length ?? 0,
232
+ identical: r.identical?.length ?? 0, backup: r.backup ?? null, bytes: r.bytes ?? 0, error: r.error ?? null,
233
+ marker: r.ok === true ? 'agent.cordis.yml' : null, subdir: r.subdir ?? null, sparse: r.sparse === true,
234
+ mode: r.mode ?? null, existed: r.existed === true,
235
+ skipped: Array.isArray(r.skipped) ? r.skipped : [],
236
+ skippedInvalid: Array.isArray(r.skippedInvalid) ? r.skippedInvalid : [],
237
+ verified: r.verified === null || r.verified === undefined ? null : r.verified.ok === true,
238
+ }))
239
+ job.presetSource = { repo, subdir: fetched.subdir, sparse: fetched.sparse === true, downgraded: fetched.downgraded === true, root: fetched.root }
240
+ // 源码出处落盘:`overwrite-preset` 动作据此**重新取源码**(临时 clone 目录在 finally 里已清理,
241
+ // 所以"仅靠内存里的路径"是做不到的)。只记 repo / 候选名 / 子目录,不记任何路径或凭据。
242
+ try {
243
+ recordPresetSource(reports.filter((r) => r.ok === true).map((r) => ({
244
+ name: r.name, repo, candidateName, subdir: fetched.subdir, sparse: fetched.sparse === true, dest: r.dest,
245
+ })), { home: deps.home, now: deps.now })
246
+ } catch {}
247
+ // 同名冲突 / 校验跳过 → 给面板一个**显式**的「覆盖该预设」按钮(结构化动作,见 plugin-actions.js)。
248
+ // 只在真的有冲突时出现;用户不点,一个字节都不会被覆盖。
249
+ const conflicted = reports.filter((r) => r.ok === true && ((r.skipped?.length ?? 0) > 0 || (r.skippedInvalid?.length ?? 0) > 0))
250
+ if (conflicted.length > 0 && job.suggestedAction === undefined) {
251
+ const first = conflicted[0]
252
+ job.suggestedAction = suggestedOverwritePresetAction({
253
+ presetName: first.name,
254
+ repo,
255
+ reason: `预设 ${first.name} 已存在同名文件(${(first.skipped ?? []).join('、') || '配置未通过校验'}),本次**按默认语义保留了你手上的版本**;如需用仓库版本覆盖,点这个按钮(会先把原目录整份备份)`,
256
+ })
257
+ job.presetOverwriteCandidates = conflicted.map((r) => r.name)
258
+ }
259
+ }
260
+ if (failed.length > 0 && failed.length === reports.length) {
261
+ return { handled: true, installed: false, error: new Error(`预设装配失败:${failed[0].error}`), presets: reports }
262
+ }
263
+ const okNames = reports.filter((r) => r.ok === true).map((r) => r.name)
264
+ const skippedFiles = reports.reduce((sum, r) => sum + (r.skipped?.length ?? 0), 0)
265
+ const invalidFiles = reports.reduce((sum, r) => sum + (r.skippedInvalid?.length ?? 0), 0)
266
+ const backedUp = reports.filter((r) => r.ok === true && typeof r.backup === 'string' && r.backup !== '')
267
+ const note = `预设已装配:${okNames.join('、')}(落盘 ${presetsRoot};**新建会话时选择**)`
268
+ + (fetched.sparse === true ? ';源码用稀疏取源码(--filter=blob:none --sparse + sparse-checkout set),只取该子包目录' : '')
269
+ + (skipped > 0 ? `;该仓库还有 ${skipped} 个预设本次未装配(单次上限 ${limit} 个)` : '')
270
+ // 默认语义必须在面板上说清(用户要先知道"你的文件没被动过",才谈得上要不要覆盖)
271
+ + (skippedFiles > 0
272
+ ? `;**预设装配默认只补不覆盖**:${skippedFiles} 个同名文件与你手上的内容不同,已原样保留未动 —— 如需覆盖,请点「覆盖该预设」(会先把原目录整份备份)`
273
+ : '')
274
+ + (invalidFiles > 0
275
+ ? `;${invalidFiles} 个预设配置文件未通过结构校验(非法 YAML),**已跳过未写入**(避免把原本可用的预设写坏)`
276
+ : '')
277
+ + (backedUp.length > 0
278
+ ? `;已存在同名预设 ${backedUp.map((r) => r.name).join('、')}:原目录已备份(${backedUp.map((r) => r.backup).join('、')}),本次覆盖 ${backedUp.reduce((s, r) => s + r.overwritten.length, 0)} 个、内容一致 ${backedUp.reduce((s, r) => s + r.identical.length, 0)} 个`
279
+ : '')
280
+ + (failed.length > 0 ? `;${failed.length} 个预设装配失败:${failed.map((r) => `${r.name}(${r.error})`).join('、')}` : '')
281
+ return { handled: true, installed: true, name: candidateName, presets: reports, note }
282
+ } finally {
283
+ // 临时目录清理复用既有降级(删不掉就改名成 .trash-*),绝不静默留下垃圾
284
+ try { disposeDir(tmpRoot) } catch {}
285
+ }
286
+ }
287
+
288
+ /** 「覆盖该预设」的**执行体**(0.5.26 加法,修 F2;只由显式动作 `overwrite-preset` 调用)。
289
+ *
290
+ * 为什么需要它:默认语义改成"只补不覆盖"之后,用户必须有**一条明确的出路**才能拿到仓库版本。
291
+ * 这条出路不能靠"临时 clone 目录"——那个目录在 tryPresetSourceChannel 的 finally 里已经清理了,
292
+ * 所以要**按记录重新取一次源码**(记录见 preset-install.js#recordPresetSource),再走
293
+ * `assemblePreset(..., { overwrite: true })`(先整目录备份 → 覆盖 → 写后读回核实)。
294
+ *
295
+ * 安全边界(与 allow-builds / pin-dependency 同一套,配套断言钉死):
296
+ * · 只认**已存在**于 `<presetsRoot>/<name>` 的目录,名字由服务端校验(客户端说了不算);
297
+ * · 只有**记过源码出处**的预设才能覆盖(没记录 = 不是本控制台装的 → 让它走正常安装流程);
298
+ * · 源码通道体积门禁照旧(巨仓不放行);取源码走既有 gitCloneRepo(不新造进程管理);
299
+ * · 本函数**不执行任何客户端传来的字符串**,也不接受任何命令参数。
300
+ *
301
+ * 返回 { ok, status, name, backup, overwritten, added, identical, verified, note, error, reason }。 */
302
+ async function overwritePreset({ presetName, deps = {} } = {}) {
303
+ const raw = String(presetName ?? '').trim()
304
+ // 目录名判据与 assemblePreset 完全一致(含"不得含路径分隔符"),这里先做一次**便宜**的拒绝
305
+ if (raw === '' || raw === '.' || raw === '..' || /[\\/]/u.test(raw)) {
306
+ return { ok: false, status: 400, name: raw, error: `预设名不合法:${JSON.stringify(presetName)}` }
307
+ }
308
+ const presetsRoot = deps.presetsRoot ?? join(dshHome(), '.agent-presets')
309
+ const dest = join(presetsRoot, raw)
310
+ if (!existsSync(dest)) {
311
+ return { ok: false, status: 400, name: raw, error: `预设 ${raw} 不存在(${dest}),没有可覆盖的对象 —— 本动作只改已存在的预设` }
312
+ }
313
+ const home = deps.home
314
+ const explicitRepo = typeof deps.repo === 'string' && deps.repo.trim() !== '' ? deps.repo.trim() : null
315
+ const record = presetSourceOf(raw, home)
316
+ const repo = explicitRepo ?? (typeof record?.repo === 'string' && record.repo !== '' ? record.repo : null)
317
+ if (repo === null) {
318
+ return {
319
+ ok: false, status: 400, name: raw,
320
+ error: `没有 ${raw} 的源码出处记录(它可能不是本控制台装配的)。请用市场卡片重新安装这个预设,安装后即可覆盖。`,
321
+ }
322
+ }
323
+ const candidateName = (typeof record?.candidateName === 'string' && record.candidateName !== '') ? record.candidateName : `dsh-${raw}`
324
+ const source = typeof deps.source === 'string' && deps.source !== '' ? deps.source : 'github'
325
+ const gate = await evaluateSourceChannel(repo, deps.sizeDeps ?? {})
326
+ if (gate.allow !== true) {
327
+ return { ok: false, status: 400, name: raw, repo, error: `覆盖需要重新取源码,但源码通道不可用:${gate.note}` }
328
+ }
329
+ const tmpRoot = deps.tmpRoot ?? join(tmpdir(), `dsh-preset-overwrite-${raw}-${Date.now()}`)
330
+ const destClone = join(tmpRoot, 'repo')
331
+ try { mkdirSync(tmpRoot, { recursive: true }) } catch {}
332
+ try {
333
+ const fetched = await fetchPresetSource({ repo, candidateName, dest: destClone, source, timeout: deps.cloneTimeoutMs, deps: deps.fetchDeps ?? {} })
334
+ if (fetched.ok !== true) {
335
+ return { ok: false, status: 200, name: raw, repo, error: `重新取源码失败:${fetched.error}` }
336
+ }
337
+ const subDir = join(fetched.root, ...String(fetched.subdir).split('/'))
338
+ const found = findPresetDirs(subDir, 2)
339
+ if (found.length === 0 && isPresetDir(subDir)) found.push(subDir)
340
+ // 优先取**同名**的那个预设目录(一个子包里可能有多个预设)
341
+ const pick = found.find((p) => basename(p) === raw) ?? found[0] ?? null
342
+ if (pick === null) {
343
+ return { ok: false, status: 200, name: raw, repo, error: `仓库 ${repo} 的 ${fetched.subdir} 里没有找到预设目录` }
344
+ }
345
+ // 覆盖的永远是**用户点名的那一个**名字(不是源目录的 basename):用户点的是哪个就改哪个
346
+ const report = assemblePreset(pick, raw, { presetsRoot, overwrite: true, ...(deps.now === undefined ? {} : { now: deps.now }) })
347
+ if (report.ok !== true) {
348
+ return { ok: false, status: 200, name: raw, repo, backup: report.backup ?? null, error: report.error ?? '覆盖失败(文件未改动)', report }
349
+ }
350
+ // 覆盖后刷新源码出处记录(时间戳更新;repo/子目录可能与旧记录不同)
351
+ try {
352
+ recordPresetSource([{ name: raw, repo, candidateName, subdir: fetched.subdir, sparse: fetched.sparse === true, dest: report.dest }], { home, now: deps.now })
353
+ } catch {}
354
+ return {
355
+ ok: true, status: 200, name: raw, repo, dest: report.dest,
356
+ backup: report.backup, added: report.added, overwritten: report.overwritten, identical: report.identical,
357
+ skippedInvalid: report.skippedInvalid, verified: report.verified, bytes: report.bytes, hashes: report.hashes,
358
+ mode: 'overwrite', note: report.note, error: null,
359
+ reason: `已用仓库 ${repo} 的版本覆盖预设 ${raw}(覆盖 ${report.overwritten.length} 个文件`
360
+ + `${report.overwritten.length > 0 ? `:${report.overwritten.slice(0, 6).join('、')}${report.overwritten.length > 6 ? ' 等' : ''}` : ''}`
361
+ + `、新增 ${report.added.length} 个;原目录已备份到 ${report.backup})`,
362
+ }
363
+ } finally {
364
+ try { disposeDir(tmpRoot) } catch {}
365
+ }
366
+ }
367
+
368
+ export {
369
+ PRESET_MARKER_FILES, PRESET_MANIFEST_FILE, GIT_READ_TIMEOUT_MS,
370
+ findPresetDirs, isPresetDir, looksLikePresetPackageName, readPresetManifest, listTreeFiles,
371
+ assemblePreset, locateSubpackageViaGit, fetchPresetSource, gitCapture, tryPresetSourceChannel,
372
+ overwritePreset,
373
+ }
@@ -0,0 +1,165 @@
1
+ // L1 · domain —— preset-yaml.js(预设配置 agent.cordis.yml 的**结构校验**;0.5.26 加法,修 F3)
2
+ //
3
+ // 为什么要有它(真机缺陷 F3,2026-09-27 独立对抗式复核抓到):
4
+ // 预设装配会把源仓库里的 `agent.cordis.yml` 覆盖到用户 `~/.dsh/.agent-presets/<预设>/` 下。
5
+ // 一旦取到的内容**不是可用的配置**(空文件、被截断、带 tab 缩进、引号没闭合…),
6
+ // 覆盖就把**原本可用**的预设写坏了 —— 复核报告里的现场就是 `overwritten=["agent.cordis.yml"]`,
7
+ // 下次新建会话挂载这个预设时报错,而用户完全不知道是自己点的那次「安装」干的。
8
+ // 修法(改错):**写盘之前**先做结构校验;不通过就**跳过这一个文件并如实报告**,绝不写坏。
9
+ //
10
+ // ⚠️ 判据宽严的分寸(为什么不用"顶层必须是 mapping"这类严格判据):
11
+ // 框架的 agent 预设是**顶层 services 列表**(`- id: …` / `name: …` / `config:`),
12
+ // 里面还合法地使用 `!!js` 自定义标签、`>-` 折叠块、`|-` 字面块、注释与空行。
13
+ // 严格的通用 YAML 校验器(或"顶层必须是 mapping")会把**真文件**判成非法 → 于是"修 F3"变成
14
+ // "所有预设都装不上",那是把缺陷换了个方向。所以这里只钉**能确证损坏**的形态(全部可离线判定):
15
+ // ① 空内容 / 全是注释与空行 —— 没有任何配置行,框架挂载它等于空预设;
16
+ // ② 行首缩进里出现 **tab** —— YAML 明确禁止用 tab 做缩进(框架的解析器必然报错);
17
+ // ③ 明显**未闭合的引号**(引号从头到尾用反斜杠转义、真正的解析器能容忍跨行,这里只抓最常见的截断);
18
+ // ④ 缩进**跳级**(父级还没打开一个映射/序列就多缩进)—— 截断与坏编辑的典型形状。
19
+ // `!!js` / 块标量 / 注释 / CRLF 一律按合法处理(见 passesStructure 的分支注释)。
20
+ //
21
+ // 分层:L1 domain —— 纯函数,无 IO、无 ctx、不抛(解析失败是正常结局,调用方按"不通过"处理)。
22
+
23
+ /** 缩进宽度(空格数;tab 会在这里被单独判掉,不参与计数)。 */
24
+ function indentOf(line) {
25
+ const m = String(line).match(/^[ \t]*/u)
26
+ return m === null ? 0 : m[0].length
27
+ }
28
+
29
+ /** 这一行的缩进里有没有 tab(YAML 禁止;框架解析器会直接报错)。 */
30
+ function hasTabIndent(line) {
31
+ return /^[ \t]*\t/u.test(String(line))
32
+ }
33
+
34
+ /** 去掉行尾注释后的"有效内容"(只处理**不在引号里**的 `#`;`#` 前必须有空白或行首)。
35
+ * 例:`name: '@deepseek-ai/dsh-persona' # 注释` → `name: '@deepseek-ai/dsh-persona'`。 */
36
+ function stripTrailingComment(line) {
37
+ const text = String(line)
38
+ let quote = null
39
+ for (let i = 0; i < text.length; i += 1) {
40
+ const ch = text[i]
41
+ if (quote !== null) {
42
+ if (ch === '\\') { i += 1; continue }
43
+ if (ch === quote) quote = null
44
+ continue
45
+ }
46
+ if (ch === "'" || ch === '"') { quote = ch; continue }
47
+ if (ch === '#' && (i === 0 || /\s/u.test(text[i - 1]))) return text.slice(0, i)
48
+ }
49
+ return text
50
+ }
51
+
52
+ /** 值里的引号是否闭合(只判**单个标量内**的成对性;跨行的引号在 YAML 里合法,故不跨行判)。 */
53
+ function quotesBalanced(value) {
54
+ const text = String(value)
55
+ let quote = null
56
+ for (let i = 0; i < text.length; i += 1) {
57
+ const ch = text[i]
58
+ if (quote === null) {
59
+ if (ch === "'" || ch === '"') quote = ch
60
+ continue
61
+ }
62
+ if (quote === '"' && ch === '\\') { i += 1; continue }
63
+ if (ch === quote) {
64
+ // YAML 的单引号转义是 `''`(两个单引号),要一起吃掉
65
+ if (quote === "'" && text[i + 1] === "'") { i += 1; continue }
66
+ quote = null
67
+ }
68
+ }
69
+ return quote === null
70
+ }
71
+
72
+ /** 块标量指示符(`|`、`>`、`|-`、`>+2`、`|2-`…)。返回 null 表示不是块标量。
73
+ * 只有**整个值**就是指示符时才认(`> something` 是普通标量,不是块头),否则会把普通行误判成块。 */
74
+ function blockScalarIndicator(value) {
75
+ const text = String(value).trim()
76
+ const m = text.match(/^([|>])([+-]?\d*|\d*[+-]?)$/u)
77
+ return m === null ? null : m[0]
78
+ }
79
+
80
+ /**
81
+ * 结构校验(**纯函数**,永不抛)。返回 `{ ok, problems: string[] }`。
82
+ * 只判"能确证损坏"的形态(见本文件顶部 ①~④),宁松勿误杀 —— 误杀的代价是预设装不上。
83
+ */
84
+ function validateAgentConfig(text) {
85
+ const source = typeof text === 'string' ? text : String(text ?? '')
86
+ const problems = []
87
+ const lines = source.split(/\r?\n/u)
88
+ const significant = []
89
+ for (let i = 0; i < lines.length; i += 1) {
90
+ const raw = lines[i]
91
+ if (raw.trim() === '') continue
92
+ if (/^\s*#/u.test(raw)) continue
93
+ significant.push({ line: i + 1, raw, indent: indentOf(raw), at: i })
94
+ }
95
+ // ① 空内容 / 全是注释与空行
96
+ if (significant.length === 0) {
97
+ problems.push('没有任何配置行(空文件,或只有注释与空行)—— 框架挂载它等于空预设')
98
+ return { ok: false, problems }
99
+ }
100
+ /** 块标量(`>-` / `|` 等)的内容由框架按字面读,**不是** YAML 结构 —— 必须整段跳过,
101
+ * 否则 persona 那种多行散文会被逐行当成"非法条目"(误杀三个真机预设,2026-09-27 实测)。
102
+ * 结束判据:出现缩进 **小于等于** 块首行缩进的非空行(或文件结束)。 */
103
+ const blockScalarEnd = (startIdx, parentIndent) => {
104
+ let end = startIdx
105
+ for (let i = startIdx + 1; i < lines.length; i += 1) {
106
+ const line = lines[i]
107
+ if (line.trim() === '') { end = i; continue }
108
+ if (indentOf(line) > parentIndent) { end = i; continue }
109
+ break
110
+ }
111
+ return end
112
+ }
113
+ const stack = [] // 已打开的父级:{ indent, opensNested }
114
+ let skipThrough = -1
115
+ for (const item of significant) {
116
+ if (item.at <= skipThrough) continue
117
+ // ② tab 缩进
118
+ if (hasTabIndent(item.raw)) {
119
+ problems.push(`第 ${item.line} 行的缩进里有 tab(YAML 禁止用 tab 缩进)`)
120
+ continue
121
+ }
122
+ const content = stripTrailingComment(item.raw).replace(/\s+$/u, '')
123
+ if (content.trim() === '') continue
124
+ const body = item.raw.slice(item.indent)
125
+ // ④ 缩进跳级:比当前父级深、但父级没有"还能放子项"的位置
126
+ while (stack.length > 0 && item.indent < stack[stack.length - 1].indent) stack.pop()
127
+ const parent = stack.length > 0 ? stack[stack.length - 1] : null
128
+ if (parent !== null && item.indent > parent.indent && parent.opensNested !== true) {
129
+ problems.push(`第 ${item.line} 行缩进 ${item.indent} 空格,但上一层的 \`${parent.label}\` 不接受子项(缩进跳级/文件被截断)`)
130
+ }
131
+ if (body.startsWith('- ') || body === '-') {
132
+ stack.push({ indent: item.indent, opensNested: true, label: body.slice(0, 40) })
133
+ continue
134
+ }
135
+ const kv = body.match(/^("[^"]*"|'[^']*'|[^\s:#][^:]*?)\s*:\s*(.*)$/u)
136
+ if (kv === null) {
137
+ problems.push(`第 ${item.line} 行不是合法的 YAML 条目(既不是 \`- \` 序列项,也不是 \`键: 值\`):${body.slice(0, 60)}`)
138
+ continue
139
+ }
140
+ const value = kv[2].trim()
141
+ // ③ 未闭合的引号(只在单行内判;`#` 已剥掉,行尾也不会再有注释)
142
+ if (!quotesBalanced(value)) {
143
+ problems.push(`第 ${item.line} 行的值里有未闭合的引号:${value.slice(0, 60)}`)
144
+ }
145
+ const isBlock = blockScalarIndicator(value) !== null
146
+ if (isBlock) skipThrough = blockScalarEnd(item.at, item.indent)
147
+ stack.push({ indent: item.indent, opensNested: value === '' || isBlock, label: `${kv[1].trim()}:` })
148
+ }
149
+ return { ok: problems.length === 0, problems }
150
+ }
151
+
152
+ /** 校验一个**目录里**的 agent 配置文件(写盘前的调用点用这个)。
153
+ * 返回 `{ ok, file, problems }`;目录里没有 agent.cordis.yml/.yaml 时 ok=true / file=null(不是预设,不归这里管)。 */
154
+ function validateAgentConfigDir(dir, { readFile = null } = {}) {
155
+ const read = typeof readFile === 'function' ? readFile : null
156
+ for (const name of ['agent.cordis.yml', 'agent.cordis.yaml']) {
157
+ const text = read === null ? null : read(name)
158
+ if (text === null || text === undefined) continue
159
+ const result = validateAgentConfig(text)
160
+ return { ...result, file: name }
161
+ }
162
+ return { ok: true, file: null, problems: [] }
163
+ }
164
+
165
+ export { validateAgentConfig, validateAgentConfigDir, stripTrailingComment, quotesBalanced, blockScalarIndicator, hasTabIndent, indentOf }