balladeer 0.0.4 → 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.
- package/LICENSE +200 -5
- package/README.md +154 -68
- package/dist/agent.d.ts +126 -0
- package/dist/agent.js +209 -0
- package/dist/cli.d.ts +34 -0
- package/dist/cli.js +392 -0
- package/dist/client.d.ts +44 -0
- package/dist/client.js +114 -0
- package/dist/commands/affected.d.ts +22 -0
- package/dist/commands/affected.js +122 -0
- package/dist/commands/check-seals.d.ts +37 -0
- package/dist/commands/check-seals.js +289 -0
- package/dist/commands/discover.d.ts +68 -0
- package/dist/commands/discover.js +395 -0
- package/dist/commands/explain.d.ts +35 -0
- package/dist/commands/explain.js +90 -0
- package/dist/commands/invite.d.ts +24 -0
- package/dist/commands/invite.js +197 -0
- package/dist/commands/mcp.d.ts +65 -0
- package/dist/commands/mcp.js +202 -0
- package/dist/commands/propose.d.ts +59 -0
- package/dist/commands/propose.js +262 -0
- package/dist/commands/repositories.d.ts +18 -0
- package/dist/commands/repositories.js +185 -0
- package/dist/commands/setup.d.ts +75 -0
- package/dist/commands/setup.js +1471 -0
- package/dist/commands/status.d.ts +35 -0
- package/dist/commands/status.js +482 -0
- package/dist/commands/touch-map.d.ts +42 -0
- package/dist/commands/touch-map.js +251 -0
- package/dist/commands/whoami.d.ts +8 -0
- package/dist/commands/whoami.js +79 -0
- package/dist/conventions.d.ts +69 -0
- package/dist/conventions.js +175 -0
- package/dist/copy.d.ts +148 -0
- package/dist/copy.js +459 -0
- package/dist/currency.d.ts +31 -0
- package/dist/currency.js +72 -0
- package/dist/gh.d.ts +80 -0
- package/dist/gh.js +188 -0
- package/dist/git.d.ts +76 -0
- package/dist/git.js +203 -0
- package/dist/markers.d.ts +76 -0
- package/dist/markers.js +125 -0
- package/dist/mcp-config.d.ts +99 -0
- package/dist/mcp-config.js +230 -0
- package/dist/release.d.ts +55 -0
- package/dist/release.js +67 -0
- package/dist/repository.d.ts +8 -0
- package/dist/repository.js +32 -0
- package/dist/seals.d.ts +48 -0
- package/dist/seals.js +112 -0
- package/dist/store.d.ts +98 -0
- package/dist/store.js +225 -0
- package/dist/touch-map.d.ts +241 -0
- package/dist/touch-map.js +487 -0
- package/dist/wire.d.ts +588 -0
- package/dist/wire.js +20 -0
- package/package.json +19 -10
- package/bin/balladeer.js +0 -136
|
@@ -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
|
+
}
|