@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.
- package/lib/client.js +79 -3
- package/lib/server/domain/bundle-refs.js +41 -0
- package/lib/server/domain/git-channel.js +33 -2
- package/lib/server/domain/install-job.js +37 -35
- package/lib/server/domain/install-verify.js +18 -1
- package/lib/server/domain/install.js +10 -26
- package/lib/server/domain/market.js +18 -1
- package/lib/server/domain/plugin-actions.js +48 -2
- package/lib/server/domain/preset-install.js +360 -0
- package/lib/server/domain/preset-source.js +373 -0
- package/lib/server/domain/preset-yaml.js +165 -0
- package/lib/server/domain/repo-size.js +155 -0
- package/lib/server/domain/repoland.js +86 -22
- package/lib/server/domain/suite.js +14 -28
- package/lib/server/infra/fsx.js +32 -6
- package/package.json +1 -1
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
// L1 · domain —— preset-install.js(预设**装配**:只补不覆盖 / 显式覆盖 / 校验 / 备份 / 读回核实)
|
|
2
|
+
//
|
|
3
|
+
// 为什么单开一个模块(0.5.26 改错 F2 + F3 + F4,独立对抗式复核抓到):
|
|
4
|
+
// · **F2**:`assemblePreset` 旧语义是"已存在同名预设 → 整目录备份 + 逐文件**合并覆盖**"。
|
|
5
|
+
// 真机一次装配改写了 3 个在用预设、**11 个文件**(router-standard 4 个,含 `agent.cordis.yml`)。
|
|
6
|
+
// 它有备份、有逐条报告(不静默),但**与上游 `install.ps1` 的语义相反** ——
|
|
7
|
+
// 上游是「预设已存在 → 请先手动删除」= **跳过**。默认写别人的文件就是错的方向。
|
|
8
|
+
// 新语义:**默认只补缺失文件,绝不覆盖已存在的同名文件**;覆盖必须由用户**显式**点
|
|
9
|
+
// 「覆盖该预设」(白名单动作 `overwrite-preset`)才发生,且改前整目录备份 + 写后读回核实。
|
|
10
|
+
// · **F3**:装配前必须校验 `agent.cordis.yml` 可解析(见 preset-yaml.js)——
|
|
11
|
+
// 旧代码把非法 YAML 覆盖到原本可用的文件上(`overwritten=["agent.cordis.yml"]`),
|
|
12
|
+
// 下次新建会话挂载这个预设就报错。校验不通过 → **跳过该文件并如实报告,绝不写**。
|
|
13
|
+
// · **F4**:同一毫秒的两次装配会生成**同一个**备份路径(`<name>.bak-<Date.now()>`),
|
|
14
|
+
// 第二次把第一次的备份覆盖掉 → 用户以为有两个回滚点,其实只剩一个。
|
|
15
|
+
// 新语义:备份路径加唯一后缀(`-2`、`-3`… 直到不冲突),两次装配的备份**各自完整**。
|
|
16
|
+
//
|
|
17
|
+
// 与 preset-source.js 的分工:那边负责"把源码取下来"(稀疏 clone / git 定位 / 网络),
|
|
18
|
+
// 这边只负责"往 `.agent-presets` 里写"(判据 / 校验 / 备份 / 核实)—— 写盘路径全项目只有这一处。
|
|
19
|
+
//
|
|
20
|
+
// 分层:L1 domain —— 不认识 cordis ctx;时间戳与 IO 可注入(便于离线断言)。
|
|
21
|
+
|
|
22
|
+
import { createHash } from 'node:crypto'
|
|
23
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
|
|
24
|
+
import { basename, dirname, join, relative, sep } from 'node:path'
|
|
25
|
+
import { dshHome } from '../infra/paths.js'
|
|
26
|
+
import { validateAgentConfig } from './preset-yaml.js'
|
|
27
|
+
|
|
28
|
+
/** 预设的判别文件(框架发现预设看的就是这个:`<presetsRoot>/<name>/agent.cordis.yml`)。 */
|
|
29
|
+
const PRESET_MARKER_FILES = ['agent.cordis.yml', 'agent.cordis.yaml']
|
|
30
|
+
/** 预设清单(可选,面板文案用)。 */
|
|
31
|
+
const PRESET_MANIFEST_FILE = 'preset.yml'
|
|
32
|
+
/** 记录"每个已装配预设的源码出处"的文件(`overwrite-preset` 动作据此重新取源码)。
|
|
33
|
+
* 为什么不放 `<presetsRoot>` 里:那里是**框架**读的目录,多一个非预设文件会污染预设列表。 */
|
|
34
|
+
const PRESET_SOURCE_RECORD_LIMIT = 80
|
|
35
|
+
|
|
36
|
+
function presetSourcesFile(home = undefined) {
|
|
37
|
+
return join(home ?? dshHome(), 'plugin-console', 'preset-sources.json')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function sha256Of(text) {
|
|
41
|
+
return createHash('sha256').update(typeof text === 'string' ? text : String(text ?? ''), 'utf8').digest('hex')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 目录树里的文件清单(相对路径 + 字节数)——只读,best-effort,绝不抛。 */
|
|
45
|
+
function listTreeFiles(root, { maxDepth = 8 } = {}) {
|
|
46
|
+
const out = []
|
|
47
|
+
const walk = (dir, depth) => {
|
|
48
|
+
if (depth > maxDepth) return
|
|
49
|
+
let entries = []
|
|
50
|
+
try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
|
|
51
|
+
for (const e of entries) {
|
|
52
|
+
const child = join(dir, e.name)
|
|
53
|
+
try {
|
|
54
|
+
if (e.isDirectory()) walk(child, depth + 1)
|
|
55
|
+
else if (e.isFile()) out.push({ rel: relative(root, child).split(sep).join('/'), bytes: statSync(child).size })
|
|
56
|
+
} catch {}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
walk(String(root), 0)
|
|
60
|
+
return out
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 备份路径:`<dest>.bak-<timeValue>`,已存在就自增后缀直到不冲突(**F4**)。
|
|
64
|
+
* 旧实现直接 `${dest}.bak-${now()}` —— 同一毫秒的两次装配拿到同一个路径,
|
|
65
|
+
* 第二次 `copyTree` 把第一次的备份**覆盖**掉:用户以为有两个回滚点,其实只剩一个。 */
|
|
66
|
+
function uniqueBackupPath(dest, timeValue) {
|
|
67
|
+
const base = `${dest}.bak-${timeValue}`
|
|
68
|
+
if (!existsSync(base)) return base
|
|
69
|
+
for (let n = 2; n < 1000; n += 1) {
|
|
70
|
+
const candidate = `${base}-${n}`
|
|
71
|
+
if (!existsSync(candidate)) return candidate
|
|
72
|
+
}
|
|
73
|
+
return `${base}-${Date.now()}`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 读回核实(**写后必做**):每个本该落盘的文件都真的在盘上、且字节与源一致。
|
|
77
|
+
* 返回 { ok, checked, mismatched[] };读不到/不一致都算不通过(绝不"写没写成"靠假设)。 */
|
|
78
|
+
function verifyWrittenFiles(dest, srcDir, rels) {
|
|
79
|
+
const mismatched = []
|
|
80
|
+
for (const rel of rels) {
|
|
81
|
+
const target = join(dest, ...String(rel).split('/'))
|
|
82
|
+
const source = join(srcDir, ...String(rel).split('/'))
|
|
83
|
+
try {
|
|
84
|
+
if (!existsSync(target)) { mismatched.push(`${rel}(写后不存在)`); continue }
|
|
85
|
+
if (!readFileSync(target).equals(readFileSync(source))) mismatched.push(`${rel}(字节与源不一致)`)
|
|
86
|
+
} catch (error) {
|
|
87
|
+
mismatched.push(`${rel}(读回失败:${error instanceof Error ? error.message : String(error)})`)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { ok: mismatched.length === 0, checked: rels.length, mismatched }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 装配一个预设到 `<presetsRoot>/<name>`(**唯一实现**)。
|
|
95
|
+
*
|
|
96
|
+
* 两种模式(`overwrite` 必须由**用户显式动作**打开,安装路径永不传 true):
|
|
97
|
+
* · 默认(`overwrite !== true`):**只补缺失文件**。已存在且内容不同的同名文件一律**不写**,
|
|
98
|
+
* 逐个记进 `conflicts` 并在 note 里点名,提示"如需覆盖,请点「覆盖该预设」"。
|
|
99
|
+
* 这个模式**不建备份**:一个字节都不改,建备份只会让用户以为目录被改过。
|
|
100
|
+
* · `overwrite: true`:先整目录备份(唯一路径)→ 再按文件合并(新增 + 覆盖同名)→ 写后读回核实。
|
|
101
|
+
*
|
|
102
|
+
* F3 校验(两种模式都做):源里的 `agent.cordis.yml/.yaml` 必须通过结构校验;
|
|
103
|
+
* 不通过 → **这个文件不写**,进 `skippedInvalid` 并如实报告(其它文件照常)。
|
|
104
|
+
*
|
|
105
|
+
* 返回 { ok, name, dest, backup, mode, added, skipped, overwritten, identical, conflicts,
|
|
106
|
+
* skippedInvalid, verified, verifiedConfig, hashes, bytes, files, note, error }。
|
|
107
|
+
*/
|
|
108
|
+
function assemblePreset(srcDir, name, { presetsRoot = null, now = Date.now, overwrite = false, verify = true } = {}) {
|
|
109
|
+
const root = presetsRoot ?? join(dshHome(), '.agent-presets')
|
|
110
|
+
const presetName = String(name ?? basename(srcDir)).trim()
|
|
111
|
+
if (presetName === '' || presetName === '.' || /[\\/]/u.test(presetName)) {
|
|
112
|
+
return { ok: false, name: presetName, dest: null, error: `预设目录名不合法:${JSON.stringify(name)}` }
|
|
113
|
+
}
|
|
114
|
+
const dest = join(root, presetName)
|
|
115
|
+
const srcFiles = listTreeFiles(srcDir)
|
|
116
|
+
if (srcFiles.length === 0) return { ok: false, name: presetName, dest, error: `源目录里没有任何文件:${srcDir}` }
|
|
117
|
+
const bytes = srcFiles.reduce((sum, f) => sum + f.bytes, 0)
|
|
118
|
+
const existed = existsSync(dest)
|
|
119
|
+
|
|
120
|
+
// ── F3 校验(**写盘之前**):预设的判别文件必须结构合法,否则绝不写 ──────────────────────
|
|
121
|
+
const skippedInvalid = []
|
|
122
|
+
const invalidConfig = []
|
|
123
|
+
for (const file of srcFiles) {
|
|
124
|
+
if (!PRESET_MARKER_FILES.includes(file.rel)) continue
|
|
125
|
+
let text = null
|
|
126
|
+
try { text = readFileSync(join(srcDir, ...file.rel.split('/')), 'utf8') } catch { text = null }
|
|
127
|
+
if (text === null) { invalidConfig.push({ rel: file.rel, problems: ['读不到内容'] }); continue }
|
|
128
|
+
const verdict = validateAgentConfig(text)
|
|
129
|
+
if (verdict.ok !== true) invalidConfig.push({ rel: file.rel, problems: verdict.problems })
|
|
130
|
+
}
|
|
131
|
+
if (invalidConfig.length > 0) {
|
|
132
|
+
for (const bad of invalidConfig) skippedInvalid.push({ rel: bad.rel, problems: bad.problems })
|
|
133
|
+
}
|
|
134
|
+
/** 预设的判别文件被拦下 = 这个预设**装不成**(框架发现预设看的就是它)→ 整个预设拒绝,
|
|
135
|
+
* 不做半成品(只铺 preset.yml/脚本、没有配置)—— 那种目录会让用户以为装上了,实际挂载不了。
|
|
136
|
+
* 这是**加法**:旧代码会把非法 YAML 直接覆盖上去,比"不装"更糟。 */
|
|
137
|
+
const markerBlocked = skippedInvalid.length > 0
|
|
138
|
+
if (markerBlocked) {
|
|
139
|
+
return {
|
|
140
|
+
ok: false, name: presetName, dest, existed, mode: overwrite === true ? 'overwrite' : 'add-only',
|
|
141
|
+
error: `预设配置未通过结构校验,拒绝装配(绝不写坏现有预设):${skippedInvalid.map((s) => `${s.rel}(${s.problems[0]})`).join(';')}`,
|
|
142
|
+
added: [], skipped: [], overwritten: [], identical: [], conflicts: [], skippedInvalid,
|
|
143
|
+
verified: null, verifiedConfig: null, hashes: [], bytes, files: srcFiles.map((f) => f.rel),
|
|
144
|
+
note: `预设 ${presetName} **未装配**:${skippedInvalid.length} 个预设配置文件未通过结构校验(${skippedInvalid.map((s) => s.rel).join('、')}),`
|
|
145
|
+
+ '已跳过未写入 —— 避免把原本可用的预设写坏;现有文件一个字节都没动',
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── 备份(**只在真要覆盖时**):改前整目录复制;备份失败就不合并(绝不在没备份的情况下覆盖)──
|
|
150
|
+
let backup = null
|
|
151
|
+
if (existed && overwrite === true) {
|
|
152
|
+
backup = uniqueBackupPath(dest, now())
|
|
153
|
+
try {
|
|
154
|
+
copyTreeStrict(dest, backup)
|
|
155
|
+
} catch (error) {
|
|
156
|
+
return {
|
|
157
|
+
ok: false, name: presetName, dest, existed, mode: 'overwrite', backup: null,
|
|
158
|
+
error: `已存在同名预设且备份失败(绝不在没备份的情况下覆盖):${error instanceof Error ? error.message : String(error)}`,
|
|
159
|
+
conflicts: srcFiles.map((f) => f.rel), added: [], skipped: [], overwritten: [], identical: [], skippedInvalid, verified: null, bytes,
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
mkdirSync(root, { recursive: true })
|
|
165
|
+
const added = []
|
|
166
|
+
const skipped = []
|
|
167
|
+
const overwritten = []
|
|
168
|
+
const identical = []
|
|
169
|
+
const written = []
|
|
170
|
+
for (const file of srcFiles) {
|
|
171
|
+
const from = join(srcDir, ...file.rel.split('/'))
|
|
172
|
+
const target = join(dest, ...file.rel.split('/'))
|
|
173
|
+
const had = existsSync(target)
|
|
174
|
+
if (had) {
|
|
175
|
+
let same = false
|
|
176
|
+
try { same = readFileSync(target).equals(readFileSync(from)) } catch { same = false }
|
|
177
|
+
if (same) { identical.push(file.rel); continue }
|
|
178
|
+
// ★ F2 的核心:默认**不覆盖**用户在用同名预设里的任何文件
|
|
179
|
+
if (overwrite !== true) { skipped.push(file.rel); continue }
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
183
|
+
writeFileSync(target, readFileSync(from))
|
|
184
|
+
written.push(file.rel)
|
|
185
|
+
} catch (error) {
|
|
186
|
+
return {
|
|
187
|
+
ok: false, name: presetName, dest, existed, mode: overwrite === true ? 'overwrite' : 'add-only', backup,
|
|
188
|
+
error: `合并 ${file.rel} 失败:${error instanceof Error ? error.message : String(error)}`,
|
|
189
|
+
added, skipped, overwritten, identical, conflicts: skipped, skippedInvalid, verified: null, bytes,
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (had) overwritten.push(file.rel)
|
|
193
|
+
else added.push(file.rel)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── 写后读回核实(只在真的写了东西时)────────────────────────────────────────────────
|
|
197
|
+
const verified = verify === true && written.length > 0 ? verifyWrittenFiles(dest, srcDir, written) : null
|
|
198
|
+
const destConfigValid = PRESET_MARKER_FILES.some((f) => existsSync(join(dest, f)))
|
|
199
|
+
? PRESET_MARKER_FILES.map((f) => (existsSync(join(dest, f)) ? validateAgentConfig(readFileSync(join(dest, f), 'utf8')) : null)).find((v) => v !== null) ?? { ok: true }
|
|
200
|
+
: { ok: false, problems: ['目标目录里没有 agent.cordis.yml/.yaml'] }
|
|
201
|
+
const mode = overwrite === true ? 'overwrite' : 'add-only'
|
|
202
|
+
|
|
203
|
+
const noteParts = []
|
|
204
|
+
if (existed !== true) {
|
|
205
|
+
noteParts.push(`预设 ${presetName} 已装配到 ${dest}(新增 ${added.length} 个文件)`)
|
|
206
|
+
} else if (mode === 'overwrite') {
|
|
207
|
+
// 逐条点名被覆盖的文件(旧实现有这句,改错时不能丢:用户要知道**具体哪几个文件**被动过)
|
|
208
|
+
noteParts.push(`预设 ${presetName}:已按你的显式要求**覆盖**(原目录整份备份到 ${backup};本次覆盖 ${overwritten.length} 个、新增 ${added.length} 个、内容一致 ${identical.length} 个)`
|
|
209
|
+
+ (overwritten.length > 0 ? `(被覆盖的:${overwritten.slice(0, 6).join('、')}${overwritten.length > 6 ? ' 等' : ''})` : ''))
|
|
210
|
+
} else {
|
|
211
|
+
noteParts.push(`预设 ${presetName}:**默认只补缺失文件、不覆盖**(本次新增 ${added.length} 个)`)
|
|
212
|
+
if (skipped.length > 0) {
|
|
213
|
+
noteParts.push(`已存在同名预设,其中 ${skipped.length} 个同名文件内容与你手上的不同,**已原样保留未动**:${skipped.slice(0, 6).join('、')}`
|
|
214
|
+
+ `${skipped.length > 6 ? ' 等' : ''} —— 如需用仓库里的版本覆盖,请点「覆盖该预设」(会先把原目录整份备份)`)
|
|
215
|
+
}
|
|
216
|
+
if (identical.length > 0) noteParts.push(`另有 ${identical.length} 个文件与你手上的内容一致,未改动`)
|
|
217
|
+
}
|
|
218
|
+
if (skippedInvalid.length > 0) {
|
|
219
|
+
noteParts.push(`${skippedInvalid.length} 个预设配置文件**未通过结构校验、已跳过未写入**:`
|
|
220
|
+
+ skippedInvalid.map((s) => `${s.rel}(${s.problems[0]})`).join('、'))
|
|
221
|
+
}
|
|
222
|
+
if (skipped.length > 0) {
|
|
223
|
+
noteParts.push(`${skipped.length} 个同名文件因默认"只补不覆盖"而保留了你手上的版本`)
|
|
224
|
+
}
|
|
225
|
+
if (verified !== null && verified.ok !== true) {
|
|
226
|
+
noteParts.push(`⚠️ 写后读回核实不通过:${verified.mismatched.join('、')}`)
|
|
227
|
+
}
|
|
228
|
+
const result = {
|
|
229
|
+
ok: true,
|
|
230
|
+
name: presetName,
|
|
231
|
+
dest,
|
|
232
|
+
backup,
|
|
233
|
+
existed,
|
|
234
|
+
mode,
|
|
235
|
+
added,
|
|
236
|
+
skipped,
|
|
237
|
+
overwritten,
|
|
238
|
+
identical,
|
|
239
|
+
conflicts: skipped,
|
|
240
|
+
skippedInvalid,
|
|
241
|
+
verified,
|
|
242
|
+
verifiedConfig: destConfigValid,
|
|
243
|
+
hashes: written.map((rel) => ({ rel, sha256: sha256Of(readFileSync(join(dest, ...rel.split('/')))) })),
|
|
244
|
+
bytes,
|
|
245
|
+
files: srcFiles.map((f) => f.rel),
|
|
246
|
+
note: noteParts.join(';'),
|
|
247
|
+
}
|
|
248
|
+
// ok 的判据:真写成的文件都通过了读回核实 + 落盘后的配置结构合法。
|
|
249
|
+
// 两点必须说清,否则"改错"会变成"动不动报失败":
|
|
250
|
+
// · 一个文件都没写(内容全一致)**不是失败** —— 那是幂等重装的正常结局(verified 为 null);
|
|
251
|
+
// · 默认模式下"跳过同名冲突文件"**不是失败** —— 那正是"只补不覆盖"的设计目的,
|
|
252
|
+
// 但要靠 note 里逐条点名 + `skipped` 字段让面板看得见(绝不静默)。
|
|
253
|
+
const writtenOk = written.length === 0 || (verified !== null && verified.ok === true)
|
|
254
|
+
result.ok = writtenOk && destConfigValid.ok === true
|
|
255
|
+
if (!result.ok) {
|
|
256
|
+
result.error = writtenOk !== true
|
|
257
|
+
? `写后读回核实不通过:${(verified?.mismatched ?? []).join('、')}`
|
|
258
|
+
: `落盘后的预设配置未通过结构校验:${(destConfigValid.problems ?? []).join(';')}`
|
|
259
|
+
}
|
|
260
|
+
return result
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** 逐文件复制目录树(不用 fs.cpSync:见 infra/fsx.js#copyTree 的本环境 EIO 说明)。
|
|
264
|
+
* 与 fsx.copyTree 的差别:这里**先清掉**同名的旧备份残留(绝不会发生,uniqueBackupPath 已保证),
|
|
265
|
+
* 并在复制后清掉只读位,保证备份可删可读。 */
|
|
266
|
+
function copyTreeStrict(src, dest) {
|
|
267
|
+
mkdirSync(dest, { recursive: true })
|
|
268
|
+
for (const entry of readdirSync(src, { withFileTypes: true })) {
|
|
269
|
+
const from = join(src, entry.name)
|
|
270
|
+
const to = join(dest, entry.name)
|
|
271
|
+
if (entry.isDirectory()) { copyTreeStrict(from, to); continue }
|
|
272
|
+
if (!entry.isFile()) continue
|
|
273
|
+
copyFileSync(from, to)
|
|
274
|
+
try { chmodSync(to, 0o666) } catch {}
|
|
275
|
+
}
|
|
276
|
+
return dest
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ── 源码出处记录(`overwrite-preset` 动作据此重新取源码)──────────────────────────────
|
|
280
|
+
|
|
281
|
+
/** 读全部记录(读不到/坏文件 → 空对象,绝不抛)。 */
|
|
282
|
+
function readPresetSources(home = undefined) {
|
|
283
|
+
try {
|
|
284
|
+
const parsed = JSON.parse(readFileSync(presetSourcesFile(home), 'utf8'))
|
|
285
|
+
return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
|
|
286
|
+
} catch {
|
|
287
|
+
return {}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** 记下"这个预设是从哪个仓库的哪个子目录装配来的"(唯一写盘点,由装配路径调用)。
|
|
292
|
+
* 只记**可重新定位源码**的最小信息:repo / candidateName / subdir / sparse。 */
|
|
293
|
+
function recordPresetSource(entries, { home = undefined, now = Date.now } = {}) {
|
|
294
|
+
const list = Array.isArray(entries) ? entries.filter((e) => e !== null && typeof e === 'object' && typeof e.name === 'string' && e.name !== '') : []
|
|
295
|
+
if (list.length === 0) return { ok: false, written: 0 }
|
|
296
|
+
const all = readPresetSources(home)
|
|
297
|
+
const at = new Date(typeof now === 'function' ? now() : now).toISOString()
|
|
298
|
+
for (const e of list) {
|
|
299
|
+
all[e.name] = {
|
|
300
|
+
repo: typeof e.repo === 'string' ? e.repo : null,
|
|
301
|
+
candidateName: typeof e.candidateName === 'string' ? e.candidateName : null,
|
|
302
|
+
subdir: typeof e.subdir === 'string' ? e.subdir : null,
|
|
303
|
+
sparse: e.sparse === true,
|
|
304
|
+
dest: typeof e.dest === 'string' ? e.dest : null,
|
|
305
|
+
at,
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
// 只保留最近 N 条(防无限增长):对象键顺序 = 插入顺序,从最老的开始丢
|
|
309
|
+
const names = Object.keys(all)
|
|
310
|
+
for (const name of names.slice(0, Math.max(0, names.length - PRESET_SOURCE_RECORD_LIMIT))) delete all[name]
|
|
311
|
+
try {
|
|
312
|
+
mkdirSync(dirname(presetSourcesFile(home)), { recursive: true })
|
|
313
|
+
writeFileSync(presetSourcesFile(home), JSON.stringify(all, null, 2), 'utf8')
|
|
314
|
+
return { ok: true, written: list.length, file: presetSourcesFile(home) }
|
|
315
|
+
} catch (error) {
|
|
316
|
+
return { ok: false, written: 0, error: error instanceof Error ? error.message : String(error) }
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** 取一个预设记下的源码出处(没有 → null)。 */
|
|
321
|
+
function presetSourceOf(name, home = undefined) {
|
|
322
|
+
const all = readPresetSources(home)
|
|
323
|
+
const hit = all[String(name ?? '').trim()]
|
|
324
|
+
return hit !== null && typeof hit === 'object' ? hit : null
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** 面板的「覆盖该预设」显式动作(**结构化**,与 pin-dependency / allow-builds 同一套安全语义):
|
|
328
|
+
* `command` 只供展示/复制;执行侧只认 `payload.action` 白名单类型,`presetName` 由服务端自己校验
|
|
329
|
+
* (必须真的是 `<DSH_HOME>/.agent-presets/<name>` 下已存在的目录,客户端说了不算)。 */
|
|
330
|
+
function suggestedOverwritePresetAction({ presetName, repo = null, reason = null } = {}) {
|
|
331
|
+
const name = typeof presetName === 'string' ? presetName.trim() : ''
|
|
332
|
+
return {
|
|
333
|
+
kind: 'overwrite-preset',
|
|
334
|
+
label: '覆盖该预设(先用仓库版本覆盖,自动整目录备份)',
|
|
335
|
+
command: `dsh-plugin-console: overwrite-preset ${name}`,
|
|
336
|
+
payload: { action: 'overwrite-preset', presetName: name, repo: typeof repo === 'string' && repo !== '' ? repo : null },
|
|
337
|
+
reason,
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** 面板视图里的**预设字段簇**(0.5.26 加法):`installJobView` 只做一行展开。
|
|
342
|
+
* 为什么放这里:`domain/install.js` 贴着 600 行架构硬顶(test-architecture-guard.mjs),
|
|
343
|
+
* 而"预设这条通道要下发什么"本来就属于本模块 —— 字段与它的产出者放在一起,才不会漂移。 */
|
|
344
|
+
function presetJobView(job) {
|
|
345
|
+
const j = job ?? {}
|
|
346
|
+
return {
|
|
347
|
+
presetNote: j.presetNote ?? null,
|
|
348
|
+
presetInstalled: j.presetInstalled ?? null,
|
|
349
|
+
presetSource: j.presetSource ?? null,
|
|
350
|
+
// 默认只补不覆盖:出现同名冲突时下发的**显式**「覆盖该预设」动作(老客户端忽略该字段,向后兼容)
|
|
351
|
+
suggestedAction: j.suggestedAction ?? null,
|
|
352
|
+
presetOverwriteCandidates: j.presetOverwriteCandidates ?? null,
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export {
|
|
357
|
+
PRESET_MARKER_FILES, PRESET_MANIFEST_FILE, PRESET_SOURCE_RECORD_LIMIT,
|
|
358
|
+
assemblePreset, copyTreeStrict, listTreeFiles, presetJobView, presetSourceOf, presetSourcesFile,
|
|
359
|
+
readPresetSources, recordPresetSource, sha256Of, suggestedOverwritePresetAction, uniqueBackupPath, verifyWrittenFiles,
|
|
360
|
+
}
|