balladeer 0.0.5 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/LICENSE +200 -5
  2. package/README.md +154 -68
  3. package/dist/agent.d.ts +126 -0
  4. package/dist/agent.js +209 -0
  5. package/dist/cli.d.ts +34 -0
  6. package/dist/cli.js +392 -0
  7. package/dist/client.d.ts +44 -0
  8. package/dist/client.js +114 -0
  9. package/dist/commands/affected.d.ts +22 -0
  10. package/dist/commands/affected.js +122 -0
  11. package/dist/commands/check-seals.d.ts +37 -0
  12. package/dist/commands/check-seals.js +289 -0
  13. package/dist/commands/discover.d.ts +68 -0
  14. package/dist/commands/discover.js +395 -0
  15. package/dist/commands/explain.d.ts +35 -0
  16. package/dist/commands/explain.js +90 -0
  17. package/dist/commands/invite.d.ts +24 -0
  18. package/dist/commands/invite.js +197 -0
  19. package/dist/commands/mcp.d.ts +65 -0
  20. package/dist/commands/mcp.js +202 -0
  21. package/dist/commands/propose.d.ts +59 -0
  22. package/dist/commands/propose.js +262 -0
  23. package/dist/commands/repositories.d.ts +18 -0
  24. package/dist/commands/repositories.js +185 -0
  25. package/dist/commands/setup.d.ts +75 -0
  26. package/dist/commands/setup.js +1471 -0
  27. package/dist/commands/status.d.ts +35 -0
  28. package/dist/commands/status.js +482 -0
  29. package/dist/commands/touch-map.d.ts +42 -0
  30. package/dist/commands/touch-map.js +251 -0
  31. package/dist/commands/whoami.d.ts +8 -0
  32. package/dist/commands/whoami.js +79 -0
  33. package/dist/conventions.d.ts +69 -0
  34. package/dist/conventions.js +175 -0
  35. package/dist/copy.d.ts +148 -0
  36. package/dist/copy.js +459 -0
  37. package/dist/currency.d.ts +31 -0
  38. package/dist/currency.js +72 -0
  39. package/dist/gh.d.ts +80 -0
  40. package/dist/gh.js +188 -0
  41. package/dist/git.d.ts +76 -0
  42. package/dist/git.js +203 -0
  43. package/dist/markers.d.ts +76 -0
  44. package/dist/markers.js +125 -0
  45. package/dist/mcp-config.d.ts +99 -0
  46. package/dist/mcp-config.js +230 -0
  47. package/dist/release.d.ts +55 -0
  48. package/dist/release.js +67 -0
  49. package/dist/repository.d.ts +8 -0
  50. package/dist/repository.js +32 -0
  51. package/dist/seals.d.ts +48 -0
  52. package/dist/seals.js +112 -0
  53. package/dist/store.d.ts +98 -0
  54. package/dist/store.js +225 -0
  55. package/dist/touch-map.d.ts +241 -0
  56. package/dist/touch-map.js +487 -0
  57. package/dist/wire.d.ts +588 -0
  58. package/dist/wire.js +20 -0
  59. package/package.json +19 -10
  60. package/bin/balladeer.js +0 -161
