@noob-stupid/dsh-plugin-console 0.5.25 → 0.5.27
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 +52 -2
- package/lib/server/domain/bundle-refs.js +41 -0
- package/lib/server/domain/install-job.js +12 -8
- package/lib/server/domain/install.js +9 -25
- package/lib/server/domain/plugin-actions.js +48 -2
- package/lib/server/domain/preset-in-package.js +148 -0
- package/lib/server/domain/preset-install.js +360 -0
- package/lib/server/domain/preset-source.js +133 -99
- package/lib/server/domain/preset-yaml.js +165 -0
- package/lib/server/domain/repo-size.js +22 -3
- 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
|
+
}
|
|
@@ -7,29 +7,30 @@
|
|
|
7
7
|
// 于是第三件连一次机会都没有;而"添加并启用"这条路上,按包名的通道(registry/curl/release)
|
|
8
8
|
// 对它必然是 404 —— 缺的就是"从仓库里把那一个目录取下来,按预设装配"这条能力。
|
|
9
9
|
//
|
|
10
|
-
//
|
|
10
|
+
// 本模块提供三件事:
|
|
11
11
|
// ① findPresetDirs / isPresetDir —— 预设目录识别(从 suite.js **原样搬来**,suite.js 改为 re-export)
|
|
12
|
-
// ②
|
|
13
|
-
// 绝不静默覆盖**:先整目录备份,再逐文件合并;同名不同内容的逐条报告)
|
|
14
|
-
// ③ fetchPresetSource / tryPresetSourceChannel —— 稀疏取源码(`--filter=blob:none --sparse`
|
|
12
|
+
// ② fetchPresetSource / tryPresetSourceChannel —— 稀疏取源码(`--filter=blob:none --sparse`
|
|
15
13
|
// + `git sparse-checkout set <subdir>`;不支持时降级普通 clone)并装配
|
|
14
|
+
// ③ 装配本身(assemblePreset / 校验 / 备份 / 读回核实)**已搬到 domain/preset-install.js**
|
|
15
|
+
// (0.5.26 改错 F2+F3+F4:默认改为"只补不覆盖"、写前校验、备份路径唯一)。
|
|
16
|
+
// 本模块只做 re-export,既有调用方与测试一个字都不用改 —— 写盘逻辑全项目只有一份。
|
|
16
17
|
//
|
|
17
18
|
// 进程管理一律复用 repoland.js 的 runGitArgs / gitCloneRepo(kill-tree + wait-for-exit + `.tryN` +
|
|
18
19
|
// `.trash-*` 降级)—— 本模块**不新造任何进程管理**。
|
|
19
20
|
|
|
20
|
-
import { existsSync, readFileSync, readdirSync, mkdirSync
|
|
21
|
-
import { basename,
|
|
21
|
+
import { existsSync, readFileSync, readdirSync, mkdirSync } from 'node:fs'
|
|
22
|
+
import { basename, join } from 'node:path'
|
|
22
23
|
import { tmpdir } from 'node:os'
|
|
23
24
|
import { gitCloneRepo, runGitArgs } from './repoland.js'
|
|
24
25
|
import { noteChannel } from './git-channel.js'
|
|
25
26
|
import { evaluateSourceChannel } from './repo-size.js'
|
|
26
|
-
import {
|
|
27
|
+
import { disposeDir } from '../infra/fsx.js'
|
|
27
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'
|
|
28
33
|
|
|
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
34
|
/** git 只读命令(ls-tree / show / sparse-checkout)的超时:都是元数据或单个 blob。 */
|
|
34
35
|
const GIT_READ_TIMEOUT_MS = 60000
|
|
35
36
|
|
|
@@ -84,88 +85,6 @@ function readPresetManifest(dir) {
|
|
|
84
85
|
}
|
|
85
86
|
}
|
|
86
87
|
|
|
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
88
|
/** 跑一条 git 命令并取回 stdout(ls-tree/show 要的就是输出)。仍然走 repoland 的 runGitArgs:
|
|
170
89
|
* 那边已经把停滞判据 + kill-tree + wait-for-exit 做完了,这里只转发(`stdout` 由 runGitArgs 采集)。 */
|
|
171
90
|
function gitCapture(argv, { cwd, timeout, runGit = null } = {}) {
|
|
@@ -299,30 +218,64 @@ async function tryPresetSourceChannel({ job, name, source = 'github', limit = 4,
|
|
|
299
218
|
const skipped = Math.max(0, presets.length - limit)
|
|
300
219
|
for (const p of presets.slice(0, limit)) {
|
|
301
220
|
// eslint-disable-next-line no-await-in-loop
|
|
221
|
+
// overwrite **永不**在这里为真:安装路径必须走"只补不覆盖"的默认语义。
|
|
222
|
+
// 覆盖只能由用户显式点「覆盖该预设」→ 白名单动作 overwrite-preset 触发(见 plugin-actions.js)。
|
|
302
223
|
const report = assemblePreset(p, basename(p), { presetsRoot, ...(deps.now === undefined ? {} : { now: deps.now }) })
|
|
303
224
|
reports.push({ ...report, manifest: readPresetManifest(p), sparse: fetched.sparse === true, subdir: fetched.subdir })
|
|
304
225
|
}
|
|
305
226
|
const failed = reports.filter((r) => r.ok !== true)
|
|
306
227
|
if (job !== null && job !== undefined) {
|
|
228
|
+
// 面板可读的结构化结果。0.5.26 加法:mode / skipped / skippedInvalid / verified ——
|
|
229
|
+
// 「默认只补不覆盖」必须**在数据里看得见**(跳过了哪几个同名文件、为什么),不能只在 note 里叙述。
|
|
307
230
|
job.presetInstalled = reports.map((r) => ({
|
|
308
231
|
name: r.name, dest: r.dest, ok: r.ok === true, added: r.added?.length ?? 0, overwritten: r.overwritten?.length ?? 0,
|
|
309
232
|
identical: r.identical?.length ?? 0, backup: r.backup ?? null, bytes: r.bytes ?? 0, error: r.error ?? null,
|
|
310
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,
|
|
311
238
|
}))
|
|
312
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
|
+
}
|
|
313
259
|
}
|
|
314
260
|
if (failed.length > 0 && failed.length === reports.length) {
|
|
315
261
|
return { handled: true, installed: false, error: new Error(`预设装配失败:${failed[0].error}`), presets: reports }
|
|
316
262
|
}
|
|
317
263
|
const okNames = reports.filter((r) => r.ok === true).map((r) => r.name)
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
const
|
|
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 !== '')
|
|
321
267
|
const note = `预设已装配:${okNames.join('、')}(落盘 ${presetsRoot};**新建会话时选择**)`
|
|
322
268
|
+ (fetched.sparse === true ? ';源码用稀疏取源码(--filter=blob:none --sparse + sparse-checkout set),只取该子包目录' : '')
|
|
323
269
|
+ (skipped > 0 ? `;该仓库还有 ${skipped} 个预设本次未装配(单次上限 ${limit} 个)` : '')
|
|
324
|
-
|
|
325
|
-
|
|
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)} 个`
|
|
326
279
|
: '')
|
|
327
280
|
+ (failed.length > 0 ? `;${failed.length} 个预设装配失败:${failed.map((r) => `${r.name}(${r.error})`).join('、')}` : '')
|
|
328
281
|
return { handled: true, installed: true, name: candidateName, presets: reports, note }
|
|
@@ -332,8 +285,89 @@ async function tryPresetSourceChannel({ job, name, source = 'github', limit = 4,
|
|
|
332
285
|
}
|
|
333
286
|
}
|
|
334
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
|
+
|
|
335
368
|
export {
|
|
336
369
|
PRESET_MARKER_FILES, PRESET_MANIFEST_FILE, GIT_READ_TIMEOUT_MS,
|
|
337
370
|
findPresetDirs, isPresetDir, looksLikePresetPackageName, readPresetManifest, listTreeFiles,
|
|
338
371
|
assemblePreset, locateSubpackageViaGit, fetchPresetSource, gitCapture, tryPresetSourceChannel,
|
|
339
|
-
|
|
372
|
+
overwritePreset,
|
|
373
|
+
}
|