balladeer 0.0.5 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/LICENSE +200 -5
  2. package/README.md +154 -68
  3. package/dist/agent.d.ts +126 -0
  4. package/dist/agent.js +209 -0
  5. package/dist/cli.d.ts +34 -0
  6. package/dist/cli.js +392 -0
  7. package/dist/client.d.ts +44 -0
  8. package/dist/client.js +114 -0
  9. package/dist/commands/affected.d.ts +22 -0
  10. package/dist/commands/affected.js +122 -0
  11. package/dist/commands/check-seals.d.ts +37 -0
  12. package/dist/commands/check-seals.js +289 -0
  13. package/dist/commands/discover.d.ts +68 -0
  14. package/dist/commands/discover.js +395 -0
  15. package/dist/commands/explain.d.ts +35 -0
  16. package/dist/commands/explain.js +90 -0
  17. package/dist/commands/invite.d.ts +24 -0
  18. package/dist/commands/invite.js +197 -0
  19. package/dist/commands/mcp.d.ts +65 -0
  20. package/dist/commands/mcp.js +202 -0
  21. package/dist/commands/propose.d.ts +59 -0
  22. package/dist/commands/propose.js +262 -0
  23. package/dist/commands/repositories.d.ts +18 -0
  24. package/dist/commands/repositories.js +185 -0
  25. package/dist/commands/setup.d.ts +75 -0
  26. package/dist/commands/setup.js +1471 -0
  27. package/dist/commands/status.d.ts +35 -0
  28. package/dist/commands/status.js +482 -0
  29. package/dist/commands/touch-map.d.ts +42 -0
  30. package/dist/commands/touch-map.js +251 -0
  31. package/dist/commands/whoami.d.ts +8 -0
  32. package/dist/commands/whoami.js +79 -0
  33. package/dist/conventions.d.ts +69 -0
  34. package/dist/conventions.js +175 -0
  35. package/dist/copy.d.ts +148 -0
  36. package/dist/copy.js +459 -0
  37. package/dist/currency.d.ts +31 -0
  38. package/dist/currency.js +72 -0
  39. package/dist/gh.d.ts +80 -0
  40. package/dist/gh.js +188 -0
  41. package/dist/git.d.ts +76 -0
  42. package/dist/git.js +203 -0
  43. package/dist/markers.d.ts +76 -0
  44. package/dist/markers.js +125 -0
  45. package/dist/mcp-config.d.ts +99 -0
  46. package/dist/mcp-config.js +230 -0
  47. package/dist/release.d.ts +55 -0
  48. package/dist/release.js +67 -0
  49. package/dist/repository.d.ts +8 -0
  50. package/dist/repository.js +32 -0
  51. package/dist/seals.d.ts +48 -0
  52. package/dist/seals.js +112 -0
  53. package/dist/store.d.ts +98 -0
  54. package/dist/store.js +225 -0
  55. package/dist/touch-map.d.ts +241 -0
  56. package/dist/touch-map.js +487 -0
  57. package/dist/wire.d.ts +588 -0
  58. package/dist/wire.js +20 -0
  59. package/package.json +19 -10
  60. package/bin/balladeer.js +0 -161
