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
package/dist/agent.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { noteServerVersion } from "./currency.js";
|
|
2
|
+
import { commandLine } from "./release.js";
|
|
3
|
+
import {} from "./store.js";
|
|
4
|
+
import { CLIENT_HEADER, CLIENT_HEADER_VALUE } from "./wire.js";
|
|
5
|
+
const REPOSITORY_NAME = /^[A-Za-z0-9._-]{1,39}\/[A-Za-z0-9._-]{1,100}$/;
|
|
6
|
+
/**
|
|
7
|
+
* Which stored credential a command uses, or a refusal saying why none.
|
|
8
|
+
*
|
|
9
|
+
* There are exactly two ways to be right about this, and taking whichever entry
|
|
10
|
+
* comes first is neither. Either the caller named a repository, in which case
|
|
11
|
+
* only that repository's credential will do, or it did not, in which case the
|
|
12
|
+
* only defensible answer is the credential belonging to the repository this
|
|
13
|
+
* process was started in. A machine set up for two repositories otherwise binds
|
|
14
|
+
* an agent working in one of them to the other one's promises, silently, and
|
|
15
|
+
* everything that agent then reads and proposes is about the wrong repository.
|
|
16
|
+
*
|
|
17
|
+
* The bearer is never written to stdout or stderr, and an entry whose MCP URL
|
|
18
|
+
* points somewhere other than the control plane it belongs to is refused: a
|
|
19
|
+
* server response must not be able to send this credential to a new host.
|
|
20
|
+
*/
|
|
21
|
+
export function selectAgent(agents, controlPlane, repositoryId, currentRepository) {
|
|
22
|
+
const here = agents.filter((agent) => agent.controlPlane === controlPlane);
|
|
23
|
+
if (repositoryId !== undefined) {
|
|
24
|
+
const named = here.find((agent) => agent.repositoryId === repositoryId);
|
|
25
|
+
if (named === undefined) {
|
|
26
|
+
return {
|
|
27
|
+
kind: "refused",
|
|
28
|
+
reason: `No Balladeer connection for repository ${repositoryId} is stored for ${controlPlane}.`,
|
|
29
|
+
missingFor: repositoryId,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
return withSafeEndpoint(named);
|
|
33
|
+
}
|
|
34
|
+
// `unknown/unknown` is what the origin parse answers when it read nothing, so
|
|
35
|
+
// it is the absence of a repository rather than the name of one.
|
|
36
|
+
if (!REPOSITORY_NAME.test(currentRepository) || currentRepository === "unknown/unknown") {
|
|
37
|
+
return {
|
|
38
|
+
kind: "refused",
|
|
39
|
+
reason: "This directory has no GitHub origin remote I could read, so I cannot tell which repository's Balladeer connection to use. Pass --repository <id>.",
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const matches = here.filter((agent) => agent.repository?.toLowerCase() === currentRepository.toLowerCase());
|
|
43
|
+
// Newest wins among the matches, which is the one the last setup wrote.
|
|
44
|
+
const chosen = matches[matches.length - 1];
|
|
45
|
+
if (chosen === undefined) {
|
|
46
|
+
return {
|
|
47
|
+
kind: "refused",
|
|
48
|
+
reason: `No Balladeer connection for ${currentRepository} is stored for ${controlPlane}.`,
|
|
49
|
+
missingFor: currentRepository,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
return withSafeEndpoint(chosen);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* What a command says when this machine holds no credential for the repository.
|
|
56
|
+
*
|
|
57
|
+
* A credential belongs to the machine it was issued on. A repository can be
|
|
58
|
+
* connected in a browser, or from a colleague's laptop, and this machine still
|
|
59
|
+
* hold nothing: the bearer is shown once, to whoever was there. Saying
|
|
60
|
+
* "Balladeer refused this workspace, run setup to pair again" in that state sent
|
|
61
|
+
* the founder to re-pair a machine whose pairing was never the problem, and a
|
|
62
|
+
* new setup session would not have helped, because these commands do not run
|
|
63
|
+
* over one.
|
|
64
|
+
*
|
|
65
|
+
* The invocation named is the checkout form, which is the one this copy was run
|
|
66
|
+
* as: nothing here has read the server's answer about publication yet.
|
|
67
|
+
*/
|
|
68
|
+
export function noAgentCredentialSentence(repository) {
|
|
69
|
+
return `This machine holds no agent credential for ${repository}. Run \`${commandLine(null, "setup")}\` to connect this machine; a setup session alone cannot propose.`;
|
|
70
|
+
}
|
|
71
|
+
export function withSafeEndpoint(agent) {
|
|
72
|
+
try {
|
|
73
|
+
if (new URL(agent.mcpUrl).origin !== new URL(agent.controlPlane).origin) {
|
|
74
|
+
return {
|
|
75
|
+
kind: "refused",
|
|
76
|
+
reason: "That connection's MCP endpoint is not on the control plane it belongs to, so I sent its credential nowhere.",
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return {
|
|
82
|
+
kind: "refused",
|
|
83
|
+
reason: "That connection's MCP endpoint is not a URL, so I did not use it.",
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
return { kind: "agent", agent };
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The headers every frame this program sends over an agent connection carries,
|
|
90
|
+
* and the one place they are written.
|
|
91
|
+
*
|
|
92
|
+
* `accept` names both media types because the Streamable HTTP transport refuses
|
|
93
|
+
* a POST that does not: a request accepting only JSON is answered 406 by the
|
|
94
|
+
* server before any tool runs. Nothing in this repository noticed, because every
|
|
95
|
+
* test that drove the endpoint wrote its own headers rather than the ones the
|
|
96
|
+
* forwarder sends. The setup probe now sends its one real call through this same
|
|
97
|
+
* constant, so a forwarder that cannot talk to the server is a failed setup step
|
|
98
|
+
* rather than a connection reported as working.
|
|
99
|
+
*
|
|
100
|
+
* `x-balladeer-client` is what lets the server refuse a copy of this program
|
|
101
|
+
* that is too old to talk to it, and answer with the line that updates it.
|
|
102
|
+
*/
|
|
103
|
+
export function forwarderHeaders(token) {
|
|
104
|
+
return {
|
|
105
|
+
authorization: `Bearer ${token}`,
|
|
106
|
+
"content-type": "application/json",
|
|
107
|
+
accept: "application/json, text/event-stream",
|
|
108
|
+
[CLIENT_HEADER]: CLIENT_HEADER_VALUE,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* One JSON-RPC tool call to the endpoint the forwarder would use, with the
|
|
113
|
+
* headers the forwarder sends and the same refusal of an endpoint on another
|
|
114
|
+
* origin. Nothing here reads the bearer back out or writes it anywhere.
|
|
115
|
+
*/
|
|
116
|
+
export async function callAgentTool(agent, name, argumentsValue, timeoutMs = 20_000) {
|
|
117
|
+
const safe = withSafeEndpoint(agent);
|
|
118
|
+
if (safe.kind === "refused")
|
|
119
|
+
return { kind: "endpoint_refused", reason: safe.reason };
|
|
120
|
+
let response;
|
|
121
|
+
try {
|
|
122
|
+
response = await fetch(agent.mcpUrl, {
|
|
123
|
+
method: "POST",
|
|
124
|
+
headers: forwarderHeaders(agent.token),
|
|
125
|
+
body: JSON.stringify({
|
|
126
|
+
jsonrpc: "2.0",
|
|
127
|
+
id: 1,
|
|
128
|
+
method: "tools/call",
|
|
129
|
+
params: { name, arguments: argumentsValue },
|
|
130
|
+
}),
|
|
131
|
+
redirect: "manual",
|
|
132
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return { kind: "unreachable" };
|
|
137
|
+
}
|
|
138
|
+
noteServerVersion(response.headers);
|
|
139
|
+
if (response.status === 426) {
|
|
140
|
+
return { kind: "client_too_old", update: updateLine(await safeJson(response)) };
|
|
141
|
+
}
|
|
142
|
+
if (response.status === 401 || response.status === 403)
|
|
143
|
+
return { kind: "unauthorized" };
|
|
144
|
+
if (!response.ok)
|
|
145
|
+
return { kind: "http", status: response.status };
|
|
146
|
+
const frame = await safeJson(response);
|
|
147
|
+
const result = resultOf(frame);
|
|
148
|
+
if (result === undefined)
|
|
149
|
+
return { kind: "malformed" };
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
export async function safeJson(response) {
|
|
153
|
+
try {
|
|
154
|
+
return (await response.json());
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* The server's own remedy, read from the 426 body.
|
|
162
|
+
*
|
|
163
|
+
* Never a literal written here. Only the control plane knows whether this
|
|
164
|
+
* program is published, and a remedy naming a specifier the registry cannot
|
|
165
|
+
* serve leaves the person worse off than the refusal did.
|
|
166
|
+
*/
|
|
167
|
+
export function updateLine(payload) {
|
|
168
|
+
const update = payload?.update;
|
|
169
|
+
return typeof update === "string" && update.length > 0 && update.length < 200
|
|
170
|
+
? update
|
|
171
|
+
: "the update line Balladeer prints on its setup page";
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* The result inside a JSON-RPC frame, read with `unknown` at every step. A shape
|
|
175
|
+
* that does not match is absence rather than a guess.
|
|
176
|
+
*/
|
|
177
|
+
function resultOf(payload) {
|
|
178
|
+
if (payload === null || typeof payload !== "object")
|
|
179
|
+
return undefined;
|
|
180
|
+
const result = payload.result;
|
|
181
|
+
if (result === null || typeof result !== "object")
|
|
182
|
+
return undefined;
|
|
183
|
+
const structured = result.structuredContent;
|
|
184
|
+
if (result.isError === true) {
|
|
185
|
+
return { kind: "tool_refusal", text: refusalText(result), structured };
|
|
186
|
+
}
|
|
187
|
+
if (structured === null || typeof structured !== "object")
|
|
188
|
+
return undefined;
|
|
189
|
+
return { kind: "result", structured };
|
|
190
|
+
}
|
|
191
|
+
/** The first text block of a refusing tool result, bounded. */
|
|
192
|
+
function refusalText(result) {
|
|
193
|
+
const content = result.content;
|
|
194
|
+
if (!Array.isArray(content))
|
|
195
|
+
return "Balladeer refused this call.";
|
|
196
|
+
for (const block of content) {
|
|
197
|
+
const text = block?.text;
|
|
198
|
+
if (typeof text === "string" && text.length > 0)
|
|
199
|
+
return text.slice(0, 800);
|
|
200
|
+
}
|
|
201
|
+
return "Balladeer refused this call.";
|
|
202
|
+
}
|
|
203
|
+
/** One field of a tool result, when it is a string. Never a guess. */
|
|
204
|
+
export function structuredString(structured, field) {
|
|
205
|
+
if (structured === null || typeof structured !== "object")
|
|
206
|
+
return undefined;
|
|
207
|
+
const value = structured[field];
|
|
208
|
+
return typeof value === "string" ? value : undefined;
|
|
209
|
+
}
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
type Parsed = Readonly<{
|
|
3
|
+
command: string;
|
|
4
|
+
subcommand: string | undefined;
|
|
5
|
+
json: boolean;
|
|
6
|
+
wait: boolean;
|
|
7
|
+
/** Repair the files a previous setup wrote, and do nothing else. */
|
|
8
|
+
refresh: boolean;
|
|
9
|
+
repo: string | undefined;
|
|
10
|
+
file: string | undefined;
|
|
11
|
+
repository: string | undefined;
|
|
12
|
+
/**
|
|
13
|
+
* Every repository named on this run, in order.
|
|
14
|
+
*
|
|
15
|
+
* `setup` is the one command that takes more than one, because adding a
|
|
16
|
+
* repository is the one step it can do for a repository this directory is not
|
|
17
|
+
* a checkout of. Everywhere else a second name is a mistake, and is refused
|
|
18
|
+
* rather than quietly resolved to the last one.
|
|
19
|
+
*/
|
|
20
|
+
repositories: readonly string[];
|
|
21
|
+
owner: string | undefined;
|
|
22
|
+
/** `check-seals --install-hook`: write the optional pre-push hook. */
|
|
23
|
+
installHook: boolean;
|
|
24
|
+
/** `check-seals --runner`: where the pinned runner is, when it has moved. */
|
|
25
|
+
runner: string | undefined;
|
|
26
|
+
createWorkspace: string | undefined;
|
|
27
|
+
/** Everything that was not a flag: the addresses `invite` sends to. */
|
|
28
|
+
positional: readonly string[];
|
|
29
|
+
role: string | undefined;
|
|
30
|
+
controlPlane: string;
|
|
31
|
+
}>;
|
|
32
|
+
export declare function parseArguments(argv: readonly string[]): Parsed;
|
|
33
|
+
export declare function main(argv: readonly string[]): Promise<number>;
|
|
34
|
+
export {};
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { runAffected } from "./commands/affected.js";
|
|
5
|
+
import { runCheckSeals } from "./commands/check-seals.js";
|
|
6
|
+
import { runDiscover } from "./commands/discover.js";
|
|
7
|
+
import { runExplain } from "./commands/explain.js";
|
|
8
|
+
import { parseRole, runInvite } from "./commands/invite.js";
|
|
9
|
+
import { runMcp } from "./commands/mcp.js";
|
|
10
|
+
import { runPropose } from "./commands/propose.js";
|
|
11
|
+
import { runRepositories } from "./commands/repositories.js";
|
|
12
|
+
import { CREATE_WORKSPACE_MAX_LENGTH, runSetup } from "./commands/setup.js";
|
|
13
|
+
import { runStatus } from "./commands/status.js";
|
|
14
|
+
import { runTouchMap } from "./commands/touch-map.js";
|
|
15
|
+
import { runWhoami } from "./commands/whoami.js";
|
|
16
|
+
import { updateNotice } from "./currency.js";
|
|
17
|
+
import { StoreError, normalizeControlPlane } from "./store.js";
|
|
18
|
+
import { CLI_VERSION, DEFAULT_CONTROL_PLANE } from "./wire.js";
|
|
19
|
+
const USAGE = `balladeer ${CLI_VERSION}
|
|
20
|
+
|
|
21
|
+
balladeer setup [--repository owner/name]... [--json] [--wait] [--control-plane <url>]
|
|
22
|
+
[--create-workspace <name>] [--refresh]
|
|
23
|
+
Pair this session, add this repository, connect this coding agent and CI,
|
|
24
|
+
and report what a person still has to do. Name --repository more than once
|
|
25
|
+
to add other repositories to the same workspace; each of those is added and
|
|
26
|
+
nothing more, because connecting an agent and CI happens in a checkout.
|
|
27
|
+
--create-workspace carries a name to the approval page, where a person
|
|
28
|
+
signs in and creates the workspace themselves; this command never creates
|
|
29
|
+
one.
|
|
30
|
+
--refresh does one thing and talks to nobody: it rewrites this
|
|
31
|
+
repository's balladeer entry in .mcp.json and its Balladeer instructions
|
|
32
|
+
block to the current form, leaves every other entry in that file alone,
|
|
33
|
+
and prints what it changed. Run it when Balladeer says a newer version is
|
|
34
|
+
available.
|
|
35
|
+
|
|
36
|
+
balladeer repositories [--json] [--control-plane <url>]
|
|
37
|
+
List the repositories this machine's GitHub account can see, marking the
|
|
38
|
+
one you are standing in and the ones Balladeer already has.
|
|
39
|
+
|
|
40
|
+
balladeer invite <email>... [--role contributor|viewer|administrator] [--json]
|
|
41
|
+
Invite teammates into this workspace by email, and say per address whether
|
|
42
|
+
the invitation went. Inviting is an administrator's act.
|
|
43
|
+
|
|
44
|
+
balladeer status [<promise id>] [--repo owner/name] [--repository <uuid>] [--json]
|
|
45
|
+
[--control-plane <url>]
|
|
46
|
+
Report this repository over its agent connection, and every repository in
|
|
47
|
+
the workspace when a setup session is still live. Named with a promise id,
|
|
48
|
+
report that one promise instead: whether it is holding, and if it is not,
|
|
49
|
+
the commit the failing run checked, the bounded reason, and the cases in
|
|
50
|
+
the agreed meaning that run was checking. That is the form the line on a
|
|
51
|
+
broken promise tells a person to run first.
|
|
52
|
+
|
|
53
|
+
balladeer propose --file <path> [--repo owner/name] [--repository <uuid>] [--json]
|
|
54
|
+
Propose one promise from a proposal file, over this repository's agent
|
|
55
|
+
connection. A named person still agrees to it.
|
|
56
|
+
|
|
57
|
+
balladeer discover --file <path> [--repo owner/name] [--repository <uuid>]
|
|
58
|
+
[--owner <membership id>] [--json]
|
|
59
|
+
Propose a whole discovered catalog, up to ten promises from one file, each
|
|
60
|
+
owned by whoever paired this machine, and print one link that opens all of
|
|
61
|
+
them. A named person still agrees to every one.
|
|
62
|
+
|
|
63
|
+
balladeer check-seals [--json] [--runner <path>] [--install-hook]
|
|
64
|
+
Say whether what you are about to push would break a promise's seal. It
|
|
65
|
+
prints nothing and exits 0 when it would not, and names the promise, its
|
|
66
|
+
owner, its page and the line that seals it again when it would. It runs
|
|
67
|
+
offline, over the runner this repository is pinned to. --install-hook
|
|
68
|
+
writes .git/hooks/pre-push so every push asks first; nothing installs it
|
|
69
|
+
for you, and deleting that file removes it.
|
|
70
|
+
|
|
71
|
+
balladeer touch-map [--json]
|
|
72
|
+
Run this repository's promise verifiers under coverage and write down
|
|
73
|
+
which files each one actually executed, to .continuity/touch-map.json.
|
|
74
|
+
It runs offline and the map stays on this machine: Balladeer is never
|
|
75
|
+
sent it. Node verifiers only in this release.
|
|
76
|
+
|
|
77
|
+
balladeer affected <paths...> [--json]
|
|
78
|
+
Say which promises the named files touch, out of that map, marking any
|
|
79
|
+
answer whose verifier has changed since the map was built. It reads one
|
|
80
|
+
local file and contacts nothing.
|
|
81
|
+
|
|
82
|
+
balladeer explain [--control-plane <url>]
|
|
83
|
+
Print, word for word, what Balladeer can and cannot see, where to watch
|
|
84
|
+
your promises, and what leaving costs.
|
|
85
|
+
|
|
86
|
+
balladeer whoami [--json] [--control-plane <url>]
|
|
87
|
+
Report the stored setup session's workspace, role, scopes, and expiry.
|
|
88
|
+
|
|
89
|
+
balladeer agent rotate [--control-plane <url>]
|
|
90
|
+
Replace this repository's agent credential. A setup session cannot do this,
|
|
91
|
+
because rotating revokes the connections the repository already has.
|
|
92
|
+
|
|
93
|
+
balladeer mcp [--repository <uuid>] [--control-plane <url>]
|
|
94
|
+
Forward one MCP session over stdio using this repository's agent connection.
|
|
95
|
+
|
|
96
|
+
Exit codes: 0 progress reported truthfully, 2 pairing expired or denied or already
|
|
97
|
+
claimed, 3 this copy is too old for the server, 4 usage or credential store problem,
|
|
98
|
+
5 transport or server failure.
|
|
99
|
+
`;
|
|
100
|
+
const SUBCOMMANDS = { agent: ["rotate"] };
|
|
101
|
+
export function parseArguments(argv) {
|
|
102
|
+
const args = [...argv];
|
|
103
|
+
const command = args.shift() ?? "help";
|
|
104
|
+
let subcommand;
|
|
105
|
+
if (SUBCOMMANDS[command] !== undefined) {
|
|
106
|
+
subcommand = args.shift();
|
|
107
|
+
if (subcommand === undefined || !SUBCOMMANDS[command].includes(subcommand)) {
|
|
108
|
+
throw new StoreError("usage", `${command} needs one of: ${SUBCOMMANDS[command].join(", ")}.`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
let json = false;
|
|
112
|
+
let wait = false;
|
|
113
|
+
let refresh = false;
|
|
114
|
+
let installHook = false;
|
|
115
|
+
let runner;
|
|
116
|
+
let controlPlane;
|
|
117
|
+
let repo;
|
|
118
|
+
let file;
|
|
119
|
+
let owner;
|
|
120
|
+
let createWorkspace;
|
|
121
|
+
let role;
|
|
122
|
+
const repositories = [];
|
|
123
|
+
const positional = [];
|
|
124
|
+
const NEEDS = {
|
|
125
|
+
"--control-plane": "a URL",
|
|
126
|
+
"--repo": "an owner/name",
|
|
127
|
+
"--file": "a path",
|
|
128
|
+
"--repository": "a repository, as owner/name for setup or as an id elsewhere",
|
|
129
|
+
"--owner": "a membership id",
|
|
130
|
+
"--create-workspace": "a workspace name",
|
|
131
|
+
"--role": "contributor, viewer, or administrator",
|
|
132
|
+
"--runner": "a path to the pinned runner's cli.js",
|
|
133
|
+
};
|
|
134
|
+
const value = (flag, inline) => {
|
|
135
|
+
const next = inline ?? args.shift();
|
|
136
|
+
if (!next)
|
|
137
|
+
throw new StoreError("usage", `${flag} needs ${NEEDS[flag] ?? "a value"}.`);
|
|
138
|
+
return next;
|
|
139
|
+
};
|
|
140
|
+
while (args.length > 0) {
|
|
141
|
+
const flag = args.shift();
|
|
142
|
+
if (!flag.startsWith("--")) {
|
|
143
|
+
positional.push(flag);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const [name, ...rest] = flag.split("=");
|
|
147
|
+
const inline = rest.length > 0 ? rest.join("=") : undefined;
|
|
148
|
+
if (name === "--json")
|
|
149
|
+
json = true;
|
|
150
|
+
else if (name === "--install-hook")
|
|
151
|
+
installHook = true;
|
|
152
|
+
else if (name === "--runner")
|
|
153
|
+
runner = value("--runner", inline);
|
|
154
|
+
else if (name === "--wait")
|
|
155
|
+
wait = true;
|
|
156
|
+
else if (name === "--refresh")
|
|
157
|
+
refresh = true;
|
|
158
|
+
else if (name === "--control-plane")
|
|
159
|
+
controlPlane = value("--control-plane", inline);
|
|
160
|
+
else if (name === "--repo")
|
|
161
|
+
repo = value("--repo", inline);
|
|
162
|
+
else if (name === "--file")
|
|
163
|
+
file = value("--file", inline);
|
|
164
|
+
else if (name === "--repository")
|
|
165
|
+
repositories.push(value("--repository", inline));
|
|
166
|
+
else if (name === "--owner")
|
|
167
|
+
owner = value("--owner", inline);
|
|
168
|
+
else if (name === "--role")
|
|
169
|
+
role = value("--role", inline);
|
|
170
|
+
else if (name === "--create-workspace") {
|
|
171
|
+
createWorkspace = value("--create-workspace", inline).trim();
|
|
172
|
+
if (createWorkspace.length === 0 || createWorkspace.length > CREATE_WORKSPACE_MAX_LENGTH) {
|
|
173
|
+
throw new StoreError("usage", `--create-workspace needs a workspace name of 1 to ${CREATE_WORKSPACE_MAX_LENGTH} characters.`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
else
|
|
177
|
+
throw new StoreError("usage", `Unknown option ${flag}.`);
|
|
178
|
+
}
|
|
179
|
+
// `invite` is the one command that takes a bare word, and the words it takes
|
|
180
|
+
// are email addresses. Anywhere else a bare word is a mistyped flag or a
|
|
181
|
+
// value that lost its flag, and it was refused before this loop learned to
|
|
182
|
+
// collect them, so it is refused here rather than ignored.
|
|
183
|
+
// `affected` is the other one, and the words it takes are paths in the change
|
|
184
|
+
// in front of the person.
|
|
185
|
+
if (positional.length > 0 && command !== "invite" && command !== "affected") {
|
|
186
|
+
throw new StoreError("usage", `${command} takes no bare arguments: ${positional.join(", ")}.`);
|
|
187
|
+
}
|
|
188
|
+
// `--repository` names one repository everywhere but `setup`, where it names
|
|
189
|
+
// as many as a person wants to add. A second one anywhere else is refused
|
|
190
|
+
// rather than resolved to the last: silently acting on one of two repositories
|
|
191
|
+
// somebody named is worse than saying no.
|
|
192
|
+
// `--refresh` repairs the files setup writes, so it belongs to setup and to
|
|
193
|
+
// nothing else. Accepting it silently elsewhere would let somebody run
|
|
194
|
+
// `status --refresh`, see no error, and believe their install was repaired.
|
|
195
|
+
if (refresh && command !== "setup") {
|
|
196
|
+
throw new StoreError("usage", "--refresh belongs to setup: run `balladeer setup --refresh`.");
|
|
197
|
+
}
|
|
198
|
+
if (command !== "setup" && repositories.length > 1) {
|
|
199
|
+
throw new StoreError("usage", `${command} acts on one repository. Name --repository once, or run it again for the other.`);
|
|
200
|
+
}
|
|
201
|
+
const chosen = controlPlane ?? process.env.BALLADEER_CONTROL_PLANE?.trim() ?? DEFAULT_CONTROL_PLANE;
|
|
202
|
+
return {
|
|
203
|
+
command,
|
|
204
|
+
subcommand,
|
|
205
|
+
json,
|
|
206
|
+
wait,
|
|
207
|
+
refresh,
|
|
208
|
+
repo,
|
|
209
|
+
file,
|
|
210
|
+
repository: repositories[0],
|
|
211
|
+
repositories,
|
|
212
|
+
owner,
|
|
213
|
+
installHook,
|
|
214
|
+
runner,
|
|
215
|
+
createWorkspace,
|
|
216
|
+
positional,
|
|
217
|
+
role,
|
|
218
|
+
controlPlane: normalizeControlPlane(chosen),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
export async function main(argv) {
|
|
222
|
+
let parsed;
|
|
223
|
+
try {
|
|
224
|
+
parsed = parseArguments(argv);
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n\n${USAGE}`);
|
|
228
|
+
return 4;
|
|
229
|
+
}
|
|
230
|
+
const write = (text) => void process.stdout.write(text);
|
|
231
|
+
const code = await dispatch(parsed, write);
|
|
232
|
+
// Said once, at the end, on stderr: an agent reading `--json` gets its steps
|
|
233
|
+
// on stdout unchanged, and a person reading a terminal gets the one line that
|
|
234
|
+
// says this copy is behind. It is a nag and never a failure, so it does not
|
|
235
|
+
// touch the exit code.
|
|
236
|
+
const notice = updateNotice();
|
|
237
|
+
if (notice !== undefined)
|
|
238
|
+
process.stderr.write(`${notice}\n`);
|
|
239
|
+
return code;
|
|
240
|
+
}
|
|
241
|
+
async function dispatch(parsed, write) {
|
|
242
|
+
switch (parsed.command) {
|
|
243
|
+
case "explain":
|
|
244
|
+
return runExplain(write, parsed.controlPlane);
|
|
245
|
+
case "setup":
|
|
246
|
+
return runSetup({
|
|
247
|
+
controlPlane: parsed.controlPlane,
|
|
248
|
+
json: parsed.json,
|
|
249
|
+
wait: parsed.wait,
|
|
250
|
+
refresh: parsed.refresh,
|
|
251
|
+
...(parsed.repo === undefined ? {} : { repo: parsed.repo }),
|
|
252
|
+
repositories: parsed.repositories,
|
|
253
|
+
...(parsed.createWorkspace === undefined
|
|
254
|
+
? {}
|
|
255
|
+
: { createWorkspace: parsed.createWorkspace }),
|
|
256
|
+
environment: process.env,
|
|
257
|
+
cwd: process.cwd(),
|
|
258
|
+
write,
|
|
259
|
+
});
|
|
260
|
+
case "repositories":
|
|
261
|
+
return runRepositories({
|
|
262
|
+
controlPlane: parsed.controlPlane,
|
|
263
|
+
json: parsed.json,
|
|
264
|
+
environment: process.env,
|
|
265
|
+
cwd: process.cwd(),
|
|
266
|
+
write,
|
|
267
|
+
});
|
|
268
|
+
case "invite": {
|
|
269
|
+
const role = parseRole(parsed.role);
|
|
270
|
+
if (role === undefined) {
|
|
271
|
+
process.stderr.write(`--role takes contributor, viewer, or administrator. It was given "${parsed.role}".\n`);
|
|
272
|
+
return 4;
|
|
273
|
+
}
|
|
274
|
+
return runInvite({
|
|
275
|
+
controlPlane: parsed.controlPlane,
|
|
276
|
+
json: parsed.json,
|
|
277
|
+
emails: parsed.positional,
|
|
278
|
+
role,
|
|
279
|
+
environment: process.env,
|
|
280
|
+
write,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
case "check-seals":
|
|
284
|
+
return runCheckSeals({
|
|
285
|
+
json: parsed.json,
|
|
286
|
+
installHook: parsed.installHook,
|
|
287
|
+
...(parsed.runner === undefined ? {} : { runner: parsed.runner }),
|
|
288
|
+
environment: process.env,
|
|
289
|
+
cwd: process.cwd(),
|
|
290
|
+
write,
|
|
291
|
+
});
|
|
292
|
+
case "touch-map":
|
|
293
|
+
return runTouchMap({
|
|
294
|
+
json: parsed.json,
|
|
295
|
+
environment: process.env,
|
|
296
|
+
cwd: process.cwd(),
|
|
297
|
+
write,
|
|
298
|
+
});
|
|
299
|
+
case "affected":
|
|
300
|
+
return runAffected({
|
|
301
|
+
json: parsed.json,
|
|
302
|
+
paths: parsed.positional,
|
|
303
|
+
cwd: process.cwd(),
|
|
304
|
+
write,
|
|
305
|
+
});
|
|
306
|
+
case "status": {
|
|
307
|
+
// At most one. Two ids on one line is a mistake, and answering for the
|
|
308
|
+
// first of them silently would report a promise nobody asked about.
|
|
309
|
+
if (parsed.positional.length > 1) {
|
|
310
|
+
process.stderr.write("status reads one promise id at a time.\n");
|
|
311
|
+
return 4;
|
|
312
|
+
}
|
|
313
|
+
const promiseId = parsed.positional[0];
|
|
314
|
+
return runStatus({
|
|
315
|
+
controlPlane: parsed.controlPlane,
|
|
316
|
+
json: parsed.json,
|
|
317
|
+
...(promiseId === undefined ? {} : { promiseId }),
|
|
318
|
+
...(parsed.repo === undefined ? {} : { repo: parsed.repo }),
|
|
319
|
+
...(parsed.repository === undefined ? {} : { repository: parsed.repository }),
|
|
320
|
+
environment: process.env,
|
|
321
|
+
cwd: process.cwd(),
|
|
322
|
+
write,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
case "propose":
|
|
326
|
+
return runPropose({
|
|
327
|
+
controlPlane: parsed.controlPlane,
|
|
328
|
+
json: parsed.json,
|
|
329
|
+
...(parsed.file === undefined ? {} : { file: parsed.file }),
|
|
330
|
+
...(parsed.repo === undefined ? {} : { repo: parsed.repo }),
|
|
331
|
+
...(parsed.repository === undefined ? {} : { repository: parsed.repository }),
|
|
332
|
+
environment: process.env,
|
|
333
|
+
cwd: process.cwd(),
|
|
334
|
+
write,
|
|
335
|
+
});
|
|
336
|
+
case "discover":
|
|
337
|
+
return runDiscover({
|
|
338
|
+
controlPlane: parsed.controlPlane,
|
|
339
|
+
json: parsed.json,
|
|
340
|
+
...(parsed.file === undefined ? {} : { file: parsed.file }),
|
|
341
|
+
...(parsed.repo === undefined ? {} : { repo: parsed.repo }),
|
|
342
|
+
...(parsed.repository === undefined ? {} : { repository: parsed.repository }),
|
|
343
|
+
...(parsed.owner === undefined ? {} : { owner: parsed.owner }),
|
|
344
|
+
environment: process.env,
|
|
345
|
+
cwd: process.cwd(),
|
|
346
|
+
write,
|
|
347
|
+
});
|
|
348
|
+
case "agent":
|
|
349
|
+
// Rotating revokes every live connection for the repository, which is
|
|
350
|
+
// removal. The approval page tells a person their setup session cannot
|
|
351
|
+
// remove anything, so this command says the same rather than trying and
|
|
352
|
+
// relaying a server refusal the person cannot act on.
|
|
353
|
+
process.stderr.write("A setup session cannot rotate an agent connection: rotating revokes the connections this repository already has, and a setup session may not remove anything.\n" +
|
|
354
|
+
`Rotate it in Balladeer under workspace settings at ${parsed.controlPlane}/settings.\n`);
|
|
355
|
+
return 4;
|
|
356
|
+
case "whoami":
|
|
357
|
+
return runWhoami({
|
|
358
|
+
controlPlane: parsed.controlPlane,
|
|
359
|
+
json: parsed.json,
|
|
360
|
+
environment: process.env,
|
|
361
|
+
write,
|
|
362
|
+
});
|
|
363
|
+
case "mcp":
|
|
364
|
+
return runMcp({
|
|
365
|
+
controlPlane: parsed.controlPlane,
|
|
366
|
+
...(parsed.repository === undefined ? {} : { repositoryId: parsed.repository }),
|
|
367
|
+
environment: process.env,
|
|
368
|
+
cwd: process.cwd(),
|
|
369
|
+
stdin: process.stdin,
|
|
370
|
+
write,
|
|
371
|
+
error: (text) => void process.stderr.write(text),
|
|
372
|
+
});
|
|
373
|
+
case "help":
|
|
374
|
+
case "--help":
|
|
375
|
+
case "-h":
|
|
376
|
+
write(USAGE);
|
|
377
|
+
return 0;
|
|
378
|
+
case "--version":
|
|
379
|
+
case "-v":
|
|
380
|
+
write(`${CLI_VERSION}\n`);
|
|
381
|
+
return 0;
|
|
382
|
+
default:
|
|
383
|
+
process.stderr.write(`Unknown command "${parsed.command}".\n\n${USAGE}`);
|
|
384
|
+
return 4;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
// Only when this file is the program, so a test may import `main` without the
|
|
388
|
+
// import itself running a command.
|
|
389
|
+
const entry = process.argv[1];
|
|
390
|
+
if (entry !== undefined && fileURLToPath(import.meta.url) === resolve(entry)) {
|
|
391
|
+
process.exitCode = await main(process.argv.slice(2));
|
|
392
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type PairRefusal } from "./wire.js";
|
|
2
|
+
export declare class TransportError extends Error {
|
|
3
|
+
readonly controlPlane: string;
|
|
4
|
+
constructor(controlPlane: string, reason: string);
|
|
5
|
+
}
|
|
6
|
+
export declare class ClientTooOldError extends Error {
|
|
7
|
+
readonly update: string;
|
|
8
|
+
readonly controlPlane: string;
|
|
9
|
+
constructor(controlPlane: string, update: string);
|
|
10
|
+
}
|
|
11
|
+
export declare class RefusalError extends Error {
|
|
12
|
+
readonly code: string;
|
|
13
|
+
readonly status: number;
|
|
14
|
+
constructor(status: number, refusal: PairRefusal);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* A link this command prints, always built from the address it paired with.
|
|
18
|
+
*
|
|
19
|
+
* Behind a platform proxy a server can resolve a link against the machine it is
|
|
20
|
+
* running on rather than against the address customers use, and the first
|
|
21
|
+
* production `propose` printed exactly that: `https://localhost:8080/candidates/...`
|
|
22
|
+
* as the place to go and agree. A command that prints such a link is worse than
|
|
23
|
+
* one that prints none, because the person tries it.
|
|
24
|
+
*
|
|
25
|
+
* So no link a server sends is ever printed. The proposal path answers with an
|
|
26
|
+
* identifier and this builds the address, which is why there is no parameter
|
|
27
|
+
* here for a server's own link to arrive through.
|
|
28
|
+
*/
|
|
29
|
+
export declare function reviewLink(controlPlane: string, path: string): string;
|
|
30
|
+
export type RequestOptions = Readonly<{
|
|
31
|
+
method: "GET" | "POST";
|
|
32
|
+
path: string;
|
|
33
|
+
body?: unknown;
|
|
34
|
+
bearer?: string;
|
|
35
|
+
timeoutMs?: number;
|
|
36
|
+
}>;
|
|
37
|
+
/**
|
|
38
|
+
* The one place this command talks to a network.
|
|
39
|
+
*
|
|
40
|
+
* `redirect: "manual"` so no redirect can carry a bearer to a host the person
|
|
41
|
+
* never paired with, and the 426 handshake is decoded here so every caller gets
|
|
42
|
+
* the same actionable refusal rather than a status code.
|
|
43
|
+
*/
|
|
44
|
+
export declare function request<T>(controlPlane: string, options: RequestOptions): Promise<T>;
|