@bakers-build/bakers 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 keytec GmbH
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,77 @@
1
- # Temporary Holding Version
1
+ # @bakers-build/bakers
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The bakers.build conductor + client CLI, published as a single self-contained npm
4
+ package. Install it globally and run `bakers` anywhere — no repo clone, no local
5
+ build (no `pnpm`, no `tsc`).
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install -g @bakers-build/bakers
11
+ ```
12
+
13
+ > The unscoped name `bakers` is taken on npm, so the package is scoped `@bakers-build/bakers`.
14
+ > The executable stays `bakers` (via the `bin` entry), so every command is just
15
+ > `bakers …`.
16
+
17
+ ```bash
18
+ bakers --help
19
+ ```
20
+
21
+ ## What it does
22
+
23
+ `bakers` is the conductor node and client for a bakers.build network: it registers with a
24
+ Drupal control plane, claims tickets over JSON:API, and runs agents through
25
+ spawn plugins (Claude, Codex, herdr, and a deterministic fake for tests). Point
26
+ it at a control plane with a `conductor.config.js` (scaffold one with
27
+ `bakers init`).
28
+
29
+ ## `bakers ui` deep links
30
+
31
+ `bakers ui` is the interactive terminal cockpit, and it takes an optional target so you open
32
+ the thing you are working on directly:
33
+
34
+ ```bash
35
+ bakers ui BB-221 # a ticket by identifier (case-insensitive)
36
+ bakers ui project:bakers # a project; also ticket:<uuid> · run:<uuid>
37
+ bakers ui --here # this branch's ticket, else this repo's project
38
+ bakers ui BB-221 --view teaser # a compact block instead of the tabbed detail
39
+ ```
40
+
41
+ At most **one** target source (the positional, `--ticket`, `--project`, `--run`, `--here`);
42
+ two exit non-zero naming both. `--here` reads the current branch for a ticket identifier and
43
+ otherwise resolves this repo's git remote against the projects that claim it — several
44
+ claimants open a picker. `bakers ui --help` lists every form.
45
+
46
+ ## Plugins
47
+
48
+ The four spawn plugins ship **inside** this package — nothing extra to install.
49
+ Import them from the `@bakers-build/bakers/plugins` entry in your `conductor.config.js`:
50
+
51
+ ```js
52
+ import {
53
+ drupalRemote,
54
+ herdrWorkspace,
55
+ claudeAgent,
56
+ herdrExecutor,
57
+ } from '@bakers-build/bakers/plugins';
58
+ ```
59
+
60
+ Plugin authors can build against the API surface at `@bakers-build/bakers/plugin`.
61
+
62
+ ## Releasing (maintainers)
63
+
64
+ Publishing is automated: a `v*.*.*` tag publishes `latest`, and every develop
65
+ deploy publishes a test build under `next`. Both need an `NPM_TOKEN` CI/CD
66
+ variable (masked, protected) with publish rights to the `@bakers-build` scope.
67
+
68
+ To reproduce the published artifact locally:
69
+
70
+ ```bash
71
+ pnpm run prepare:publish # stage conductor/publish/ + each plugins/*/publish/
72
+ pnpm run verify:package # pack the staged set, install it in a temp dir, smoke-test
73
+ ```
74
+
75
+ ## License
76
+
77
+ MIT — see [LICENSE](./LICENSE).
package/bin/bakers ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ // BB-201: the `bakers` entrypoint lives in the meta package now (AC-1). It runs
3
+ // the plugin host, which dynamically mounts the ui/conductor/dropsh command
4
+ // plugins — conductor is no longer the CLI owner.
5
+ import('../dist/src/host.js').then((m) => m.main(process.argv));
@@ -0,0 +1,14 @@
1
+ import type { BbCommandHost, ConfigReport } from '@bakers-build/core';
2
+ import { type Command } from 'commander';
3
+ export interface ConfigCommandDeps {
4
+ cwd?: string;
5
+ home?: string;
6
+ isTTY?: boolean;
7
+ env?: NodeJS.ProcessEnv;
8
+ host?: BbCommandHost;
9
+ stdout?: (text: string) => void;
10
+ prompt?: (label: string, secret?: boolean) => Promise<string>;
11
+ stateDir?: string;
12
+ }
13
+ export declare function renderConfigReport(report: ConfigReport, dryRun?: boolean): string;
14
+ export declare function registerConfig(program: Command, deps?: ConfigCommandDeps): void;
@@ -0,0 +1,523 @@
1
+ import { hostname } from 'node:os';
2
+ import { dirname, join } from 'node:path';
3
+ import { createInterface } from 'node:readline';
4
+ import { pathToFileURL } from 'node:url';
5
+ import { homeBbDir, readConfigAddons, reportLoadedBbConfigFields, resolveBbConnectionSelection, scopeAddons, } from '@bakers-build/core';
6
+ import { Option } from 'commander';
7
+ /** Terminal-only input. Secret characters are never echoed. */
8
+ async function promptInput(label, secret = false) {
9
+ const rl = createInterface({
10
+ input: process.stdin,
11
+ output: process.stderr,
12
+ terminal: true,
13
+ });
14
+ const terminal = rl;
15
+ let muted = false;
16
+ terminal._writeToOutput = (text) => {
17
+ if (!muted)
18
+ process.stderr.write(text);
19
+ };
20
+ try {
21
+ return await new Promise((resolve, reject) => {
22
+ rl.once('SIGINT', () => reject(new Error('cancelled')));
23
+ rl.question(`${label}: `, (answer) => {
24
+ if (secret)
25
+ process.stderr.write('\n');
26
+ resolve(answer.trim());
27
+ });
28
+ muted = secret;
29
+ });
30
+ }
31
+ finally {
32
+ rl.close();
33
+ }
34
+ }
35
+ const SCOPE_FILE = {
36
+ machine: '~/.bakers/machine.config.js',
37
+ project: '.bakers/conductor.config.js',
38
+ };
39
+ /**
40
+ * Report each machine/project `addons[]` entry listed in a scope its addon
41
+ * does not declare. Resolving an addon imports its preset, so an offline
42
+ * check (which never imports source) only notes that it skipped this.
43
+ */
44
+ async function checkAddonScopes(report, online, cwd) {
45
+ const files = report.files.filter((f) => f.selected &&
46
+ f.state !== 'absent' &&
47
+ (f.kind === 'machine' || f.kind === 'conductor'));
48
+ if (files.length === 0)
49
+ return;
50
+ const base = {
51
+ stage: 'schema',
52
+ key: 'addons',
53
+ related: [],
54
+ statically_determined: false,
55
+ recheck: 'bakers config check --scope all --online',
56
+ };
57
+ if (!online) {
58
+ report.findings.push({
59
+ ...base,
60
+ code: 'addon_scope_skipped',
61
+ severity: 'info',
62
+ path: files[0].path,
63
+ expected: 'Each addon is listed only in a scope it declares.',
64
+ observed: null,
65
+ owner: files[0].layer,
66
+ remediation: 'Addon scopes are not checked offline; run bakers config check --online to resolve each addons[] entry.',
67
+ });
68
+ return;
69
+ }
70
+ const hostBases = [import.meta.url];
71
+ const { findings } = await scopeAddons(await Promise.all(files.map(async (f) => ({
72
+ scope: f.kind === 'machine' ? 'machine' : 'project',
73
+ path: f.path,
74
+ entries: await readConfigAddons(f.path),
75
+ bases: [
76
+ pathToFileURL(`${dirname(f.path)}/`).href,
77
+ ...(f.kind === 'machine' ? [] : [pathToFileURL(`${cwd}/`).href]),
78
+ ...hostBases,
79
+ ],
80
+ }))), { unresolved: 'skip', logger: { warn: () => { } } });
81
+ for (const finding of findings) {
82
+ if (finding.code === 'addon_with_invalid') {
83
+ report.findings.push({
84
+ ...base,
85
+ code: 'addon_with_invalid',
86
+ severity: 'error',
87
+ path: finding.path,
88
+ key: `addons[${finding.index}].with${finding.key ? `.${finding.key}` : ''}`,
89
+ expected: `'${finding.spec}' setting ${finding.key || 'with'} ${finding.message}.`,
90
+ observed: finding.spec,
91
+ owner: finding.scope,
92
+ remediation: `Fix addons[${finding.index}].with${finding.key ? `.${finding.key}` : ''} of ${finding.path}; see the addon's documentation.`,
93
+ });
94
+ continue;
95
+ }
96
+ const allowed = finding.allowed.map((s) => SCOPE_FILE[s]).join(' or ');
97
+ report.findings.push({
98
+ ...base,
99
+ code: 'addon_scope_not_allowed',
100
+ severity: 'error',
101
+ path: finding.path,
102
+ expected: `'${finding.spec}' may be configured only in ${allowed}.`,
103
+ observed: finding.spec,
104
+ owner: finding.scope,
105
+ remediation: `Move '${finding.spec}' from addons[] of ${finding.path} to addons[] of ${allowed}.`,
106
+ });
107
+ }
108
+ if (findings.length > 0 && report.result !== 'failed')
109
+ report.result = 'blocked';
110
+ }
111
+ export function renderConfigReport(report, dryRun = false) {
112
+ const lines = [
113
+ `bakers config ${report.operation}: ${report.result}${dryRun ? ' (dry run; no files written)' : ''}`,
114
+ `Scope: ${report.resolved_scopes.join(', ')}`,
115
+ `Machine root: ${report.roots.machine}`,
116
+ `Connection root: ${report.roots.connection}`,
117
+ `Project root: ${report.roots.project ?? 'not applicable'}`,
118
+ ];
119
+ for (const file of report.files.filter((file) => file.selected))
120
+ lines.push(`${file.layer}: ${file.path} (${file.state}; schema ${file.version.effective ?? 'unversioned'} → ${file.version.current ?? 'unversioned'}; ${file.fingerprint ?? 'absent'})`);
121
+ if (report.machine_policy) {
122
+ const { tunnel, source } = report.machine_policy;
123
+ lines.push(`Machine tunnel: ${tunnel === null ? 'unresolved' : tunnel} (from ${source})`);
124
+ }
125
+ if (report.connection_resolution) {
126
+ const selected = report.connection_resolution;
127
+ lines.push(`Connection: ${selected.id} (${selected.selected_by}) — ${selected.path}`);
128
+ for (const field of selected.fields)
129
+ lines.push(` ${field.key}: ${String(field.value)} (from ${field.source})`);
130
+ }
131
+ for (const change of report.changes)
132
+ lines.push(`${change.path}: schema ${change.before_version ?? 'absent/unversioned'} → ${change.after_version ?? 'unversioned'}\n${change.diff}`);
133
+ for (const finding of report.findings)
134
+ lines.push(`${finding.severity} ${finding.code}: ${finding.path}${finding.key ? ` [${finding.key}]` : ''}\n ${finding.expected}\n ${finding.remediation}\n Recheck: ${finding.recheck}`);
135
+ lines.push(`Offline: ${report.verification.offline.status}. Online: ${report.verification.online.status} — ${report.verification.online.reason}`);
136
+ if (report.operation === 'init' && report.result !== 'ok')
137
+ lines.push('Setup incomplete. Prepared files remain available for repair; inspect the file states above.');
138
+ if (report.operation === 'upgrade' &&
139
+ report.verification.readiness === 'unverified')
140
+ lines.push('Connection readiness is unverified; successful migration does not verify server access.');
141
+ return `${lines.join('\n')}\n`;
142
+ }
143
+ /**
144
+ * Attaches one `config <operation>` command to `parent` (either the `config`
145
+ * group or, for the `bakers init` shortcut, `program` itself). `hiddenScope`
146
+ * exists only for that shortcut: `bakers init` resolves to machine + connection
147
+ * without ever showing `--scope` or its value anywhere (not in `--help`, not
148
+ * in a default), since a human running `bakers init` never chooses or sees a
149
+ * scope at all — while `bakers config init` keeps asking for one (or requiring
150
+ * `--scope` explicitly) exactly as before.
151
+ */
152
+ function buildConfigCommand(parent, operation, deps, hiddenScope, description) {
153
+ {
154
+ const command = parent
155
+ .command(operation)
156
+ .description(description ??
157
+ (operation === 'init'
158
+ ? 'initialize or repair selected configuration and verify readiness'
159
+ : operation === 'upgrade'
160
+ ? 'migrate selected configuration without changing existing values'
161
+ : 'validate configuration offline; optionally verify online access'))
162
+ .option('--scope <scope>', 'machine | connection | project | all; init requires a scope')
163
+ .addOption(new Option('--format <format>', 'report format; JSON disables prompts')
164
+ .choices(['text', 'json'])
165
+ .default('text'))
166
+ .option('--path <directory>', 'project root only')
167
+ .option('--project <name>', 'explicit project name for initialization')
168
+ .option('--machine-path <file>', 'override machine configuration path')
169
+ .option('--config <file>', 'override global connection configuration path')
170
+ .option('--connection <id>', 'named connection from ~/.bakers/connections')
171
+ .option('--yes', 'disable prompts and automatic selections')
172
+ .option('--online', 'authenticate and verify each selected workspace (project scopes); resolve addons[] entries to check their scopes');
173
+ if (operation !== 'check')
174
+ command
175
+ .option('--dry-run', 'report planned changes without imports, network or writes')
176
+ .addOption(new Option('--project-connection <choice>', 'retired; use --connection'))
177
+ .option('--machine-id <id>', 'explicit host identity')
178
+ .option('--user-id <id>', 'local operator identifier')
179
+ .option('--max-parallel <number>', 'positive integer host capacity')
180
+ .option('--base-url <url>', 'connection server URL')
181
+ .option('--client-id <id>', 'OAuth client identifier')
182
+ .option('--workspace-id <uuid>', 'explicit workspace UUID')
183
+ .option('--workspace <name>', 'committed project workspace name')
184
+ .option('--secret-env <name>', 'environment variable containing the OAuth secret')
185
+ .option('--after-create <command>', "committed conductor config's after_create hook; empty means none, bakers-cli names no provisioning tool")
186
+ .option('--after-done <command>', "committed conductor config's after_done hook; empty means none, bakers-cli names no provisioning tool")
187
+ .addOption(new Option('--auth-type <type>', 'sign in with device code (default, no secret) or a client secret (internal/CI)').choices(['client_credentials', 'device']));
188
+ command.action(async (options) => {
189
+ if (hiddenScope !== undefined && options.scope === undefined)
190
+ options.scope = hiddenScope;
191
+ const { applyConfigPlan, planConfig } = await import('@bakers-build/conductor/config');
192
+ if (options.projectConnection !== undefined)
193
+ throw new Error('--project-connection is retired; use --connection <id> and .bakers/connection.config.local.js.');
194
+ const cwd = deps.cwd ?? process.cwd();
195
+ const env = deps.env ?? process.env;
196
+ const interactive = (deps.isTTY ?? Boolean(process.stdin.isTTY && process.stdout.isTTY)) &&
197
+ !options.yes &&
198
+ !options.dryRun &&
199
+ !env.CI &&
200
+ options.format !== 'json';
201
+ const prompt = deps.prompt ?? promptInput;
202
+ let cancelled = false;
203
+ if (operation === 'init' && !options.scope && interactive) {
204
+ try {
205
+ options.scope = await prompt('Configuration scope (machine, connection, project, all)');
206
+ }
207
+ catch {
208
+ cancelled = true;
209
+ }
210
+ }
211
+ const inputs = {};
212
+ for (const key of [
213
+ 'machineId',
214
+ 'userId',
215
+ 'baseUrl',
216
+ 'clientId',
217
+ 'workspaceId',
218
+ 'workspace',
219
+ ]) {
220
+ const value = options[key];
221
+ if (value !== undefined)
222
+ inputs[key] = value;
223
+ }
224
+ if (options.maxParallel !== undefined)
225
+ inputs.maxParallel = Number(options.maxParallel);
226
+ const secret = options.secretEnv ? env[options.secretEnv] : undefined;
227
+ if (secret !== undefined)
228
+ inputs.secret = secret;
229
+ if (options.authType)
230
+ inputs.authType =
231
+ options.authType === 'client_credentials'
232
+ ? 'oauth2_client_credentials'
233
+ : 'oauth2_device_code';
234
+ if (options.afterCreate !== undefined || options.afterDone !== undefined)
235
+ inputs.hooks = {
236
+ ...(options.afterCreate ? { after_create: options.afterCreate } : {}),
237
+ ...(options.afterDone ? { after_done: options.afterDone } : {}),
238
+ };
239
+ const planning = {
240
+ operation,
241
+ cwd,
242
+ // Only forward an explicitly injected home; absent, core resolves the
243
+ // `.bakers` root itself and honours BB_HOME.
244
+ ...(deps.home !== undefined ? { home: deps.home } : {}),
245
+ inputs,
246
+ // `bakers init` (hiddenScope !== undefined) never pulls the project
247
+ // layer in, even inside a project checkout (BB-496 finding 1).
248
+ ...(hiddenScope !== undefined ? { excludeProjectLayer: true } : {}),
249
+ };
250
+ for (const key of ['scope', 'path', 'project', 'machinePath']) {
251
+ const value = options[key];
252
+ if (value !== undefined)
253
+ planning[key] = value;
254
+ }
255
+ if (options.config !== undefined)
256
+ planning.connectionPath = options.config;
257
+ else if (options.connection !== undefined) {
258
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(options.connection))
259
+ throw new Error('--connection must be an identifier containing letters, numbers, dot, underscore or hyphen.');
260
+ planning.connectionPath = join(homeBbDir(deps.home), 'connections', `${options.connection}.js`);
261
+ }
262
+ let selectedConnection;
263
+ const initializesNamedConnection = operation === 'init' &&
264
+ options.connection !== undefined &&
265
+ ['connection', 'all'].includes(options.scope ?? '');
266
+ if (!initializesNamedConnection && options.scope !== 'machine') {
267
+ selectedConnection = await resolveBbConnectionSelection({
268
+ cwd,
269
+ // Only forward an explicitly injected home; absent, core resolves
270
+ // the `.bakers` root itself and honours BB_HOME (same rule as the
271
+ // `planning` object above — a stray `?? homedir()` here would
272
+ // resolve this command's connection selection under `~/.bakers` while
273
+ // the rest of it honours the override).
274
+ ...(deps.home !== undefined ? { home: deps.home } : {}),
275
+ env,
276
+ ...(options.config ? { explicit: options.config } : {}),
277
+ ...(options.connection ? { connection: options.connection } : {}),
278
+ });
279
+ planning.connectionPath = selectedConnection.path;
280
+ }
281
+ let plan = planConfig(planning);
282
+ if (operation === 'init' &&
283
+ options.project &&
284
+ !options.path &&
285
+ ['project', 'all'].includes(options.scope ?? '') &&
286
+ !plan.report.roots.project) {
287
+ planning.path = cwd;
288
+ plan = planConfig(planning);
289
+ }
290
+ const freshConnection = operation === 'init' &&
291
+ plan.report.resolved_scopes.includes('connection') &&
292
+ plan.report.files.some((file) => file.kind === 'connection' &&
293
+ file.selected &&
294
+ file.state === 'absent');
295
+ // No sign-in question: device code is the only way `bakers config init`
296
+ // offers (BB-496). `--auth-type client_credentials` remains for the
297
+ // internal/CI path but is never interviewed for or invented outside
298
+ // it — this default applies whether or not a terminal is attached, so
299
+ // a non-interactive `bakers init --yes` gets the same secret-free
300
+ // connection an interactive run does. A caller who already supplied a
301
+ // secret (`--secret-env`) evidently wants client_credentials and is
302
+ // left alone rather than silently switched to device code.
303
+ if (freshConnection &&
304
+ inputs.authType === undefined &&
305
+ inputs.secret === undefined) {
306
+ inputs.authType = 'oauth2_device_code';
307
+ plan = planConfig(planning);
308
+ }
309
+ if (interactive &&
310
+ operation !== 'check' &&
311
+ !cancelled &&
312
+ !plan.report.findings.some((f) => f.stage === 'scope')) {
313
+ const deviceCode = inputs.authType === 'oauth2_device_code';
314
+ const fields = [
315
+ ['machineId', 'machine_id', 'Machine identity'],
316
+ ['userId', 'user_id', 'Local operator identifier'],
317
+ ['baseUrl', 'site.base_url', 'Server base URL'],
318
+ ['clientId', 'client_id', 'OAuth client identifier'],
319
+ ['secret', 'client_secret', 'OAuth client secret'],
320
+ ['workspaceId', 'workspace_id', 'Workspace UUID'],
321
+ ['workspace', 'bakers.workspace', 'Workspace name'],
322
+ ].filter(([input]) => !(deviceCode && input === 'secret'));
323
+ for (const [input, key, label] of fields) {
324
+ const requiredForFreshConnection = freshConnection &&
325
+ (deviceCode
326
+ ? ['baseUrl', 'clientId']
327
+ : ['baseUrl', 'clientId', 'secret']).includes(input);
328
+ if (inputs[input] !== undefined ||
329
+ (!requiredForFreshConnection &&
330
+ !plan.report.findings.some((f) => plan.report.resolved_scopes.includes(f.owner) &&
331
+ f.code === 'missing_required_key' &&
332
+ (f.key === key || f.key?.endsWith(`.${key}`)))))
333
+ continue;
334
+ try {
335
+ const suggested = input === 'machineId'
336
+ ? hostname().split('.')[0]
337
+ : input === 'baseUrl'
338
+ ? // Suggests keytec's own fleet instance so pressing Enter
339
+ // reaches a working setup with no site to look up first;
340
+ // it is a default, not a requirement — any base URL
341
+ // typed over it is used verbatim (BB-496).
342
+ 'https://app.bakers.build'
343
+ : deviceCode && input === 'clientId'
344
+ ? 'bakers-cli'
345
+ : undefined;
346
+ const answer = (await prompt(`${label}${suggested ? ` [${suggested}]` : ''}`, input === 'secret')) || suggested;
347
+ if (answer !== undefined)
348
+ inputs[input] = answer;
349
+ }
350
+ catch {
351
+ cancelled = true;
352
+ break;
353
+ }
354
+ plan = planConfig(planning);
355
+ }
356
+ }
357
+ let report = plan.report;
358
+ if (cancelled) {
359
+ report.findings.push({
360
+ code: 'cancelled_selection',
361
+ severity: 'error',
362
+ stage: 'scope',
363
+ path: cwd,
364
+ key: null,
365
+ expected: 'Explicit setup input is required.',
366
+ observed: null,
367
+ owner: 'project',
368
+ related: [],
369
+ statically_determined: false,
370
+ remediation: 'Rerun bakers config init with an explicit --scope and required input flags.',
371
+ recheck: 'bakers config check --scope all',
372
+ });
373
+ report.result = 'blocked';
374
+ }
375
+ if (!cancelled && !options.dryRun && operation !== 'check') {
376
+ report = applyConfigPlan(plan);
377
+ if (!report.findings.some((f) => f.severity === 'error')) {
378
+ const current = planConfig({
379
+ ...planning,
380
+ operation: 'check',
381
+ }).report;
382
+ report.files = current.files;
383
+ report.findings = current.findings;
384
+ report.result = current.result;
385
+ report.verification = current.verification;
386
+ }
387
+ }
388
+ if (!cancelled && operation === 'check')
389
+ await checkAddonScopes(report, options.online === true, cwd);
390
+ // A device-code connection authenticates from a session that sign-in
391
+ // creates, and nothing has signed in yet when `init` has only just
392
+ // written the file. Verifying here therefore reports
393
+ // `authentication_failure` on a perfectly correct machine — a symptom
394
+ // named in place of its cause, and the reason `bakers init` used to end
395
+ // `failed` on every fresh setup. Writing the connection and signing in
396
+ // are two steps, so `init` reports the second as outstanding instead.
397
+ // An explicit `--online` still verifies, so the deferral is gated on
398
+ // the whole predicate rather than on the file read alone — otherwise
399
+ // `--auth-type device` defers the very check the operator asked for.
400
+ // Read the grant off the connection that is about to be verified, not
401
+ // off `--auth-type`: a repeat `bakers init` supplies no flag and writes
402
+ // nothing, and keying on the input would leave exactly that run — the
403
+ // one an operator uses as a diagnostic — reporting a failure again. A
404
+ // read that fails falls through to verifying, so a broken file is
405
+ // reported rather than quietly skipped.
406
+ let connectionGrant;
407
+ if (operation === 'init' && planning.connectionPath)
408
+ try {
409
+ const fields = await reportLoadedBbConfigFields(planning.connectionPath);
410
+ // Locate the OAuth plugin by identity. Its grant is the only one
411
+ // that says anything about signing in, and any plugin may carry a
412
+ // `with.type` of its own, so matching on field shape alone reads
413
+ // whichever happens to come first.
414
+ const prefix = fields
415
+ .find((field) => /^plugins\[\d+\]\.plugin$/.test(field.key) &&
416
+ field.value === '@dropsh/plugin-oauth2')
417
+ ?.key.replace(/\.plugin$/, '');
418
+ if (prefix !== undefined)
419
+ connectionGrant = fields.find((field) => field.key === `${prefix}.with.type`)?.value;
420
+ }
421
+ catch {
422
+ // Inventory findings already describe an unreadable or malformed file.
423
+ }
424
+ const awaitsSignIn = !options.online &&
425
+ (inputs.authType === 'oauth2_device_code' ||
426
+ connectionGrant === 'oauth2_device_code');
427
+ if (!cancelled &&
428
+ !options.dryRun &&
429
+ !awaitsSignIn &&
430
+ (operation === 'init' || options.online) &&
431
+ !report.findings.some((f) => f.severity === 'error')) {
432
+ const { verifyConfigConnections } = await import('@bakers-build/core');
433
+ report = await verifyConfigConnections(report, {
434
+ cwd,
435
+ host: deps.host ?? {
436
+ resolveBases: [import.meta.url, pathToFileURL(`${cwd}/`).href],
437
+ },
438
+ ...(deps.stateDir ? { stateDir: deps.stateDir } : {}),
439
+ // A fresh device-code connection cannot be verified without a
440
+ // human confirming the code, so it is left to the ordinary
441
+ // session-based check (which fails fast rather than polling
442
+ // forever for a confirmation that never arrives).
443
+ ...(freshConnection && inputs.authType !== 'oauth2_device_code'
444
+ ? { ephemeralClientCredentials: true }
445
+ : {}),
446
+ });
447
+ }
448
+ else if (!options.dryRun &&
449
+ (operation === 'init' || options.online) &&
450
+ report.verification.online.status !== 'not_applicable') {
451
+ // `bakers init` is also the diagnostic an operator re-runs on a finished
452
+ // setup. Naming sign-in as the next step there is the one instruction
453
+ // that is certainly wrong, so say which of the two situations this is.
454
+ const { hasStoredBbSession } = await import('@bakers-build/core');
455
+ const reason = awaitsSignIn
456
+ ? (await hasStoredBbSession(deps.host ?? {
457
+ resolveBases: [import.meta.url, pathToFileURL(`${cwd}/`).href],
458
+ }, {
459
+ cwd,
460
+ ...(planning.connectionPath
461
+ ? { explicit: planning.connectionPath }
462
+ : {}),
463
+ ...(deps.stateDir ? { stateDir: deps.stateDir } : {}),
464
+ }))
465
+ ? 'A session for this connection already exists; verify it with bakers config check --scope connection --online.'
466
+ : 'Sign-in is the next step: run bakers dropsh auth login, then bakers config check --scope connection --online.'
467
+ : 'Online validation skipped because offline errors require repair or input was cancelled.';
468
+ report.verification.online.reason = reason;
469
+ report.verification.skipped.push({
470
+ path: report.roots.connection,
471
+ stage: 'online',
472
+ reason,
473
+ });
474
+ }
475
+ const resolution = selectedConnection ??
476
+ (planning.connectionPath
477
+ ? {
478
+ path: planning.connectionPath,
479
+ connectionId: options.connection ?? 'default',
480
+ selectionSource: options.connection
481
+ ? 'flag-connection'
482
+ : 'flag-config',
483
+ }
484
+ : undefined);
485
+ if (resolution) {
486
+ let fields = [];
487
+ try {
488
+ fields = await reportLoadedBbConfigFields(resolution.path);
489
+ }
490
+ catch {
491
+ // Inventory findings already describe an unreadable or malformed file.
492
+ }
493
+ report.connection_resolution = {
494
+ id: resolution.connectionId ?? 'default',
495
+ selected_by: resolution.selectionSource,
496
+ path: resolution.path,
497
+ fields,
498
+ };
499
+ }
500
+ (deps.stdout ?? ((text) => process.stdout.write(text)))(options.format === 'json'
501
+ ? `${JSON.stringify(report)}\n`
502
+ : renderConfigReport(report, options.dryRun));
503
+ if (report.result !== 'ok')
504
+ process.exitCode = 1;
505
+ });
506
+ }
507
+ }
508
+ export function registerConfig(program, deps = {}) {
509
+ const group = program
510
+ .command('config')
511
+ .description('initialize, migrate and validate scoped bakers.build configuration');
512
+ for (const operation of ['init', 'upgrade', 'check'])
513
+ buildConfigCommand(group, operation, deps);
514
+ // `bakers init` is the user-facing shortcut that sets up machine identity AND
515
+ // the sign-in connection — the two things a human actually needs before
516
+ // running anything else. There is no `--scope` for it at all: it silently
517
+ // resolves both layers (via the same `all` inventory scope `bakers config
518
+ // init --scope all` uses outside a project — config-inventory.ts — but
519
+ // that value is never shown, defaulted, or documented here). The project
520
+ // scope stays reachable only through the full `bakers config init --scope
521
+ // project` command.
522
+ buildConfigCommand(program, 'init', deps, 'all', 'sign in: sets up machine identity and the connection (not project)');
523
+ }