dsh-subagent-profile 0.2.0 → 0.3.0

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,328 @@
1
+ // lib/core/http-routes.mjs — settings HTTP loopback routes for the Client UI,
2
+ // moved verbatim from index.mjs's `ctx.inject(['webServer'], (scope) => {...})`
3
+ // block. Local lib references only: sanitizeProfile from
4
+ // lib/core/pure.mjs, TOOL_ZH/TOOL_CATEGORY from lib/core/catalog.mjs, BUILTIN_SEEDS from
5
+ // lib/core/profiles-store.mjs; no @deepseek-ai dependency.
6
+ //
7
+ // Injection: every apply-closure / ctx dependency is an explicit parameter —
8
+ // store the profile store (profiles Map / persistProfiles /
9
+ // persistEnabled / deletedBuiltins),
10
+ // getEnabled reads the apply-closure `enabled` flag (mutated by
11
+ // /set-enabled),
12
+ // setEnabled writes it,
13
+ // syncTool unregisters/registers the dispatch tool on /set-enabled,
14
+ // getLlm / getAgentPresets / getTools
15
+ // request-time service getters (ctx.get('llm') etc. are read
16
+ // per request, never at apply time),
17
+ // logger ctx.logger (route error / tools-directory warnings).
18
+ // The factory returns the scope.effect setup function so the caller keeps the
19
+ // exact original registration shape: effect(() => register + disposer).
20
+ //
21
+ // 8 条路由各抽为模块级处理函数(if 链分发保持);/options 的目录构建抽
22
+ // collectModelDirectory / collectSystemPresets / collectToolsDirectory。
23
+ // 行为逐字不变(错误文案/状态码/schema)。
24
+
25
+ import { sanitizeProfile } from './pure.mjs';
26
+ import { TOOL_ZH, TOOL_CATEGORY } from './catalog.mjs';
27
+ import { BUILTIN_SEEDS } from './profiles-store.mjs';
28
+
29
+ // Only the loopback interfaces may drive the settings HTTP routes.
30
+ const LOOPBACKS = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
31
+
32
+ function json(res, code, data) {
33
+ res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
34
+ res.end(JSON.stringify(data));
35
+ }
36
+
37
+ function readBody(req) {
38
+ return new Promise((resolve, reject) => {
39
+ let data = '';
40
+ let size = 0;
41
+ req.on('data', (chunk) => {
42
+ size += chunk.length;
43
+ if (size > 1 << 20) { reject(new Error('请求体过大')); req.destroy(); return; }
44
+ data += chunk;
45
+ });
46
+ req.on('end', () => {
47
+ try { resolve(data === '' ? {} : JSON.parse(data)); } catch { reject(new Error('请求体不是合法 JSON')); }
48
+ });
49
+ req.on('error', reject);
50
+ });
51
+ }
52
+
53
+ // Write-failure contract: the write routes return HTTP 200 with
54
+ // `persisted` always present; when the disk write failed, persistWarning
55
+ // explains "已保存但未持久化" (in-memory state drives this process, the
56
+ // disk did not update). The client renders that as the amber warning.
57
+ function persistOk(res, payload, persist) {
58
+ return json(res, 200, {
59
+ ok: true,
60
+ ...payload,
61
+ persisted: persist.persisted,
62
+ ...(persist.persisted ? {} : { persistWarning: '已保存但未持久化' }),
63
+ });
64
+ }
65
+
66
+ function listClean(store) {
67
+ return [...store.profiles.values()].map((profile) => {
68
+ const clean = {};
69
+ for (const [key, value] of Object.entries(profile)) if (value !== undefined && key !== 'persisted') clean[key] = value;
70
+ // The internal `persisted` flag is stripped above; expose a UI-facing
71
+ // "modified" signal so the reset panel can label a changed builtin.
72
+ if (profile.builtin === true && profile.persisted === true) clean.modified = true;
73
+ return clean;
74
+ });
75
+ }
76
+
77
+ // --- 路由处理函数(从 createHttpRoutes 拆出;if 链分发在内保持)----------------
78
+
79
+ async function handleList(deps, res) {
80
+ return json(res, 200, { ok: true, profiles: listClean(deps.store) });
81
+ }
82
+
83
+ // /options 的模型目录 + 每模型 reasoning-effort 等级(llm 可选,失败仅清空)。
84
+ async function collectModelDirectory(llm) {
85
+ const models = [];
86
+ const efforts = {};
87
+ const providers = await llm.listProviders();
88
+ for (const provider of (providers ?? [])) {
89
+ const providerId = provider && provider.id;
90
+ if (typeof providerId !== 'string') continue;
91
+ let modelList = [];
92
+ try { modelList = await llm.listModels(providerId); } catch { /* skip this provider's catalog */ }
93
+ for (const model of (modelList ?? [])) {
94
+ if (!model || typeof model.id !== 'string') continue;
95
+ models.push({
96
+ provider: providerId,
97
+ providerName: provider.name ?? providerId,
98
+ id: model.id,
99
+ name: model.name ?? model.id
100
+ });
101
+ try {
102
+ const info = await llm.resolveModelInfo(providerId, model.id);
103
+ const effortsList = info && info.reasoning && Array.isArray(info.reasoning.efforts) ? info.reasoning.efforts : [];
104
+ efforts[model.id] = effortsList.map((effort) => ({
105
+ id: effort.id,
106
+ name: effort.name ?? effort.id,
107
+ ...(effort.description !== undefined ? { description: effort.description } : {})
108
+ }));
109
+ } catch { /* exact-model lookup may reject; skip its efforts */ }
110
+ }
111
+ }
112
+ return { models, efforts };
113
+ }
114
+
115
+ // System-trust presets(agentPresets 可选,fail-soft)。
116
+ async function collectSystemPresets(agentPresets) {
117
+ const presets = [];
118
+ try {
119
+ const list = await agentPresets.list();
120
+ for (const preset of (list ?? [])) {
121
+ if (preset && preset.trust === 'system') {
122
+ presets.push({ id: preset.id, name: preset.name ?? preset.id });
123
+ }
124
+ }
125
+ } catch { /* presets roster unavailable; leave empty */ }
126
+ return presets;
127
+ }
128
+
129
+ // Full tool directory = global layer (deployment plugins) + every preset's
130
+ // standing scope (the agent.cordis.yml tool rows). Each tool is tagged with
131
+ // its source: 'global' or the preset id — the grouping is fully dynamic,
132
+ // derived from the runtime's preset roster.
133
+ async function collectToolsDirectory(getTools, getAgentPresets) {
134
+ const tools = [];
135
+ const seen = new Set();
136
+ const OFFICIAL_PRESETS = ['standard', 'code', 'minimal', 'cordis'];
137
+ const layerOf = (source) => {
138
+ if (source === 'global') return 'plugin';
139
+ if (OFFICIAL_PRESETS.includes(source)) return 'core';
140
+ return 'custom';
141
+ };
142
+ const groupOf = (name, source) => {
143
+ const layer = layerOf(source);
144
+ if (layer === 'core') return TOOL_CATEGORY[name] ?? '其他';
145
+ if (layer === 'plugin') return name.includes('_') ? name.split('_')[0] : name;
146
+ return source;
147
+ };
148
+ const push = (schemas, source) => {
149
+ for (const s of (Array.isArray(schemas) ? schemas : [])) {
150
+ if (!s || typeof s.name !== 'string' || s.name === 'run_code' || seen.has(s.name)) continue;
151
+ seen.add(s.name);
152
+ tools.push({ name: s.name, description: typeof s.description === 'string' ? s.description : '', zh: TOOL_ZH[s.name] ?? '', source, layer: layerOf(source), group: groupOf(s.name, source) });
153
+ }
154
+ };
155
+ const toolsService = getTools();
156
+ if (toolsService && typeof toolsService.schemas === 'function') {
157
+ push(toolsService.schemas(), 'global');
158
+ const agentPresets = getAgentPresets();
159
+ if (agentPresets !== undefined && typeof agentPresets.list === 'function' && typeof agentPresets.standingKeyFor === 'function') {
160
+ const presets = await agentPresets.list();
161
+ for (const preset of (presets ?? [])) {
162
+ if (!preset || typeof preset.id !== 'string') continue;
163
+ try {
164
+ push(toolsService.schemas(await agentPresets.standingKeyFor(preset.id)), preset.id);
165
+ } catch { /* one preset's standing scope unavailable; skip */ }
166
+ }
167
+ }
168
+ }
169
+ return tools;
170
+ }
171
+
172
+ async function handleOptions(deps, res) {
173
+ const models = [];
174
+ const efforts = {};
175
+ const presets = [];
176
+ // Model directory + per-model reasoning-effort levels. The `llm` service
177
+ // is optional (headless): a failure only empties the lists, never breaks
178
+ // the settings page.
179
+ const llm = deps.getLlm();
180
+ if (llm !== undefined) {
181
+ try {
182
+ const { models: found, efforts: levels } = await collectModelDirectory(llm);
183
+ models.push(...found);
184
+ Object.assign(efforts, levels);
185
+ } catch { /* llm directory unavailable; leave options empty */ }
186
+ }
187
+ // System-trust presets (agentPresets is optional; fail-soft).
188
+ const agentPresets = deps.getAgentPresets();
189
+ if (agentPresets !== undefined) {
190
+ presets.push(...await collectSystemPresets(agentPresets));
191
+ }
192
+ // Full tool directory = global layer + every preset's standing scope.
193
+ let tools = [];
194
+ try {
195
+ tools = await collectToolsDirectory(deps.getTools, deps.getAgentPresets);
196
+ } catch (error) {
197
+ deps.logger.warn('[dsh-subagent-profile] tools directory failed:', error instanceof Error ? error.message : String(error));
198
+ }
199
+ return json(res, 200, { ok: true, enabled: deps.getEnabled(), models, efforts, presets, tools });
200
+ }
201
+
202
+ async function handleSetEnabled(deps, req, res) {
203
+ const body = await readBody(req);
204
+ const next = !!(body && body.enabled === true);
205
+ deps.setEnabled(next);
206
+ deps.store.persistEnabled(next);
207
+ deps.syncTool();
208
+ return json(res, 200, { ok: true, enabled: deps.getEnabled() });
209
+ }
210
+
211
+ async function handleAdd(deps, req, res) {
212
+ const body = await readBody(req);
213
+ const profile = body && typeof body === 'object' ? body : {};
214
+ if (typeof profile.id !== 'string' || profile.id.length === 0) {
215
+ return json(res, 400, { ok: false, error: 'subagent-profiles: profile id must be a non-empty string' });
216
+ }
217
+ // 写路径上限:strict=true —— 超限/非法字段直接 400 拒绝,
218
+ // 与 loadProfiles(strict=false 迁移宽松读取)的行为区分。列被拒字段与中文原因。
219
+ const { clean, warnings } = sanitizeProfile(profile, { strict: true });
220
+ if (warnings.length > 0) {
221
+ const detail = warnings.map((w) => `${w.field}:${w.reason}`).join(';');
222
+ return json(res, 400, { ok: false, error: `写入被拒绝:${detail}` });
223
+ }
224
+ const hadToolFilter = profile.toolFilter !== undefined;
225
+ const existing = deps.store.profiles.get(clean.id);
226
+ const seed = BUILTIN_SEEDS.find((s) => s.id === clean.id);
227
+ const isBuiltin = (existing !== undefined && existing.builtin === true) || seed !== undefined;
228
+ // Merge (not replace): start from the existing profile — or its seed when it
229
+ // was deleted — so fields not present in the form (e.g. a builtin's
230
+ // persona/preset) survive an edit or a re-add.
231
+ const merged = { ...(existing ?? seed ?? {}) };
232
+ merged.id = clean.id;
233
+ for (const key of ['name', 'description', 'preset', 'provider', 'model', 'reasoningEffort', 'persona', 'enabled']) {
234
+ if (clean[key] === undefined) continue; // 未传:保留 existing 原值
235
+ if (clean[key] === '' || clean[key] === null) { delete merged[key]; continue; } // 空:清除字段
236
+ merged[key] = clean[key];
237
+ }
238
+ // toolFilter 特殊处理:前端改成多选下拉后总是传数组,空数组 = 清除。请求未传
239
+ // toolFilter 时保留 existing 原值(merge 语义);传了但被 sanitize 归一为空则清除。
240
+ if (hadToolFilter) {
241
+ const tf = clean.toolFilter;
242
+ if (tf !== undefined && ((Array.isArray(tf.allow) && tf.allow.length > 0) || (Array.isArray(tf.deny) && tf.deny.length > 0))) {
243
+ merged.toolFilter = { ...(Array.isArray(tf.allow) && tf.allow.length > 0 ? { allow: tf.allow } : {}), ...(Array.isArray(tf.deny) && tf.deny.length > 0 ? { deny: tf.deny } : {}) };
244
+ } else {
245
+ delete merged.toolFilter;
246
+ }
247
+ }
248
+ if (merged.enabled !== undefined) merged.enabled = merged.enabled === false ? false : true;
249
+ deps.store.profiles.set(merged.id, { ...merged, ...(isBuiltin ? { builtin: true } : {}), persisted: true });
250
+ deps.store.deletedBuiltins.delete(merged.id);
251
+ return persistOk(res, { id: merged.id }, deps.store.persistProfiles());
252
+ }
253
+
254
+ async function handleRemove(deps, req, res) {
255
+ const body = await readBody(req);
256
+ const id = body && typeof body === 'object' && typeof body.id === 'string' ? body.id : '';
257
+ const existing = deps.store.profiles.get(id);
258
+ if (existing === undefined) {
259
+ return json(res, 404, { ok: false, error: `subagent-profiles: profile "${id}" does not exist` });
260
+ }
261
+ deps.store.profiles.delete(id);
262
+ if (existing.builtin === true) deps.store.deletedBuiltins.add(id);
263
+ return persistOk(res, { id }, deps.store.persistProfiles());
264
+ }
265
+
266
+ async function handleReset(deps, req, res) {
267
+ const body = await readBody(req);
268
+ const id = body && typeof body === 'object' && typeof body.id === 'string' ? body.id : '';
269
+ const seed = BUILTIN_SEEDS.find((s) => s.id === id);
270
+ if (seed === undefined) {
271
+ return json(res, 404, { ok: false, error: `subagent-profiles: profile "${id}" is not a builtin (nothing to reset)` });
272
+ }
273
+ deps.store.profiles.set(id, { ...seed });
274
+ deps.store.deletedBuiltins.delete(id);
275
+ return persistOk(res, { id }, deps.store.persistProfiles());
276
+ }
277
+
278
+ async function handleResetAll(deps, res) {
279
+ for (const seed of BUILTIN_SEEDS) {
280
+ deps.store.profiles.set(seed.id, { ...seed });
281
+ deps.store.deletedBuiltins.delete(seed.id);
282
+ }
283
+ return persistOk(res, { count: BUILTIN_SEEDS.length }, deps.store.persistProfiles());
284
+ }
285
+
286
+ async function handleSetProfileEnabled(deps, req, res) {
287
+ const body = await readBody(req);
288
+ const id = body && typeof body === 'object' && typeof body.id === 'string' ? body.id : '';
289
+ const existing = deps.store.profiles.get(id);
290
+ if (existing === undefined) {
291
+ return json(res, 404, { ok: false, error: `subagent-profiles: profile "${id}" does not exist` });
292
+ }
293
+ existing.enabled = body && body.enabled === false ? false : true;
294
+ // Persist unconditionally (not just for builtins): a runtime-registered
295
+ // profile's enable/disable must also survive a restart.
296
+ existing.persisted = true;
297
+ return persistOk(res, { id, enabled: existing.enabled }, deps.store.persistProfiles());
298
+ }
299
+
300
+ export function createHttpRoutes({ webServer, store, getEnabled, setEnabled, syncTool, getLlm, getAgentPresets, getTools, logger }) {
301
+ const deps = { store, getEnabled, setEnabled, syncTool, getLlm, getAgentPresets, getTools, logger };
302
+ // 路由分发(if 链保持,判断顺序与 404/500 兜底不变)。
303
+ const handler = async (req, res) => {
304
+ const remote = req.socket?.remoteAddress;
305
+ if (!LOOPBACKS.has(remote)) return json(res, 403, { ok: false, error: '仅限本机访问' });
306
+ const url = new URL(req.url ?? '/', 'http://localhost');
307
+ const sub = (url.pathname.replace(/^\/subagent-profiles/, '') || '/').replace(/\/+$/, '') || '/';
308
+ try {
309
+ if (req.method === 'GET' && (sub === '/' || sub === '/list')) return handleList(deps, res);
310
+ if (req.method === 'GET' && sub === '/options') return handleOptions(deps, res);
311
+ if (req.method === 'POST' && sub === '/set-enabled') return handleSetEnabled(deps, req, res);
312
+ if (req.method === 'POST' && sub === '/add') return handleAdd(deps, req, res);
313
+ if (req.method === 'POST' && sub === '/remove') return handleRemove(deps, req, res);
314
+ if (req.method === 'POST' && sub === '/reset') return handleReset(deps, req, res);
315
+ if (req.method === 'POST' && sub === '/reset-all') return handleResetAll(deps, res);
316
+ if (req.method === 'POST' && sub === '/set-profile-enabled') return handleSetProfileEnabled(deps, req, res);
317
+ json(res, 404, { ok: false, error: `未知路由 ${sub}` });
318
+ } catch (error) {
319
+ // 通用 500 不回显内部错误信息(防泄漏),详情只进宿主日志。
320
+ deps.logger.error('[dsh-subagent-profile] settings route error:', error instanceof Error ? (error.stack ?? error.message) : String(error));
321
+ json(res, 500, { ok: false, error: '内部错误,详情见宿主日志' });
322
+ }
323
+ };
324
+ return () => {
325
+ const disposeRoutes = webServer.register({ kind: 'prefix', path: '/subagent-profiles', handler });
326
+ return () => disposeRoutes();
327
+ };
328
+ }
@@ -0,0 +1,27 @@
1
+ // lib/core/intersection.mjs — tool intersection(安全门 1)纯函数核心,从 index.mjs
2
+ // 的 provider start 拆出。无 @deepseek-ai 依赖。此处只做 allow 收窄计算;
3
+ // 调用方(provider start)保留空集 fail-loud throw(错误文案逐字不变)与
4
+ // restrict 的 try/catch 包裹。
5
+ //
6
+ // Relationship to lib/core/pure.mjs computeContinuableAllow(parentNames, toolFilter):
7
+ // the continuable variant has NO childNames — it assumes the child toolset ≈
8
+ // parent toolset (continuable inherits the parent preset, so a true parent∩child
9
+ // intersection cannot be recomputed there) and computes parent − run_code − deny
10
+ // → allow. computeEffectiveAllow additionally intersects with the ACTUAL child
11
+ // toolset (parentNames ∩ childNames), so it stays correct even when the child
12
+ // composes a different toolset. The two serve different call paths and safety
13
+ // guarantees and must NOT be merged.
14
+
15
+ // parent∩child − run_code − deny → allow 收窄;空集返回 [](fail-loud 由调用方
16
+ // provider start 以逐字不变的 error 文案 throw)。
17
+ export function computeEffectiveAllow(parentNames, childNames, toolFilter) {
18
+ let effective = childNames.filter((name) =>
19
+ parentNames.has(name) &&
20
+ name !== 'run_code' &&
21
+ !(toolFilter !== undefined && toolFilter.deny !== undefined && toolFilter.deny.includes(name))
22
+ );
23
+ if (toolFilter !== undefined && Array.isArray(toolFilter.allow)) {
24
+ effective = effective.filter((name) => toolFilter.allow.includes(name));
25
+ }
26
+ return effective;
27
+ }
@@ -0,0 +1,136 @@
1
+ // lib/core/presets-sync.mjs — bundled agent-preset self-install (moved verbatim from
2
+ // index.mjs; import-free — node builtins only, no @deepseek-ai dependency).
3
+ //
4
+ // On host startup the plugin syncs the bundled `presets/` tree into the DSH
5
+ // agent-presets discovery root (~/.dsh/.agent-presets) so the "orchestrator"
6
+ // mode appears in the new-session picker without manual copying — the same
7
+ // self-install pattern as the shipped dsh-liangshen bundle. The sync is
8
+ // per-directory and idempotent (byte-identical trees are skipped; target files
9
+ // the bundle no longer ships are pruned); directories the plugin does not own
10
+ // are never touched. node:fs cpSync is avoided deliberately: on Node 22 for
11
+ // Windows, fs.cpSync({ recursive: true }) can crash the process when a source
12
+ // path contains non-ASCII (CJK home dir, nodejs/node#54476), so the copy is
13
+ // per-entry, preserving source mtimes.
14
+
15
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, utimesSync } from 'node:fs';
16
+ import { basename, dirname, join, relative } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ // Absolute path of the bundled preset tree inside this package. This module
20
+ // lives in lib/core/, two levels below the package root, so the URL must climb
21
+ // back up two segments to still point at the repo-root `presets/` directory
22
+ // (same resolved value as when this function lived in index.mjs at the package
23
+ // root; Task 8.6 moved the module from lib/ to lib/core/ and the climb must be
24
+ // `../../`, not `../` — preflight's preset-tree reconciliation caught the stale
25
+ // one returning lib/presets, which silently disabled the startup self-install).
26
+ export function bundledPresetsRoot() {
27
+ return fileURLToPath(new URL('../../presets', import.meta.url));
28
+ }
29
+
30
+ export const MTIME_TOLERANCE_MS = 1000;
31
+
32
+ export function filesUnder(root) {
33
+ const out = [];
34
+ const walk = (dir) => {
35
+ for (const entry of readdirSync(dir)) {
36
+ const path = join(dir, entry);
37
+ if (statSync(path).isDirectory()) walk(path);
38
+ else out.push(path);
39
+ }
40
+ };
41
+ walk(root);
42
+ return out;
43
+ }
44
+
45
+ // File identity is bytes; size/mtime are only a fast negative check.
46
+ export function sameFile(a, b) {
47
+ const sa = statSync(a);
48
+ const sb = statSync(b);
49
+ if (sa.size !== sb.size) return false;
50
+ if (Math.abs(sa.mtimeMs - sb.mtimeMs) > MTIME_TOLERANCE_MS) return false;
51
+ return readFileSync(a).equals(readFileSync(b));
52
+ }
53
+
54
+ export function copyTreeSync(sourceDir, targetDir) {
55
+ mkdirSync(targetDir, { recursive: true });
56
+ for (const entry of readdirSync(sourceDir)) {
57
+ const source = join(sourceDir, entry);
58
+ const target = join(targetDir, entry);
59
+ const st = statSync(source);
60
+ if (st.isDirectory()) copyTreeSync(source, target);
61
+ else {
62
+ copyFileSync(source, target);
63
+ utimesSync(target, st.atime, st.mtime);
64
+ }
65
+ }
66
+ }
67
+
68
+ // Remove target files not in `keep`, then only the directories emptied by it.
69
+ export function pruneExtras(root, keep) {
70
+ const parents = new Set();
71
+ for (const file of filesUnder(root)) {
72
+ if (!keep.has(relative(root, file))) {
73
+ parents.add(dirname(file));
74
+ rmSync(file, { force: true });
75
+ }
76
+ }
77
+ for (const start of parents) {
78
+ let dir = start;
79
+ while (dir !== undefined && relative(root, dir) !== '') {
80
+ if (existsSync(dir) && readdirSync(dir).length === 0) {
81
+ rmSync(dir, { recursive: true, force: true });
82
+ dir = dirname(dir);
83
+ } else dir = undefined;
84
+ }
85
+ }
86
+ }
87
+
88
+ // Copy `sourceDir` into `targetDir` idempotently; returns 'synced' or 'current'.
89
+ export function syncOnePreset(sourceDir, targetDir) {
90
+ const sourceFiles = filesUnder(sourceDir);
91
+ const sourceSet = new Set(sourceFiles.map((f) => relative(sourceDir, f)));
92
+ if (existsSync(targetDir) && !statSync(targetDir).isDirectory()) {
93
+ rmSync(targetDir, { recursive: true, force: true });
94
+ }
95
+ if (!existsSync(targetDir)) {
96
+ copyTreeSync(sourceDir, targetDir);
97
+ pruneExtras(targetDir, sourceSet);
98
+ return 'synced';
99
+ }
100
+ let dirty = false;
101
+ for (const file of sourceFiles) {
102
+ const dest = join(targetDir, relative(sourceDir, file));
103
+ if (!existsSync(dest) || !sameFile(file, dest)) { dirty = true; break; }
104
+ }
105
+ if (!dirty) {
106
+ for (const file of filesUnder(targetDir)) {
107
+ if (!sourceSet.has(relative(targetDir, file))) { dirty = true; break; }
108
+ }
109
+ }
110
+ if (!dirty) return 'current';
111
+ pruneExtras(targetDir, sourceSet);
112
+ copyTreeSync(sourceDir, targetDir);
113
+ pruneExtras(targetDir, sourceSet);
114
+ return 'synced';
115
+ }
116
+
117
+ // Sync every preset directory under `presets/` into the target discovery root.
118
+ export function syncBundledPresets(targetRoot) {
119
+ const result = { synced: [], current: [], failed: [] };
120
+ const sourceRoot = bundledPresetsRoot();
121
+ mkdirSync(targetRoot, { recursive: true });
122
+ if (existsSync(sourceRoot)) {
123
+ for (const entry of readdirSync(sourceRoot)) {
124
+ const source = join(sourceRoot, entry);
125
+ if (!statSync(source).isDirectory()) continue;
126
+ const id = basename(source);
127
+ try {
128
+ const outcome = syncOnePreset(source, join(targetRoot, id));
129
+ (outcome === 'synced' ? result.synced : result.current).push(id);
130
+ } catch (error) {
131
+ result.failed.push({ id, error: error instanceof Error ? error.message : String(error) });
132
+ }
133
+ }
134
+ }
135
+ return result;
136
+ }