@@ -0,0 +1,1471 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { existsSync, mkdtempSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { ClientTooOldError, RefusalError, TransportError, request } from "../client.js";
6
+ import { writeConventions } from "../conventions.js";
7
+ import { DISCOVERY_PLAYBOOK, JOIN_OR_CREATE } from "../copy.js";
8
+ import { readPublicRepositoryFacts, readRepositoryFacts, runCommand, setRepositoryVariable, } from "../gh.js";
9
+ import { CI_BRANCH, commitWorkflowOnBranch, repositoryRoot, workflowOnDefaultBranch, } from "../git.js";
10
+ import { MCP_CONFIG_FILE, currentEntry, entryRepositoryId, mergeMcpConfig, readMcpConfig, stdioEntry, } from "../mcp-config.js";
11
+ import { selectAgent } from "../agent.js";
12
+ import { CONVENTIONS_VERSION } from "../conventions.js";
13
+ import { checkoutEntryPath, commandLine, runningFromRegistryInstall } from "../release.js";
14
+ import { hostHint, repositoryHint } from "../repository.js";
15
+ import { StoreError, credentialsPath, dropPendingPairing, findPendingPairing, findSession, putAgent, putPendingPairing, putSession, readCredentials, writeCredentials, } from "../store.js";
16
+ import { CLI_VERSION, DELEGATED_SCOPES, } from "../wire.js";
17
+ import { explainText } from "./explain.js";
18
+ import { probeAgentConnection } from "./mcp.js";
19
+ const REFUSAL_SENTENCE = "It may not agree to what a promise means, agree a change to what a promise means, grant an exception, transfer ownership, supersede, retire, or offboard anything.";
20
+ const MAX_WAIT_MS = 10 * 60 * 1000;
21
+ /** The bound the control plane's own name check holds, refused here so a name
22
+ * nobody could create never costs a pairing code. */
23
+ export const CREATE_WORKSPACE_MAX_LENGTH = 120;
24
+ const REPOSITORY_NAME = /^[A-Za-z0-9._-]{1,39}\/[A-Za-z0-9._-]{1,100}$/;
25
+ export function chooseRepositories(named, cwd) {
26
+ const here = repositoryHint(cwd);
27
+ // `unknown/unknown` is what the origin parse answers when it read nothing, so
28
+ // it is the absence of a repository rather than a repository with that name.
29
+ const inCheckoutOf = here === "unknown/unknown" || !REPOSITORY_NAME.test(here) ? undefined : here;
30
+ if (named.length === 0) {
31
+ return { kind: "chosen", primary: inCheckoutOf ?? here, also: [] };
32
+ }
33
+ if (inCheckoutOf === undefined) {
34
+ return { kind: "chosen", primary: named[0], also: named.slice(1) };
35
+ }
36
+ const match = named.find((name) => name.toLowerCase() === inCheckoutOf.toLowerCase());
37
+ if (match === undefined) {
38
+ const list = named.join(", ");
39
+ return {
40
+ kind: "mismatch",
41
+ message: `This run names ${list}, and none of them is ${inCheckoutOf}, which is this directory's origin remote. ` +
42
+ `Nothing was changed. One named repository has to be the one you are standing in, because connecting a coding agent and CI writes files here and pushes a branch to this remote. ` +
43
+ `Run this command inside a checkout of ${named[0]}, add ${inCheckoutOf} to the list, or drop the names to set up ${inCheckoutOf}.`,
44
+ };
45
+ }
46
+ return {
47
+ kind: "chosen",
48
+ primary: match,
49
+ also: named.filter((name) => name.toLowerCase() !== inCheckoutOf.toLowerCase()),
50
+ };
51
+ }
52
+ /**
53
+ * The approval link, carrying the workspace name this run was given.
54
+ *
55
+ * `--create-workspace` creates nothing. A workspace is created by a person who
56
+ * signs in and clicks Create, and an agent may not do that on anybody's behalf,
57
+ * so all this flag can honestly do is carry the name the person typed to the
58
+ * page where that click happens, so they do not type it twice. The parameter is
59
+ * a pre-fill and the control plane treats it as one.
60
+ *
61
+ * A link the server sent that does not parse is printed exactly as it arrived.
62
+ * Rewriting somebody's address to add a convenience is not worth the chance of
63
+ * printing an address they cannot open.
64
+ */
65
+ function approvalLink(options, verificationUri) {
66
+ const name = options.createWorkspace?.trim();
67
+ if (name === undefined || name === "")
68
+ return verificationUri;
69
+ try {
70
+ const url = new URL(verificationUri);
71
+ url.searchParams.set("create", name);
72
+ return url.toString();
73
+ }
74
+ catch {
75
+ return verificationUri;
76
+ }
77
+ }
78
+ function newVerifier() {
79
+ const verifier = randomBytes(32).toString("base64url");
80
+ return {
81
+ verifier,
82
+ digest: `sha256:${createHash("sha256").update(verifier, "utf8").digest("hex")}`,
83
+ };
84
+ }
85
+ function emit(options, step) {
86
+ if (options.json)
87
+ options.write(`${JSON.stringify(step)}\n`);
88
+ }
89
+ function say(options, text) {
90
+ if (!options.json)
91
+ options.write(`${text}\n`);
92
+ }
93
+ /**
94
+ * The one exit every failure takes.
95
+ *
96
+ * `--json` is the mode an agent runs in, so a failure has to arrive as an object
97
+ * it can read rather than as a sentence suppressed by that very flag. The plain
98
+ * sentence and the object carry the same words, and `changed` answers the
99
+ * question a caller asks next: does anything need undoing?
100
+ */
101
+ function fail(options, reason, message, exitCode, changed = false) {
102
+ say(options, message);
103
+ emit(options, { step: "error", reason, message, changed, exitCode });
104
+ return exitCode;
105
+ }
106
+ function storeFailure(options, error) {
107
+ const reason = error instanceof StoreError ? error.code : "credential_store_unusable";
108
+ const message = error instanceof StoreError ? error.message : String(error);
109
+ return fail(options, reason, message, 4);
110
+ }
111
+ function transportFailure(options, message) {
112
+ return fail(options, "control_plane_unreachable", `Balladeer could not be reached at ${options.controlPlane}: ${message}. Nothing was changed.`, 5);
113
+ }
114
+ function pairedLines(session) {
115
+ // The boundary copy printed above every run describes the widest approval a
116
+ // person can give. An approver whose own role is narrower hands over less, and
117
+ // saying so here is what keeps the two consistent for a contributor.
118
+ const narrowed = session.scopes.length < DELEGATED_SCOPES.length
119
+ ? [
120
+ " The approver's role granted less than the boundary explanation above describes. This session carries only what is listed here.",
121
+ ]
122
+ : [];
123
+ return [
124
+ "Step 1 of 5 Paired",
125
+ ` Signed in as ${session.membershipDisplayName}, workspace "${session.workspaceName}".`,
126
+ ` This session may: ${session.scopeMeanings.join(", ")}.`,
127
+ ...narrowed,
128
+ ` ${REFUSAL_SENTENCE}`,
129
+ ` It expires at ${session.expiresAt}. Revoke it any time under Connected sessions in Balladeer.`,
130
+ ];
131
+ }
132
+ /**
133
+ * A pairing nobody has collected yet. The opening line is the only difference
134
+ * between the run that created it and a later run that found it still waiting;
135
+ * everything under it is the same, so a person re-reading the output does not
136
+ * have to work out which run they are looking at.
137
+ *
138
+ * The link is printed on every run, not only the first. A person whose terminal
139
+ * scrolled, or an agent whose context was compacted, is told to approve in the
140
+ * browser, and no other command in this release can produce that URL again.
141
+ */
142
+ function pendingLines(pending, repository, host, waiting, approvalUri) {
143
+ const opening = waiting
144
+ ? ["Step 1 of 5 Waiting for approval", ` Nobody has approved ${pending.userCode} yet.`]
145
+ : ["Step 1 of 5 Sign in and approve this session"];
146
+ return [
147
+ ...opening,
148
+ ` Open: ${approvalUri}`,
149
+ ` The page will show this code: ${pending.userCode}`,
150
+ ` Sent to Balladeer so far: a random code, the name "${repository}", and the hostname "${host}". Nothing else.`,
151
+ ` This pairing expires at ${pending.expiresAt}.`,
152
+ " Approve in the browser, then run this command again to finish.",
153
+ "",
154
+ // Printed on the pairing step and nowhere else, because this is the one
155
+ // moment the answer matters: the person is standing in front of a page that
156
+ // either joins them to a workspace or makes one, and until now nothing said
157
+ // so. The bytes are the control plane's `/agent` copy, compared by a test,
158
+ // so the terminal and the page cannot come to describe it differently.
159
+ ...JOIN_OR_CREATE.split("\n").map((line) => (line.length === 0 ? "" : ` ${line}`)),
160
+ ];
161
+ }
162
+ function storeSession(credentials, controlPlane, token, session) {
163
+ return putSession(dropPendingPairing(credentials, controlPlane), {
164
+ controlPlane,
165
+ sessionId: session.sessionId,
166
+ token,
167
+ workspaceId: session.workspaceId,
168
+ workspaceName: session.workspaceName,
169
+ workspaceSlug: session.workspaceSlug,
170
+ ...(session.membershipId === undefined ? {} : { membershipId: session.membershipId }),
171
+ membershipDisplayName: session.membershipDisplayName,
172
+ scopes: session.scopes,
173
+ expiresAt: session.expiresAt,
174
+ });
175
+ }
176
+ function reportPaired(options, session) {
177
+ for (const line of pairedLines(session))
178
+ say(options, line);
179
+ emit(options, {
180
+ step: "pair",
181
+ status: "paired",
182
+ expiresAt: session.expiresAt,
183
+ workspace: {
184
+ id: session.workspaceId,
185
+ name: session.workspaceName,
186
+ slug: session.workspaceSlug,
187
+ },
188
+ scopes: session.scopes,
189
+ });
190
+ }
191
+ /**
192
+ * The receipt for a run that never got past pairing. There is nothing to report
193
+ * about repositories, an agent, CI, or a promise, so it says exactly that rather
194
+ * than printing four empty rows.
195
+ */
196
+ function pairingOnlyReceipt(options, sentence) {
197
+ emit(options, {
198
+ step: "receipt",
199
+ setup: { complete: false, sentence },
200
+ firstPromise: {
201
+ complete: false,
202
+ sentence: "Not yet. Nothing has been proposed, because this session is not paired.",
203
+ },
204
+ protection: { complete: false, sentence: "Not yet." },
205
+ connection: {
206
+ complete: false,
207
+ sentence: "Not yet. No agent connection has been issued here, so nothing this run leaves behind outlives the setup session.",
208
+ },
209
+ repositories: [],
210
+ });
211
+ if (options.json)
212
+ return;
213
+ options.write("\n");
214
+ options.write(`Setup receipt ${sentence}\n`);
215
+ options.write("First-promise receipt Not yet. Nothing has been proposed, because this session is not paired.\n");
216
+ options.write("Protection receipt Not yet.\n");
217
+ options.write("Connection receipt Not yet. No agent connection has been issued here, so nothing this run leaves behind outlives the setup session.\n");
218
+ }
219
+ /**
220
+ * Every repository this run was told about, however it was told.
221
+ *
222
+ * `repo` is the single name the flag has always taken and the shape the tests
223
+ * and every existing caller pass; `repositories` is the list a caller building
224
+ * a whole team's workspace passes. They are the same instruction, so they are
225
+ * read as one list rather than as two features that could disagree.
226
+ */
227
+ function namedRepositories(options) {
228
+ const named = [
229
+ ...(options.repositories ?? []),
230
+ ...(options.repo === undefined ? [] : [options.repo]),
231
+ ];
232
+ const seen = new Set();
233
+ return named.filter((name) => {
234
+ const key = name.toLowerCase();
235
+ if (seen.has(key))
236
+ return false;
237
+ seen.add(key);
238
+ return true;
239
+ });
240
+ }
241
+ /**
242
+ * One run. It prints what it found, does what it can, and exits within seconds.
243
+ * Waiting is the person's choice, behind `--wait`, because an agent shell kills
244
+ * a command that blocks for minutes and the pairing would be lost with it.
245
+ */
246
+ export async function runSetup(options) {
247
+ if (options.refresh === true)
248
+ return runRefresh(options);
249
+ const { controlPlane } = options;
250
+ const now = options.now ?? (() => new Date());
251
+ say(options, `Balladeer setup, version ${CLI_VERSION}.\n`);
252
+ emit(options, { step: "explain", version: CLI_VERSION });
253
+ say(options, explainText(controlPlane));
254
+ const chosen = chooseRepositories(namedRepositories(options), options.cwd);
255
+ if (chosen.kind === "mismatch") {
256
+ return fail(options, "repository_mismatch", chosen.message, 4);
257
+ }
258
+ // Before the pairing is started, not after: a name the control plane would
259
+ // refuse must cost nobody a code they then have to abandon, and this run has
260
+ // sent nothing yet.
261
+ const carriedName = options.createWorkspace?.trim() ?? "";
262
+ if (options.createWorkspace !== undefined &&
263
+ (carriedName.length === 0 || carriedName.length > CREATE_WORKSPACE_MAX_LENGTH)) {
264
+ return fail(options, "workspace_name_invalid", `--create-workspace needs a workspace name of 1 to ${CREATE_WORKSPACE_MAX_LENGTH} characters. Nothing was changed.`, 4);
265
+ }
266
+ let credentials;
267
+ try {
268
+ credentials = readCredentials(options.environment);
269
+ }
270
+ catch (error) {
271
+ return storeFailure(options, error);
272
+ }
273
+ // A still-valid session carries straight on to the steps that need it.
274
+ const stored = findSession(credentials, controlPlane);
275
+ if (stored && Date.parse(stored.expiresAt) > now().getTime()) {
276
+ try {
277
+ const answer = await request(controlPlane, {
278
+ method: "GET",
279
+ path: "/api/pair/session",
280
+ bearer: stored.token,
281
+ });
282
+ reportPaired(options, answer.session);
283
+ return runRemainingSteps(options, credentials, stored);
284
+ }
285
+ catch (error) {
286
+ if (error instanceof ClientTooOldError) {
287
+ return fail(options, "client_too_old", error.message, 3);
288
+ }
289
+ if (error instanceof TransportError) {
290
+ return transportFailure(options, error.message);
291
+ }
292
+ // The session is gone on the server's side; fall through and pair again.
293
+ credentials = {
294
+ ...credentials,
295
+ sessions: credentials.sessions.filter((entry) => entry.controlPlane !== controlPlane),
296
+ };
297
+ try {
298
+ writeCredentials(credentials, options.environment);
299
+ }
300
+ catch (writeError) {
301
+ return storeFailure(options, writeError);
302
+ }
303
+ }
304
+ }
305
+ const repository = chosen.primary;
306
+ const host = hostHint();
307
+ // Filtered by exact origin, so a pending pairing's claim secret is never sent
308
+ // to a control plane other than the one that issued it.
309
+ let pending = findPendingPairing(credentials, controlPlane);
310
+ // A code that ran out is dropped, and remembered until this run has said so.
311
+ // Replacing it silently is what happened to the founder: the page in front of
312
+ // him showed one code, the terminal printed another, and nothing on either
313
+ // said the first one had expired.
314
+ let lapsed;
315
+ if (pending && Date.parse(pending.expiresAt) <= now().getTime()) {
316
+ lapsed = pending;
317
+ credentials = dropPendingPairing(credentials, controlPlane);
318
+ pending = undefined;
319
+ }
320
+ if (pending) {
321
+ const outcome = await claimOnce(options, credentials, pending);
322
+ if (outcome.kind === "claimed") {
323
+ return runRemainingSteps(options, outcome.credentials, outcome.session);
324
+ }
325
+ if (outcome.kind === "failed")
326
+ return outcome.exitCode;
327
+ // Printed before the wait as well as instead of it: a run that blocks for ten
328
+ // minutes without ever naming the link is the run whose person cannot act.
329
+ for (const line of pendingLines(pending, repository, host, true, approvalLink(options, pending.verificationUri)))
330
+ say(options, line);
331
+ emit(options, {
332
+ step: "pair",
333
+ status: "pending",
334
+ userCode: pending.userCode,
335
+ verificationUri: approvalLink(options, pending.verificationUri),
336
+ expiresAt: pending.expiresAt,
337
+ });
338
+ if (!options.wait) {
339
+ pairingOnlyReceipt(options, "Nothing yet: this pairing is still waiting for someone to approve it.");
340
+ return 0;
341
+ }
342
+ return waitForApproval(options, credentials, pending, outcome.pollIntervalSeconds);
343
+ }
344
+ let started;
345
+ try {
346
+ const { verifier, digest } = newVerifier();
347
+ started = await request(controlPlane, {
348
+ method: "POST",
349
+ path: "/api/pair/start",
350
+ body: { verifierDigest: digest, repositoryHint: repository, hostHint: host },
351
+ });
352
+ pending = {
353
+ controlPlane,
354
+ pairingId: started.pairingId,
355
+ userCode: started.userCode,
356
+ verifier,
357
+ verificationUri: started.verificationUriComplete,
358
+ expiresAt: started.expiresAt,
359
+ startedAt: now().toISOString(),
360
+ };
361
+ credentials = putPendingPairing(credentials, pending);
362
+ writeCredentials(credentials, options.environment);
363
+ }
364
+ catch (error) {
365
+ return reportStartFailure(options, error);
366
+ }
367
+ if (lapsed !== undefined) {
368
+ say(options, `The earlier code ${lapsed.userCode} expired unapproved at ${lapsed.expiresAt}; here is a new one.`);
369
+ emit(options, {
370
+ step: "pair",
371
+ status: "expired",
372
+ userCode: lapsed.userCode,
373
+ expiresAt: lapsed.expiresAt,
374
+ });
375
+ }
376
+ for (const line of pendingLines(pending, repository, host, false, approvalLink(options, pending.verificationUri)))
377
+ say(options, line);
378
+ emit(options, {
379
+ step: "pair",
380
+ status: "pending",
381
+ userCode: pending.userCode,
382
+ verificationUri: approvalLink(options, pending.verificationUri),
383
+ expiresAt: pending.expiresAt,
384
+ });
385
+ if (options.wait) {
386
+ return waitForApproval(options, credentials, pending, started.pollIntervalSeconds);
387
+ }
388
+ pairingOnlyReceipt(options, "Started a pairing and printed the link. Nobody has approved it yet.");
389
+ return 0;
390
+ }
391
+ function reportStartFailure(options, error) {
392
+ if (error instanceof ClientTooOldError) {
393
+ return fail(options, "client_too_old", error.message, 3);
394
+ }
395
+ if (error instanceof StoreError) {
396
+ return storeFailure(options, error);
397
+ }
398
+ if (error instanceof TransportError) {
399
+ return transportFailure(options, error.message);
400
+ }
401
+ if (error instanceof RefusalError) {
402
+ return fail(options, error.code, `Balladeer refused to start a pairing (${error.code}). Nothing was changed.`, 5);
403
+ }
404
+ return fail(options, "pairing_start_failed", "Balladeer could not start a pairing. Nothing was changed.", 5);
405
+ }
406
+ /** Exactly one claim attempt, and every refusal turns into one plain sentence. */
407
+ async function claimOnce(options, credentials, pending) {
408
+ try {
409
+ const answer = await request(options.controlPlane, {
410
+ method: "POST",
411
+ path: "/api/pair/poll",
412
+ body: { pairingId: pending.pairingId, verifier: pending.verifier },
413
+ });
414
+ if (answer.status === "pending") {
415
+ return { kind: "pending", pollIntervalSeconds: answer.pollIntervalSeconds };
416
+ }
417
+ const updated = storeSession(credentials, options.controlPlane, answer.token, answer.session);
418
+ writeCredentials(updated, options.environment);
419
+ reportPaired(options, answer.session);
420
+ const session = findSession(updated, options.controlPlane);
421
+ if (session === undefined) {
422
+ return {
423
+ kind: "failed",
424
+ exitCode: fail(options, "session_not_stored", "Balladeer paired this session but could not store it.", 4),
425
+ };
426
+ }
427
+ return { kind: "claimed", credentials: updated, session };
428
+ }
429
+ catch (error) {
430
+ if (error instanceof ClientTooOldError) {
431
+ return { kind: "failed", exitCode: fail(options, "client_too_old", error.message, 3) };
432
+ }
433
+ if (error instanceof StoreError) {
434
+ return { kind: "failed", exitCode: storeFailure(options, error) };
435
+ }
436
+ if (error instanceof TransportError) {
437
+ return { kind: "failed", exitCode: transportFailure(options, error.message) };
438
+ }
439
+ if (error instanceof RefusalError) {
440
+ if (error.code === "poll_too_fast") {
441
+ return { kind: "pending", pollIntervalSeconds: 5 };
442
+ }
443
+ const terminal = terminalRefusal(pending, error.code);
444
+ if (terminal) {
445
+ // The stale pairing goes, so the next run starts a clean one rather than
446
+ // re-claiming something the server has finished with.
447
+ let changed = false;
448
+ try {
449
+ writeCredentials(dropPendingPairing(credentials, options.controlPlane), options.environment);
450
+ changed = true;
451
+ }
452
+ catch {
453
+ // The refusal has already been reported; a store failure here changes
454
+ // nothing about what the person must do next.
455
+ }
456
+ if (terminal.status) {
457
+ emit(options, {
458
+ step: "pair",
459
+ status: terminal.status,
460
+ userCode: pending.userCode,
461
+ });
462
+ }
463
+ return {
464
+ kind: "failed",
465
+ exitCode: fail(options, error.code, terminal.message, 2, changed),
466
+ };
467
+ }
468
+ return {
469
+ kind: "failed",
470
+ exitCode: fail(options, error.code, `Balladeer refused this claim (${error.code}). Nothing was changed.`, 5),
471
+ };
472
+ }
473
+ return {
474
+ kind: "failed",
475
+ exitCode: fail(options, "pairing_claim_failed", "Balladeer could not complete this pairing. Nothing was changed.", 5),
476
+ };
477
+ }
478
+ }
479
+ /**
480
+ * The sentence names the state that actually happened.
481
+ *
482
+ * A verifier mismatch is not a browser denial and a pairing the server has never
483
+ * heard of did not expire at a time this machine recorded. Both used to be
484
+ * reported as the wrong event, which is the one thing a person then repeats to
485
+ * someone else.
486
+ */
487
+ function terminalRefusal(pending, code) {
488
+ const again = "Run this command again to start a new one.";
489
+ if (code === "pairing_expired") {
490
+ return {
491
+ message: `Pairing ${pending.userCode} expired at ${pending.expiresAt}. ${again}`,
492
+ status: "expired",
493
+ };
494
+ }
495
+ if (code === "pairing_not_found") {
496
+ return {
497
+ message: `Balladeer has no pairing ${pending.userCode}. This machine cannot finish it. ${again}`,
498
+ };
499
+ }
500
+ if (code === "pairing_denied") {
501
+ return {
502
+ message: `Pairing ${pending.userCode} was denied in the browser. ${again}`,
503
+ status: "denied",
504
+ };
505
+ }
506
+ if (code === "pairing_verifier_mismatch") {
507
+ return {
508
+ message: `Pairing ${pending.userCode} did not accept this machine's claim secret, so this credential store is not the one that started it. Nobody denied it in the browser. ${again}`,
509
+ };
510
+ }
511
+ if (code === "pairing_already_claimed") {
512
+ return {
513
+ message: `Pairing ${pending.userCode} was already claimed by another process. ${again}`,
514
+ };
515
+ }
516
+ return undefined;
517
+ }
518
+ async function waitForApproval(options, credentials, pending, pollIntervalSeconds) {
519
+ const sleep = options.sleep ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
520
+ const now = options.now ?? (() => new Date());
521
+ const deadline = now().getTime() + MAX_WAIT_MS;
522
+ say(options, ` Waiting up to ${MAX_WAIT_MS / 60_000} minutes for someone to approve ${pending.userCode} at ${approvalLink(options, pending.verificationUri)}. Press Ctrl+C to stop; the pairing is saved either way, and the code stays approvable until ${pending.expiresAt}.`);
523
+ let interval = pollIntervalSeconds;
524
+ while (now().getTime() < deadline) {
525
+ await sleep(Math.max(interval, 2) * 1000);
526
+ const outcome = await claimOnce(options, credentials, pending);
527
+ if (outcome.kind === "claimed") {
528
+ return runRemainingSteps(options, outcome.credentials, outcome.session);
529
+ }
530
+ if (outcome.kind === "failed")
531
+ return outcome.exitCode;
532
+ interval = outcome.pollIntervalSeconds;
533
+ }
534
+ // The pending step object was already emitted before the wait began, so this
535
+ // is the human line only: the link again, because ten minutes have passed
536
+ // since the person last saw it.
537
+ say(options, ` Still waiting for ${pending.userCode}. Approve it at ${approvalLink(options, pending.verificationUri)}, then run this command again to finish.`);
538
+ pairingOnlyReceipt(options, "Nothing yet: this pairing is still waiting for someone to approve it.");
539
+ return 0;
540
+ }
541
+ /* ------------------------------------------------------------------------- *
542
+ * Steps 2 to 5. Each one reads server truth first, does only what is missing,
543
+ * and reports a state it observed rather than a state it attempted.
544
+ * ------------------------------------------------------------------------- */
545
+ function refusalSentence(error) {
546
+ if (error instanceof RefusalError)
547
+ return `${error.message} (${error.code})`;
548
+ if (error instanceof TransportError)
549
+ return error.message;
550
+ return error instanceof Error ? error.message : String(error);
551
+ }
552
+ async function runRemainingSteps(options, credentials, session) {
553
+ let state;
554
+ try {
555
+ state = await request(options.controlPlane, {
556
+ method: "GET",
557
+ path: "/api/setup/v1/state",
558
+ bearer: session.token,
559
+ });
560
+ }
561
+ catch (error) {
562
+ if (error instanceof ClientTooOldError) {
563
+ return fail(options, "client_too_old", error.message, 3);
564
+ }
565
+ if (error instanceof TransportError)
566
+ return transportFailure(options, error.message);
567
+ return fail(options, "setup_state_unreadable", `Balladeer could not report this workspace's setup: ${refusalSentence(error)}. Nothing was changed.`, 5);
568
+ }
569
+ const chosen = chooseRepositories(namedRepositories(options), options.cwd);
570
+ if (chosen.kind === "mismatch") {
571
+ return fail(options, "repository_mismatch", chosen.message, 4);
572
+ }
573
+ const name = chosen.primary;
574
+ // How this deployment says the command is run. It is the server's answer
575
+ // rather than a literal here, because only the server knows whether the
576
+ // package has been published, and a sentence naming a registry version that
577
+ // does not exist is worse than one naming a checkout.
578
+ const published = state.client.publishedVersion;
579
+ const step2 = await stepRepository(options, session, state, name);
580
+ // The rest of the named repositories, added and nothing more, before this run
581
+ // goes on to the steps that can only touch the tree it is standing in. They
582
+ // are printed here rather than at the end so a person reads the whole
583
+ // enrollment in one place.
584
+ await addAlsoNamed(options, session, state, chosen.also);
585
+ let view = step2.view;
586
+ if (view !== undefined) {
587
+ const step3 = await stepAgent(options, credentials, session, view, name, published);
588
+ view = step3 ?? view;
589
+ view = (await stepCi(options, session, view, name)) ?? view;
590
+ await stepPromise(options, session, view, published);
591
+ }
592
+ const finalState = await readState(options, session);
593
+ printReceipts(options, session, finalState ?? state, view);
594
+ return 0;
595
+ }
596
+ async function readState(options, session) {
597
+ try {
598
+ return await request(options.controlPlane, {
599
+ method: "GET",
600
+ path: "/api/setup/v1/state",
601
+ bearer: session.token,
602
+ });
603
+ }
604
+ catch {
605
+ return undefined;
606
+ }
607
+ }
608
+ /* ----------------------------- Step 2 ------------------------------------ */
609
+ function byHandLine(controlPlane, name) {
610
+ return ` By hand: add ${name} at ${controlPlane}/setup`;
611
+ }
612
+ function enrollmentHeadline(role, rest) {
613
+ return role === "primary" ? `Step 2 of 5 ${rest}` : `Also ${rest}`;
614
+ }
615
+ async function stepRepository(options, session, state, name, role = "primary") {
616
+ const existing = state.repositories.find((repository) => repository.status === "active" && repository.displayName.toLowerCase() === name.toLowerCase());
617
+ if (existing !== undefined) {
618
+ // The enrollment is real, so nothing below this line may report it as not
619
+ // added. The one thing that can still be missing is the GitHub owner id: a
620
+ // repository added through the web page before Balladeer recorded owner ids
621
+ // holds none, and step 4 refuses without one because Balladeer cannot say
622
+ // whose signed runs count. This run already reads that id from `gh`, so it
623
+ // offers it rather than sending the person to a form.
624
+ const view = existing.githubOwnerId === null
625
+ ? await backfillOwnerId(options, session, name, existing)
626
+ : existing;
627
+ const recorded = existing.githubOwnerId === null ? view.githubOwnerId : null;
628
+ say(options, enrollmentHeadline(role, `${name} is already added default branch ${view.defaultBranch}` +
629
+ (recorded === null ? "" : ` recorded its GitHub owner id ${recorded}`)));
630
+ if (view.githubOwnerId === null) {
631
+ say(options, ` Balladeer holds no GitHub owner id for it, so step 4 cannot say whose runs count, and I could not record one from here.`);
632
+ say(options, ` Next: add it at ${options.controlPlane}/setup, in "Connect your CI", in the field "GitHub account or organization ID".`);
633
+ }
634
+ emit(options, {
635
+ step: "repository",
636
+ status: "already",
637
+ repository: name,
638
+ repositoryId: view.id,
639
+ defaultBranch: view.defaultBranch,
640
+ ...(recorded === null ? {} : { githubOwnerIdRecorded: recorded }),
641
+ });
642
+ return { view };
643
+ }
644
+ if (!/^[A-Za-z0-9._-]{1,39}\/[A-Za-z0-9._-]{1,100}$/.test(name) || name === "unknown/unknown") {
645
+ return blockedRepository(options, role, name, "this directory has no GitHub origin remote I could read", "Run this command inside a repository with a GitHub origin, or name one with --repository owner/name.");
646
+ }
647
+ const lookup = await readRepositoryFacts(name);
648
+ if (lookup.kind === "unavailable") {
649
+ // The public read is for rendering the by-hand block with real values, and
650
+ // never for enrolling: an unauthenticated answer proves nothing about who is
651
+ // asking, and the enrollment is the record that says this workspace speaks
652
+ // for this repository.
653
+ const publicFacts = await readPublicRepositoryFacts(name);
654
+ return blockedRepository(options, role, name, `I could not read this repository's ids: ${lookup.reason}`, lookup.next, publicFacts.kind === "found" ? publicFacts.facts : undefined);
655
+ }
656
+ // A courtesy check on this machine, and nothing more. Balladeer holds no
657
+ // GitHub token, so the server cannot see who controls a repository and does
658
+ // not pretend to: what the enrollment records is an identity, and the first
659
+ // authenticated OIDC run from that repository's own CI is what proves it. This
660
+ // check exists so a person who cannot push finds out here rather than after
661
+ // opening a pull request they cannot merge.
662
+ if (!lookup.facts.canWrite) {
663
+ return blockedRepository(options, role, name, role === "primary"
664
+ ? `your GitHub account can read ${name} but cannot push to it, and the later steps push a branch and open a pull request`
665
+ : `your GitHub account can read ${name} but cannot push to it, and adding it records that this workspace speaks for the repository`, `Ask someone with write access to ${name} to run this command, or add it by hand.`);
666
+ }
667
+ try {
668
+ const answer = await request(options.controlPlane, {
669
+ method: "POST",
670
+ path: "/api/setup/v1/repositories",
671
+ bearer: session.token,
672
+ body: {
673
+ repository: name,
674
+ githubRepositoryId: lookup.facts.githubRepositoryId,
675
+ githubOwnerId: lookup.facts.githubOwnerId,
676
+ defaultBranch: lookup.facts.defaultBranch,
677
+ },
678
+ });
679
+ const verb = answer.alreadyEnrolled ? "is already added" : `added to ${session.workspaceName}`;
680
+ say(options, enrollmentHeadline(role, `${name} ${verb} default branch ${answer.repository.defaultBranch}`));
681
+ emit(options, {
682
+ step: "repository",
683
+ status: answer.alreadyEnrolled ? "already" : "added",
684
+ repository: name,
685
+ repositoryId: answer.repository.id,
686
+ defaultBranch: answer.repository.defaultBranch,
687
+ });
688
+ return { view: answer.repository };
689
+ }
690
+ catch (error) {
691
+ return blockedRepository(options, role, name, `Balladeer refused this repository: ${refusalSentence(error)}`, `Add it by hand at ${options.controlPlane}/setup.`);
692
+ }
693
+ }
694
+ /**
695
+ * Every other repository this run was told to add, added and no more.
696
+ *
697
+ * A repository named from somewhere that is not a checkout of it can honestly
698
+ * be enrolled and nothing else: connecting its coding agent writes files into
699
+ * its working tree, and connecting its CI pushes a branch to its remote. So this
700
+ * does the one step it can do from here, and says per repository what is still
701
+ * waiting, rather than reporting a repository as set up because the workspace
702
+ * has heard of it.
703
+ *
704
+ * Each one is independent. A repository the person cannot read, or cannot push
705
+ * to, refuses on its own line and the rest still go in.
706
+ */
707
+ async function addAlsoNamed(options, session, state, names) {
708
+ if (names.length === 0)
709
+ return;
710
+ for (const name of names) {
711
+ const added = await stepRepository(options, session, state, name, "also");
712
+ if (added.view === undefined)
713
+ continue;
714
+ say(options, ` ${name} is in the workspace and nothing else: run this command inside a checkout of it to connect its coding agent and its CI.`);
715
+ }
716
+ }
717
+ /**
718
+ * Offers the GitHub owner id for a repository that was added without one.
719
+ *
720
+ * The enrollment endpoint is idempotent and reports a repeat rather than
721
+ * refusing it, so re-sending it is the whole mechanism: the server fills in a
722
+ * missing owner id from the request and never replaces a recorded one. Every
723
+ * failure here answers with the repository exactly as it was, because a
724
+ * repository that is added stays added whatever `gh` or the network did.
725
+ */
726
+ async function backfillOwnerId(options, session, name, existing) {
727
+ const lookup = await readRepositoryFacts(name);
728
+ if (lookup.kind !== "found")
729
+ return existing;
730
+ try {
731
+ const answer = await request(options.controlPlane, {
732
+ method: "POST",
733
+ path: "/api/setup/v1/repositories",
734
+ bearer: session.token,
735
+ body: {
736
+ repository: name,
737
+ githubRepositoryId: lookup.facts.githubRepositoryId,
738
+ githubOwnerId: lookup.facts.githubOwnerId,
739
+ defaultBranch: lookup.facts.defaultBranch,
740
+ },
741
+ });
742
+ return answer.repository;
743
+ }
744
+ catch {
745
+ return existing;
746
+ }
747
+ }
748
+ function blockedRepository(options, role, name, reason, next, publicFacts) {
749
+ say(options, enrollmentHeadline(role, role === "primary"
750
+ ? "Add this repository to the workspace not done"
751
+ : `Add ${name} to the workspace not done`));
752
+ say(options, ` ${reason}.`);
753
+ say(options, ` Next: ${next}`);
754
+ say(options, byHandLine(options.controlPlane, name));
755
+ if (publicFacts !== undefined) {
756
+ say(options, ` The page asks for these: repository id ${publicFacts.githubRepositoryId}, owner id ${publicFacts.githubOwnerId}, default branch ${publicFacts.defaultBranch}. They are public, and reading them proved nothing about who is asking, which is why I did not add it myself.`);
757
+ }
758
+ emit(options, { step: "repository", status: "blocked", repository: name, reason });
759
+ return { view: undefined };
760
+ }
761
+ /**
762
+ * What the person or the agent does next, for their own host.
763
+ *
764
+ * Two things go wrong here and look identical from the terminal: an agent host
765
+ * that is already running does not pick up a project MCP entry written under it,
766
+ * and a project-scoped server needs the person's approval before its tools
767
+ * appear at all. Neither is a failure, and neither was mentioned anywhere, so a
768
+ * truthful report of a proved connection still ended in a session with no
769
+ * Balladeer tools in it and nobody knowing why.
770
+ *
771
+ * A restart is not part of the happy path. Setup finishes here either way, and
772
+ * the next session loads the entry on its own.
773
+ */
774
+ function hostNextSteps() {
775
+ return [
776
+ ` Your agent host reads ${MCP_CONFIG_FILE} when it starts, so the tools appear in its next session rather than in one already running. Nothing here needs a restart: finish setup first.`,
777
+ " Claude Code asks you to approve a project MCP server the first time it sees one, so approve balladeer when it asks. In a session already running, /mcp lists the servers and reconnects them.",
778
+ " Another host reads its own configuration rather than this file: add the same entry there, and start it again.",
779
+ ];
780
+ }
781
+ /**
782
+ * One real call over the connection, through the forwarder's own request path.
783
+ *
784
+ * Never a claim assembled from what this command just did. A credential that was
785
+ * minted, stored, and written into a configuration file is still a credential no
786
+ * server has been asked to accept, and reporting success from the steps
787
+ * performed rather than from the state observed is exactly how a broken install
788
+ * reads as a working one.
789
+ */
790
+ async function proveAgent(agent) {
791
+ const probe = await probeAgentConnection(agent);
792
+ return probe.kind === "answered"
793
+ ? { kind: "answered" }
794
+ : { kind: "unproven", reason: probe.reason, nextAction: probe.nextAction };
795
+ }
796
+ /**
797
+ * Whether this machine already holds a credential for this repository.
798
+ *
799
+ * Keyed on the control plane and the repository, exactly as the store keys what
800
+ * it writes, so a credential for another workspace's repository of the same name
801
+ * is not mistaken for this one's.
802
+ */
803
+ function credentialOnThisMachine(agents, controlPlane, repositoryId) {
804
+ return agents.some((agent) => agent.controlPlane === controlPlane && agent.repositoryId === repositoryId);
805
+ }
806
+ /**
807
+ * Whose credential this step is about: this machine's.
808
+ *
809
+ * The repository being connected is a fact about the repository; holding a
810
+ * credential is a fact about the machine, and this step used to read the first
811
+ * and report the second. A repository connected in the browser, where the bearer
812
+ * is shown once to whoever was there, made this step say "already connected" and
813
+ * issue nothing, and the machine it said that on ended up with `agents: []` and
814
+ * with `status` and `propose` both unable to run.
815
+ *
816
+ * So the store decides, with the server's answer as the check on it. Where this
817
+ * machine holds nothing, a connection is issued for it even when the repository
818
+ * already has others: the service allows a repository many live connections,
819
+ * authenticates each bearer against its own record, and revokes them one at a
820
+ * time, so issuing here rotates nothing and takes nothing away from the machine
821
+ * or the browser that has one already. Rotation is a separate act, refused to a
822
+ * delegated session, and this step never asks for it. Where the machine holds
823
+ * one and the server reports no live connection at all, the stored entry is a
824
+ * revoked credential rather than a working one, and holding it is not being
825
+ * connected: that issues too.
826
+ */
827
+ async function stepAgent(options, credentials, session, view, name, published) {
828
+ const heldHere = credentialOnThisMachine(credentials.agents, options.controlPlane, view.id);
829
+ if (heldHere && view.agentConfigured) {
830
+ say(options, "Step 3 of 5 This coding agent is already connected on this machine");
831
+ say(options, ` ${name}'s credential is in ${credentialsPath(options.environment)}, so nothing was issued and nothing was rotated. A credential belongs to the machine it was issued on: another machine runs this command there to get its own.`);
832
+ emit(options, {
833
+ step: "agent",
834
+ status: "already",
835
+ repositoryId: view.id,
836
+ storedHere: true,
837
+ ...(view.agentConnectionId === null ? {} : { connectionId: view.agentConnectionId }),
838
+ });
839
+ return undefined;
840
+ }
841
+ // The repository already has one, issued somewhere else. It stays exactly as it
842
+ // is; what this run adds is a second live connection, for here.
843
+ const connectedElsewhere = view.agentConfigured;
844
+ // A credential in this store, and no live connection anywhere for the
845
+ // repository: the one this machine holds was revoked. Holding it is not being
846
+ // connected, so this issues rather than reporting the stored entry as working.
847
+ const revokedHere = heldHere && !view.agentConfigured;
848
+ let packet;
849
+ try {
850
+ packet = await request(options.controlPlane, {
851
+ method: "POST",
852
+ path: `/api/setup/v1/repositories/${view.id}/agent`,
853
+ bearer: session.token,
854
+ body: { label: `${session.workspaceName.slice(0, 100)} planning agent` },
855
+ });
856
+ }
857
+ catch (error) {
858
+ return blockedAgent(options, view, `Balladeer refused to issue a connection: ${refusalSentence(error)}`);
859
+ }
860
+ const agent = {
861
+ controlPlane: options.controlPlane,
862
+ workspaceId: session.workspaceId,
863
+ repositoryId: packet.repositoryId,
864
+ repository: name,
865
+ connectionId: packet.connectionId,
866
+ mcpUrl: packet.mcpUrl,
867
+ token: packet.token,
868
+ };
869
+ // The bearer goes to the developer's own credential store, mode 600 inside a
870
+ // 0700 directory, and never into the repository or into a config file.
871
+ try {
872
+ writeCredentials(putAgent(credentials, agent), options.environment);
873
+ }
874
+ catch (error) {
875
+ return blockedAgent(options, view, error instanceof StoreError ? error.message : "the credential store could not be written");
876
+ }
877
+ const root = await repositoryRoot(options.cwd);
878
+ const files = [];
879
+ let merged;
880
+ let conventions;
881
+ if (root !== undefined) {
882
+ // The stdio forwarder, bound to this repository. It reads the bearer from
883
+ // the credential store this step just wrote, so the connection works from
884
+ // here with nothing further for anyone to set, and the file carries no
885
+ // secret.
886
+ merged = mergeMcpConfig(root, stdioEntry(packet.repositoryId, published), options.controlPlane);
887
+ if (merged.kind === "written") {
888
+ if (merged.changed)
889
+ files.push(MCP_CONFIG_FILE);
890
+ conventions = writeConventions(root);
891
+ if (conventions.kind === "written" && conventions.changed)
892
+ files.push(conventions.file);
893
+ }
894
+ }
895
+ // The probe runs after the files, because the files are not what it proves.
896
+ // The credential, the endpoint, and the frames this command's own forwarder
897
+ // sends are.
898
+ const proof = await proveAgent(agent);
899
+ const wrote = merged?.kind === "written" && conventions?.kind === "written"
900
+ ? `, ${MCP_CONFIG_FILE} and ${conventions.file} updated`
901
+ : "";
902
+ say(options, proof.kind === "answered"
903
+ ? `Step 3 of 5 Connected this coding agent on this machine credential stored in ${credentialsPath(options.environment)}${wrote}`
904
+ : `Step 3 of 5 Issued this coding agent's connection not yet proven${wrote}`);
905
+ say(options, connectedElsewhere
906
+ ? ` Already connected elsewhere; issued a credential for this machine. ${name} had a live connection issued somewhere else, and this machine held none. That one is untouched: a repository's connections coexist, and each is revoked on its own.`
907
+ : revokedHere
908
+ ? ` The credential this machine held is not a live connection for ${name} any more, so this issued a new one for this machine and replaced the stored entry.`
909
+ : ` This credential belongs to this machine. Another machine runs this command there and gets its own; revoking either leaves the other working.`);
910
+ if (proof.kind === "answered") {
911
+ say(options, ` Balladeer answered a get_promise_setup call for ${name} over this connection just now, so the credential and the endpoint are proved rather than assumed.`);
912
+ }
913
+ else {
914
+ say(options, ` The credential is stored, but ${proof.reason}, so this is not reported as connected.`);
915
+ say(options, ` ${proof.nextAction}`);
916
+ }
917
+ if (root === undefined) {
918
+ say(options, ` This directory is not a git work tree, so I wrote no ${MCP_CONFIG_FILE} and no instructions block.`);
919
+ }
920
+ else {
921
+ if (merged?.kind === "refused") {
922
+ say(options, ` ${merged.reason}`);
923
+ say(options, " Merge this block into it yourself:");
924
+ for (const line of merged.block.split("\n"))
925
+ say(options, ` ${line}`);
926
+ }
927
+ if (conventions?.kind === "refused") {
928
+ say(options, ` ${conventions.reason}`);
929
+ say(options, ` Repair the markers in ${conventions.file}, or delete the block between them, then run this command again.`);
930
+ }
931
+ if (merged?.kind === "written") {
932
+ // Nothing further is asked of anybody about the credential. The entry runs
933
+ // this command's own forwarder, which reads the bearer out of the store
934
+ // above, so there is no variable to export and no secret to carry by hand.
935
+ say(options, ` The ${MCP_CONFIG_FILE} entry runs this command's own MCP forwarder for this repository and reads the credential from that store, so there is nothing else to set. This command never prints the credential.`);
936
+ if (published === null && !existsSync(checkoutEntryPath())) {
937
+ say(options, ` That entry runs ${checkoutEntryPath()}, which this checkout has not built yet. Run \`pnpm --filter balladeer build\` once so your agent host can start it.`);
938
+ }
939
+ }
940
+ }
941
+ // Said on every ending of this step, including the ones where no file was
942
+ // written: whoever is reading has to configure that host by hand, and they
943
+ // need the same two facts about when a host picks an entry up.
944
+ for (const line of hostNextSteps())
945
+ say(options, line);
946
+ if (files.length > 0) {
947
+ say(options, ` ${files.join(" and ")} ${files.length === 1 ? "is an uncommitted change" : "are uncommitted changes"} in your working tree. Commit ${files.length === 1 ? "it" : "them"} when you are ready; they carry no secret.`);
948
+ }
949
+ emit(options, {
950
+ step: "agent",
951
+ status: proof.kind === "answered" ? "connected" : "unproven",
952
+ repositoryId: view.id,
953
+ connectionId: packet.connectionId,
954
+ storedHere: true,
955
+ ...(connectedElsewhere ? { alreadyConnectedElsewhere: true } : {}),
956
+ files,
957
+ ...(proof.kind === "answered"
958
+ ? { provedBy: "get_promise_setup" }
959
+ : { reason: proof.reason, nextAction: proof.nextAction }),
960
+ ...(merged?.kind === "refused" ? { mcpConfig: merged.reason } : {}),
961
+ ...(conventions?.kind === "refused" ? { conventions: conventions.reason } : {}),
962
+ });
963
+ return undefined;
964
+ }
965
+ function blockedAgent(options, view, reason) {
966
+ say(options, "Step 3 of 5 Connect this coding agent not done");
967
+ say(options, ` ${reason}.`);
968
+ say(options, ` By hand: issue a connection at ${options.controlPlane}/setup`);
969
+ emit(options, { step: "agent", status: "blocked", repositoryId: view.id, reason });
970
+ return undefined;
971
+ }
972
+ /* ----------------------------- Step 4 ------------------------------------ */
973
+ async function stepCi(options, session, view, name) {
974
+ // Checked on every run, and BEFORE the connected branch below, because an
975
+ // already-connected repository is exactly the one this happens to. Balladeer
976
+ // moves the enrollment onto a newly published release itself, in every
977
+ // workspace, so the server is never asked to move anything here. What
978
+ // Balladeer cannot touch is the pinned line in this repository's own workflow
979
+ // file: until that changes, the repository still runs the release it was
980
+ // moved off, and this step exists to open the pull request that changes it. A
981
+ // truthy test rather than a comparison with null, so a deployment that
982
+ // predates the field is silent here rather than being read as "the pin is
983
+ // behind".
984
+ const upgrading = Boolean(view.ciSupersededAttestorReleaseId);
985
+ let current = view;
986
+ if (!upgrading && view.ciConfigured) {
987
+ // "Connected" appears on this branch and nowhere else in the command: it is
988
+ // the one state Balladeer observed rather than recorded.
989
+ say(options, `Step 4 of 5 CI connected authenticated run observed at ${view.ciFirstValidatedAt ?? "an earlier run"}`);
990
+ emit(options, {
991
+ step: "ci",
992
+ status: "connected",
993
+ repositoryId: view.id,
994
+ validatedRunCount: view.ciValidatedRunCount,
995
+ ...(view.ciFirstValidatedAt === null ? {} : { firstValidatedAt: view.ciFirstValidatedAt }),
996
+ });
997
+ return undefined;
998
+ }
999
+ if (!upgrading) {
1000
+ // Re-asserted on every run until a real run has been observed, not only when
1001
+ // nothing is recorded yet. The server derives every value from the enrollment
1002
+ // and rotates the registration only when they differ, so a repeat is a no-op
1003
+ // when the identity is right and a repair when it is wrong. The first dogfood
1004
+ // run recorded a mangled identity by hand and had no way back but this one.
1005
+ //
1006
+ // Skipped while the pin is behind. The enrollment is already on the release
1007
+ // the deployment publishes, the caller identity is not what is out of date,
1008
+ // and re-recording it would put this repository back through a request whose
1009
+ // whole purpose is the identity rather than the file.
1010
+ try {
1011
+ const answer = await request(options.controlPlane, {
1012
+ method: "POST",
1013
+ path: `/api/setup/v1/repositories/${view.id}/ci`,
1014
+ bearer: session.token,
1015
+ body: { eventClasses: ["push", "pull_request"] },
1016
+ });
1017
+ current = answer.repository;
1018
+ }
1019
+ catch (error) {
1020
+ return blockedCi(options, view, `Balladeer refused to record a CI identity: ${refusalSentence(error)}`);
1021
+ }
1022
+ }
1023
+ let workflow;
1024
+ try {
1025
+ workflow = await request(options.controlPlane, {
1026
+ method: "GET",
1027
+ path: `/api/setup/v1/repositories/${view.id}/caller-workflow`,
1028
+ bearer: session.token,
1029
+ });
1030
+ }
1031
+ catch (error) {
1032
+ return blockedCi(options, current, `Balladeer could not render the workflow: ${refusalSentence(error)}`);
1033
+ }
1034
+ const root = await repositoryRoot(options.cwd);
1035
+ // Two states, one set of words each, so every ending below says the same true
1036
+ // thing about which of them this run is in. A move is never reported as
1037
+ // "connected": the enrollment moved, the repository's own file has not, and
1038
+ // the proof is a run on the new pin that has not happened yet.
1039
+ const headline = upgrading
1040
+ ? `Step 4 of 5 Connect CI moved to Balladeer release ${workflow.attestorSha}`
1041
+ : "Step 4 of 5 Connect CI identity recorded, waiting for the first authenticated run";
1042
+ const recorded = upgrading
1043
+ ? { status: "release_upgraded", attestorReleaseSha: workflow.attestorSha }
1044
+ : { status: "identity_recorded" };
1045
+ const proof = upgrading ? "that run, on the new pin, is the proof" : "that run is the proof";
1046
+ // The finished case first. Once the pull request is merged the workflow is on
1047
+ // the default branch and the branch it arrived on is usually deleted, so
1048
+ // looking only for that branch would answer "not done" for a repository where
1049
+ // the work is complete, and the command would go on to tell a person to commit
1050
+ // a file they had already merged.
1051
+ const onDefaultBranch = root === undefined
1052
+ ? undefined
1053
+ : await workflowOnDefaultBranch(root, workflow.defaultBranch, workflow.workflowPath);
1054
+ if (onDefaultBranch === workflow.workflowYaml) {
1055
+ say(options, headline);
1056
+ say(options, ` ${workflow.workflowPath} is already on ${workflow.defaultBranch}, so there is nothing to commit. GitHub runs it there; ${proof}.`);
1057
+ say(options, " Next: run this command again in a minute to see the run.");
1058
+ emit(options, {
1059
+ step: "ci",
1060
+ ...recorded,
1061
+ repositoryId: current.id,
1062
+ workflowInstalled: true,
1063
+ });
1064
+ return current;
1065
+ }
1066
+ const alreadyPushed = root === undefined
1067
+ ? false
1068
+ : (await runCommand("git", [
1069
+ "-C",
1070
+ root,
1071
+ "ls-remote",
1072
+ "--exit-code",
1073
+ "--heads",
1074
+ "origin",
1075
+ CI_BRANCH,
1076
+ ])).ok;
1077
+ // A pushed branch is a finished state only while the pin on it is the one
1078
+ // Balladeer now expects. On a move it is the branch that has to change, so the
1079
+ // work below updates it rather than reporting it as already done.
1080
+ if (alreadyPushed && !upgrading) {
1081
+ say(options, headline);
1082
+ say(options, ` The ${CI_BRANCH} branch is already pushed. GitHub runs the workflow on its pull request; ${proof}.`);
1083
+ say(options, " Next: run this command again in a minute to see the run.");
1084
+ emit(options, { step: "ci", ...recorded, repositoryId: current.id });
1085
+ return current;
1086
+ }
1087
+ for (const variable of workflow.variables) {
1088
+ const set = await setRepositoryVariable(name, variable.name, variable.value);
1089
+ if (!set.ok) {
1090
+ return ciFallback(options, current, workflow, name, set.stderr || "gh could not set a repository variable");
1091
+ }
1092
+ }
1093
+ if (root === undefined) {
1094
+ return ciFallback(options, current, workflow, name, "this directory is not a git work tree");
1095
+ }
1096
+ const bodyPath = join(mkdtempSync(join(tmpdir(), "balladeer-pr-")), "body.md");
1097
+ writeFileSync(bodyPath, (upgrading
1098
+ ? [
1099
+ `Moves the pinned Balladeer runner to release ${workflow.attestorSha}.`,
1100
+ "",
1101
+ "Balladeer has already recorded this repository against the new release, and it still",
1102
+ "accepts runs from the release this file pins today, so merging this is an ordinary change",
1103
+ "rather than a cutover. The run GitHub makes on the new pin is what proves the move landed.",
1104
+ "",
1105
+ "The check is advisory on Balladeer's side. Whether it blocks a merge is this repository's own",
1106
+ "branch protection.",
1107
+ "",
1108
+ ]
1109
+ : [
1110
+ "Adds the Balladeer continuity workflow.",
1111
+ "",
1112
+ "GitHub runs it on this pull request. That authenticated run is what proves the CI connection;",
1113
+ "until Balladeer observes it, the connection is recorded and not connected.",
1114
+ "",
1115
+ "The check is advisory on Balladeer's side. Whether it blocks a merge is this repository's own",
1116
+ "branch protection.",
1117
+ "",
1118
+ ]).join("\n"), "utf8");
1119
+ const opened = await commitWorkflowOnBranch({
1120
+ root,
1121
+ defaultBranch: workflow.defaultBranch,
1122
+ workflowPath: workflow.workflowPath,
1123
+ workflowYaml: workflow.workflowYaml,
1124
+ repository: name,
1125
+ bodyPath,
1126
+ ...(upgrading ? { subject: "Update the Balladeer CI release pin" } : {}),
1127
+ });
1128
+ if (opened.kind === "refused") {
1129
+ return ciFallback(options, current, workflow, name, opened.reason);
1130
+ }
1131
+ if (opened.kind === "unchanged") {
1132
+ say(options, headline);
1133
+ say(options, ` ${workflow.workflowPath} is already what this branch carries, so there was nothing to commit. GitHub runs it; ${proof}.`);
1134
+ say(options, " Next: run this command again in a minute to see the run.");
1135
+ emit(options, {
1136
+ step: "ci",
1137
+ ...recorded,
1138
+ repositoryId: current.id,
1139
+ workflowInstalled: true,
1140
+ });
1141
+ return current;
1142
+ }
1143
+ say(options, headline);
1144
+ say(options, ` ${opened.kind === "updated" ? "Updated the open pull request" : "Opened a pull request"}${opened.pullRequestUrl ? ` (${opened.pullRequestUrl})` : ""}. GitHub runs the workflow on that pull request; ${proof}.`);
1145
+ say(options, " Next: run this command again in a minute to see the run.");
1146
+ emit(options, {
1147
+ step: "ci",
1148
+ ...recorded,
1149
+ repositoryId: current.id,
1150
+ ...(opened.pullRequestUrl === undefined ? {} : { pullRequestUrl: opened.pullRequestUrl }),
1151
+ });
1152
+ return current;
1153
+ }
1154
+ /**
1155
+ * What a person who can actually do it needs, when this machine's `gh` or `git`
1156
+ * could not: every action in order, numbered as a list a person works down, with
1157
+ * the rendered file and both values. The step is reported unfinished and the
1158
+ * command exits 0, because a truthful partial result is not a failure and an
1159
+ * agent that sees a non-zero code stops.
1160
+ */
1161
+ function ciFallback(options, view, workflow, name, reason) {
1162
+ say(options, "Step 4 of 5 Connect CI identity recorded, the repository changes are not done");
1163
+ say(options, ` I could not make the changes in ${name}: ${reason}.`);
1164
+ say(options, " Someone with write access to the repository does these things, in order:");
1165
+ // Numbered across the whole list rather than inside the loop: two variables
1166
+ // printed as step 1 twice reads as one instruction somebody repeated.
1167
+ let action = 1;
1168
+ for (const variable of workflow.variables) {
1169
+ say(options, ` ${action}. Set the repository variable ${variable.name} to ${variable.value}`);
1170
+ action += 1;
1171
+ }
1172
+ say(options, ` ${action}. Commit this file as ${workflow.workflowPath}:`);
1173
+ for (const line of workflow.workflowYaml.split("\n"))
1174
+ say(options, ` ${line}`);
1175
+ action += 1;
1176
+ say(options, ` ${action}. Open a pull request into ${workflow.defaultBranch}. Its run is the proof.`);
1177
+ emit(options, { step: "ci", status: "blocked", repositoryId: view.id, reason });
1178
+ return view;
1179
+ }
1180
+ function blockedCi(options, view, reason) {
1181
+ say(options, "Step 4 of 5 Connect CI not done");
1182
+ say(options, ` ${reason}.`);
1183
+ say(options, ` By hand: connect CI at ${options.controlPlane}/setup`);
1184
+ emit(options, { step: "ci", status: "blocked", repositoryId: view.id, reason });
1185
+ return view;
1186
+ }
1187
+ /* ----------------------------- Step 5 ------------------------------------ */
1188
+ /**
1189
+ * The playbook, printed for the agent that is reading this output, followed by
1190
+ * the one runnable line only the server can name.
1191
+ *
1192
+ * It used to ask for a single behavior, which left the person a shelf with one
1193
+ * thing on it and no way to tell whether Balladeer had understood their
1194
+ * repository. The command still invents no meaning: the agent reads the
1195
+ * repository's own tests, pull requests, documents and reverts, and a named
1196
+ * person agrees to what it wrote.
1197
+ *
1198
+ * The playbook lines are printed unindented and unwrapped, because the very same
1199
+ * bytes are served at the control plane's `/agent` page and a packaging test
1200
+ * compares the two. Anything this function did to them would be drift.
1201
+ */
1202
+ function discoveryGuidance(published) {
1203
+ return [
1204
+ ...DISCOVERY_PLAYBOOK.split("\n"),
1205
+ "",
1206
+ ` ${commandLine(published, "discover --file <path>")}`,
1207
+ "",
1208
+ "One promise at a time still works, with the propose subcommand or with the propose tool on the",
1209
+ "Balladeer MCP server you just connected. Both go the same way this one does, over this",
1210
+ "repository's agent connection, which does not expire, so proposing keeps working long after this",
1211
+ "setup session has ended.",
1212
+ "",
1213
+ ` ${commandLine(published, "propose --file <path>")}`,
1214
+ ];
1215
+ }
1216
+ /**
1217
+ * What this repository's first promise is actually doing, in three states that
1218
+ * cannot be confused for one another: agreed, waiting for a person, or not yet
1219
+ * proposed.
1220
+ *
1221
+ * The distinction is the whole point of the step. An agreed promise reported as
1222
+ * waiting sends the person who just agreed to it back to the browser to agree
1223
+ * again, and a rejected proposal reported as waiting sends them to a record
1224
+ * somebody already refused. The server now counts only what is genuinely
1225
+ * waiting, so the states are read from it rather than inferred.
1226
+ */
1227
+ async function stepPromise(options, session, view, published) {
1228
+ const state = await readState(options, session);
1229
+ const current = state?.repositories.find((repository) => repository.id === view.id) ?? view;
1230
+ if (current.firstPromiseId !== null) {
1231
+ const promiseUrl = `${options.controlPlane}/promises/${current.firstPromiseId}`;
1232
+ say(options, "Step 5 of 5 A named person agreed to the first promise");
1233
+ say(options, ` ${promiseUrl}`);
1234
+ say(options, " It stands agreed and is not yet protected. Protection starts on its own the moment a verifier qualifies, once the verifier pull request has merged. Nobody is asked to turn it on.");
1235
+ emit(options, {
1236
+ step: "promise",
1237
+ status: "agreed",
1238
+ promiseId: current.firstPromiseId,
1239
+ promiseUrl,
1240
+ });
1241
+ return;
1242
+ }
1243
+ if (current.latestCandidateId !== null) {
1244
+ const reviewUrl = `${options.controlPlane}/candidates/${current.latestCandidateId}`;
1245
+ say(options, "Step 5 of 5 A first promise is proposed and waiting for a person");
1246
+ say(options, ` Review it: ${reviewUrl}`);
1247
+ say(options, " A named person reads the proposal and clicks Agree. Nothing else can.");
1248
+ emit(options, {
1249
+ step: "promise",
1250
+ status: "proposed",
1251
+ candidateId: current.latestCandidateId,
1252
+ reviewUrl,
1253
+ });
1254
+ return;
1255
+ }
1256
+ say(options, "Step 5 of 5 Discover this repository's promises");
1257
+ for (const line of discoveryGuidance(published))
1258
+ say(options, line);
1259
+ emit(options, { step: "promise", status: "none" });
1260
+ }
1261
+ /* ---------------------------- The receipts ------------------------------- */
1262
+ function repositoryLine(view) {
1263
+ return {
1264
+ repository: view.displayName,
1265
+ agent: view.agentConfigured ? "agent connected" : "no agent connection",
1266
+ ci: view.ciConfigured
1267
+ ? `CI connected, ${view.ciValidatedRunCount} authenticated run${view.ciValidatedRunCount === 1 ? "" : "s"}`
1268
+ : view.ciIdentityRecorded
1269
+ ? "CI identity recorded, waiting for the first authenticated run"
1270
+ : "CI not recorded",
1271
+ };
1272
+ }
1273
+ /**
1274
+ * What this machine holds now, read from the store at receipt time.
1275
+ *
1276
+ * Read again rather than carried down from the start of the run, because step 3
1277
+ * may have written a credential since, and a receipt that reported the state
1278
+ * before its own run would be exactly the stale claim these receipts exist to
1279
+ * prevent. A store this run cannot read is reported as holding nothing, which is
1280
+ * the truthful answer to "can `propose` run here".
1281
+ */
1282
+ function credentialHere(options, repositoryId) {
1283
+ try {
1284
+ return readCredentials(options.environment).agents.some((agent) => agent.controlPlane === options.controlPlane && agent.repositoryId === repositoryId);
1285
+ }
1286
+ catch {
1287
+ return false;
1288
+ }
1289
+ }
1290
+ function printReceipts(options, session, state, view) {
1291
+ const active = state.repositories.filter((repository) => repository.status === "active");
1292
+ const rows = active.map(repositoryLine);
1293
+ const current = view === undefined ? undefined : active.find((item) => item.id === view.id);
1294
+ const setupComplete = current !== undefined && current.agentConfigured && current.ciConfigured;
1295
+ const names = active.map((repository) => repository.displayName).join(", ");
1296
+ const setupSentence = active.length === 0
1297
+ ? `Paired as ${session.membershipDisplayName} in ${state.workspace.name}. No repository is enrolled yet.`
1298
+ : `Paired as ${session.membershipDisplayName} in ${state.workspace.name}. ${active.length} repositor${active.length === 1 ? "y" : "ies"} enrolled: ${names}.`;
1299
+ // Agreed first, waiting second, and nothing proposed last. Reading the two in
1300
+ // the other order is what produced a receipt that asked a person to agree to a
1301
+ // promise the receipt below it said already stood agreed.
1302
+ const promiseId = current?.firstPromiseId ?? null;
1303
+ const candidateId = current?.latestCandidateId ?? null;
1304
+ const promiseUrl = promiseId === null ? undefined : `${options.controlPlane}/promises/${promiseId}`;
1305
+ const reviewUrl = promiseId !== null || candidateId === null
1306
+ ? undefined
1307
+ : `${options.controlPlane}/candidates/${candidateId}`;
1308
+ const promiseLink = promiseUrl ?? reviewUrl;
1309
+ const promiseSentence = promiseId !== null
1310
+ ? "Agreed. A named person agreed to this repository's first promise."
1311
+ : candidateId !== null
1312
+ ? "Proposed, and not yet agreed. A named person agrees to it in the browser."
1313
+ : "Not yet. No promise has been proposed for this repository.";
1314
+ const protectedCount = current?.promiseCount ?? 0;
1315
+ const protectionSentence = protectedCount > 0
1316
+ ? `${protectedCount} promise${protectedCount === 1 ? " stands" : "s stand"} agreed. Protection starts on its own the moment a verifier qualifies, once the verifier pull request has merged.`
1317
+ : "Not yet. Next: a person agrees to the promise, then merge the verifier pull request; protection starts on its own when the verifier qualifies.";
1318
+ // What a person can still do tomorrow, from this machine, which is a different
1319
+ // fact from what was set up today and a different fact again from what the
1320
+ // repository has. The setup session ends; the credential this machine holds
1321
+ // does not, and `propose` and `status` run over that one. A repository
1322
+ // connected from a browser or another laptop leaves this machine with nothing
1323
+ // to run over, so this receipt reads the store rather than the server.
1324
+ const currentName = current?.displayName ?? "This repository";
1325
+ const heldHere = current !== undefined && credentialHere(options, current.id);
1326
+ const connectionSentence = heldHere
1327
+ ? `${currentName} has an agent connection issued for this machine, which does not expire. \`propose\` and \`status\` run over it here, so they keep working after this setup session ends. Each machine holds its own: another one runs setup there to get one.`
1328
+ : current?.agentConfigured === true
1329
+ ? `${currentName} has an agent connection, but not on this machine. \`propose\` and \`status\` run over a credential this machine holds, so run setup here to issue one; the connections already out there are untouched.`
1330
+ : "Not yet. Without an agent credential on this machine, `propose` and `status` have nothing to run over once this setup session ends.";
1331
+ emit(options, {
1332
+ step: "receipt",
1333
+ setup: { complete: setupComplete, sentence: setupSentence },
1334
+ firstPromise: {
1335
+ complete: promiseId !== null,
1336
+ sentence: promiseSentence,
1337
+ ...(reviewUrl === undefined ? {} : { reviewUrl }),
1338
+ ...(promiseUrl === undefined ? {} : { promiseUrl }),
1339
+ },
1340
+ protection: { complete: false, sentence: protectionSentence },
1341
+ connection: { complete: heldHere, sentence: connectionSentence },
1342
+ repositories: rows,
1343
+ });
1344
+ if (options.json)
1345
+ return;
1346
+ options.write("\n");
1347
+ options.write(`Setup receipt ${setupSentence}\n`);
1348
+ for (const row of rows) {
1349
+ options.write(` ${row.repository}: ${row.agent}, ${row.ci}.\n`);
1350
+ }
1351
+ options.write(`First-promise receipt ${promiseSentence}\n`);
1352
+ if (promiseLink !== undefined)
1353
+ options.write(` ${promiseLink}\n`);
1354
+ options.write(`Protection receipt ${protectionSentence}\n`);
1355
+ options.write(`Connection receipt ${connectionSentence}\n`);
1356
+ // An offer, and nothing more than an offer. Writing into somebody's
1357
+ // hooks directory uninvited takes over a file they may already be using, and
1358
+ // the first they would know of it is a push that stopped for a reason they
1359
+ // cannot find. So the line names the command and waits to be typed.
1360
+ const published = state.client.publishedVersion;
1361
+ options.write("\n");
1362
+ options.write("Optional A promise's files are sealed, and an ordinary edit inside one breaks the seal.\n");
1363
+ options.write(" This says so before you push, and says nothing otherwise:\n");
1364
+ options.write(` ${commandLine(published, "check-seals")}\n`);
1365
+ options.write(" This makes every push from this checkout ask first:\n");
1366
+ options.write(` ${commandLine(published, "check-seals --install-hook")}\n`);
1367
+ options.write(" Nothing installs that hook for you, and deleting the file it writes removes it.\n");
1368
+ }
1369
+ /* ---------------------------- setup --refresh ----------------------------- */
1370
+ /**
1371
+ * Which invocation this repair writes, decided without asking anybody.
1372
+ *
1373
+ * A repair runs where the stale install is, and the server is not always
1374
+ * reachable from there, so the two honest local sources are how this copy was
1375
+ * itself obtained and what the file already says. A copy running out of a
1376
+ * registry install writes the registry form. A copy running out of a checkout
1377
+ * leaves a registry form alone rather than downgrading a published install to
1378
+ * an absolute path on one person's laptop, and writes the checkout form only
1379
+ * where the file was already a checkout form or had no entry at all.
1380
+ */
1381
+ export function refreshPublishedForm(existing) {
1382
+ if (runningFromRegistryInstall())
1383
+ return CLI_VERSION;
1384
+ const command = existing?.command;
1385
+ return command === "npx" ? CLI_VERSION : null;
1386
+ }
1387
+ /**
1388
+ * `balladeer setup --refresh`: a stale install repairs itself in one command.
1389
+ *
1390
+ * The two files setup writes are the two files that go stale, and they go stale
1391
+ * silently: an `.mcp.json` entry naming a version keeps working, and a
1392
+ * conventions block from three releases ago keeps being read. This rewrites both
1393
+ * to the current form, touches nothing else in either file, and says what it
1394
+ * changed. Run twice it changes nothing the second time, which is what makes it
1395
+ * safe to put in a nag an agent will act on without asking.
1396
+ */
1397
+ async function runRefresh(options) {
1398
+ say(options, `Balladeer setup --refresh, version ${CLI_VERSION}.\n`);
1399
+ const root = await repositoryRoot(options.cwd);
1400
+ if (root === undefined) {
1401
+ return failRefresh(options, "not_a_repository", "This directory is not a git work tree, so there is no repository configuration to refresh.");
1402
+ }
1403
+ const existing = currentEntry(readMcpConfig(root));
1404
+ let stored;
1405
+ try {
1406
+ const credentials = readCredentials(options.environment);
1407
+ const selection = selectAgent(credentials.agents, options.controlPlane, undefined, repositoryHint(options.cwd));
1408
+ if (selection.kind === "agent")
1409
+ stored = selection.agent.repositoryId;
1410
+ }
1411
+ catch {
1412
+ // A store this machine cannot read is not a reason to refuse the repair:
1413
+ // the entry in the file names the repository too, and that is what is being
1414
+ // rewritten.
1415
+ }
1416
+ // The stored credential first, because it is this machine's own record of
1417
+ // which repository it is connected for; the file second, because a machine
1418
+ // that never paired can still repair a checkout somebody else set up.
1419
+ const repositoryId = stored ?? entryRepositoryId(existing);
1420
+ if (repositoryId === undefined) {
1421
+ return failRefresh(options, "not_connected", `Nothing here names a repository to refresh: this machine holds no Balladeer credential for it and ${MCP_CONFIG_FILE} has no balladeer entry. Run \`${commandLine(null, "setup")}\` to connect it.`);
1422
+ }
1423
+ const merged = mergeMcpConfig(root, stdioEntry(repositoryId, refreshPublishedForm(existing)), options.controlPlane);
1424
+ if (merged.kind === "refused") {
1425
+ say(options, merged.reason);
1426
+ say(options, "Merge this block into it yourself:");
1427
+ for (const line of merged.block.split("\n"))
1428
+ say(options, ` ${line}`);
1429
+ return failRefresh(options, "mcp_config_refused", merged.reason);
1430
+ }
1431
+ const conventions = writeConventions(root);
1432
+ if (conventions.kind === "refused") {
1433
+ say(options, conventions.reason);
1434
+ return failRefresh(options, "conventions_refused", conventions.reason);
1435
+ }
1436
+ const files = [];
1437
+ if (merged.changed)
1438
+ files.push(MCP_CONFIG_FILE);
1439
+ if (conventions.changed)
1440
+ files.push(conventions.file);
1441
+ say(options, merged.changed
1442
+ ? `Rewrote the balladeer entry in ${MCP_CONFIG_FILE}. Every other entry in that file is untouched.`
1443
+ : `${MCP_CONFIG_FILE} already names the current command; I left it alone.`);
1444
+ say(options, conventions.changed
1445
+ ? `Rewrote the Balladeer block in ${conventions.file} to conventions v${conventions.version}${conventions.previousVersion === undefined
1446
+ ? ""
1447
+ : ` (it was v${conventions.previousVersion})`}. Everything outside the markers is untouched.`
1448
+ : `${conventions.file} already carries conventions v${conventions.version}; I left it alone.`);
1449
+ if (files.length === 0) {
1450
+ say(options, "Nothing to change: this repository is already on the current form.");
1451
+ }
1452
+ else {
1453
+ say(options, `Commit ${files.join(" and ")} when you are ready.`);
1454
+ }
1455
+ emit(options, {
1456
+ step: "refresh",
1457
+ status: files.length === 0 ? "current" : "updated",
1458
+ repositoryId,
1459
+ files,
1460
+ conventionsVersion: conventions.version,
1461
+ ...(conventions.previousVersion === undefined
1462
+ ? {}
1463
+ : { previousConventionsVersion: conventions.previousVersion }),
1464
+ });
1465
+ return 0;
1466
+ }
1467
+ function failRefresh(options, reason, message) {
1468
+ say(options, message);
1469
+ emit(options, { step: "refresh", status: "blocked", files: [], reason, message });
1470
+ return 4;
1471
+ }