@bakers-build/conductor 0.0.0-stage → 0.1.0-next.42
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/LICENSE +21 -0
- package/README.md +4 -2
- package/dist/src/bakers-project-context.d.ts +12 -0
- package/dist/src/bakers-project-context.js +59 -0
- package/dist/src/cli/config-plan.d.ts +35 -0
- package/dist/src/cli/config-plan.js +1264 -0
- package/dist/src/cli/config-schema.d.ts +137 -0
- package/dist/src/cli/config-schema.js +239 -0
- package/dist/src/cli/config-source-edit.d.ts +17 -0
- package/dist/src/cli/config-source-edit.js +95 -0
- package/dist/src/cli/config-templates.d.ts +43 -0
- package/dist/src/cli/config-templates.js +130 -0
- package/dist/src/cli/config-validation.d.ts +12 -0
- package/dist/src/cli/config-validation.js +353 -0
- package/dist/src/cli/migrate-addon-names.d.ts +82 -0
- package/dist/src/cli/migrate-addon-names.js +334 -0
- package/dist/src/cli/project-context.d.ts +21 -0
- package/dist/src/cli/project-context.js +105 -0
- package/dist/src/commands/conductor.d.ts +91 -0
- package/dist/src/commands/conductor.js +843 -0
- package/dist/src/config.d.ts +109 -0
- package/dist/src/config.js +712 -0
- package/dist/src/contract.d.ts +13 -0
- package/dist/src/contract.js +19 -0
- package/dist/src/core/action-queue.d.ts +31 -0
- package/dist/src/core/action-queue.js +69 -0
- package/dist/src/core/attempt.d.ts +25 -0
- package/dist/src/core/attempt.js +41 -0
- package/dist/src/core/conductor.d.ts +236 -0
- package/dist/src/core/conductor.js +1293 -0
- package/dist/src/core/host-key.d.ts +19 -0
- package/dist/src/core/host-key.js +27 -0
- package/dist/src/core/inbox-runtime.d.ts +48 -0
- package/dist/src/core/inbox-runtime.js +180 -0
- package/dist/src/core/mr-changes.d.ts +18 -0
- package/dist/src/core/mr-changes.js +47 -0
- package/dist/src/index.d.ts +8 -0
- package/dist/src/index.js +27 -0
- package/dist/src/plugins/agent-args.d.ts +43 -0
- package/dist/src/plugins/agent-args.js +221 -0
- package/dist/src/plugins/agent.d.ts +121 -0
- package/dist/src/plugins/agent.js +12 -0
- package/dist/src/plugins/executor.d.ts +182 -0
- package/dist/src/plugins/executor.js +1 -0
- package/dist/src/plugins/message.d.ts +61 -0
- package/dist/src/plugins/message.js +47 -0
- package/dist/src/plugins/plugins.d.ts +96 -0
- package/dist/src/plugins/plugins.js +120 -0
- package/dist/src/plugins/preset.d.ts +61 -0
- package/dist/src/plugins/preset.js +23 -0
- package/dist/src/plugins/process-host.d.ts +7 -0
- package/dist/src/plugins/process-host.js +1 -0
- package/dist/src/plugins/remote.d.ts +420 -0
- package/dist/src/plugins/remote.js +1 -0
- package/dist/src/plugins/workspace.d.ts +69 -0
- package/dist/src/plugins/workspace.js +1 -0
- package/dist/src/preset.d.ts +2 -0
- package/dist/src/preset.js +8 -0
- package/dist/src/types.d.ts +132 -0
- package/dist/src/types.js +6 -0
- package/package.json +38 -4
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { discoverAddons, discoverScoped, findDefaultExportObject, machineContextPath, matchDelimiter, readMachineContext, resolveConductorSlots, resolveModuleEslintStyle, scanTopLevelProperties, scopeAddons, } from '@bakers-build/core';
|
|
5
|
+
import { readBbProjectSelection } from './cli/project-context.js';
|
|
6
|
+
import { loadInbox } from './plugins/message.js';
|
|
7
|
+
import { narrowConductorContributions, } from './plugins/preset.js';
|
|
8
|
+
// BB-201: the `.bakers/` walk-up + connection resolution moved to `@bakers-build/core`
|
|
9
|
+
// (`resolveConfigPath` / `resolveBbConfigPath` / `findBbDir`). Re-export the
|
|
10
|
+
// engine resolver here so existing conductor callers/tests keep importing it
|
|
11
|
+
// from `../config.js`.
|
|
12
|
+
export { findBbDir, resolveConfigPath, } from '@bakers-build/core';
|
|
13
|
+
/**
|
|
14
|
+
* Default agent prompt - the project instruction contract. One run works EXACTLY one state;
|
|
15
|
+
* the agent must stop instead of running the whole flow in one session. The run
|
|
16
|
+
* is closed automatically when the ticket state changes on the next claim - the
|
|
17
|
+
* agent does not release it. Run mechanics live here (not in the repo's
|
|
18
|
+
* WORKFLOW.md). The prompt points directly at the matching project-owned state
|
|
19
|
+
* section and does not prescribe an agent skill. A conductor config may override
|
|
20
|
+
* it via the `prompt` field.
|
|
21
|
+
*
|
|
22
|
+
* The ticket + its comments are NOT embedded (BB-112): embedding unbounded
|
|
23
|
+
* ticket content into a single typed pane line overran the PTY canonical line
|
|
24
|
+
* cap and truncated the dispatch command. Instead the prompt is a bounded,
|
|
25
|
+
* constant-size pointer and the agent reads the ticket + all comments at run
|
|
26
|
+
* start via `bakers dropsh read … --include comments`.
|
|
27
|
+
*
|
|
28
|
+
* herdr no longer types this into the pane: the launch command goes to a file
|
|
29
|
+
* that a short `sh '<file>'` line runs (BB-635), so its length is not bound
|
|
30
|
+
* by the tty canonical cap (macOS 1024 B, Linux 4096 B). The prompt is still
|
|
31
|
+
* kept single-line (BB-128 test).
|
|
32
|
+
* Placeholders: `{identifier}`, `{state}`, `{runUuid}` ({state} falls back to
|
|
33
|
+
* `qualification` for unclassified tickets).
|
|
34
|
+
*
|
|
35
|
+
* WHERE THE PROMPT SENDS THE AGENT, and why it no longer names a WORKFLOW.md
|
|
36
|
+
* section. This used to say `execute only its ordered "State: {state}" section`,
|
|
37
|
+
* which described the pre-BB-204 loader: WORKFLOW.md then carried one ordered
|
|
38
|
+
* `## State: <state>` section per state. BB-204 replaced that with a pure
|
|
39
|
+
* loader — WORKFLOW.md lists skills, each skill self-declares the
|
|
40
|
+
* `(work_type, workflow, step)` triple it owns, and the step's flow lives in the
|
|
41
|
+
* skill. No project's WORKFLOW.md has carried a `State:` section since.
|
|
42
|
+
*
|
|
43
|
+
* The prompt kept pointing at one anyway, and that is not cosmetic: a dispatched
|
|
44
|
+
* agent that reads the instruction literally finds no such section and STOPS.
|
|
45
|
+
* Observed on a fixture run — "WORKFLOW.md has no ordered State: spec section;
|
|
46
|
+
* it instead declares skill-based routing. Stopped at this configuration
|
|
47
|
+
* mismatch." Agents that completed runs were working around the prompt rather
|
|
48
|
+
* than following it, which is why this survived so long: it failed silently and
|
|
49
|
+
* only against the most literal readers.
|
|
50
|
+
*/
|
|
51
|
+
export const DEFAULT_AGENT_PROMPT = `You are working ticket {identifier}, current state: {state}. Read the ticket and all comments ` +
|
|
52
|
+
`(bakers dropsh --format md read bb_ticket/bb_ticket/$BB_ID --include comments), ` +
|
|
53
|
+
`then read ./WORKFLOW.md, load the bakers.build skill its loader names for this ticket's work type ` +
|
|
54
|
+
`and state, and execute only that skill's {state} section. ` +
|
|
55
|
+
`Do not start or prepare a later state.`;
|
|
56
|
+
const HOOK_SLOTS = [
|
|
57
|
+
'after_create',
|
|
58
|
+
'before_run',
|
|
59
|
+
'after_run',
|
|
60
|
+
'after_done',
|
|
61
|
+
'on_freeze',
|
|
62
|
+
'on_resume',
|
|
63
|
+
];
|
|
64
|
+
/**
|
|
65
|
+
* Loads every addon's contract-2 inbox messages; one that fails validation
|
|
66
|
+
* is reported and left out, so a broken addon never stops the conductor.
|
|
67
|
+
*/
|
|
68
|
+
async function loadInboxAddons(found) {
|
|
69
|
+
const loaded = [];
|
|
70
|
+
for (const addon of found) {
|
|
71
|
+
try {
|
|
72
|
+
loaded.push(await loadInbox(addon, addon.options));
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
console.error(`addon inbox skipped: ${err instanceof Error ? err.message : String(err)}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return loaded;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Validate the `hooks` accumulator result (BB-599). A malformed declaration
|
|
82
|
+
* is a config error, raised before anything is dispatched; a valid callback
|
|
83
|
+
* that later fails is best effort and handled by the conductor.
|
|
84
|
+
*/
|
|
85
|
+
function validateHookListeners(value) {
|
|
86
|
+
if (!Array.isArray(value))
|
|
87
|
+
throw new Error('conductor addons[] hooks must accumulate an array');
|
|
88
|
+
for (const [index, listener] of value.entries()) {
|
|
89
|
+
if (!isRecord(listener))
|
|
90
|
+
throw new Error(`hooks[${index}] must be a listener object`);
|
|
91
|
+
for (const slot of HOOK_SLOTS) {
|
|
92
|
+
if (listener[slot] !== undefined && typeof listener[slot] !== 'function')
|
|
93
|
+
throw new Error(`hooks[${index}].${slot} must be a function`);
|
|
94
|
+
}
|
|
95
|
+
if (listener.id !== undefined && typeof listener.id !== 'string')
|
|
96
|
+
throw new Error(`hooks[${index}].id must be a string`);
|
|
97
|
+
}
|
|
98
|
+
return value;
|
|
99
|
+
}
|
|
100
|
+
function validateProcessHost(value) {
|
|
101
|
+
if (!Array.isArray(value) || value.length === 0)
|
|
102
|
+
return undefined;
|
|
103
|
+
const host = value[value.length - 1];
|
|
104
|
+
if (!isRecord(host) || typeof host.spawn !== 'function') {
|
|
105
|
+
throw new Error('conductor addons[] processHosts must accumulate a host with spawn()');
|
|
106
|
+
}
|
|
107
|
+
return host;
|
|
108
|
+
}
|
|
109
|
+
function requirePlugin(value, kind) {
|
|
110
|
+
if (!isRecord(value) || value.kind !== kind) {
|
|
111
|
+
throw new Error(`conductor config requires a ${kind} plugin in the "${kind}" slot`);
|
|
112
|
+
}
|
|
113
|
+
return value;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* A named-plugin descriptor `{ plugin, export?, with?/options? }` — the
|
|
117
|
+
* import-free form. `config.ts` resolves it into a constructed plugin, so a
|
|
118
|
+
* committed conductor.config.js need not `import` its plugin packages (which
|
|
119
|
+
* would resolve relative to the config file, not the CLI — the source of the
|
|
120
|
+
* "unknown command dropsh" breakage after the @bakers → @bakers-ai scope rename).
|
|
121
|
+
* Mirror of dropsh 0.4.1 `loadNamedPlugin` / `resolveSlot`.
|
|
122
|
+
*/
|
|
123
|
+
function isPluginDescriptor(entry) {
|
|
124
|
+
return isRecord(entry) && typeof entry.plugin === 'string';
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Resolve + construct a descriptor. Resolution base order, ESLint-style:
|
|
128
|
+
* config dir → cwd → conductor install (`import.meta.url`) — the first
|
|
129
|
+
* `createRequire(base).resolve(name)` that succeeds wins. Then `import` the
|
|
130
|
+
* module, pick `export` (default 'default'), and call the factory with
|
|
131
|
+
* `with` (falling back to `options`).
|
|
132
|
+
*/
|
|
133
|
+
async function loadNamedPlugin(entry, configPath) {
|
|
134
|
+
const bases = [
|
|
135
|
+
pathToFileURL(configPath).href,
|
|
136
|
+
pathToFileURL(`${process.cwd()}/`).href,
|
|
137
|
+
import.meta.url,
|
|
138
|
+
];
|
|
139
|
+
const resolved = resolveModuleEslintStyle(entry.plugin, bases);
|
|
140
|
+
if (resolved === undefined) {
|
|
141
|
+
throw new Error(`conductor config cannot resolve plugin '${entry.plugin}'`);
|
|
142
|
+
}
|
|
143
|
+
const mod = (await import(pathToFileURL(resolved).href));
|
|
144
|
+
let factory;
|
|
145
|
+
if (typeof entry.export === 'string') {
|
|
146
|
+
factory = mod[entry.export];
|
|
147
|
+
if (typeof factory !== 'function') {
|
|
148
|
+
throw new Error(`plugin '${entry.plugin}' has no callable export '${entry.export}'`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
else if (typeof mod.default === 'function') {
|
|
152
|
+
factory = mod.default;
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
const fns = Object.keys(mod).filter((k) => typeof mod[k] === 'function');
|
|
156
|
+
if (fns.length === 1) {
|
|
157
|
+
factory = mod[fns[0]];
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
throw new Error(`plugin '${entry.plugin}' has no default export and ${fns.length} function exports (${fns.join(', ')}); specify "export"`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return factory(entry.with ?? entry.options);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Resolve a slot value: a descriptor is constructed via `loadNamedPlugin`, an
|
|
167
|
+
* already-constructed plugin passes straight through (backward-compat). Either
|
|
168
|
+
* way the kind-guard runs, so a wrong-target descriptor still errors.
|
|
169
|
+
*/
|
|
170
|
+
async function resolveSlot(value, kind, configPath) {
|
|
171
|
+
const resolved = isPluginDescriptor(value)
|
|
172
|
+
? await loadNamedPlugin(value, configPath)
|
|
173
|
+
: value;
|
|
174
|
+
return requirePlugin(resolved, kind);
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Resolve the `agent` slot into candidate(s), **preserving the authored shape**:
|
|
178
|
+
* a single descriptor/plugin/wrapper → a single `AgentCandidate`, an array →
|
|
179
|
+
* `AgentCandidate[]`. Each entry is either a bare descriptor/plugin or a
|
|
180
|
+
* `{ agent, priority? }` wrapper; the inner agent value is resolved through the
|
|
181
|
+
* same `agent` kind-guard as any slot, and a `priority` function is preserved
|
|
182
|
+
* verbatim (AC-1, AC-2). Normalization to a list happens at selection time.
|
|
183
|
+
*/
|
|
184
|
+
export async function resolveAgents(raw, configPath) {
|
|
185
|
+
const resolveOne = async (entry) => {
|
|
186
|
+
const hasWrapper = isRecord(entry) &&
|
|
187
|
+
'agent' in entry &&
|
|
188
|
+
!('kind' in entry) &&
|
|
189
|
+
!('plugin' in entry);
|
|
190
|
+
const agentValue = hasWrapper ? entry.agent : entry;
|
|
191
|
+
const priority = hasWrapper
|
|
192
|
+
? entry.priority
|
|
193
|
+
: undefined;
|
|
194
|
+
const agent = await resolveSlot(agentValue, 'agent', configPath);
|
|
195
|
+
return {
|
|
196
|
+
agent,
|
|
197
|
+
...(priority ? { priority } : {}),
|
|
198
|
+
...(hasWrapper && typeof entry.alias === 'string'
|
|
199
|
+
? { alias: entry.alias }
|
|
200
|
+
: {}),
|
|
201
|
+
...(hasWrapper && typeof entry.description === 'string'
|
|
202
|
+
? { description: entry.description }
|
|
203
|
+
: {}),
|
|
204
|
+
...(hasWrapper && typeof entry.model === 'string'
|
|
205
|
+
? { model: entry.model }
|
|
206
|
+
: {}),
|
|
207
|
+
...(hasWrapper && typeof entry.modelVersion === 'string'
|
|
208
|
+
? { modelVersion: entry.modelVersion }
|
|
209
|
+
: {}),
|
|
210
|
+
};
|
|
211
|
+
};
|
|
212
|
+
return Array.isArray(raw)
|
|
213
|
+
? Promise.all(raw.map(resolveOne))
|
|
214
|
+
: resolveOne(raw);
|
|
215
|
+
}
|
|
216
|
+
function isRecord(value) {
|
|
217
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
218
|
+
}
|
|
219
|
+
function requireNonEmptyString(value, key) {
|
|
220
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
221
|
+
throw new Error(`conductor config requires non-empty ${key}`);
|
|
222
|
+
}
|
|
223
|
+
return value;
|
|
224
|
+
}
|
|
225
|
+
function optionalPositiveInteger(value, fallback, key) {
|
|
226
|
+
if (value === undefined) {
|
|
227
|
+
return fallback;
|
|
228
|
+
}
|
|
229
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
|
|
230
|
+
throw new Error(`conductor config requires positive integer ${key}`);
|
|
231
|
+
}
|
|
232
|
+
return value;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Load the ENGINE half of a conductor config (BB-201 split model). Reads
|
|
236
|
+
* remote/executor/agent/workspace + project/states/machine_id/label/prompt/
|
|
237
|
+
* scheduler/hooks — and deliberately NOT `site` or the auth `plugins`, which now
|
|
238
|
+
* live in a `bakers.config.js` connection config. The conductor command composes
|
|
239
|
+
* the two via `composeConductorConfig`. A legacy `conductor.config.js` still
|
|
240
|
+
* carrying `site`/`plugins` loads fine — those two keys are simply ignored here
|
|
241
|
+
* (the connection loader reads them, back-compat).
|
|
242
|
+
*/
|
|
243
|
+
export async function loadConductorConfig(configFile) {
|
|
244
|
+
const configPath = resolve(configFile);
|
|
245
|
+
const bakers = readBbProjectSelection(configPath);
|
|
246
|
+
const module = (await import(pathToFileURL(configPath).href));
|
|
247
|
+
const raw = module.default;
|
|
248
|
+
if (!isRecord(raw)) {
|
|
249
|
+
throw new Error('conductor config default export must be an object');
|
|
250
|
+
}
|
|
251
|
+
// BB-218 (AC-6): the engine config must not carry connection/auth material.
|
|
252
|
+
// A legacy config still declaring `site`/`plugins` gets exactly ONE actionable
|
|
253
|
+
// warning naming the file + the offending key(s); the values are never merged
|
|
254
|
+
// (`ConductorEngineConfig` omits both structurally).
|
|
255
|
+
const leftover = ['site', 'plugins'].filter((k) => k in raw);
|
|
256
|
+
if (leftover.length > 0) {
|
|
257
|
+
console.warn(`warning: ${configPath} declares ${leftover.join('/')} — ignored; ` +
|
|
258
|
+
'auth/connection belong in the sibling bakers.config.js (BB-218)');
|
|
259
|
+
}
|
|
260
|
+
const config = raw;
|
|
261
|
+
const retiredAgentKeys = [
|
|
262
|
+
'agent',
|
|
263
|
+
'agents',
|
|
264
|
+
'default_agent',
|
|
265
|
+
'agent_routes',
|
|
266
|
+
].filter((key) => key in raw);
|
|
267
|
+
if (retiredAgentKeys.length > 0) {
|
|
268
|
+
throw new Error(`${configPath}: retired conductor agent policy ${retiredAgentKeys.join(', ')}; run bakers config upgrade --scope all`);
|
|
269
|
+
}
|
|
270
|
+
const project = bakers.project;
|
|
271
|
+
// `states` is optional. An empty (or absent) list means the conductor serves
|
|
272
|
+
// every claimable state in its project; a non-empty list narrows it to those
|
|
273
|
+
// states (BB-207). When present it must be an array of non-empty strings.
|
|
274
|
+
const states = Array.isArray(config.states)
|
|
275
|
+
? config.states.map((state) => requireNonEmptyString(state, 'states'))
|
|
276
|
+
: [];
|
|
277
|
+
// conductor_id is required (BB-353 renamed from machine_id). Prefer the new
|
|
278
|
+
// key; accept legacy machine_id with a one-shot warning. No derived fallback.
|
|
279
|
+
// A repo's committed config composes it from the machine context
|
|
280
|
+
// (`${user_id}-${host}-${project}`); co-located configs each compose their
|
|
281
|
+
// own from their project.
|
|
282
|
+
let conductorId;
|
|
283
|
+
if (typeof config.conductor_id === 'string' &&
|
|
284
|
+
config.conductor_id.trim() !== '') {
|
|
285
|
+
conductorId = config.conductor_id.trim();
|
|
286
|
+
}
|
|
287
|
+
else if (typeof config.machine_id === 'string' &&
|
|
288
|
+
config.machine_id.trim() !== '') {
|
|
289
|
+
console.warn(`[bakers] ${configPath}: engine key "machine_id" is deprecated; use "conductor_id" (BB-353)`);
|
|
290
|
+
conductorId = config.machine_id.trim();
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
throw new Error(`conductor config requires non-empty conductor_id (legacy machine_id accepted)`);
|
|
294
|
+
}
|
|
295
|
+
const label = typeof config.label === 'string' && config.label.trim() !== ''
|
|
296
|
+
? config.label
|
|
297
|
+
: conductorId;
|
|
298
|
+
// Host capacity has one owner. Stale engine values need an explicit migration.
|
|
299
|
+
if (config.max_parallel !== undefined) {
|
|
300
|
+
throw new Error(`${configPath}: misplaced max_parallel belongs in ${machineContextPath()}; run bakers config upgrade --scope all`);
|
|
301
|
+
}
|
|
302
|
+
const machineCtx = await readMachineContext();
|
|
303
|
+
const maxParallel = typeof machineCtx.max_parallel === 'number' &&
|
|
304
|
+
Number.isFinite(machineCtx.max_parallel) &&
|
|
305
|
+
machineCtx.max_parallel >= 1
|
|
306
|
+
? Math.floor(machineCtx.max_parallel)
|
|
307
|
+
: 1;
|
|
308
|
+
const tunnel = machineCtx.tunnel === true;
|
|
309
|
+
// Host stem for bb_machine / registration (BB-353) — prefer the machine
|
|
310
|
+
// context id over hyphen-splitting the composed conductor_id.
|
|
311
|
+
const hostKey = typeof machineCtx.machine_id === 'string' &&
|
|
312
|
+
machineCtx.machine_id.trim() !== ''
|
|
313
|
+
? machineCtx.machine_id.trim()
|
|
314
|
+
: undefined;
|
|
315
|
+
// BB-215: discover the conductor `addons: []` surface (last-wins singletons,
|
|
316
|
+
// agent candidate list). A pre-existing per-slot descriptor still loads and
|
|
317
|
+
// WINS over a discovered contributor of the same kind (back-compat additive).
|
|
318
|
+
// BB-224: `discoverAddons` is surface-AGNOSTIC — it returns opaque
|
|
319
|
+
// accumulators. This is the single, reviewed narrowing seam where the engine
|
|
320
|
+
// reinterprets them as its own concrete conductor contributions; the runtime
|
|
321
|
+
// `.kind` guard inside `resolveConductorSlots` still fails loudly on a
|
|
322
|
+
// mis-declared contribution.
|
|
323
|
+
// The machine and this project config each carry one `addons[]`, read the
|
|
324
|
+
// same way: each addon declares the scopes it may be listed in, and the
|
|
325
|
+
// machine entry wins (`scopeAddons`). Agents come from this list too.
|
|
326
|
+
const policy = machineCtx.agents;
|
|
327
|
+
if (policy !== undefined && !isRecord(policy))
|
|
328
|
+
throw new Error('machine agents must be an object');
|
|
329
|
+
if (policy?.addons !== undefined)
|
|
330
|
+
throw new Error('machine agents.addons is retired: move its entries into machine.config.js addons[] (run bakers config upgrade --scope machine)');
|
|
331
|
+
const machineAddons = machineCtx.addons ?? [];
|
|
332
|
+
if (!Array.isArray(machineAddons))
|
|
333
|
+
throw new Error('machine addons must be an array');
|
|
334
|
+
const addonEntries = Array.isArray(config.addons)
|
|
335
|
+
? config.addons
|
|
336
|
+
: [];
|
|
337
|
+
const addonBases = [
|
|
338
|
+
pathToFileURL(configPath).href,
|
|
339
|
+
pathToFileURL(`${process.cwd()}/`).href,
|
|
340
|
+
import.meta.url,
|
|
341
|
+
];
|
|
342
|
+
const machineBases = [
|
|
343
|
+
pathToFileURL(machineContextPath()).href,
|
|
344
|
+
pathToFileURL(`${process.cwd()}/`).href,
|
|
345
|
+
import.meta.url,
|
|
346
|
+
];
|
|
347
|
+
const { addons: effectiveAddons } = await scopeAddons([
|
|
348
|
+
{
|
|
349
|
+
scope: 'machine',
|
|
350
|
+
path: machineContextPath(),
|
|
351
|
+
entries: machineAddons,
|
|
352
|
+
bases: machineBases,
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
scope: 'project',
|
|
356
|
+
path: configPath,
|
|
357
|
+
entries: addonEntries,
|
|
358
|
+
bases: addonBases,
|
|
359
|
+
},
|
|
360
|
+
]);
|
|
361
|
+
const discovered = narrowConductorContributions(await discoverScoped(effectiveAddons, 'conductor'));
|
|
362
|
+
const wired = resolveConductorSlots(discovered);
|
|
363
|
+
const hookListeners = validateHookListeners(discovered.hooks);
|
|
364
|
+
const inboxAddons = await loadInboxAddons(discovered.addonInbox);
|
|
365
|
+
// Explicit executor/workspace descriptors still load those slots, but they
|
|
366
|
+
// skip addons[] discovery — so a pre-BB-215 config never contributes a
|
|
367
|
+
// process host unless we load that package's processHosts accumulator too.
|
|
368
|
+
let processHost = validateProcessHost(discovered.processHosts);
|
|
369
|
+
if (processHost === undefined) {
|
|
370
|
+
const slotPlugins = [
|
|
371
|
+
...new Set([config.executor, config.workspace]
|
|
372
|
+
.filter(isPluginDescriptor)
|
|
373
|
+
.map((entry) => entry.plugin)),
|
|
374
|
+
];
|
|
375
|
+
for (const spec of slotPlugins) {
|
|
376
|
+
// A descriptor may be a plain factory file (`./fixture.js`), not an
|
|
377
|
+
// addon preset. Slot resolution owns that error; skip a host we cannot
|
|
378
|
+
// discover and keep looking at the other slot.
|
|
379
|
+
try {
|
|
380
|
+
const found = validateProcessHost(narrowConductorContributions(await discoverAddons([spec], 'conductor', addonBases, {
|
|
381
|
+
only: ['processHosts'],
|
|
382
|
+
})).processHosts);
|
|
383
|
+
if (found !== undefined)
|
|
384
|
+
processHost = found;
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
// no process host from this descriptor
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
const remote = config.remote !== undefined
|
|
392
|
+
? await resolveSlot(config.remote, 'remote', configPath)
|
|
393
|
+
: wired.remote;
|
|
394
|
+
if (!remote) {
|
|
395
|
+
throw new Error('conductor config resolves no remote — name a remote addon in addons[] or set the remote slot');
|
|
396
|
+
}
|
|
397
|
+
const executor = config.executor !== undefined
|
|
398
|
+
? await resolveSlot(config.executor, 'executor', configPath)
|
|
399
|
+
: wired.executor;
|
|
400
|
+
if (!executor) {
|
|
401
|
+
throw new Error('conductor config resolves no executor — name an executor addon in addons[] or set the executor slot');
|
|
402
|
+
}
|
|
403
|
+
const workspace = config.workspace !== undefined
|
|
404
|
+
? await resolveSlot(config.workspace, 'workspace', configPath)
|
|
405
|
+
: wired.workspace;
|
|
406
|
+
if (!workspace) {
|
|
407
|
+
throw new Error('conductor config resolves no workspace — name a workspace addon in addons[] or set the workspace slot');
|
|
408
|
+
}
|
|
409
|
+
const installedHarnesses = new Set(machineAddons.flatMap((entry) => {
|
|
410
|
+
if (typeof entry === 'string')
|
|
411
|
+
return [entry];
|
|
412
|
+
if (!isRecord(entry))
|
|
413
|
+
return [];
|
|
414
|
+
const packageName = entry.use ?? entry.name;
|
|
415
|
+
return typeof packageName === 'string' ? [packageName] : [];
|
|
416
|
+
}));
|
|
417
|
+
const dedupeCandidates = (candidates) => {
|
|
418
|
+
const last = new Map(candidates.map((candidate, index) => [candidate.alias, index]));
|
|
419
|
+
return candidates.filter((candidate, index) => candidate.alias === undefined || last.get(candidate.alias) === index);
|
|
420
|
+
};
|
|
421
|
+
let candidates = dedupeCandidates(wired.agents);
|
|
422
|
+
const catalog = policy?.catalog;
|
|
423
|
+
if (catalog !== undefined && !Array.isArray(catalog))
|
|
424
|
+
throw new Error('agents.catalog must be an array');
|
|
425
|
+
for (const [index, entry] of (catalog ?? []).entries()) {
|
|
426
|
+
if (!isRecord(entry) || typeof entry.harness !== 'string')
|
|
427
|
+
throw new Error(`agents.catalog[${index}] requires a non-empty harness`);
|
|
428
|
+
const harness = entry.harness.trim();
|
|
429
|
+
if (!harness || !installedHarnesses.has(harness))
|
|
430
|
+
throw new Error(`agents.catalog[${index}] harness '${entry.harness}' must also appear in machine.config.js addons[]`);
|
|
431
|
+
if (entry.alias !== undefined &&
|
|
432
|
+
(typeof entry.alias !== 'string' || !entry.alias.trim()))
|
|
433
|
+
throw new Error(`agents.catalog[${index}].alias must be non-empty`);
|
|
434
|
+
const contribution = narrowConductorContributions(await discoverAddons([
|
|
435
|
+
{
|
|
436
|
+
use: harness,
|
|
437
|
+
...(entry.with !== undefined ? { with: entry.with } : {}),
|
|
438
|
+
},
|
|
439
|
+
], 'conductor', machineBases, { only: ['agents'] })).agents;
|
|
440
|
+
const alias = typeof entry.alias === 'string' ? entry.alias.trim() : undefined;
|
|
441
|
+
if (!alias && contribution.length > 1)
|
|
442
|
+
throw new Error(`agents.catalog[${index}] harness '${harness}' publishes multiple candidates; name an alias`);
|
|
443
|
+
const selected = alias
|
|
444
|
+
? (contribution.find((candidate) => candidate.alias === alias) ??
|
|
445
|
+
(contribution.length === 1 ? contribution[0] : undefined))
|
|
446
|
+
: contribution[0];
|
|
447
|
+
if (!selected)
|
|
448
|
+
throw new Error(`agents.catalog[${index}] cannot select one candidate from '${harness}'`);
|
|
449
|
+
const overlaid = alias ? { ...selected, alias } : selected;
|
|
450
|
+
if (!overlaid.alias)
|
|
451
|
+
throw new Error(`agents.catalog[${index}] resolves a candidate without an alias`);
|
|
452
|
+
const existing = candidates.findIndex((candidate) => candidate.alias === overlaid.alias);
|
|
453
|
+
if (existing >= 0)
|
|
454
|
+
candidates[existing] = overlaid;
|
|
455
|
+
else
|
|
456
|
+
candidates.push(overlaid);
|
|
457
|
+
}
|
|
458
|
+
candidates = candidates.map((candidate) => ({
|
|
459
|
+
...candidate,
|
|
460
|
+
alias: typeof candidate.alias === 'string' && candidate.alias.trim() !== ''
|
|
461
|
+
? candidate.alias
|
|
462
|
+
: candidate.agent.id,
|
|
463
|
+
}));
|
|
464
|
+
if (candidates.length === 0)
|
|
465
|
+
throw new Error('machine.config.js addons[] must resolve a non-empty catalog of aliased agents');
|
|
466
|
+
const declaredAliases = candidates.map((candidate) => candidate.alias);
|
|
467
|
+
const readAliasList = (key) => {
|
|
468
|
+
const value = policy?.[key];
|
|
469
|
+
if (value === undefined)
|
|
470
|
+
return [];
|
|
471
|
+
if (!Array.isArray(value) ||
|
|
472
|
+
value.some((alias) => typeof alias !== 'string' || !alias.trim()))
|
|
473
|
+
throw new Error(`agents.${key} must be an array of non-empty aliases`);
|
|
474
|
+
const aliases = value.map((alias) => alias.trim());
|
|
475
|
+
const unknown = aliases.find((alias) => !declaredAliases.includes(alias));
|
|
476
|
+
if (unknown)
|
|
477
|
+
throw new Error(`agents.${key} names unknown alias '${unknown}'`);
|
|
478
|
+
return [...new Set(aliases)];
|
|
479
|
+
};
|
|
480
|
+
const enabled = readAliasList('enabled');
|
|
481
|
+
const disabled = readAliasList('disabled');
|
|
482
|
+
const allowed = policy?.enabled === undefined ? null : new Set(enabled);
|
|
483
|
+
const denied = new Set(disabled);
|
|
484
|
+
candidates = candidates.filter((candidate) => (!allowed || allowed.has(candidate.alias)) &&
|
|
485
|
+
!denied.has(candidate.alias));
|
|
486
|
+
if (candidates.length === 0)
|
|
487
|
+
throw new Error('effective agent catalog is empty after agents filters');
|
|
488
|
+
const enabledAgents = [
|
|
489
|
+
...new Set(candidates.map((candidate) => candidate.alias)),
|
|
490
|
+
];
|
|
491
|
+
const configuredDefault = policy?.default_agent;
|
|
492
|
+
if (configuredDefault !== undefined &&
|
|
493
|
+
(typeof configuredDefault !== 'string' || !configuredDefault.trim()))
|
|
494
|
+
throw new Error('agents.default_agent must be a non-empty alias');
|
|
495
|
+
const defaultAgent = typeof configuredDefault === 'string'
|
|
496
|
+
? configuredDefault.trim()
|
|
497
|
+
: enabledAgents[0];
|
|
498
|
+
if (!enabledAgents.includes(defaultAgent))
|
|
499
|
+
throw new Error(`agents.default_agent names unavailable alias '${defaultAgent}'`);
|
|
500
|
+
const rawRoutes = policy?.routes;
|
|
501
|
+
if (rawRoutes !== undefined && !isRecord(rawRoutes))
|
|
502
|
+
throw new Error('agents.routes must be an object of named routes');
|
|
503
|
+
const routes = {};
|
|
504
|
+
for (const [name, route] of Object.entries(rawRoutes ?? {})) {
|
|
505
|
+
if (!name.trim() ||
|
|
506
|
+
!isRecord(route) ||
|
|
507
|
+
Object.keys(route).length === 0 ||
|
|
508
|
+
Object.keys(route).some((state) => !state.trim()) ||
|
|
509
|
+
Object.values(route).some((alias) => typeof alias !== 'string' || !alias.trim()))
|
|
510
|
+
throw new Error(`agents.routes.${name || '<empty>'} must map states (or *) to non-empty aliases`);
|
|
511
|
+
routes[name] = { ...route };
|
|
512
|
+
}
|
|
513
|
+
if (!routes.default)
|
|
514
|
+
routes.default = { '*': defaultAgent };
|
|
515
|
+
if (config.selectAgent !== undefined &&
|
|
516
|
+
typeof config.selectAgent !== 'function') {
|
|
517
|
+
throw new Error('selectAgent must be a function');
|
|
518
|
+
}
|
|
519
|
+
return {
|
|
520
|
+
remote,
|
|
521
|
+
executor,
|
|
522
|
+
agents: candidates,
|
|
523
|
+
enabled_agents: enabledAgents,
|
|
524
|
+
disabled_agents: disabled,
|
|
525
|
+
default_agent: defaultAgent,
|
|
526
|
+
routes,
|
|
527
|
+
...(typeof config.selectAgent === 'function'
|
|
528
|
+
? {
|
|
529
|
+
selectAgent: config.selectAgent,
|
|
530
|
+
}
|
|
531
|
+
: {}),
|
|
532
|
+
workspace,
|
|
533
|
+
hookListeners,
|
|
534
|
+
...(inboxAddons.length > 0 ? { inboxAddons } : {}),
|
|
535
|
+
...(processHost !== undefined ? { processHost } : {}),
|
|
536
|
+
bakers,
|
|
537
|
+
label,
|
|
538
|
+
conductor_id: conductorId,
|
|
539
|
+
// Soft alias so callers that still read machine_id keep working this cut.
|
|
540
|
+
machine_id: conductorId,
|
|
541
|
+
project,
|
|
542
|
+
states,
|
|
543
|
+
prompt: typeof config.prompt === 'string' && config.prompt.trim() !== ''
|
|
544
|
+
? config.prompt
|
|
545
|
+
: DEFAULT_AGENT_PROMPT,
|
|
546
|
+
max_parallel: maxParallel,
|
|
547
|
+
tunnel,
|
|
548
|
+
...(hostKey !== undefined ? { host_key: hostKey } : {}),
|
|
549
|
+
poll_interval_ms: optionalPositiveInteger(config.poll_interval_ms, 5000, 'poll_interval_ms'),
|
|
550
|
+
lease_seconds: optionalPositiveInteger(config.lease_seconds, 300, 'lease_seconds'),
|
|
551
|
+
...(config.hooks !== undefined ? { hooks: config.hooks } : {}),
|
|
552
|
+
config_path: configPath,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Compose the full `ConductorFileConfig` the engine consumes from the engine
|
|
557
|
+
* half (`loadConductorConfig`) and the connection half (`loadBbConfig`). The
|
|
558
|
+
* connection supplies `site` + the auth `plugins`; `ensureAuthenticated` /
|
|
559
|
+
* `selectRemote` read them exactly as before — the fields just arrive from
|
|
560
|
+
* `bakers.config.js` now.
|
|
561
|
+
*/
|
|
562
|
+
export function composeConductorConfig(engine, connection) {
|
|
563
|
+
return {
|
|
564
|
+
...engine,
|
|
565
|
+
site: connection.site,
|
|
566
|
+
...(connection.plugins.length > 0 ? { plugins: connection.plugins } : {}),
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
const DEFAULT_CONFIG = 'conductor.config.js';
|
|
570
|
+
const VARIANT_SUFFIX = '.conductor.config.js';
|
|
571
|
+
/**
|
|
572
|
+
* The conductor **stems** in a `.bakers/` dir — the file naming predicate of
|
|
573
|
+
* BB-137: exactly `conductor.config.js` (stem `conductor`) or
|
|
574
|
+
* `<variant>.conductor.config.js` (stem `<variant>`). The resolution itself
|
|
575
|
+
* lives in `@bakers-build/core` (re-exported above); this is the enumeration.
|
|
576
|
+
*/
|
|
577
|
+
export function configStems(bakersDir) {
|
|
578
|
+
return readdirSync(bakersDir)
|
|
579
|
+
.map((f) => f === DEFAULT_CONFIG
|
|
580
|
+
? 'conductor'
|
|
581
|
+
: f.endsWith(VARIANT_SUFFIX)
|
|
582
|
+
? f.slice(0, -VARIANT_SUFFIX.length)
|
|
583
|
+
: undefined)
|
|
584
|
+
.filter((s) => s !== undefined)
|
|
585
|
+
.sort();
|
|
586
|
+
}
|
|
587
|
+
/** Map a conductor stem back to its file name. */
|
|
588
|
+
export function fileForStem(stem) {
|
|
589
|
+
return stem === 'conductor' ? DEFAULT_CONFIG : `${stem}${VARIANT_SUFFIX}`;
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* List the conductor-config **file names** in a `.bakers/` dir, using the same
|
|
593
|
+
* naming predicate the loader uses (`configStems`). This is the only supported
|
|
594
|
+
* enumeration for the `@bakers-build/upgrade-project` states-strip: it visits the
|
|
595
|
+
* default `conductor.config.js` + every `<variant>.conductor.config.js`, and
|
|
596
|
+
* never an unrelated JS config (`vite.config.js`, the near-miss
|
|
597
|
+
* `myconductor.config.js`).
|
|
598
|
+
*/
|
|
599
|
+
export function listConductorConfigFiles(bakersDir) {
|
|
600
|
+
return configStems(bakersDir).map(fileForStem);
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* Remove the obsolete `states:` array property from a conductor-config JS
|
|
604
|
+
* source (BB-207 made `states` optional — empty ⇒ serve all claimable
|
|
605
|
+
* states — so `@bakers-build/upgrade-project` drops the now-dead key). Pure string
|
|
606
|
+
* transform: it strips the `states: [ ... ]` property (line form or inline in
|
|
607
|
+
* an object literal) with its trailing comma, and rewrites nothing else. A
|
|
608
|
+
* `substates:`/other key containing "states" is not matched.
|
|
609
|
+
*/
|
|
610
|
+
export function stripStatesFromConfigSource(source) {
|
|
611
|
+
return source.replace(/\n?[ \t]*(?<![A-Za-z0-9_$])states[ \t]*:[ \t]*\[[^\]]*\][ \t]*,?/g, '');
|
|
612
|
+
}
|
|
613
|
+
// ---------------------------------------------------------------------------
|
|
614
|
+
// BB-218 (AC-7): strip legacy connection/auth from an ENGINE config source.
|
|
615
|
+
//
|
|
616
|
+
// Unlike `stripStatesFromConfigSource`'s flat `\[[^\]]*\]` regex (nested-unsafe),
|
|
617
|
+
// this is a balanced-delimiter, comment-aware source transform. The engine
|
|
618
|
+
// config's `plugins: [ … ]` array holds nested `{}` (oauth2 profiles AND
|
|
619
|
+
// `@bakers-build/addon-essentials`), a sibling `agent: [ … ]` array, and the word
|
|
620
|
+
// "plugins" also appears in comments — so the removal keys on the REAL `site:` /
|
|
621
|
+
// `plugins:` *properties* of the default-export object, scanning each value from
|
|
622
|
+
// its opening delimiter to the matching close while skipping string/comment
|
|
623
|
+
// bytes. It also removes the now-orphaned connection preamble consts
|
|
624
|
+
// (`baseUrl`/`clientId`/`clientSecret`) and a connection-only `loadMachine`
|
|
625
|
+
// (with its `const machine = await loadMachine()`) when they become unreferenced.
|
|
626
|
+
// ---------------------------------------------------------------------------
|
|
627
|
+
// BB-230: the scanner primitives themselves now live in `@bakers-build/core`
|
|
628
|
+
// (`core/src/cli/config-source.ts`) — the kernel needs them for the content-gated
|
|
629
|
+
// legacy connection read, and layer 0 cannot import this package. Imported above.
|
|
630
|
+
/** Count word-boundary occurrences of an identifier in the source. */
|
|
631
|
+
function countIdent(src, name) {
|
|
632
|
+
const re = new RegExp(`(?<![A-Za-z0-9_$])${name}(?![A-Za-z0-9_$])`, 'g');
|
|
633
|
+
return (src.match(re) ?? []).length;
|
|
634
|
+
}
|
|
635
|
+
/** Remove a single-line `const <name> = …;` declaration (no-op if absent). */
|
|
636
|
+
function removeConstDecl(src, name) {
|
|
637
|
+
const re = new RegExp(`^[ \\t]*const ${name}\\b[^\\n]*\\n`, 'm');
|
|
638
|
+
return src.replace(re, '');
|
|
639
|
+
}
|
|
640
|
+
/** Remove an `[async ]function <name>(…) { … }` declaration (balanced body). */
|
|
641
|
+
function removeFunctionDecl(src, name) {
|
|
642
|
+
const re = new RegExp(`(?:async[ \\t]+)?function[ \\t]+${name}[ \\t]*\\(`);
|
|
643
|
+
const m = re.exec(src);
|
|
644
|
+
if (!m)
|
|
645
|
+
return src;
|
|
646
|
+
const braceIdx = src.indexOf('{', m.index);
|
|
647
|
+
if (braceIdx === -1)
|
|
648
|
+
return src;
|
|
649
|
+
const end = matchDelimiter(src, braceIdx);
|
|
650
|
+
if (end === -1)
|
|
651
|
+
return src;
|
|
652
|
+
let e = end + 1;
|
|
653
|
+
if (src[e] === '\n')
|
|
654
|
+
e++;
|
|
655
|
+
return src.slice(0, m.index) + src.slice(e);
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* Is `machine` referenced anywhere OTHER than its own `const machine = …;` decl
|
|
659
|
+
* and inside the `loadMachine` function body? (The body's `…/machine.config.js`
|
|
660
|
+
* path string must not count as a live reference.) Used to decide whether a
|
|
661
|
+
* connection-only `loadMachine` has become orphaned.
|
|
662
|
+
*/
|
|
663
|
+
function machineReferencedElsewhere(src) {
|
|
664
|
+
let probe = removeFunctionDecl(src, 'loadMachine');
|
|
665
|
+
probe = removeConstDecl(probe, 'machine');
|
|
666
|
+
return countIdent(probe, 'machine') > 0;
|
|
667
|
+
}
|
|
668
|
+
/** Drop connection preamble that is orphaned once `site`/`plugins` are gone. */
|
|
669
|
+
function removeOrphanedPreamble(src) {
|
|
670
|
+
let text = src;
|
|
671
|
+
for (const name of ['baseUrl', 'clientId', 'clientSecret']) {
|
|
672
|
+
if (countIdent(text, name) <= 1)
|
|
673
|
+
text = removeConstDecl(text, name);
|
|
674
|
+
}
|
|
675
|
+
// A connection-only `loadMachine`: remove `const machine = await loadMachine()`
|
|
676
|
+
// + the function itself only when `machine` is otherwise unreferenced (so an
|
|
677
|
+
// engine config that still composes `machine_id` from `machine.user_id` keeps
|
|
678
|
+
// its loadMachine — the "connection-only" qualifier).
|
|
679
|
+
if (/\bconst machine\b/.test(text) &&
|
|
680
|
+
/\bfunction loadMachine\b/.test(text) &&
|
|
681
|
+
!machineReferencedElsewhere(text)) {
|
|
682
|
+
text = removeConstDecl(text, 'machine');
|
|
683
|
+
text = removeFunctionDecl(text, 'loadMachine');
|
|
684
|
+
}
|
|
685
|
+
return text;
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* Strip the `site:` and `plugins:` properties from an engine config's
|
|
689
|
+
* `export default { … }` (with their leading comment/whitespace), plus the
|
|
690
|
+
* now-orphaned connection preamble consts. Returns the rewritten source and the
|
|
691
|
+
* list of object keys removed (`[]` — source returned unchanged — when there is
|
|
692
|
+
* nothing connection-related to remove). remote/executor/agent/workspace/hooks/
|
|
693
|
+
* project/machine_id/loadLocal are left intact.
|
|
694
|
+
*/
|
|
695
|
+
export function stripLegacyConnectionFromConfigSource(source) {
|
|
696
|
+
const obj = findDefaultExportObject(source);
|
|
697
|
+
if (!obj)
|
|
698
|
+
return { text: source, removed: [] };
|
|
699
|
+
const entries = scanTopLevelProperties(source, obj.bodyStart, obj.bodyEnd);
|
|
700
|
+
const targets = new Set(['site', 'plugins']);
|
|
701
|
+
const toRemove = entries.filter((e) => e.key !== undefined && targets.has(e.key));
|
|
702
|
+
if (toRemove.length === 0)
|
|
703
|
+
return { text: source, removed: [] };
|
|
704
|
+
let text = source;
|
|
705
|
+
for (const e of [...toRemove].sort((a, b) => b.start - a.start)) {
|
|
706
|
+
text = text.slice(0, e.start) + text.slice(e.end);
|
|
707
|
+
}
|
|
708
|
+
text = removeOrphanedPreamble(text);
|
|
709
|
+
// Report in source order for a stable `site/plugins` message.
|
|
710
|
+
const removed = ['site', 'plugins'].filter((k) => toRemove.some((e) => e.key === k));
|
|
711
|
+
return { text, removed };
|
|
712
|
+
}
|