@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,1264 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { dirname } from 'node:path';
|
|
6
|
+
import { configRecheckCommand, finalizeConfigReport, inspectConfigSource, inventoryConfig, shellQuote, } from '@bakers-build/core';
|
|
7
|
+
import { applyMigrations, BB_CONFIG_SCHEMA_VERSION } from './config-schema.js';
|
|
8
|
+
import { ConfigSourceEditor, redactConfigSource, sameValue, } from './config-source-edit.js';
|
|
9
|
+
import { configImportExpression, DEFAULT_CONDUCTOR_AGENT_ADDONS, renderCommittedConfig, renderGlobalConnection, renderHostMachine, } from './config-templates.js';
|
|
10
|
+
import { CONDUCTOR_ADDON_SLOTS, validateConfigSources, } from './config-validation.js';
|
|
11
|
+
import { migrateAddonNames } from './migrate-addon-names.js';
|
|
12
|
+
const CURRENT = {
|
|
13
|
+
machine: 3,
|
|
14
|
+
'legacy-machine': 3,
|
|
15
|
+
// v1 is the first versioned conductor shape: the conductor owns only
|
|
16
|
+
// remote/executor/workspace and declares no agent addons. Zero was the
|
|
17
|
+
// implicit pre-versioned state (no @bakers-schema-version marker present).
|
|
18
|
+
conductor: 1,
|
|
19
|
+
connection: 4,
|
|
20
|
+
'project-connection': 4,
|
|
21
|
+
};
|
|
22
|
+
const CONNECTION_KEYS = [
|
|
23
|
+
'base_url',
|
|
24
|
+
'client_id',
|
|
25
|
+
'client_secret',
|
|
26
|
+
'workspace_id',
|
|
27
|
+
];
|
|
28
|
+
function fingerprint(text) {
|
|
29
|
+
return `sha256:${createHash('sha256').update(text).digest('hex')}`;
|
|
30
|
+
}
|
|
31
|
+
function readFingerprint(path) {
|
|
32
|
+
try {
|
|
33
|
+
return fingerprint(readFileSync(path, 'utf8'));
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (error.code === 'ENOENT')
|
|
37
|
+
return null;
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Inventory, transform in memory, validate the complete proposed installation. */
|
|
42
|
+
export function planConfig(options = {}) {
|
|
43
|
+
// `conductor` is a bakers.build-specific sub-scope of `project`: it selects the same
|
|
44
|
+
// files but communicates to the planner that only conductor-config changes are
|
|
45
|
+
// requested. We map it to `project` for the inventory, which uses a strict
|
|
46
|
+
// union type, and track the original scope so conductor-specific migrations
|
|
47
|
+
// (agent-addon removal) are always applied when the requested scope is either
|
|
48
|
+
// `conductor` or `all`.
|
|
49
|
+
const requestedScope = options.scope;
|
|
50
|
+
// Map the bakers.build-specific `conductor` sub-scope to `project` for the inventory,
|
|
51
|
+
// which uses a strict ConfigScope union type that does not include `conductor`.
|
|
52
|
+
// Use a spread to avoid passing an explicit `undefined` value (exactOptionalPropertyTypes).
|
|
53
|
+
const inventoryScopeOverride = requestedScope === 'conductor'
|
|
54
|
+
? { scope: 'project' }
|
|
55
|
+
: requestedScope !== undefined
|
|
56
|
+
? { scope: requestedScope }
|
|
57
|
+
: {};
|
|
58
|
+
const inventoryOptions = {
|
|
59
|
+
...options,
|
|
60
|
+
...inventoryScopeOverride,
|
|
61
|
+
operation: options.operation ?? 'upgrade',
|
|
62
|
+
currentVersions: CURRENT,
|
|
63
|
+
};
|
|
64
|
+
const inventory = inventoryConfig(inventoryOptions);
|
|
65
|
+
const report = inventory.report;
|
|
66
|
+
const original = new Map([...inventory.sources].map(([path, source]) => [path, source.text]));
|
|
67
|
+
const editors = new Map([...original].map(([path, text]) => [path, new ConfigSourceEditor(text)]));
|
|
68
|
+
const files = report.files;
|
|
69
|
+
const machine = files.find((f) => f.kind === 'machine');
|
|
70
|
+
const connection = files.find((f) => f.kind === 'connection');
|
|
71
|
+
const homeLocal = files.find((f) => f.kind === 'home-local');
|
|
72
|
+
const local = files.find((f) => f.kind === 'local');
|
|
73
|
+
if (options.operation === 'check') {
|
|
74
|
+
const validated = validateConfigSources(inventoryOptions, new Map(original), files);
|
|
75
|
+
return {
|
|
76
|
+
report: finalizeConfigReport(validated),
|
|
77
|
+
writes: [],
|
|
78
|
+
fingerprints: new Map(files.map((f) => [f.path, f.fingerprint])),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
const errors = report.findings.filter((f) => (['scope', 'syntax', 'inventory'].includes(f.stage) &&
|
|
82
|
+
f.code !== 'missing_required_file') ||
|
|
83
|
+
['future_schema_version', 'version_marker_disagreement'].includes(f.code));
|
|
84
|
+
const recheck = configRecheckCommand((options.scope ?? 'all'), report.roots.project) +
|
|
85
|
+
(options.machinePath
|
|
86
|
+
? ` --machine-path ${shellQuote(options.machinePath)}`
|
|
87
|
+
: '') +
|
|
88
|
+
(options.connectionPath
|
|
89
|
+
? ` --config ${shellQuote(options.connectionPath)}`
|
|
90
|
+
: '');
|
|
91
|
+
const problem = (file, code, key, expected, related = []) => {
|
|
92
|
+
const p = key ? editors.get(file.path)?.property(key) : undefined;
|
|
93
|
+
errors.push({
|
|
94
|
+
code,
|
|
95
|
+
severity: 'error',
|
|
96
|
+
stage: 'planning',
|
|
97
|
+
path: file.path,
|
|
98
|
+
key,
|
|
99
|
+
expected,
|
|
100
|
+
observed: p?.staticallyDetermined && !/secret|client_id|url/i.test(key ?? '')
|
|
101
|
+
? (p.value ?? null)
|
|
102
|
+
: '[REDACTED]',
|
|
103
|
+
owner: file.layer,
|
|
104
|
+
related,
|
|
105
|
+
statically_determined: p?.staticallyDetermined ?? false,
|
|
106
|
+
remediation: code === 'out_of_scope_write_required'
|
|
107
|
+
? 'bakers config upgrade --scope all'
|
|
108
|
+
: `Resolve ${key ?? 'configuration'} in ${shellQuote(file.path)}, then run ${recheck}.`,
|
|
109
|
+
recheck,
|
|
110
|
+
...(p ? { line: p.line, column: p.column } : {}),
|
|
111
|
+
});
|
|
112
|
+
};
|
|
113
|
+
const protectedFile = (file) => errors.some((e) => e.path === file.path &&
|
|
114
|
+
[
|
|
115
|
+
'future_schema_version',
|
|
116
|
+
'version_marker_disagreement',
|
|
117
|
+
'syntax_error',
|
|
118
|
+
'unreadable_file',
|
|
119
|
+
].includes(e.code));
|
|
120
|
+
const editor = (file) => {
|
|
121
|
+
let e = editors.get(file.path);
|
|
122
|
+
if (!e) {
|
|
123
|
+
e = new ConfigSourceEditor('export default {};\n');
|
|
124
|
+
editors.set(file.path, e);
|
|
125
|
+
}
|
|
126
|
+
return e;
|
|
127
|
+
};
|
|
128
|
+
const fill = (file, key, value) => {
|
|
129
|
+
const e = editor(file);
|
|
130
|
+
const p = e.property(key);
|
|
131
|
+
if (value !== undefined &&
|
|
132
|
+
(!p || (p.staticallyDetermined && (p.value === '' || p.value === null))))
|
|
133
|
+
e.set(key, JSON.stringify(value));
|
|
134
|
+
};
|
|
135
|
+
const move = (from, key, to, target) => {
|
|
136
|
+
const source = editors.get(from.path);
|
|
137
|
+
const p = source?.property(key);
|
|
138
|
+
if (!p || !source)
|
|
139
|
+
return;
|
|
140
|
+
if (!from.selected && !to.selected)
|
|
141
|
+
return;
|
|
142
|
+
if (!from.selected || !to.selected) {
|
|
143
|
+
problem(from, 'out_of_scope_write_required', key, 'Both source and destination are selected writable layers.', [{ path: to.path, key: target }]);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (protectedFile(from) || protectedFile(to))
|
|
147
|
+
return;
|
|
148
|
+
const dest = editor(to);
|
|
149
|
+
const existing = dest.property(target);
|
|
150
|
+
if (!p.staticallyDetermined || p.value === undefined) {
|
|
151
|
+
problem(from, 'unknown_value', key, 'The moved value must be a known literal.', [{ path: to.path, key: target }]);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (existing && !sameValue(p, existing)) {
|
|
155
|
+
problem(from, 'conflicting_values', key, 'Source and destination values agree.', [
|
|
156
|
+
{
|
|
157
|
+
path: to.path,
|
|
158
|
+
key: target,
|
|
159
|
+
line: existing.line,
|
|
160
|
+
column: existing.column,
|
|
161
|
+
},
|
|
162
|
+
]);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (!existing)
|
|
166
|
+
dest.set(target, source.expression(key));
|
|
167
|
+
source.remove(key);
|
|
168
|
+
};
|
|
169
|
+
if (!machine || !connection)
|
|
170
|
+
return { report, writes: [], fingerprints: new Map() };
|
|
171
|
+
if (machine.selected &&
|
|
172
|
+
!editors.has(machine.path) &&
|
|
173
|
+
options.inputs?.machineId &&
|
|
174
|
+
options.inputs?.userId)
|
|
175
|
+
editors.set(machine.path, new ConfigSourceEditor(renderHostMachine({
|
|
176
|
+
machineId: options.inputs.machineId,
|
|
177
|
+
userId: options.inputs.userId,
|
|
178
|
+
maxParallel: options.inputs.maxParallel ?? 1,
|
|
179
|
+
})));
|
|
180
|
+
const authType = options.inputs?.authType ?? 'oauth2_client_credentials';
|
|
181
|
+
if (connection.selected &&
|
|
182
|
+
!editors.has(connection.path) &&
|
|
183
|
+
options.inputs?.baseUrl &&
|
|
184
|
+
options.inputs?.clientId &&
|
|
185
|
+
(authType === 'oauth2_device_code' || options.inputs?.secret))
|
|
186
|
+
editors.set(connection.path, new ConfigSourceEditor(renderGlobalConnection({
|
|
187
|
+
baseUrl: options.inputs.baseUrl,
|
|
188
|
+
clientId: options.inputs.clientId,
|
|
189
|
+
...(options.inputs.secret !== undefined
|
|
190
|
+
? { secret: options.inputs.secret }
|
|
191
|
+
: {}),
|
|
192
|
+
...(options.inputs.workspaceId
|
|
193
|
+
? { workspaceId: options.inputs.workspaceId }
|
|
194
|
+
: {}),
|
|
195
|
+
authType,
|
|
196
|
+
})));
|
|
197
|
+
const legacy = files.find((f) => f.kind === 'legacy-machine');
|
|
198
|
+
if (legacy?.selected && !protectedFile(legacy) && editors.has(legacy.path)) {
|
|
199
|
+
const old = editor(legacy);
|
|
200
|
+
const current = editors.get(machine.path);
|
|
201
|
+
if (!current)
|
|
202
|
+
editors.set(machine.path, new ConfigSourceEditor(old.text));
|
|
203
|
+
else
|
|
204
|
+
for (const [key, p] of old.source.properties)
|
|
205
|
+
if (!key.includes('.') && !key.includes('[')) {
|
|
206
|
+
const other = current.property(key);
|
|
207
|
+
if (other && !sameValue(p, other))
|
|
208
|
+
problem(legacy, 'conflicting_values', key, 'Legacy and canonical machine values agree.', [{ path: machine.path, key }]);
|
|
209
|
+
else if (!other)
|
|
210
|
+
current.set(key, old.expression(key));
|
|
211
|
+
}
|
|
212
|
+
editors.delete(legacy.path);
|
|
213
|
+
}
|
|
214
|
+
const machineEditor = editor(machine);
|
|
215
|
+
const literalSubtreeIssue = (source, key) => {
|
|
216
|
+
const expression = source.expression(key);
|
|
217
|
+
if (expression === undefined)
|
|
218
|
+
return key;
|
|
219
|
+
const inspected = inspectConfigSource(`export default { value: ${expression} };`);
|
|
220
|
+
if (inspected.syntax !== 'valid' || inspected.shape !== 'object')
|
|
221
|
+
return key;
|
|
222
|
+
const computed = inspected.propertyOccurrences.find((property) => (property.key === 'value' ||
|
|
223
|
+
property.key.startsWith('value.') ||
|
|
224
|
+
property.key.startsWith('value[')) &&
|
|
225
|
+
!property.staticallyDetermined);
|
|
226
|
+
if (computed)
|
|
227
|
+
return `${key}${computed.key.slice('value'.length)}`;
|
|
228
|
+
return inspected.unknownKeys ? key : undefined;
|
|
229
|
+
};
|
|
230
|
+
const setMachineAgentValue = (sourceFile, source, sourceKey, targetKey, expression) => {
|
|
231
|
+
const property = source.property(sourceKey);
|
|
232
|
+
if (!property)
|
|
233
|
+
return false;
|
|
234
|
+
const issue = literalSubtreeIssue(source, sourceKey);
|
|
235
|
+
if (!property.staticallyDetermined || issue) {
|
|
236
|
+
problem(sourceFile, 'unknown_value', issue ?? sourceKey, 'The retired agent policy must be a statically known literal.', [{ path: machine.path, key: targetKey }]);
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
const existing = machineEditor.expression(targetKey);
|
|
240
|
+
if (existing !== undefined && existing !== expression) {
|
|
241
|
+
problem(sourceFile, 'conflicting_values', sourceKey, 'The retired and nested machine agent policies agree.', [{ path: machine.path, key: targetKey }]);
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
if (existing === undefined)
|
|
245
|
+
machineEditor.set(targetKey, expression);
|
|
246
|
+
source.remove(sourceKey);
|
|
247
|
+
return true;
|
|
248
|
+
};
|
|
249
|
+
const legacyRoutesExpression = (sourceFile, source, key) => {
|
|
250
|
+
const routes = source.property(key);
|
|
251
|
+
if (!routes)
|
|
252
|
+
return undefined;
|
|
253
|
+
if (routes.nodeType !== 'ObjectExpression') {
|
|
254
|
+
problem(sourceFile, 'unknown_value', key, 'The retired route policy must be a statically known object.', [{ path: machine.path, key: 'agents.routes' }]);
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
const subtreeIssue = literalSubtreeIssue(source, key);
|
|
258
|
+
if (subtreeIssue) {
|
|
259
|
+
problem(sourceFile, 'unknown_value', subtreeIssue, 'Every retired route must be a statically known state-to-alias map.', [{ path: machine.path, key: 'agents.routes' }]);
|
|
260
|
+
return undefined;
|
|
261
|
+
}
|
|
262
|
+
const direct = [...source.source.properties]
|
|
263
|
+
.filter(([name]) => {
|
|
264
|
+
if (!name.startsWith(`${key}.`))
|
|
265
|
+
return false;
|
|
266
|
+
return !name.slice(key.length + 1).includes('.');
|
|
267
|
+
})
|
|
268
|
+
.map(([name, property]) => [name.slice(key.length + 1), property]);
|
|
269
|
+
const reject = (member) => {
|
|
270
|
+
problem(sourceFile, 'unknown_value', `${key}.${member}`, 'Every retired route must be a statically known state-to-alias map.', [{ path: machine.path, key: `agents.routes.${member}` }]);
|
|
271
|
+
};
|
|
272
|
+
const validRoute = (name) => {
|
|
273
|
+
const routeKey = `${key}.${name}`;
|
|
274
|
+
const route = source.property(routeKey);
|
|
275
|
+
if (!route?.staticallyDetermined ||
|
|
276
|
+
route.nodeType !== 'ObjectExpression') {
|
|
277
|
+
reject(name);
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
const states = [...source.source.properties].filter(([member]) => {
|
|
281
|
+
if (!member.startsWith(`${routeKey}.`))
|
|
282
|
+
return false;
|
|
283
|
+
return !member.slice(routeKey.length + 1).includes('.');
|
|
284
|
+
});
|
|
285
|
+
const invalid = states.find(([, property]) => !property.staticallyDetermined || typeof property.value !== 'string');
|
|
286
|
+
if (states.length === 0 || invalid) {
|
|
287
|
+
reject(invalid?.[0].slice(key.length + 1) ?? name);
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
return true;
|
|
291
|
+
};
|
|
292
|
+
for (const [name, property] of direct) {
|
|
293
|
+
if (name === 'default' && property.nodeType !== 'ObjectExpression') {
|
|
294
|
+
if (!property.staticallyDetermined ||
|
|
295
|
+
typeof property.value !== 'string') {
|
|
296
|
+
reject(name);
|
|
297
|
+
return undefined;
|
|
298
|
+
}
|
|
299
|
+
if (!direct.some(([routeName]) => routeName === property.value)) {
|
|
300
|
+
reject(name);
|
|
301
|
+
return undefined;
|
|
302
|
+
}
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (!validRoute(name))
|
|
306
|
+
return undefined;
|
|
307
|
+
}
|
|
308
|
+
const named = direct.filter(([name, property]) => name !== 'default' && property.nodeType === 'ObjectExpression');
|
|
309
|
+
const oldDefault = direct.find(([name]) => name === 'default')?.[1];
|
|
310
|
+
let defaultExpression;
|
|
311
|
+
if (oldDefault?.nodeType === 'ObjectExpression')
|
|
312
|
+
defaultExpression = source.expression(`${key}.default`);
|
|
313
|
+
else if (oldDefault?.staticallyDetermined &&
|
|
314
|
+
typeof oldDefault.value === 'string')
|
|
315
|
+
defaultExpression = source.expression(`${key}.${oldDefault.value}`);
|
|
316
|
+
const entries = [
|
|
317
|
+
...(defaultExpression ? [`default: ${defaultExpression}`] : []),
|
|
318
|
+
...named.map(([name]) => `${JSON.stringify(name)}: ${source.expression(`${key}.${name}`)}`),
|
|
319
|
+
];
|
|
320
|
+
return `{ ${entries.join(', ')} }`;
|
|
321
|
+
};
|
|
322
|
+
const migrateStateDefaults = (sourceFile, source) => {
|
|
323
|
+
const defaults = source.property('default_agent');
|
|
324
|
+
if (!defaults)
|
|
325
|
+
return;
|
|
326
|
+
const expression = source.expression('default_agent');
|
|
327
|
+
const subtreeIssue = literalSubtreeIssue(source, 'default_agent');
|
|
328
|
+
if (!defaults.staticallyDetermined ||
|
|
329
|
+
defaults.nodeType !== 'ObjectExpression' ||
|
|
330
|
+
expression === undefined ||
|
|
331
|
+
subtreeIssue) {
|
|
332
|
+
problem(sourceFile, 'unknown_value', subtreeIssue ?? 'default_agent', 'The retired state default must be a statically known object.', [{ path: machine.path, key: 'agents.routes.default' }]);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
const currentRoute = machineEditor.expression('agents.routes.default');
|
|
336
|
+
if (currentRoute !== undefined && currentRoute !== expression) {
|
|
337
|
+
problem(sourceFile, 'conflicting_values', 'default_agent', 'The retired state default and reserved default route agree.', [{ path: machine.path, key: 'agents.routes.default' }]);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
if (currentRoute === undefined)
|
|
341
|
+
machineEditor.set('agents.routes.default', expression);
|
|
342
|
+
const catchAll = source.property('default_agent.*');
|
|
343
|
+
if (catchAll?.staticallyDetermined &&
|
|
344
|
+
typeof catchAll.value === 'string' &&
|
|
345
|
+
!machineEditor.property('agents.default_agent'))
|
|
346
|
+
machineEditor.set('agents.default_agent', JSON.stringify(catchAll.value));
|
|
347
|
+
source.remove('default_agent');
|
|
348
|
+
};
|
|
349
|
+
const migrateCatalog = (sourceFile, source) => {
|
|
350
|
+
const agent = source.property('agent');
|
|
351
|
+
if (!agent)
|
|
352
|
+
return;
|
|
353
|
+
if (!agent.staticallyDetermined ||
|
|
354
|
+
!['ArrayExpression', 'ObjectExpression'].includes(agent.nodeType)) {
|
|
355
|
+
problem(sourceFile, 'unknown_value', 'agent', 'The retired agent descriptors must be statically known.', [{ path: machine.path, key: 'agents.catalog' }]);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
const prefixes = agent.nodeType === 'ArrayExpression'
|
|
359
|
+
? Array.from({ length: agent.arrayLength ?? 0 }, (_, index) => `agent[${index}]`)
|
|
360
|
+
: ['agent'];
|
|
361
|
+
const catalog = [];
|
|
362
|
+
const addonPackages = [];
|
|
363
|
+
const unsupportedDirectMember = (prefix, allowed) => [...source.source.properties.keys()].find((key) => {
|
|
364
|
+
if (!key.startsWith(`${prefix}.`))
|
|
365
|
+
return false;
|
|
366
|
+
const member = key.slice(prefix.length + 1);
|
|
367
|
+
return (!member.includes('.') && !member.includes('[') && !allowed.has(member));
|
|
368
|
+
});
|
|
369
|
+
for (const prefix of prefixes) {
|
|
370
|
+
const wrapped = Boolean(source.property(`${prefix}.agent`));
|
|
371
|
+
const descriptor = wrapped ? `${prefix}.agent` : prefix;
|
|
372
|
+
const unsupported = wrapped
|
|
373
|
+
? (unsupportedDirectMember(prefix, new Set(['agent', 'alias'])) ??
|
|
374
|
+
unsupportedDirectMember(descriptor, new Set(['plugin', 'with'])))
|
|
375
|
+
: unsupportedDirectMember(descriptor, new Set(['plugin', 'with', 'alias']));
|
|
376
|
+
if (unsupported) {
|
|
377
|
+
problem(sourceFile, 'unknown_value', unsupported, 'Legacy candidate metadata without a lossless machine-catalog destination must be resolved manually.', [{ path: machine.path, key: 'agents.catalog' }]);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
const harness = source.property(`${descriptor}.plugin`);
|
|
381
|
+
if (!harness?.staticallyDetermined || typeof harness.value !== 'string') {
|
|
382
|
+
problem(sourceFile, 'unknown_value', 'agent', 'Every retired agent descriptor names a literal addon package.', [{ path: machine.path, key: 'agents.catalog' }]);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
addonPackages.push(harness.value);
|
|
386
|
+
const alias = source.property(`${prefix}.alias`);
|
|
387
|
+
if (alias &&
|
|
388
|
+
(!alias.staticallyDetermined || typeof alias.value !== 'string')) {
|
|
389
|
+
problem(sourceFile, 'unknown_value', `${prefix}.alias`, 'A migrated custom agent alias is a literal string.', [{ path: machine.path, key: 'agents.catalog' }]);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
const withKey = `${descriptor}.with`;
|
|
393
|
+
const withProperty = source.property(withKey);
|
|
394
|
+
const withExpression = source.expression(withKey);
|
|
395
|
+
if (withProperty) {
|
|
396
|
+
const computedKey = literalSubtreeIssue(source, withKey);
|
|
397
|
+
if (computedKey || withExpression === undefined) {
|
|
398
|
+
problem(sourceFile, 'unknown_value', computedKey ?? withKey, 'Custom agent options must be statically known literals.', [{ path: machine.path, key: 'agents.catalog' }]);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if (alias || withProperty)
|
|
403
|
+
catalog.push(`{ harness: ${JSON.stringify(harness.value)}${alias ? `, alias: ${JSON.stringify(alias.value)}` : ''}${withProperty ? `, with: ${withExpression}` : ''} }`);
|
|
404
|
+
}
|
|
405
|
+
if (catalog.length) {
|
|
406
|
+
const expression = `[${catalog.join(', ')}]`;
|
|
407
|
+
const existing = machineEditor.expression('agents.catalog');
|
|
408
|
+
if (existing !== undefined && existing !== expression) {
|
|
409
|
+
problem(sourceFile, 'conflicting_values', 'agent', 'Retired custom variants agree with agents.catalog.', [{ path: machine.path, key: 'agents.catalog' }]);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
if (existing === undefined)
|
|
413
|
+
machineEditor.set('agents.catalog', expression);
|
|
414
|
+
}
|
|
415
|
+
const addon = source.property('addons');
|
|
416
|
+
const uniquePackages = [...new Set(addonPackages)];
|
|
417
|
+
if (!addon)
|
|
418
|
+
source.set('addons', `[${uniquePackages
|
|
419
|
+
.map((use) => `{ use: ${JSON.stringify(use)} }`)
|
|
420
|
+
.join(', ')}]`);
|
|
421
|
+
else {
|
|
422
|
+
if (!addon.staticallyDetermined || addon.nodeType !== 'ArrayExpression') {
|
|
423
|
+
problem(sourceFile, 'unknown_value', 'addons', 'Agent harnesses can only be merged into a literal addons array.', [{ path: sourceFile.path, key: 'addons' }]);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
const entries = Array.from({ length: addon.arrayLength ?? 0 }, (_, index) => `addons[${index}]`);
|
|
427
|
+
const installed = new Set();
|
|
428
|
+
const authored = [];
|
|
429
|
+
for (const entryKey of entries) {
|
|
430
|
+
const entry = source.property(entryKey);
|
|
431
|
+
const expression = source.expression(entryKey);
|
|
432
|
+
if (!entry || expression === undefined) {
|
|
433
|
+
problem(sourceFile, 'unknown_value', entryKey, 'Every existing addon entry must have a statically known package.', [{ path: sourceFile.path, key: 'addons' }]);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
authored.push(expression);
|
|
437
|
+
if (entry.staticallyDetermined && typeof entry.value === 'string') {
|
|
438
|
+
installed.add(entry.value);
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
if (entry.nodeType !== 'ObjectExpression') {
|
|
442
|
+
problem(sourceFile, 'unknown_value', entryKey, 'Every existing addon entry must have a statically known package.', [{ path: sourceFile.path, key: 'addons' }]);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
const packageProperty = source.property(`${entryKey}.use`) ??
|
|
446
|
+
source.property(`${entryKey}.name`);
|
|
447
|
+
if (!packageProperty?.staticallyDetermined ||
|
|
448
|
+
typeof packageProperty.value !== 'string') {
|
|
449
|
+
problem(sourceFile, 'unknown_value', entryKey, 'Every existing addon entry must have a statically known package.', [{ path: sourceFile.path, key: 'addons' }]);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
installed.add(packageProperty.value);
|
|
453
|
+
}
|
|
454
|
+
const missing = uniquePackages.filter((use) => !installed.has(use));
|
|
455
|
+
if (missing.length > 0)
|
|
456
|
+
source.set('addons', `[${[
|
|
457
|
+
...authored,
|
|
458
|
+
...missing.map((use) => `{ use: ${JSON.stringify(use)} }`),
|
|
459
|
+
].join(', ')}]`);
|
|
460
|
+
}
|
|
461
|
+
source.remove('agent');
|
|
462
|
+
};
|
|
463
|
+
const deriveMachineDefault = () => {
|
|
464
|
+
if (machineEditor.property('agents.default_agent'))
|
|
465
|
+
return;
|
|
466
|
+
const catchAll = machineEditor.property('agents.routes.default.*');
|
|
467
|
+
if (catchAll?.staticallyDetermined && typeof catchAll.value === 'string')
|
|
468
|
+
machineEditor.set('agents.default_agent', JSON.stringify(catchAll.value));
|
|
469
|
+
};
|
|
470
|
+
if (machine.selected && !protectedFile(machine)) {
|
|
471
|
+
const oldAgents = machineEditor.property('agents');
|
|
472
|
+
if (oldAgents?.nodeType === 'ArrayExpression') {
|
|
473
|
+
const expression = machineEditor.expression('agents');
|
|
474
|
+
const issue = literalSubtreeIssue(machineEditor, 'agents');
|
|
475
|
+
if (issue) {
|
|
476
|
+
problem(machine, 'unknown_value', issue, 'The retired agent policy must be a statically known literal.', [{ path: machine.path, key: 'agents.enabled' }]);
|
|
477
|
+
}
|
|
478
|
+
else if (expression !== undefined) {
|
|
479
|
+
machineEditor.remove('agents');
|
|
480
|
+
machineEditor.set('agents.enabled', expression);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
for (const [sourceKey, targetKey] of [
|
|
484
|
+
['enabled_agents', 'agents.enabled'],
|
|
485
|
+
['disabled_agents', 'agents.disabled'],
|
|
486
|
+
]) {
|
|
487
|
+
const expression = machineEditor.expression(sourceKey);
|
|
488
|
+
if (expression !== undefined)
|
|
489
|
+
setMachineAgentValue(machine, machineEditor, sourceKey, targetKey, expression);
|
|
490
|
+
}
|
|
491
|
+
const routes = legacyRoutesExpression(machine, machineEditor, 'agent_routes');
|
|
492
|
+
if (routes !== undefined)
|
|
493
|
+
setMachineAgentValue(machine, machineEditor, 'agent_routes', 'agents.routes', routes);
|
|
494
|
+
migrateStateDefaults(machine, machineEditor);
|
|
495
|
+
deriveMachineDefault();
|
|
496
|
+
}
|
|
497
|
+
for (const file of files.filter((candidate) => candidate.selected &&
|
|
498
|
+
['conductor', 'local', 'home-local'].includes(candidate.kind))) {
|
|
499
|
+
const source = editors.get(file.path);
|
|
500
|
+
if (!source || protectedFile(file))
|
|
501
|
+
continue;
|
|
502
|
+
const available = source.expression('agents');
|
|
503
|
+
if (available !== undefined)
|
|
504
|
+
setMachineAgentValue(file, source, 'agents', 'agents.enabled', available);
|
|
505
|
+
const disabled = source.expression('disabled_agents');
|
|
506
|
+
if (disabled !== undefined)
|
|
507
|
+
setMachineAgentValue(file, source, 'disabled_agents', 'agents.disabled', disabled);
|
|
508
|
+
const routes = legacyRoutesExpression(file, source, 'agent_routes');
|
|
509
|
+
if (routes !== undefined)
|
|
510
|
+
setMachineAgentValue(file, source, 'agent_routes', 'agents.routes', routes);
|
|
511
|
+
migrateStateDefaults(file, source);
|
|
512
|
+
if (file.kind === 'conductor')
|
|
513
|
+
migrateCatalog(file, source);
|
|
514
|
+
deriveMachineDefault();
|
|
515
|
+
}
|
|
516
|
+
if (editors.has(connection.path) &&
|
|
517
|
+
connection.selected &&
|
|
518
|
+
!protectedFile(connection) &&
|
|
519
|
+
!protectedFile(machine))
|
|
520
|
+
replaceGeneratedMachineWiring(editor(connection), editor(machine), connection, homeLocal ? editors.get(homeLocal.path) : undefined, homeLocal, machine.selected, options.inputs, problem);
|
|
521
|
+
// Machine connection values must move atomically with their connection destinations.
|
|
522
|
+
for (const key of CONNECTION_KEYS) {
|
|
523
|
+
const target = key === 'base_url'
|
|
524
|
+
? 'site.base_url'
|
|
525
|
+
: key === 'workspace_id'
|
|
526
|
+
? key
|
|
527
|
+
: `plugins[0].with.${key}`;
|
|
528
|
+
const e = editors.get(machine.path);
|
|
529
|
+
if (!e?.property(key))
|
|
530
|
+
continue;
|
|
531
|
+
if (!machine.selected || !connection.selected) {
|
|
532
|
+
problem(machine, 'out_of_scope_write_required', key, 'Machine and connection scopes are both required.', [{ path: connection.path, key: target }]);
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
if (protectedFile(machine) || protectedFile(connection))
|
|
536
|
+
continue;
|
|
537
|
+
const conn = editor(connection);
|
|
538
|
+
// Generated v1-v3 wiring is resolved only from the recognized machine literal.
|
|
539
|
+
if (key === 'client_id' || key === 'client_secret') {
|
|
540
|
+
let prefix = oauthPrefix(conn);
|
|
541
|
+
if (!prefix) {
|
|
542
|
+
if (!conn.property('plugins'))
|
|
543
|
+
conn.set('plugins', "[{ plugin: '@bakers-build/addon-essentials' }, { plugin: '@dropsh/plugin-oauth2', export: 'oauth2Plugin', with: { id: 'bakers', default: true, type: 'oauth2_client_credentials', scope: 'bb:session' } }]");
|
|
544
|
+
prefix = oauthPrefix(conn);
|
|
545
|
+
}
|
|
546
|
+
if (!prefix) {
|
|
547
|
+
problem(connection, 'unsupported_source_shape', 'plugins', 'A statically known OAuth provider accepts migrated credentials.');
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
move(machine, key, connection, `${prefix}.with.${key}`);
|
|
551
|
+
}
|
|
552
|
+
else
|
|
553
|
+
move(machine, key, connection, target);
|
|
554
|
+
}
|
|
555
|
+
for (const file of files) {
|
|
556
|
+
if (protectedFile(file) || !file.selected || file.kind === 'legacy-machine')
|
|
557
|
+
continue;
|
|
558
|
+
let e = editors.get(file.path);
|
|
559
|
+
if (e && e.source.shape !== 'object') {
|
|
560
|
+
problem(file, 'unsupported_source_shape', null, 'A supported object source is required for migration.');
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
try {
|
|
564
|
+
if (file.kind === 'machine') {
|
|
565
|
+
fill(file, 'machine_id', options.inputs?.machineId);
|
|
566
|
+
fill(file, 'user_id', options.inputs?.userId);
|
|
567
|
+
fill(file, 'max_parallel', options.inputs?.maxParallel);
|
|
568
|
+
e = editors.get(file.path);
|
|
569
|
+
// Addon settings live only in the top-level addons[]: move the retired
|
|
570
|
+
// `agents.addons` and `ports` keys there first.
|
|
571
|
+
// Entries moved from agents.addons are the operator's harnesses; an
|
|
572
|
+
// empty list names none, so it seeds like a missing one.
|
|
573
|
+
const agentAddons = e?.property('agents.addons');
|
|
574
|
+
const hadAgentAddons = !!agentAddons && agentAddons.arrayLength !== 0;
|
|
575
|
+
if (e)
|
|
576
|
+
migrateMachineAddons(e);
|
|
577
|
+
// Machine 2 → 3: seed agent harnesses into addons[] when the machine
|
|
578
|
+
// lists no harness (isHarness). Source: harness packages found in the
|
|
579
|
+
// project conductor.config.js addons[], or a known default set when no
|
|
580
|
+
// conductor config is available.
|
|
581
|
+
const machineAddons = e?.property('addons');
|
|
582
|
+
if (e &&
|
|
583
|
+
!hadAgentAddons &&
|
|
584
|
+
(!machineAddons || machineAddons.nodeType === 'ArrayExpression') &&
|
|
585
|
+
arrayEntries(e, 'addons').every((x) => typeof x.pkg === 'string' && !isHarness(x.pkg))) {
|
|
586
|
+
const conductorFile = files.find((f) => f.kind === 'conductor');
|
|
587
|
+
const conductorEditor = conductorFile
|
|
588
|
+
? editors.get(conductorFile.path)
|
|
589
|
+
: undefined;
|
|
590
|
+
let agentAddonsExpression;
|
|
591
|
+
if (conductorEditor) {
|
|
592
|
+
const agentEntries = [...conductorEditor.source.properties]
|
|
593
|
+
.filter(([key]) => /^addons\[\d+\](?:\.use)?$/.test(key))
|
|
594
|
+
.map(([, property]) => property.value)
|
|
595
|
+
.filter((value) => typeof value === 'string' && isHarness(value));
|
|
596
|
+
if (agentEntries.length > 0)
|
|
597
|
+
agentAddonsExpression = `[${agentEntries.map((pkg) => `{ use: ${JSON.stringify(pkg)} }`).join(', ')}]`;
|
|
598
|
+
}
|
|
599
|
+
if (!agentAddonsExpression) {
|
|
600
|
+
// No conductor config or no agent entries found — seed a known default
|
|
601
|
+
// set and surface a warning so the operator can review it.
|
|
602
|
+
agentAddonsExpression = `['@bakers-build/addon-claude', '@bakers-build/addon-codex', '@bakers-build/addon-grok']`;
|
|
603
|
+
report.findings.push({
|
|
604
|
+
code: 'missing_required_file',
|
|
605
|
+
severity: 'warning',
|
|
606
|
+
stage: 'planning',
|
|
607
|
+
path: file.path,
|
|
608
|
+
key: 'addons',
|
|
609
|
+
expected: 'addons seeded with a default set of agent harnesses; review it and adjust to match your installed agent packages.',
|
|
610
|
+
observed: null,
|
|
611
|
+
owner: file.layer,
|
|
612
|
+
related: [],
|
|
613
|
+
statically_determined: true,
|
|
614
|
+
remediation: `Edit ${file.path} to set addons to the packages you want, then run ${recheck}.`,
|
|
615
|
+
recheck,
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
const listed = arrayEntries(e, 'addons').map((x) => x.text);
|
|
619
|
+
e.set('addons', listed.length
|
|
620
|
+
? `[${[...listed, agentAddonsExpression.slice(1, -1)].join(', ')}]`
|
|
621
|
+
: agentAddonsExpression);
|
|
622
|
+
}
|
|
623
|
+
if (e)
|
|
624
|
+
e.stamp(3);
|
|
625
|
+
}
|
|
626
|
+
if (file.kind === 'conductor' ||
|
|
627
|
+
file.kind === 'local' ||
|
|
628
|
+
file.kind === 'home-local') {
|
|
629
|
+
if ((!e || e.text.trim() === 'export default {};') &&
|
|
630
|
+
file.required &&
|
|
631
|
+
options.project) {
|
|
632
|
+
e = new ConfigSourceEditor(renderCommittedConfig({
|
|
633
|
+
project: options.project,
|
|
634
|
+
...(options.inputs?.hooks ? { hooks: options.inputs.hooks } : {}),
|
|
635
|
+
...(options.inputs?.workspace
|
|
636
|
+
? { bakersWorkspace: options.inputs.workspace }
|
|
637
|
+
: {}),
|
|
638
|
+
}));
|
|
639
|
+
editors.set(file.path, e);
|
|
640
|
+
}
|
|
641
|
+
if (!e)
|
|
642
|
+
continue;
|
|
643
|
+
if (file.kind === 'conductor') {
|
|
644
|
+
e = editor(file);
|
|
645
|
+
const legacyProject = e.property('project');
|
|
646
|
+
const legacyWorkspace = e.property('bb_workspace');
|
|
647
|
+
const nestedBb = e.property('bakers');
|
|
648
|
+
const nestedExpression = e.expression('bakers');
|
|
649
|
+
const nestedHasSpread = nestedExpression
|
|
650
|
+
? new ConfigSourceEditor(`export default ${nestedExpression};`)
|
|
651
|
+
.source.unknownKeys
|
|
652
|
+
: false;
|
|
653
|
+
let projectExpression = e.expression('project');
|
|
654
|
+
if (projectExpression === 'project' &&
|
|
655
|
+
/const project\s*=\s*local\.project\s*\?\?\s*([^;]+);/.test(e.text)) {
|
|
656
|
+
const fallback = /const project\s*=\s*local\.project\s*\?\?\s*([^;]+);/
|
|
657
|
+
.exec(e.text)?.[1]
|
|
658
|
+
?.trim();
|
|
659
|
+
const literal = fallback
|
|
660
|
+
? new ConfigSourceEditor(`export default { value: ${fallback} };`).property('value')
|
|
661
|
+
: undefined;
|
|
662
|
+
if (literal?.staticallyDetermined &&
|
|
663
|
+
typeof literal.value === 'string') {
|
|
664
|
+
projectExpression = fallback;
|
|
665
|
+
e.text = e.text.replace(/const project\s*=\s*local\.project\s*\?\?\s*[^;]+;/, `const project = ${fallback};`);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
const staticProject = projectExpression
|
|
669
|
+
? new ConfigSourceEditor(`export default { value: ${projectExpression} };`).property('value')
|
|
670
|
+
: undefined;
|
|
671
|
+
if (nestedBb &&
|
|
672
|
+
(nestedBb.nodeType !== 'ObjectExpression' ||
|
|
673
|
+
!nestedBb.staticallyDetermined ||
|
|
674
|
+
nestedHasSpread))
|
|
675
|
+
problem(file, 'unsupported_source_shape', 'bakers', 'A literal bakers object is required.');
|
|
676
|
+
else if (legacyProject &&
|
|
677
|
+
(!staticProject?.staticallyDetermined ||
|
|
678
|
+
typeof staticProject.value !== 'string'))
|
|
679
|
+
problem(file, 'unknown_value', 'project', 'A literal project name is required for migration.');
|
|
680
|
+
else if (legacyWorkspace &&
|
|
681
|
+
(!legacyWorkspace.staticallyDetermined ||
|
|
682
|
+
typeof legacyWorkspace.value !== 'string'))
|
|
683
|
+
problem(file, 'unknown_value', 'bb_workspace', 'A literal workspace name is required for migration.');
|
|
684
|
+
else if (legacyProject || legacyWorkspace) {
|
|
685
|
+
const existingProject = e.property('bakers.project');
|
|
686
|
+
const existingWorkspace = e.property('bakers.workspace');
|
|
687
|
+
if (legacyProject &&
|
|
688
|
+
existingProject &&
|
|
689
|
+
(!existingProject.staticallyDetermined ||
|
|
690
|
+
existingProject.value !== staticProject?.value))
|
|
691
|
+
problem(file, 'conflicting_values', 'project', 'Legacy and nested project names must agree.');
|
|
692
|
+
else if (legacyWorkspace &&
|
|
693
|
+
existingWorkspace &&
|
|
694
|
+
(!existingWorkspace.staticallyDetermined ||
|
|
695
|
+
existingWorkspace.value !== legacyWorkspace.value))
|
|
696
|
+
problem(file, 'conflicting_values', 'bb_workspace', 'Legacy and nested workspace names must agree.');
|
|
697
|
+
else {
|
|
698
|
+
if (legacyProject && !existingProject)
|
|
699
|
+
e.set('bakers.project', projectExpression);
|
|
700
|
+
if (legacyWorkspace && !existingWorkspace)
|
|
701
|
+
e.set('bakers.workspace', e.expression('bb_workspace'));
|
|
702
|
+
e.remove('project');
|
|
703
|
+
e.remove('bb_workspace');
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
fill(file, 'bakers.project', options.project);
|
|
707
|
+
const canonical = new ConfigSourceEditor(renderCommittedConfig({
|
|
708
|
+
project: options.project ?? 'project',
|
|
709
|
+
}));
|
|
710
|
+
const addonPackages = new Set([...e.source.properties]
|
|
711
|
+
.filter(([key]) => /^addons\[\d+\](?:\.use)?$/.test(key))
|
|
712
|
+
.map(([, property]) => property.value)
|
|
713
|
+
.filter((value) => typeof value === 'string'));
|
|
714
|
+
const providers = {
|
|
715
|
+
remote: [
|
|
716
|
+
'@bakers-build/addon-remote-drupal',
|
|
717
|
+
'@bakers-build/addon-fake',
|
|
718
|
+
],
|
|
719
|
+
executor: ['@bakers-build/addon-herdr', '@bakers-build/addon-fake'],
|
|
720
|
+
workspace: [
|
|
721
|
+
'@bakers-build/addon-herdr',
|
|
722
|
+
'@bakers-build/addon-fake',
|
|
723
|
+
'@bakers-build/addon-workspace-git',
|
|
724
|
+
],
|
|
725
|
+
};
|
|
726
|
+
for (const slot of ['remote', 'executor', 'workspace']) {
|
|
727
|
+
if (e.property(slot) ||
|
|
728
|
+
providers[slot]?.some((provider) => addonPackages.has(provider)))
|
|
729
|
+
continue;
|
|
730
|
+
e.set(slot, canonical.expression(slot));
|
|
731
|
+
}
|
|
732
|
+
// Only seed default agent addons into an unversioned conductor config.
|
|
733
|
+
// A versioned config (schema v1+) has already been migrated: agents
|
|
734
|
+
// belong to machine.config.js and the conductor addons[] must not be
|
|
735
|
+
// repopulated with the old agent harnesses on re-run.
|
|
736
|
+
if (!e.property('agent') &&
|
|
737
|
+
!e.property('addons') &&
|
|
738
|
+
!(file.version.effective ?? 0))
|
|
739
|
+
e.set('addons', DEFAULT_CONDUCTOR_AGENT_ADDONS);
|
|
740
|
+
if (options.operation === 'init' &&
|
|
741
|
+
options.inputs?.workspace &&
|
|
742
|
+
!e.property('bakers.workspace'))
|
|
743
|
+
e.set('bakers.workspace', JSON.stringify(options.inputs.workspace));
|
|
744
|
+
if (!e.property('conductor_id') && !e.property('machine_id')) {
|
|
745
|
+
const machineEditor = editors.get(machine.path);
|
|
746
|
+
const machineId = machineEditor?.property('machine_id');
|
|
747
|
+
const userId = machineEditor?.property('user_id');
|
|
748
|
+
const project = e.property('bakers.project');
|
|
749
|
+
if (typeof machineId?.value === 'string' &&
|
|
750
|
+
typeof userId?.value === 'string' &&
|
|
751
|
+
typeof project?.value === 'string')
|
|
752
|
+
e.set('conductor_id', JSON.stringify(`${userId.value}-${machineId.value}-${project.value}`));
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
for (const key of ['max_parallel', 'conductor.max_parallel'])
|
|
756
|
+
move(file, key, machine, 'max_parallel');
|
|
757
|
+
const old = e.property('machine_id');
|
|
758
|
+
const current = e.property('conductor_id');
|
|
759
|
+
if (old) {
|
|
760
|
+
if (current &&
|
|
761
|
+
!sameValue(old, current) &&
|
|
762
|
+
e.expression('machine_id') !== e.expression('conductor_id'))
|
|
763
|
+
problem(file, 'conflicting_values', 'machine_id', 'The deprecated and current conductor identity must agree.', [
|
|
764
|
+
{
|
|
765
|
+
path: file.path,
|
|
766
|
+
key: 'conductor_id',
|
|
767
|
+
line: current.line,
|
|
768
|
+
column: current.column,
|
|
769
|
+
},
|
|
770
|
+
]);
|
|
771
|
+
else if (current)
|
|
772
|
+
e.remove('machine_id');
|
|
773
|
+
else
|
|
774
|
+
e.rename('machine_id', 'conductor_id');
|
|
775
|
+
}
|
|
776
|
+
if (file.kind === 'conductor' &&
|
|
777
|
+
e.text.includes('async function loadMachine()'))
|
|
778
|
+
// The committed conductor config never names this machine's path
|
|
779
|
+
// (AC 4): the replacement is the same portable BB_HOME/HOME
|
|
780
|
+
// expression init renders, not a serialised file:// URL. The match
|
|
781
|
+
// is widened past a single backtick pair so it also catches the
|
|
782
|
+
// nested-backtick form the current template emits.
|
|
783
|
+
e.text = e.text.replace(/import\(`(?:[^`\\]|\\.|`[^`]*`)*\/(?:machine\.config\.js|conductor\.config\.machine\.js)`\)/g, `import(${configImportExpression('machine.config.js')})`);
|
|
784
|
+
e.text = migrateAddonNames(e.text, 'engine').text;
|
|
785
|
+
if (file.kind === 'conductor') {
|
|
786
|
+
// Conductor 0 → 1: remove agent-addon entries from addons[], leaving
|
|
787
|
+
// only remote/executor/workspace provider packages (and any spread
|
|
788
|
+
// expressions such as `...(local.agent_addons ?? [])`). If addons[]
|
|
789
|
+
// becomes empty, remove the key. Idempotent: a file already at v1
|
|
790
|
+
// (effective version set) is left entirely unchanged on re-run.
|
|
791
|
+
// Agent packages are those NOT in the canonical non-agent provider set
|
|
792
|
+
// (same definition as the conductor slot-filling heuristic).
|
|
793
|
+
if (!(file.version.effective ?? 0)) {
|
|
794
|
+
const NON_AGENT_PACKAGES = new Set([
|
|
795
|
+
'@bakers-build/addon-remote-drupal',
|
|
796
|
+
'@bakers-build/addon-herdr',
|
|
797
|
+
'@bakers-build/addon-fake',
|
|
798
|
+
'@bakers-build/addon-workspace-git',
|
|
799
|
+
]);
|
|
800
|
+
const addonsProperty = e.property('addons');
|
|
801
|
+
if (addonsProperty?.staticallyDetermined &&
|
|
802
|
+
addonsProperty.nodeType === 'ArrayExpression') {
|
|
803
|
+
const length = addonsProperty.arrayLength ?? 0;
|
|
804
|
+
const kept = [];
|
|
805
|
+
for (let i = 0; i < length; i++) {
|
|
806
|
+
const entryKey = `addons[${i}]`;
|
|
807
|
+
const entry = e.property(entryKey);
|
|
808
|
+
// Keep non-statically-known entries (e.g. spread) and entries
|
|
809
|
+
// whose package is a known non-agent provider.
|
|
810
|
+
const useProperty = e.property(`${entryKey}.use`) ??
|
|
811
|
+
e.property(`${entryKey}.name`);
|
|
812
|
+
const pkg = entry?.staticallyDetermined && typeof entry.value === 'string'
|
|
813
|
+
? entry.value
|
|
814
|
+
: useProperty?.staticallyDetermined &&
|
|
815
|
+
typeof useProperty.value === 'string'
|
|
816
|
+
? useProperty.value
|
|
817
|
+
: undefined;
|
|
818
|
+
if (pkg === undefined || NON_AGENT_PACKAGES.has(pkg)) {
|
|
819
|
+
const expr = e.expression(entryKey);
|
|
820
|
+
if (expr !== undefined)
|
|
821
|
+
kept.push(expr);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
if (kept.length === 0)
|
|
825
|
+
e.remove('addons');
|
|
826
|
+
else if (kept.length < length)
|
|
827
|
+
e.set('addons', `[${kept.join(', ')}]`);
|
|
828
|
+
}
|
|
829
|
+
// Stamp v1 so subsequent runs skip this block (idempotent).
|
|
830
|
+
e.stamp(1);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
catch {
|
|
836
|
+
problem(file, 'unsupported_source_shape', null, 'The required source spans can be safely rewritten.');
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
// Connection files share one real schema chain. Missing fields use explicit inputs only.
|
|
840
|
+
for (const file of files.filter((f) => f.selected && f.kind === 'connection')) {
|
|
841
|
+
if (protectedFile(file))
|
|
842
|
+
continue;
|
|
843
|
+
let e = editors.get(file.path);
|
|
844
|
+
// An absent connection is rendered wholesale above once the grant's full
|
|
845
|
+
// precondition is met; without it, per-field filling would write an
|
|
846
|
+
// incomplete file (BB-496).
|
|
847
|
+
if (!e && file.kind === 'connection')
|
|
848
|
+
continue;
|
|
849
|
+
if (!e && !file.required)
|
|
850
|
+
continue;
|
|
851
|
+
if (!e)
|
|
852
|
+
e = editor(file);
|
|
853
|
+
if (e.source.shape !== 'object') {
|
|
854
|
+
problem(file, 'unsupported_source_shape', null, 'A supported connection object can be migrated.');
|
|
855
|
+
continue;
|
|
856
|
+
}
|
|
857
|
+
try {
|
|
858
|
+
if (file.kind === 'connection') {
|
|
859
|
+
fill(file, 'site.base_url', options.inputs?.baseUrl);
|
|
860
|
+
fill(file, 'workspace_id', options.inputs?.workspaceId);
|
|
861
|
+
if (!e.property('plugins') &&
|
|
862
|
+
(options.inputs?.clientId || options.inputs?.secret))
|
|
863
|
+
e.set('plugins', "[{ plugin: '@bakers-build/addon-essentials' }, { plugin: '@dropsh/plugin-oauth2', export: 'oauth2Plugin', with: { id: 'bakers', default: true, type: 'oauth2_client_credentials', scope: 'bb:session' } }]");
|
|
864
|
+
const prefix = oauthPrefix(e);
|
|
865
|
+
if (prefix) {
|
|
866
|
+
fill(file, `${prefix}.with.client_id`, options.inputs?.clientId);
|
|
867
|
+
fill(file, `${prefix}.with.client_secret`, options.inputs?.secret);
|
|
868
|
+
const url = e.property('site.base_url')?.value;
|
|
869
|
+
if (typeof url === 'string' &&
|
|
870
|
+
!e.property(`${prefix}.with.token_url`))
|
|
871
|
+
e.set(`${prefix}.with.token_url`, JSON.stringify(`${url.replace(/\/$/, '')}/oauth/token`));
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
if (file.kind === 'project-connection')
|
|
875
|
+
migrateGeneratedProject(e);
|
|
876
|
+
const version = file.version.effective ?? 1;
|
|
877
|
+
if (version < BB_CONFIG_SCHEMA_VERSION)
|
|
878
|
+
e.text = applyMigrations(e.text, version, BB_CONFIG_SCHEMA_VERSION);
|
|
879
|
+
e.text = migrateAddonNames(e.text, 'connection').text;
|
|
880
|
+
if (file.kind === 'project-connection' && local) {
|
|
881
|
+
const prefix = oauthPrefix(e);
|
|
882
|
+
const secret = prefix
|
|
883
|
+
? e.property(`${prefix}.with.client_secret`)
|
|
884
|
+
: undefined;
|
|
885
|
+
if (secret?.staticallyDetermined && secret.value) {
|
|
886
|
+
move(file, `${prefix}.with.client_secret`, local, 'oauth.client_secret');
|
|
887
|
+
if (!e.property(`${prefix}.with.client_secret`)) {
|
|
888
|
+
e.set(`${prefix}.with.client_secret`, 'local.oauth?.client_secret');
|
|
889
|
+
if (!/const local\s*=|let local\s*=/.test(e.text))
|
|
890
|
+
e.text =
|
|
891
|
+
"let local = {};\ntry { local = (await import('./conductor.config.local.js')).default; } catch (error) { if (error.code !== 'ERR_MODULE_NOT_FOUND') throw error; }\n" +
|
|
892
|
+
e.text;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
e.stamp(4);
|
|
897
|
+
}
|
|
898
|
+
catch {
|
|
899
|
+
problem(file, 'unsupported_source_shape', null, 'The supported connection migration chain completes safely.');
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
if (machine.selected &&
|
|
903
|
+
!protectedFile(machine) &&
|
|
904
|
+
editors.has(machine.path) &&
|
|
905
|
+
!editor(machine).property('max_parallel'))
|
|
906
|
+
editor(machine).set('max_parallel', '1');
|
|
907
|
+
const overlay = new Map([...editors].map(([path, e]) => [path, e.text]));
|
|
908
|
+
for (const [path] of original)
|
|
909
|
+
if (!editors.has(path))
|
|
910
|
+
overlay.set(path, null);
|
|
911
|
+
const proposed = validateConfigSources(inventoryOptions, overlay, files);
|
|
912
|
+
report.findings = [
|
|
913
|
+
...errors,
|
|
914
|
+
...proposed.findings.filter((f) => !errors.some((e) => e.path === f.path && e.code === f.code && e.key === f.key)),
|
|
915
|
+
];
|
|
916
|
+
report.verification.skipped = proposed.verification.skipped;
|
|
917
|
+
const writes = [];
|
|
918
|
+
for (const file of files.filter((f) => f.selected)) {
|
|
919
|
+
const before = original.get(file.path) ?? null;
|
|
920
|
+
const after = overlay.has(file.path)
|
|
921
|
+
? (overlay.get(file.path) ?? null)
|
|
922
|
+
: before;
|
|
923
|
+
if (before === after)
|
|
924
|
+
continue;
|
|
925
|
+
writes.push({
|
|
926
|
+
path: file.path,
|
|
927
|
+
before,
|
|
928
|
+
after,
|
|
929
|
+
mode: before !== null
|
|
930
|
+
? statSync(file.path).mode & 0o777
|
|
931
|
+
: file.kind === 'project-connection' || file.kind === 'conductor'
|
|
932
|
+
? 0o644
|
|
933
|
+
: 0o600,
|
|
934
|
+
});
|
|
935
|
+
report.changes.push({
|
|
936
|
+
path: file.path,
|
|
937
|
+
layer: file.layer,
|
|
938
|
+
before_version: file.version.effective,
|
|
939
|
+
after_version: after === null
|
|
940
|
+
? null
|
|
941
|
+
: ['machine', 'legacy-machine'].includes(file.kind)
|
|
942
|
+
? 3
|
|
943
|
+
: ['connection', 'project-connection'].includes(file.kind)
|
|
944
|
+
? 4
|
|
945
|
+
: file.kind === 'conductor'
|
|
946
|
+
? 1
|
|
947
|
+
: null,
|
|
948
|
+
diff: `--- ${file.path}\n+++ ${file.path}\n${before === null
|
|
949
|
+
? ''
|
|
950
|
+
: redactConfigSource(before)
|
|
951
|
+
.split('\n')
|
|
952
|
+
.map((l) => `- ${l}`)
|
|
953
|
+
.join('\n')}\n${after === null
|
|
954
|
+
? ''
|
|
955
|
+
: redactConfigSource(after)
|
|
956
|
+
.split('\n')
|
|
957
|
+
.map((l) => `+ ${l}`)
|
|
958
|
+
.join('\n')}`,
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
const fingerprints = new Map(files.map((file) => [file.path, file.fingerprint]));
|
|
962
|
+
finalizeConfigReport(report);
|
|
963
|
+
return { report, writes, fingerprints };
|
|
964
|
+
}
|
|
965
|
+
const PORTS_ADDON = '@bakers-build/addon-ports';
|
|
966
|
+
/** A package known to fill no agent slot is not a harness; an unknown one may be. */
|
|
967
|
+
function isHarness(pkg) {
|
|
968
|
+
return CONDUCTOR_ADDON_SLOTS[pkg]?.includes('agent') ?? true;
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
971
|
+
* Source text, package and alias of each element of the array at `key`; the
|
|
972
|
+
* alias is '' when absent and undefined when not a literal.
|
|
973
|
+
*/
|
|
974
|
+
function arrayEntries(e, key) {
|
|
975
|
+
const length = e.property(key)?.arrayLength ?? 0;
|
|
976
|
+
return Array.from({ length }, (_, i) => {
|
|
977
|
+
const item = `${key}[${i}]`;
|
|
978
|
+
const alias = e.property(`${item}.alias`);
|
|
979
|
+
return {
|
|
980
|
+
text: e.expression(item) ?? '',
|
|
981
|
+
pkg: e.property(item)?.value ??
|
|
982
|
+
e.property(`${item}.use`)?.value ??
|
|
983
|
+
e.property(`${item}.name`)?.value,
|
|
984
|
+
alias: !alias ? '' : alias.staticallyDetermined ? alias.value : undefined,
|
|
985
|
+
};
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
/**
|
|
989
|
+
* Move the retired machine keys into the top-level `addons[]`: each
|
|
990
|
+
* `agents.addons` entry not already listed with the same text, and `ports`
|
|
991
|
+
* as the `@bakers-build/addon-ports` entry's `with`. Only literal arrays/objects
|
|
992
|
+
* move; an opaque value stays for `bakers config check` to report.
|
|
993
|
+
*/
|
|
994
|
+
function migrateMachineAddons(e) {
|
|
995
|
+
const addons = e.property('addons');
|
|
996
|
+
if (addons && addons.nodeType !== 'ArrayExpression')
|
|
997
|
+
return;
|
|
998
|
+
// A spread has no element span to carry over; rebuilding would drop it.
|
|
999
|
+
const opaque = (key) => arrayEntries(e, key).some((x) => !x.text);
|
|
1000
|
+
if (opaque('addons') || opaque('agents.addons'))
|
|
1001
|
+
return;
|
|
1002
|
+
const agentAddons = e.property('agents.addons');
|
|
1003
|
+
if (agentAddons?.nodeType === 'ArrayExpression') {
|
|
1004
|
+
const listed = arrayEntries(e, 'addons');
|
|
1005
|
+
const merged = [...listed];
|
|
1006
|
+
for (const x of arrayEntries(e, 'agents.addons')) {
|
|
1007
|
+
if (merged.some((y) => y.text === x.text))
|
|
1008
|
+
continue;
|
|
1009
|
+
// Possibly the same package as a listed entry under a possibly equal
|
|
1010
|
+
// alias but other text: moving would drop or override settings, so leave
|
|
1011
|
+
// it for `bakers config check`. A variable package may be any harness; two
|
|
1012
|
+
// variables still move, as distinct entries. Entries of agents.addons
|
|
1013
|
+
// already stood together, so they are not compared with each other.
|
|
1014
|
+
const clash = listed.some((y) => (typeof x.pkg === 'string' && typeof y.pkg === 'string'
|
|
1015
|
+
? x.pkg === y.pkg
|
|
1016
|
+
: typeof x.pkg === 'string'
|
|
1017
|
+
? isHarness(x.pkg)
|
|
1018
|
+
: typeof y.pkg === 'string' && isHarness(y.pkg)) &&
|
|
1019
|
+
(x.alias === undefined ||
|
|
1020
|
+
y.alias === undefined ||
|
|
1021
|
+
x.alias === y.alias));
|
|
1022
|
+
if (clash)
|
|
1023
|
+
return movePorts(e);
|
|
1024
|
+
merged.push(x);
|
|
1025
|
+
}
|
|
1026
|
+
e.set('addons', `[${merged.map((x) => x.text).join(', ')}]`);
|
|
1027
|
+
e.remove('agents.addons');
|
|
1028
|
+
const agents = e.property('agents');
|
|
1029
|
+
if (agents?.nodeType === 'ObjectExpression' &&
|
|
1030
|
+
![...e.source.properties.keys()].some((k) => k.startsWith('agents.')))
|
|
1031
|
+
e.remove('agents');
|
|
1032
|
+
}
|
|
1033
|
+
movePorts(e);
|
|
1034
|
+
}
|
|
1035
|
+
function movePorts(e) {
|
|
1036
|
+
const ports = e.property('ports');
|
|
1037
|
+
if (ports?.nodeType !== 'ObjectExpression')
|
|
1038
|
+
return;
|
|
1039
|
+
const settings = e.expression('ports');
|
|
1040
|
+
const listed = arrayEntries(e, 'addons');
|
|
1041
|
+
const at = listed.findIndex((x) => x.pkg === PORTS_ADDON);
|
|
1042
|
+
const entry = `{ use: '${PORTS_ADDON}', with: ${settings} }`;
|
|
1043
|
+
if (at < 0) {
|
|
1044
|
+
e.set('addons', `[${[...listed.map((x) => x.text), entry].join(', ')}]`);
|
|
1045
|
+
}
|
|
1046
|
+
else if (e.property(`addons[${at}]`)?.nodeType === 'Literal') {
|
|
1047
|
+
e.set(`addons[${at}]`, entry);
|
|
1048
|
+
}
|
|
1049
|
+
else if (!e.property(`addons[${at}].with`)) {
|
|
1050
|
+
e.set(`addons[${at}].with`, settings);
|
|
1051
|
+
}
|
|
1052
|
+
else
|
|
1053
|
+
return; // both set: leave `ports` for the check to report
|
|
1054
|
+
e.remove('ports');
|
|
1055
|
+
}
|
|
1056
|
+
function oauthPrefix(e) {
|
|
1057
|
+
return [...e.source.properties]
|
|
1058
|
+
.find(([key, p]) => /^plugins\[\d+\]\.plugin$/.test(key) &&
|
|
1059
|
+
p.value === '@dropsh/plugin-oauth2')?.[0]
|
|
1060
|
+
.replace(/\.plugin$/, '');
|
|
1061
|
+
}
|
|
1062
|
+
function replaceGeneratedMachineWiring(e, machine, file, local, localFile, machineSelected, inputs, problem) {
|
|
1063
|
+
if (!e.text.includes('async function loadMachine()'))
|
|
1064
|
+
return;
|
|
1065
|
+
const mappings = {
|
|
1066
|
+
baseUrl: 'base_url',
|
|
1067
|
+
clientId: 'client_id',
|
|
1068
|
+
clientSecret: 'client_secret',
|
|
1069
|
+
workspaceId: 'workspace_id',
|
|
1070
|
+
};
|
|
1071
|
+
const resolved = new Map();
|
|
1072
|
+
for (const [name, key] of Object.entries(mappings)) {
|
|
1073
|
+
const declaration = new RegExp(`const ${name} = ([^;]+);`).exec(e.text);
|
|
1074
|
+
if (!declaration)
|
|
1075
|
+
continue;
|
|
1076
|
+
const literal = new ConfigSourceEditor(`export default {value:${declaration[1]}};`).property('value');
|
|
1077
|
+
if (literal?.staticallyDetermined && literal.value !== undefined)
|
|
1078
|
+
resolved.set(name, JSON.stringify(literal.value));
|
|
1079
|
+
else if (new RegExp(`^(?:local\\.[^;]+\\?\\? )?machine\\.${key}(?: \\?\\? 'bakers-agent')?$`).test(declaration[1] ?? '')) {
|
|
1080
|
+
const localKey = key.startsWith('client_') ? `oauth.${key}` : key;
|
|
1081
|
+
const override = local?.property(localKey);
|
|
1082
|
+
const parent = localKey.startsWith('oauth.')
|
|
1083
|
+
? local?.property('oauth')
|
|
1084
|
+
: undefined;
|
|
1085
|
+
if (local?.source.unknownKeys ||
|
|
1086
|
+
(parent && !parent.staticallyDetermined) ||
|
|
1087
|
+
(override && !override.staticallyDetermined)) {
|
|
1088
|
+
problem(localFile ?? file, 'unknown_value', localKey, 'The generated home-local override is a known literal.');
|
|
1089
|
+
continue;
|
|
1090
|
+
}
|
|
1091
|
+
const p = override?.value != null ? override : machine.property(key);
|
|
1092
|
+
const input = key === 'workspace_id' ? inputs?.workspaceId : undefined;
|
|
1093
|
+
const value = p?.staticallyDetermined && p.value != null
|
|
1094
|
+
? p.value
|
|
1095
|
+
: (input ?? (key === 'client_id' ? 'bakers-agent' : undefined));
|
|
1096
|
+
if (value !== undefined) {
|
|
1097
|
+
resolved.set(name, JSON.stringify(value));
|
|
1098
|
+
// This generated expression proves the override won before migration.
|
|
1099
|
+
// Removing the shadowed machine value requires its selected write scope.
|
|
1100
|
+
if (override?.value != null && machine.property(key) && machineSelected)
|
|
1101
|
+
machine.remove(key);
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
for (const key of [...e.source.properties.keys()]) {
|
|
1106
|
+
const expr = e.expression(key);
|
|
1107
|
+
const value = expr ? resolved.get(expr) : undefined;
|
|
1108
|
+
if (value !== undefined)
|
|
1109
|
+
e.set(key, value);
|
|
1110
|
+
}
|
|
1111
|
+
for (const [name, value] of resolved)
|
|
1112
|
+
e.text = e.text.replace(new RegExp(`const ${name} = [^;]+;`), `const ${name} = ${value};`);
|
|
1113
|
+
if ([...e.source.properties.keys()].some((key) => Object.hasOwn(mappings, e.expression(key) ?? ''))) {
|
|
1114
|
+
problem(file, 'unknown_value', null, 'Generated connection values require known inputs before removing machine ownership.');
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
if ([...e.source.properties.keys()].some((key) => /\bmachine\./.test(e.expression(key) ?? '')))
|
|
1118
|
+
return;
|
|
1119
|
+
e.text = e.text
|
|
1120
|
+
.replace(/async function loadMachine\(\) \{[\s\S]*?\n\}\n/, '')
|
|
1121
|
+
.replace(/const machine = await loadMachine\(\);\n?/, '');
|
|
1122
|
+
}
|
|
1123
|
+
function migrateGeneratedProject(e) {
|
|
1124
|
+
if (!e.text.includes('async function loadMachine()'))
|
|
1125
|
+
return;
|
|
1126
|
+
if ([...e.source.properties.keys()].some((key) => /\bmachine\./.test(e.expression(key) ?? '')))
|
|
1127
|
+
return;
|
|
1128
|
+
if (!e.property('workspace_id'))
|
|
1129
|
+
e.set('workspace_id', 'local.workspace_id ?? connection.workspace_id');
|
|
1130
|
+
// Both imports land in a COMMITTED file, so neither may carry this machine's
|
|
1131
|
+
// path — the connection import leaks ~/.bakers just as the machine one does.
|
|
1132
|
+
e.text = e.text
|
|
1133
|
+
.replace(/async function loadMachine\(\) \{[\s\S]*?\n\}\n/, `async function loadConnection() { return (await import(${configImportExpression('bakers.config.js')})).default; }\n`)
|
|
1134
|
+
.replace('const machine = await loadMachine();', "const connection = await loadConnection();\nconst auth = connection.plugins.find(entry => entry.plugin === '@dropsh/plugin-oauth2')?.with;")
|
|
1135
|
+
.replace(/machine\.base_url/g, 'connection.site.base_url')
|
|
1136
|
+
.replace(/machine\.client_id(?: \?\? 'bakers-agent')?/g, 'auth?.client_id')
|
|
1137
|
+
.replace(/machine\.client_secret/g, 'auth?.client_secret')
|
|
1138
|
+
.replace(/machine\.workspace_id/g, 'connection.workspace_id');
|
|
1139
|
+
if (/\bmachine\.(?:user_id|machine_id)/.test(e.text))
|
|
1140
|
+
e.text =
|
|
1141
|
+
`const machine = (await import(${configImportExpression('machine.config.js')})).default;\n` +
|
|
1142
|
+
e.text;
|
|
1143
|
+
}
|
|
1144
|
+
/** Fingerprints are checked for the whole inventory before creating any backup. */
|
|
1145
|
+
/**
|
|
1146
|
+
* Best-effort pretty-print of a written `.js` config through biome. The planner
|
|
1147
|
+
* builds config text with surgical `ConfigSourceEditor` inserts (to preserve
|
|
1148
|
+
* operator edits, comments and exact values), which can leave a freshly inserted
|
|
1149
|
+
* line mis-indented; a format pass normalizes the whole file. Explicit options
|
|
1150
|
+
* (space/2/single) make the result deterministic regardless of any biome.json
|
|
1151
|
+
* discovered near the target — a user's `~/.bakers/` has none, and biome's built-in
|
|
1152
|
+
* defaults (tabs, double quotes) would clash with the bakers.build config style.
|
|
1153
|
+
*
|
|
1154
|
+
* NEVER fails the upgrade: biome is a dev/monorepo dependency, absent on a global
|
|
1155
|
+
* install. If it cannot be resolved or errors, the (already-correct) file stands.
|
|
1156
|
+
*/
|
|
1157
|
+
function formatConfigFileBestEffort(path) {
|
|
1158
|
+
if (!path.endsWith('.js'))
|
|
1159
|
+
return;
|
|
1160
|
+
try {
|
|
1161
|
+
const require = createRequire(import.meta.url);
|
|
1162
|
+
const biomeBin = require.resolve('@biomejs/biome/bin/biome');
|
|
1163
|
+
execFileSync(process.execPath, [
|
|
1164
|
+
biomeBin,
|
|
1165
|
+
'format',
|
|
1166
|
+
'--write',
|
|
1167
|
+
'--indent-style=space',
|
|
1168
|
+
'--indent-width=2',
|
|
1169
|
+
'--javascript-formatter-quote-style=single',
|
|
1170
|
+
path,
|
|
1171
|
+
], { stdio: 'ignore' });
|
|
1172
|
+
}
|
|
1173
|
+
catch {
|
|
1174
|
+
// biome unavailable or non-zero exit — leave the surgically-edited file as is.
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
export function applyConfigPlan(plan) {
|
|
1178
|
+
const report = plan.report;
|
|
1179
|
+
if (report.findings.some((f) => f.severity === 'error'))
|
|
1180
|
+
return report;
|
|
1181
|
+
const add = (code, path, expected, remediation) => {
|
|
1182
|
+
const file = report.files.find((f) => f.path === path);
|
|
1183
|
+
report.findings.push({
|
|
1184
|
+
code,
|
|
1185
|
+
severity: 'error',
|
|
1186
|
+
stage: 'write',
|
|
1187
|
+
path,
|
|
1188
|
+
key: null,
|
|
1189
|
+
expected,
|
|
1190
|
+
observed: null,
|
|
1191
|
+
owner: file?.layer ?? 'project',
|
|
1192
|
+
related: [],
|
|
1193
|
+
statically_determined: true,
|
|
1194
|
+
remediation,
|
|
1195
|
+
recheck: configRecheckCommand((report.requested_scope ?? 'all'), report.roots.project),
|
|
1196
|
+
});
|
|
1197
|
+
};
|
|
1198
|
+
for (const [path, expected] of plan.fingerprints) {
|
|
1199
|
+
try {
|
|
1200
|
+
if (readFingerprint(path) !== expected)
|
|
1201
|
+
add('changed_since_planning', path, 'Source is unchanged since planning.', 'Re-run bakers config upgrade with the selected scope.');
|
|
1202
|
+
}
|
|
1203
|
+
catch {
|
|
1204
|
+
add('changed_since_planning', path, 'Source remains readable.', 'Restore access and re-run the planner.');
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
if (report.findings.some((f) => f.severity === 'error'))
|
|
1208
|
+
return finalizeConfigReport(report);
|
|
1209
|
+
const applied = [];
|
|
1210
|
+
const temps = [];
|
|
1211
|
+
try {
|
|
1212
|
+
for (const write of plan.writes) {
|
|
1213
|
+
mkdirSync(dirname(write.path), { recursive: true, mode: 0o700 });
|
|
1214
|
+
if (write.before !== null) {
|
|
1215
|
+
let backup = `${write.path}.bakers.bak`;
|
|
1216
|
+
while (existsSync(backup))
|
|
1217
|
+
backup = `${write.path}.${randomUUID()}.bakers.bak`;
|
|
1218
|
+
writeFileSync(backup, write.before, { mode: 0o600, flag: 'wx' });
|
|
1219
|
+
}
|
|
1220
|
+
if (write.after === null) {
|
|
1221
|
+
rmSync(write.path);
|
|
1222
|
+
applied.push(write);
|
|
1223
|
+
continue;
|
|
1224
|
+
}
|
|
1225
|
+
const temp = `${write.path}.${randomUUID()}.tmp`;
|
|
1226
|
+
temps.push(temp);
|
|
1227
|
+
writeFileSync(temp, write.after, { mode: write.mode, flag: 'wx' });
|
|
1228
|
+
renameSync(temp, write.path);
|
|
1229
|
+
applied.push(write);
|
|
1230
|
+
// Pretty-print the surgically-edited file (best-effort), then re-assert mode.
|
|
1231
|
+
formatConfigFileBestEffort(write.path);
|
|
1232
|
+
chmodSync(write.path, write.mode);
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
catch {
|
|
1236
|
+
const remaining = [];
|
|
1237
|
+
for (const write of applied.reverse()) {
|
|
1238
|
+
try {
|
|
1239
|
+
if (write.before === null)
|
|
1240
|
+
rmSync(write.path, { force: true });
|
|
1241
|
+
else {
|
|
1242
|
+
const temp = `${write.path}.${randomUUID()}.restore`;
|
|
1243
|
+
temps.push(temp);
|
|
1244
|
+
writeFileSync(temp, write.before, { mode: 0o600, flag: 'wx' });
|
|
1245
|
+
renameSync(temp, write.path);
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
catch {
|
|
1249
|
+
remaining.push(write.path);
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
for (const temp of temps)
|
|
1253
|
+
try {
|
|
1254
|
+
rmSync(temp, { force: true });
|
|
1255
|
+
}
|
|
1256
|
+
catch {
|
|
1257
|
+
/* The failure report names the affected target. */
|
|
1258
|
+
}
|
|
1259
|
+
add('write_failure', remaining[0] ?? plan.writes[applied.length]?.path ?? report.roots.machine, 'All planned writes complete.', remaining.length
|
|
1260
|
+
? `Restore remaining changed files from private backups: ${remaining.join(', ')}.`
|
|
1261
|
+
: 'Caught write failure; changed configuration files were restored. Recheck permissions and retry.');
|
|
1262
|
+
}
|
|
1263
|
+
return finalizeConfigReport(report);
|
|
1264
|
+
}
|