@@ -0,0 +1,262 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { callAgentTool, noAgentCredentialSentence, selectAgent, structuredString, } from "../agent.js";
3
+ import { reviewLink } from "../client.js";
4
+ import { commandLine } from "../release.js";
5
+ import { repositoryHint } from "../repository.js";
6
+ import { StoreError, readCredentials } from "../store.js";
7
+ import {} from "../wire.js";
8
+ const MAX_FILE_BYTES = 64 * 1024;
9
+ /**
10
+ * Every field the server's schema requires with no default. It is every
11
+ * required key of `semanticMeaningSchema` and nothing else: three of these were
12
+ * missing once, and a file without them passed this gate, left the machine, and
13
+ * was refused on the other side, which is exactly what this check exists to
14
+ * prevent. `refactorExamples` is deliberately absent because the schema now
15
+ * accepts a meaning without one; a file that still carries one is still sent.
16
+ */
17
+ const REQUIRED_MEANING_FIELDS = [
18
+ "title",
19
+ "beneficiary",
20
+ "trigger",
21
+ "preconditions",
22
+ "observableOutcome",
23
+ "allowedVariations",
24
+ "nonGoals",
25
+ "passingExamples",
26
+ "failingExamples",
27
+ "scope",
28
+ ];
29
+ /** The only keys this command sends, at each of the two levels it reads. */
30
+ const ALLOWED_TOP_LEVEL = ["teachBack", "explicitIntent"];
31
+ const ALLOWED_TEACH_BACK = [
32
+ "meaning",
33
+ "unresolvedQuestions",
34
+ "proposedOwnerId",
35
+ "confidence",
36
+ ];
37
+ function unexpectedKeys(value, allowed) {
38
+ return Object.keys(value).filter((key) => !allowed.includes(key));
39
+ }
40
+ /**
41
+ * The shape check that happens on the developer's machine, and the body it
42
+ * builds.
43
+ *
44
+ * `--file` reads whatever path it is handed, and an agent that mistypes one
45
+ * would otherwise upload a `.env`, a source file, or a log to a service whose
46
+ * whole boundary is that it never receives those. Refusing here means the wrong
47
+ * file never leaves the machine, rather than leaving it and being refused.
48
+ *
49
+ * So this returns the exact object to send rather than a verdict on the file.
50
+ * Spreading the parsed file into the request was the same mistake in a quieter
51
+ * form: whatever else the file happened to carry travelled with it, and the
52
+ * strict schema on the other side is a refusal after the fact, not a boundary.
53
+ */
54
+ export function readTeachBackFile(raw) {
55
+ if (Buffer.byteLength(raw, "utf8") > MAX_FILE_BYTES) {
56
+ return { ok: false, reason: "the file is larger than 64 KiB" };
57
+ }
58
+ let parsed;
59
+ try {
60
+ parsed = JSON.parse(raw);
61
+ }
62
+ catch {
63
+ return { ok: false, reason: "the file is not JSON" };
64
+ }
65
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
66
+ return { ok: false, reason: "the file is not a JSON object" };
67
+ }
68
+ const extra = unexpectedKeys(parsed, ALLOWED_TOP_LEVEL);
69
+ if (extra.length > 0) {
70
+ return {
71
+ ok: false,
72
+ reason: `it carries ${extra.join(", ")}, which a proposal does not have and this command will not send`,
73
+ };
74
+ }
75
+ const value = parsed;
76
+ const teachBack = value.teachBack;
77
+ if (teachBack === undefined || typeof teachBack !== "object" || Array.isArray(teachBack)) {
78
+ return { ok: false, reason: "it has no teachBack object" };
79
+ }
80
+ const extraTeachBack = unexpectedKeys(teachBack, ALLOWED_TEACH_BACK);
81
+ if (extraTeachBack.length > 0) {
82
+ return {
83
+ ok: false,
84
+ reason: `teachBack carries ${extraTeachBack.join(", ")}, which this command will not send`,
85
+ };
86
+ }
87
+ const meaning = teachBack.meaning;
88
+ if (meaning === undefined || typeof meaning !== "object" || Array.isArray(meaning)) {
89
+ return { ok: false, reason: "it has no teachBack.meaning" };
90
+ }
91
+ const missing = REQUIRED_MEANING_FIELDS.filter((field) => meaning[field] === undefined);
92
+ if (missing.length > 0) {
93
+ return { ok: false, reason: `teachBack.meaning is missing ${missing.join(", ")}` };
94
+ }
95
+ if (typeof value.explicitIntent !== "string" || value.explicitIntent.trim().length === 0) {
96
+ return { ok: false, reason: "it has no explicitIntent saying why this is being proposed" };
97
+ }
98
+ // Read from the file, never assumed. Every proposal this command ever filed
99
+ // was recorded at 1 because 1 was written here in the code, so the number an
100
+ // owner reads on the review page said nothing about this proposal: it said
101
+ // what the constant said. A file that will not state how sure its author was
102
+ // is refused rather than answered on their behalf.
103
+ const confidence = teachBack.confidence;
104
+ if (typeof confidence !== "number" || !Number.isFinite(confidence)) {
105
+ return {
106
+ ok: false,
107
+ reason: "teachBack.confidence is missing. Say how sure you are that this is the behavior the person meant, as a number from 0 to 1",
108
+ };
109
+ }
110
+ if (confidence < 0 || confidence > 1) {
111
+ return { ok: false, reason: "teachBack.confidence must be between 0 and 1" };
112
+ }
113
+ return {
114
+ ok: true,
115
+ value: {
116
+ explicitIntent: value.explicitIntent,
117
+ teachBack: {
118
+ meaning,
119
+ confidence,
120
+ ...(teachBack.unresolvedQuestions === undefined
121
+ ? {}
122
+ : { unresolvedQuestions: teachBack.unresolvedQuestions }),
123
+ ...(teachBack.proposedOwnerId === undefined
124
+ ? {}
125
+ : { proposedOwnerId: teachBack.proposedOwnerId }),
126
+ },
127
+ },
128
+ };
129
+ }
130
+ /**
131
+ * The repository a proposal is about, taken from the connection rather than from
132
+ * the file.
133
+ *
134
+ * A teach-back file names surfaces and labels; it has never named a repository
135
+ * id, because the setup route stamped one from the repository the request
136
+ * resolved. The connection is now what resolves it, so it stamps it: a file that
137
+ * carried some other repository's id cannot reach that repository, because this
138
+ * overwrites it and the server refuses a candidate whose scope does not match
139
+ * the principal's repository anyway.
140
+ */
141
+ function scopedMeaning(meaning, repositoryId) {
142
+ const scope = meaning.scope;
143
+ if (scope === null || typeof scope !== "object" || Array.isArray(scope))
144
+ return meaning;
145
+ return { ...meaning, scope: { ...scope, repositoryId } };
146
+ }
147
+ /**
148
+ * Proposes one promise over this repository's own agent connection.
149
+ *
150
+ * It used to go over the delegated setup session, which lives for a bounded time
151
+ * and is meant for setting Balladeer up. That made proposing something a person
152
+ * could only do for a while after pairing: the credential expired, and a command
153
+ * whose whole job is "propose this" answered "run setup again". The connection
154
+ * this uses instead is the one setup issued for this repository, the same one
155
+ * the agent host forwards, and it does not expire.
156
+ *
157
+ * Nothing widens by moving: the call is `propose_promise` on the MCP endpoint,
158
+ * so it is authenticated as the agent principal, bound to that one repository,
159
+ * and refused everything except proposing. It lands as a candidate awaiting a
160
+ * named person's agreement, and nothing this command can do agrees to it.
161
+ */
162
+ export async function runPropose(options) {
163
+ const emit = (step) => {
164
+ if (options.json)
165
+ options.write(`${JSON.stringify(step)}\n`);
166
+ };
167
+ const fail = (reason, message, exitCode) => {
168
+ if (options.json)
169
+ emit({ step: "error", reason, message, changed: false, exitCode });
170
+ else
171
+ options.write(`${message}\n`);
172
+ return exitCode;
173
+ };
174
+ if (!options.file) {
175
+ return fail("usage", "Give a proposal file: balladeer propose --file <path>.", 4);
176
+ }
177
+ let raw;
178
+ try {
179
+ raw = readFileSync(options.file, "utf8");
180
+ }
181
+ catch {
182
+ return fail("teachback_unreadable", `I could not read ${options.file}.`, 4);
183
+ }
184
+ const parsed = readTeachBackFile(raw);
185
+ if (!parsed.ok) {
186
+ return fail("teachback_malformed", `${options.file} is not a proposal: ${parsed.reason}. Nothing was sent.`, 4);
187
+ }
188
+ let credentials;
189
+ try {
190
+ credentials = readCredentials(options.environment);
191
+ }
192
+ catch (error) {
193
+ return fail(error instanceof StoreError ? error.code : "credential_store_unusable", error instanceof StoreError ? error.message : String(error), 4);
194
+ }
195
+ const selection = selectAgent(credentials.agents, options.controlPlane, options.repository, options.repo ?? repositoryHint(options.cwd));
196
+ if (selection.kind === "refused") {
197
+ // The repository being connected somewhere else is not this machine holding
198
+ // a credential, and a proposal runs over the one this machine holds. Said in
199
+ // its own sentence, before anything is sent, because the remedy is to
200
+ // connect this machine rather than to pair again: a setup session, however
201
+ // fresh, cannot carry a proposal.
202
+ if (selection.missingFor !== undefined) {
203
+ return fail("no_agent_credential_on_this_machine", `${noAgentCredentialSentence(selection.missingFor)} Nothing was sent.`, 4);
204
+ }
205
+ return fail("no_agent_connection", `${selection.reason} Run \`${commandLine(null, "setup")}\` in this repository first. Nothing was sent.`, 4);
206
+ }
207
+ const { agent } = selection;
208
+ const call = await callAgentTool(agent, "propose_promise", {
209
+ // A person or an agent ran this command against a file that says why. That
210
+ // is the explicit act the server requires; nothing here infers one.
211
+ explicitHumanAction: true,
212
+ source: "protect_behavior",
213
+ explicitIntent: parsed.value.explicitIntent,
214
+ meaning: scopedMeaning(parsed.value.teachBack.meaning, agent.repositoryId),
215
+ unresolvedQuestions: parsed.value.teachBack.unresolvedQuestions ?? [],
216
+ ...(parsed.value.teachBack.proposedOwnerId === undefined
217
+ ? {}
218
+ : { proposedOwnerId: parsed.value.teachBack.proposedOwnerId }),
219
+ confidence: parsed.value.teachBack.confidence,
220
+ });
221
+ switch (call.kind) {
222
+ case "endpoint_refused":
223
+ return fail("agent_endpoint_unsafe", `${call.reason} Nothing was sent.`, 4);
224
+ case "unreachable":
225
+ return fail("control_plane_unreachable", `Balladeer could not be reached at ${options.controlPlane}. Nothing was changed.`, 5);
226
+ case "client_too_old":
227
+ return fail("client_too_old", `This copy of the Balladeer command is too old for ${options.controlPlane}. Update it with: ${call.update}`, 3);
228
+ case "unauthorized":
229
+ return fail("agent_connection_revoked", `Balladeer refused this repository's agent connection: it was revoked, or the repository it was bound to is no longer enrolled. Retrying will not help. Run \`${commandLine(null, "setup")}\` here again, or issue a new connection at ${options.controlPlane}/setup. Nothing was recorded.`, 5);
230
+ case "tool_refusal":
231
+ return fail("propose_refused", `Balladeer refused this proposal: ${call.text}`, 5);
232
+ case "http":
233
+ return fail("propose_failed", `Balladeer answered ${call.status} to this proposal. Nothing was recorded.`, 5);
234
+ case "malformed":
235
+ return fail("propose_failed", "Balladeer could not record this proposal.", 5);
236
+ case "result":
237
+ break;
238
+ }
239
+ const candidateId = structuredString(call.structured, "id");
240
+ const status = structuredString(call.structured, "status");
241
+ if (candidateId === undefined) {
242
+ return fail("propose_failed", "Balladeer could not record this proposal.", 5);
243
+ }
244
+ if (status !== "pending_review") {
245
+ // The server matched something already on file rather than recording a new
246
+ // proposal, which is what the setup route used to answer as a conflict. It
247
+ // is reported as one here too: sending a person to agree to a record that
248
+ // was already decided is worse than saying nothing was recorded.
249
+ return fail("candidate_not_pending", `Balladeer matched an existing proposal, ${candidateId}, which is ${status ?? "in an unreported state"}. Nothing new was recorded.`, 5);
250
+ }
251
+ // The review link is built from the address this copy paired with. The tool
252
+ // answers with an id and no link at all, so there is nothing here a
253
+ // misconfigured public base URL could redirect.
254
+ const review = reviewLink(options.controlPlane, `candidates/${candidateId}`);
255
+ emit({ step: "promise", status: "proposed", candidateId, reviewUrl: review });
256
+ if (!options.json) {
257
+ options.write("Proposed. You will own it unless you named someone else; the owner reads it and clicks Agree, and nothing else can.\n");
258
+ options.write(`${review}\n`);
259
+ options.write(`Sent over ${agent.repository ?? "this repository"}'s Balladeer agent connection, which does not expire.\n`);
260
+ }
261
+ return 0;
262
+ }
@@ -0,0 +1,18 @@
1
+ export type RepositoriesOptions = Readonly<{
2
+ controlPlane: string;
3
+ json: boolean;
4
+ environment: NodeJS.ProcessEnv;
5
+ cwd: string;
6
+ write: (text: string) => void;
7
+ }>;
8
+ /**
9
+ * What this machine could add to Balladeer, and which of them are already in.
10
+ *
11
+ * It exists so the agent asking "which repositories shall I set up" has
12
+ * something to show. Setting one up is still `setup`, run in a checkout of it or
13
+ * named with `--repository`; this command changes nothing and sends nothing
14
+ * anywhere. The one network call it makes is to Balladeer, to find out which of
15
+ * these are already enrolled, and it is skipped entirely when no session is
16
+ * stored.
17
+ */
18
+ export declare function runRepositories(options: RepositoriesOptions): Promise<number>;
@@ -0,0 +1,185 @@
1
+ import { ClientTooOldError, TransportError, request } from "../client.js";
2
+ import { ghAvailable, ghLogin, ghSignedIn, listVisibleRepositories } from "../gh.js";
3
+ import { commandLine } from "../release.js";
4
+ import { StoreError, findSession, readCredentials } from "../store.js";
5
+ import { repositoryHint } from "../repository.js";
6
+ import {} from "../wire.js";
7
+ /** How many to offer. A list nobody reads to the end is a list nobody chooses from. */
8
+ const LIMIT = 100;
9
+ function emit(options, step) {
10
+ if (options.json)
11
+ options.write(`${JSON.stringify(step)}\n`);
12
+ }
13
+ function say(options, text) {
14
+ if (!options.json)
15
+ options.write(`${text}\n`);
16
+ }
17
+ /**
18
+ * What this machine could add to Balladeer, and which of them are already in.
19
+ *
20
+ * It exists so the agent asking "which repositories shall I set up" has
21
+ * something to show. Setting one up is still `setup`, run in a checkout of it or
22
+ * named with `--repository`; this command changes nothing and sends nothing
23
+ * anywhere. The one network call it makes is to Balladeer, to find out which of
24
+ * these are already enrolled, and it is skipped entirely when no session is
25
+ * stored.
26
+ */
27
+ export async function runRepositories(options) {
28
+ const here = repositoryHint(options.cwd);
29
+ const known = here === "unknown/unknown" ? undefined : here;
30
+ if (!(await ghAvailable())) {
31
+ return withoutGh(options, known, "the GitHub CLI is not installed", "Install the GitHub CLI from https://cli.github.com, sign in with `gh auth login`, then run this command again to see the whole list.");
32
+ }
33
+ if (!(await ghSignedIn())) {
34
+ return withoutGh(options, known, "gh is installed but not signed in", "Run `gh auth login`, then run this command again to see the whole list.");
35
+ }
36
+ // The account's own repositories, and the organization this checkout belongs
37
+ // to. A Didero engineer sitting in one of their own repositories wants the
38
+ // other eleven, and those belong to the organization rather than to them.
39
+ const owners = new Set([undefined]);
40
+ const hereOwner = known?.split("/")[0];
41
+ if (hereOwner !== undefined)
42
+ owners.add(hereOwner);
43
+ const login = await ghLogin();
44
+ if (login !== undefined &&
45
+ hereOwner !== undefined &&
46
+ login.toLowerCase() === hereOwner.toLowerCase()) {
47
+ owners.delete(hereOwner);
48
+ }
49
+ const seen = new Map();
50
+ for (const owner of owners) {
51
+ for (const found of await listVisibleRepositories(owner, LIMIT)) {
52
+ if (!seen.has(found.repository.toLowerCase())) {
53
+ seen.set(found.repository.toLowerCase(), found.canWrite);
54
+ }
55
+ }
56
+ }
57
+ if (known !== undefined && !seen.has(known.toLowerCase()))
58
+ seen.set(known.toLowerCase(), true);
59
+ const enrolled = await enrolledNames(options);
60
+ const rows = [...seen.keys()]
61
+ .map((name) => ({
62
+ repository: name,
63
+ here: known !== undefined && name === known.toLowerCase(),
64
+ ...(enrolled === undefined ? {} : { enrolled: enrolled.has(name) }),
65
+ }))
66
+ // The one you are standing in first, then the rest alphabetically, so the
67
+ // default is the first thing read rather than something to hunt for.
68
+ .sort((left, right) => left.here === right.here
69
+ ? left.repository.localeCompare(right.repository)
70
+ : left.here
71
+ ? -1
72
+ : 1);
73
+ if (rows.length === 0) {
74
+ say(options, "Your GitHub account can see no repositories to add.");
75
+ emit(options, {
76
+ step: "repositories",
77
+ source: "none",
78
+ workspaceKnown: enrolled !== undefined,
79
+ repositories: [],
80
+ });
81
+ return 0;
82
+ }
83
+ report(options, rows, enrolled !== undefined);
84
+ emit(options, {
85
+ step: "repositories",
86
+ source: "gh",
87
+ workspaceKnown: enrolled !== undefined,
88
+ repositories: rows,
89
+ });
90
+ return 0;
91
+ }
92
+ /** The listing a person reads, and the one line that sets any of them up. */
93
+ function report(options, rows, workspaceKnown) {
94
+ say(options, workspaceKnown
95
+ ? "Repositories your GitHub account can see. Ones already in Balladeer are marked."
96
+ : "Repositories your GitHub account can see. Nothing is stored for this Balladeer, so which of them are already added is unknown.");
97
+ say(options, "");
98
+ for (const row of rows) {
99
+ const marks = [
100
+ row.here ? "this directory" : undefined,
101
+ row.enrolled === true ? "already in Balladeer" : undefined,
102
+ ].filter((mark) => mark !== undefined);
103
+ say(options, ` ${row.repository}${marks.length === 0 ? "" : ` (${marks.join(", ")})`}`);
104
+ }
105
+ const addable = rows.filter((row) => row.enrolled !== true);
106
+ say(options, "");
107
+ if (addable.length === 0) {
108
+ say(options, "Every repository this machine can see is already in Balladeer.");
109
+ return;
110
+ }
111
+ say(options, "Add one, or several, with:");
112
+ say(options, ` ${commandLine(null, `setup ${addable
113
+ .slice(0, 2)
114
+ .map((row) => `--repository ${row.repository}`)
115
+ .join(" ")}`)}`);
116
+ say(options, "Connecting a repository's coding agent and its CI happens in a checkout of that repository, so run setup there for each one. Naming a repository from somewhere else adds it to the workspace and stops there, which is said again when it happens.");
117
+ }
118
+ /**
119
+ * Which repositories the workspace already holds, or nothing at all.
120
+ *
121
+ * Nothing rather than an empty set when there is no session or the read failed:
122
+ * an empty set would render every repository as addable, and an agent reading
123
+ * that would offer to add repositories the workspace already has.
124
+ */
125
+ async function enrolledNames(options) {
126
+ let session;
127
+ try {
128
+ session = findSession(readCredentials(options.environment), options.controlPlane);
129
+ }
130
+ catch (error) {
131
+ if (!(error instanceof StoreError))
132
+ throw error;
133
+ return undefined;
134
+ }
135
+ if (session === undefined)
136
+ return undefined;
137
+ try {
138
+ const state = await request(options.controlPlane, {
139
+ method: "GET",
140
+ path: "/api/setup/v1/state",
141
+ bearer: session.token,
142
+ });
143
+ return new Set(state.repositories
144
+ .filter((repository) => repository.status === "active")
145
+ .map((repository) => repository.displayName.toLowerCase()));
146
+ }
147
+ catch (error) {
148
+ if (error instanceof ClientTooOldError || error instanceof TransportError)
149
+ return undefined;
150
+ return undefined;
151
+ }
152
+ }
153
+ /**
154
+ * The answer when `gh` cannot list anything.
155
+ *
156
+ * Still an answer rather than a refusal: this directory's own repository is a
157
+ * real thing an agent can offer, and telling somebody with no `gh` installed
158
+ * that there is nothing to add would be false.
159
+ */
160
+ function withoutGh(options, here, reason, next) {
161
+ if (here === undefined) {
162
+ say(options, `I could not list your repositories: ${reason}.`);
163
+ say(options, ` This directory has no GitHub origin remote either, so I have nothing to offer.`);
164
+ say(options, ` Next: ${next}`);
165
+ emit(options, {
166
+ step: "repositories",
167
+ source: "none",
168
+ workspaceKnown: false,
169
+ repositories: [],
170
+ reason,
171
+ });
172
+ return 0;
173
+ }
174
+ say(options, `I could not list your repositories: ${reason}.`);
175
+ say(options, ` This directory's origin remote is ${here}, which is the one setup adds by default.`);
176
+ say(options, ` Next: ${next}`);
177
+ emit(options, {
178
+ step: "repositories",
179
+ source: "origin",
180
+ workspaceKnown: false,
181
+ repositories: [{ repository: here.toLowerCase(), here: true }],
182
+ reason,
183
+ });
184
+ return 0;
185
+ }
@@ -0,0 +1,75 @@
1
+ export type SetupOptions = Readonly<{
2
+ controlPlane: string;
3
+ json: boolean;
4
+ wait: boolean;
5
+ repo?: string;
6
+ createWorkspace?: string;
7
+ /**
8
+ * Every repository this run was told to set up, in the order they were named.
9
+ *
10
+ * One of them is this run's own: the steps after the enrollment write files
11
+ * and push a branch, and they can only do that to the working tree this
12
+ * process was started in. The rest are added to the workspace and nothing
13
+ * else, which is exactly what an agent enrolling a team's repositories from
14
+ * one checkout can honestly do from here, and the run says so per repository.
15
+ */
16
+ repositories?: readonly string[];
17
+ /**
18
+ * Repair the two files a previous setup wrote in this repository, and do
19
+ * nothing else. No pairing, no enrollment, no credential, no network: a
20
+ * stale install is repaired where it is, which may be a laptop that cannot
21
+ * reach the control plane at that moment.
22
+ */
23
+ refresh?: boolean;
24
+ environment: NodeJS.ProcessEnv;
25
+ cwd: string;
26
+ write: (text: string) => void;
27
+ sleep?: (ms: number) => Promise<void>;
28
+ now?: () => Date;
29
+ }>;
30
+ /** The bound the control plane's own name check holds, refused here so a name
31
+ * nobody could create never costs a pairing code. */
32
+ export declare const CREATE_WORKSPACE_MAX_LENGTH = 120;
33
+ /**
34
+ * Which repository this run acts on, and which it only adds.
35
+ *
36
+ * Every step after the enrollment acts on the working tree this process was
37
+ * started in: it writes `.mcp.json` and the conventions block there, and it
38
+ * cuts, commits and pushes the CI branch to that tree's own remote. So exactly
39
+ * one named repository can be this run's own, and it has to be the one this
40
+ * directory is a checkout of. Naming one repository while sitting in another
41
+ * would enroll one and change the other, and push a branch to a repository
42
+ * nobody named.
43
+ *
44
+ * A directory with no readable GitHub origin is not a disagreement: naming a
45
+ * repository is exactly how a person adds one from somewhere that is not a
46
+ * checkout of it, and every step below says what it could not do there.
47
+ */
48
+ type Chosen = Readonly<{
49
+ kind: "chosen";
50
+ primary: string;
51
+ also: readonly string[];
52
+ }> | Readonly<{
53
+ kind: "mismatch";
54
+ message: string;
55
+ }>;
56
+ export declare function chooseRepositories(named: readonly string[], cwd: string): Chosen;
57
+ /**
58
+ * One run. It prints what it found, does what it can, and exits within seconds.
59
+ * Waiting is the person's choice, behind `--wait`, because an agent shell kills
60
+ * a command that blocks for minutes and the pairing would be lost with it.
61
+ */
62
+ export declare function runSetup(options: SetupOptions): Promise<number>;
63
+ /**
64
+ * Which invocation this repair writes, decided without asking anybody.
65
+ *
66
+ * A repair runs where the stale install is, and the server is not always
67
+ * reachable from there, so the two honest local sources are how this copy was
68
+ * itself obtained and what the file already says. A copy running out of a
69
+ * registry install writes the registry form. A copy running out of a checkout
70
+ * leaves a registry form alone rather than downgrading a published install to
71
+ * an absolute path on one person's laptop, and writes the checkout form only
72
+ * where the file was already a checkout form or had no entry at all.
73
+ */
74
+ export declare function refreshPublishedForm(existing: unknown): string | null;
75
+ export {};