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,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 {};
|