balladeer 0.0.5 → 1.0.1

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