balladeer 0.0.5 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +200 -5
- package/README.md +167 -68
- package/dist/agent.d.ts +126 -0
- package/dist/agent.js +209 -0
- package/dist/cli.d.ts +48 -0
- package/dist/cli.js +531 -0
- package/dist/client.d.ts +66 -0
- package/dist/client.js +142 -0
- package/dist/commands/affected.d.ts +22 -0
- package/dist/commands/affected.js +123 -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 +403 -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 +198 -0
- package/dist/commands/mcp.d.ts +65 -0
- package/dist/commands/mcp.js +202 -0
- package/dist/commands/prepare.d.ts +74 -0
- package/dist/commands/prepare.js +217 -0
- package/dist/commands/propose.d.ts +69 -0
- package/dist/commands/propose.js +284 -0
- package/dist/commands/repositories.d.ts +18 -0
- package/dist/commands/repositories.js +185 -0
- package/dist/commands/session.d.ts +35 -0
- package/dist/commands/session.js +118 -0
- package/dist/commands/setup.d.ts +98 -0
- package/dist/commands/setup.js +1600 -0
- package/dist/commands/status.d.ts +51 -0
- package/dist/commands/status.js +542 -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 +80 -0
- package/dist/conventions.d.ts +77 -0
- package/dist/conventions.js +183 -0
- package/dist/copy.d.ts +224 -0
- package/dist/copy.js +641 -0
- package/dist/currency.d.ts +31 -0
- package/dist/currency.js +72 -0
- package/dist/desktop-config.d.ts +85 -0
- package/dist/desktop-config.js +217 -0
- package/dist/gh.d.ts +80 -0
- package/dist/gh.js +188 -0
- package/dist/git.d.ts +91 -0
- package/dist/git.js +226 -0
- package/dist/legacy.d.ts +41 -0
- package/dist/legacy.js +143 -0
- package/dist/local-time.d.ts +66 -0
- package/dist/local-time.js +84 -0
- package/dist/markers.d.ts +76 -0
- package/dist/markers.js +125 -0
- package/dist/mcp-config.d.ts +109 -0
- package/dist/mcp-config.js +234 -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/session.d.ts +84 -0
- package/dist/session.js +135 -0
- package/dist/store.d.ts +108 -0
- package/dist/store.js +237 -0
- package/dist/touch-map.d.ts +241 -0
- package/dist/touch-map.js +487 -0
- package/dist/wire.d.ts +674 -0
- package/dist/wire.js +20 -0
- package/package.json +19 -10
- package/bin/balladeer.js +0 -161
package/dist/cli.js
ADDED
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { runAffected } from "./commands/affected.js";
|
|
6
|
+
import { runCheckSeals } from "./commands/check-seals.js";
|
|
7
|
+
import { runDiscover } from "./commands/discover.js";
|
|
8
|
+
import { runExplain } from "./commands/explain.js";
|
|
9
|
+
import { parseRole, runInvite } from "./commands/invite.js";
|
|
10
|
+
import { runMcp } from "./commands/mcp.js";
|
|
11
|
+
import { runPrepare } from "./commands/prepare.js";
|
|
12
|
+
import { runPropose } from "./commands/propose.js";
|
|
13
|
+
import { runRepositories } from "./commands/repositories.js";
|
|
14
|
+
import { CREATE_WORKSPACE_MAX_LENGTH, runSetup } from "./commands/setup.js";
|
|
15
|
+
import { runSession } from "./commands/session.js";
|
|
16
|
+
import { runStatus } from "./commands/status.js";
|
|
17
|
+
import { runTouchMap } from "./commands/touch-map.js";
|
|
18
|
+
import { runWhoami } from "./commands/whoami.js";
|
|
19
|
+
import { updateNotice } from "./currency.js";
|
|
20
|
+
import { StoreError, normalizeControlPlane } from "./store.js";
|
|
21
|
+
import { CLI_VERSION, DEFAULT_CONTROL_PLANE } from "./wire.js";
|
|
22
|
+
const USAGE = `balladeer ${CLI_VERSION}
|
|
23
|
+
|
|
24
|
+
balladeer setup [--repository owner/name]... [--json] [--wait] [--control-plane <url>]
|
|
25
|
+
[--create-workspace <name>] [--refresh] [--force]
|
|
26
|
+
[--claude-desktop | --no-claude-desktop]
|
|
27
|
+
Pair this session, add this repository, connect this coding agent and CI,
|
|
28
|
+
and report what a person still has to do. Name --repository more than once
|
|
29
|
+
to add other repositories to the same workspace; each of those is added and
|
|
30
|
+
nothing more, because connecting an agent and CI happens in a checkout.
|
|
31
|
+
--create-workspace carries a name to the approval page, where a person
|
|
32
|
+
signs in and creates the workspace themselves; this command never creates
|
|
33
|
+
one.
|
|
34
|
+
--refresh does one thing and talks to nobody: it rewrites this
|
|
35
|
+
repository's balladeer entry in .mcp.json, its Balladeer instructions
|
|
36
|
+
block and its Claude desktop chat entry to the current form, leaves every
|
|
37
|
+
other entry in those files alone, and prints what it changed. Run it when
|
|
38
|
+
Balladeer says a newer version is available.
|
|
39
|
+
--force sets up even though an earlier Balladeer is still installed on
|
|
40
|
+
this machine. Without it, a run that finds the older client's machine-wide
|
|
41
|
+
MCP entry or session hook stops and says to remove it first.
|
|
42
|
+
Claude desktop chat is connected too, under a key named for this
|
|
43
|
+
repository, wherever the app is installed. --claude-desktop asks for it by
|
|
44
|
+
name, so a machine with no Claude desktop on it says so instead of
|
|
45
|
+
skipping quietly; --no-claude-desktop leaves that file alone entirely.
|
|
46
|
+
Quit and reopen the app afterwards: it reads its configuration at startup.
|
|
47
|
+
|
|
48
|
+
balladeer repositories [--json] [--control-plane <url>]
|
|
49
|
+
List the repositories this machine's GitHub account can see, marking the
|
|
50
|
+
one you are standing in and the ones Balladeer already has.
|
|
51
|
+
|
|
52
|
+
balladeer invite <email>... [--role contributor|viewer|administrator] [--json]
|
|
53
|
+
Invite teammates into this workspace by email, and say per address whether
|
|
54
|
+
the invitation went. Inviting is an administrator's act.
|
|
55
|
+
|
|
56
|
+
balladeer status [<promise id>] [--repo owner/name] [--repository <uuid>] [--json]
|
|
57
|
+
[--control-plane <url>]
|
|
58
|
+
Report this repository over its agent connection, and every repository in
|
|
59
|
+
the workspace when a setup session is still live. Named with a promise id,
|
|
60
|
+
report that one promise instead: whether it is holding, and if it is not,
|
|
61
|
+
the commit the failing run checked, the bounded reason, and the cases in
|
|
62
|
+
the agreed meaning that run was checking. That is the form the line on a
|
|
63
|
+
broken promise tells a person to run first.
|
|
64
|
+
|
|
65
|
+
balladeer propose --file <path> [--repo owner/name] [--repository <uuid>] [--json]
|
|
66
|
+
Propose one promise from a proposal file, over this repository's agent
|
|
67
|
+
connection. A named person still agrees to it.
|
|
68
|
+
|
|
69
|
+
balladeer prepare <promise id> [--again] [--repo owner/name] [--repository <uuid>]
|
|
70
|
+
[--json] [--control-plane <url>]
|
|
71
|
+
Prepare the one-time qualification setup for a promise whose meaning is
|
|
72
|
+
agreed and which nothing is checking yet, and write it to
|
|
73
|
+
.continuity/qualification/<promise id>.json, which is where the sealed run
|
|
74
|
+
reads it. No sign-off and no code: agreeing the meaning was the person's
|
|
75
|
+
act, and building the check that proves it is yours. Then build the
|
|
76
|
+
verifier, seal it, and push to the default branch; protection starts by
|
|
77
|
+
itself when that run qualifies. It is one-time, so while nobody has
|
|
78
|
+
published against the packet already prepared this refuses and says who
|
|
79
|
+
prepared it and when. --again prepares a replacement and invalidates that
|
|
80
|
+
earlier packet: a run publishing its identities afterwards is refused.
|
|
81
|
+
|
|
82
|
+
balladeer discover --file <path> [--repo owner/name] [--repository <uuid>]
|
|
83
|
+
[--owner <membership id>] [--json]
|
|
84
|
+
Propose a whole discovered catalog, up to ten promises from one file, each
|
|
85
|
+
owned by whoever paired this machine, and print one link that opens all of
|
|
86
|
+
them. A named person still agrees to every one.
|
|
87
|
+
|
|
88
|
+
balladeer check-seals [--json] [--runner <path>] [--install-hook]
|
|
89
|
+
Say whether what you are about to push would break a promise's seal. It
|
|
90
|
+
prints nothing and exits 0 when it would not, and names the promise, its
|
|
91
|
+
owner, its page and the line that seals it again when it would. It runs
|
|
92
|
+
offline, over the runner this repository is pinned to. --install-hook
|
|
93
|
+
writes .git/hooks/pre-push so every push asks first; nothing installs it
|
|
94
|
+
for you, and deleting that file removes it.
|
|
95
|
+
|
|
96
|
+
balladeer touch-map [--json]
|
|
97
|
+
Run this repository's promise verifiers under coverage and write down
|
|
98
|
+
which files each one actually executed, to .continuity/touch-map.json.
|
|
99
|
+
It runs offline and the map stays on this machine: Balladeer is never
|
|
100
|
+
sent it. Node verifiers only in this release.
|
|
101
|
+
|
|
102
|
+
balladeer affected <paths...> [--json]
|
|
103
|
+
Say which promises the named files touch, out of that map, marking any
|
|
104
|
+
answer whose verifier has changed since the map was built. It reads one
|
|
105
|
+
local file and contacts nothing.
|
|
106
|
+
|
|
107
|
+
balladeer session [--new] [--record] [--repo owner/name] [--repository <uuid>] [--json]
|
|
108
|
+
[--control-plane <url>]
|
|
109
|
+
Print the id for this piece of work, and the one line to write into the
|
|
110
|
+
commit it produces. Pass the id to every promise read you make, so a check
|
|
111
|
+
that goes red later can be read back against what Balladeer told you
|
|
112
|
+
before you started. --new starts a different session; --record reads the
|
|
113
|
+
Balladeer-Session line out of the commit at HEAD and tells Balladeer which
|
|
114
|
+
commit this session wrote. Your commit message never leaves this machine:
|
|
115
|
+
only the session id and the commit SHA are sent.
|
|
116
|
+
|
|
117
|
+
balladeer explain [--control-plane <url>]
|
|
118
|
+
Print, word for word, what Balladeer can and cannot see, where to watch
|
|
119
|
+
your promises, and what leaving costs.
|
|
120
|
+
|
|
121
|
+
balladeer whoami [--json] [--control-plane <url>]
|
|
122
|
+
Report the stored setup session's workspace, role, scopes, and expiry.
|
|
123
|
+
|
|
124
|
+
balladeer agent rotate [--control-plane <url>]
|
|
125
|
+
Replace this repository's agent credential. A setup session cannot do this,
|
|
126
|
+
because rotating revokes the connections the repository already has.
|
|
127
|
+
|
|
128
|
+
balladeer mcp [--repository <uuid>] [--control-plane <url>]
|
|
129
|
+
Forward one MCP session over stdio using this repository's agent connection.
|
|
130
|
+
|
|
131
|
+
Exit codes: 0 progress reported truthfully, 2 pairing expired or denied or already
|
|
132
|
+
claimed, 3 this copy is too old for the server, 4 usage or credential store problem,
|
|
133
|
+
5 transport or server failure, 6 an earlier Balladeer is still installed on this
|
|
134
|
+
machine and nothing was changed.
|
|
135
|
+
`;
|
|
136
|
+
const SUBCOMMANDS = { agent: ["rotate"] };
|
|
137
|
+
export function parseArguments(argv) {
|
|
138
|
+
const args = [...argv];
|
|
139
|
+
const command = args.shift() ?? "help";
|
|
140
|
+
let subcommand;
|
|
141
|
+
if (SUBCOMMANDS[command] !== undefined) {
|
|
142
|
+
subcommand = args.shift();
|
|
143
|
+
if (subcommand === undefined || !SUBCOMMANDS[command].includes(subcommand)) {
|
|
144
|
+
throw new StoreError("usage", `${command} needs one of: ${SUBCOMMANDS[command].join(", ")}.`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
let json = false;
|
|
148
|
+
let wait = false;
|
|
149
|
+
let refresh = false;
|
|
150
|
+
let force = false;
|
|
151
|
+
let claudeDesktop;
|
|
152
|
+
let installHook = false;
|
|
153
|
+
let fresh = false;
|
|
154
|
+
let record = false;
|
|
155
|
+
let again = false;
|
|
156
|
+
let runner;
|
|
157
|
+
let controlPlane;
|
|
158
|
+
let repo;
|
|
159
|
+
let file;
|
|
160
|
+
let owner;
|
|
161
|
+
let createWorkspace;
|
|
162
|
+
let role;
|
|
163
|
+
const repositories = [];
|
|
164
|
+
const positional = [];
|
|
165
|
+
const NEEDS = {
|
|
166
|
+
"--control-plane": "a URL",
|
|
167
|
+
"--repo": "an owner/name",
|
|
168
|
+
"--file": "a path",
|
|
169
|
+
"--repository": "a repository, as owner/name for setup or as an id elsewhere",
|
|
170
|
+
"--owner": "a membership id",
|
|
171
|
+
"--create-workspace": "a workspace name",
|
|
172
|
+
"--role": "contributor, viewer, or administrator",
|
|
173
|
+
"--runner": "a path to the pinned runner's cli.js",
|
|
174
|
+
};
|
|
175
|
+
const value = (flag, inline) => {
|
|
176
|
+
const next = inline ?? args.shift();
|
|
177
|
+
if (!next)
|
|
178
|
+
throw new StoreError("usage", `${flag} needs ${NEEDS[flag] ?? "a value"}.`);
|
|
179
|
+
return next;
|
|
180
|
+
};
|
|
181
|
+
while (args.length > 0) {
|
|
182
|
+
const flag = args.shift();
|
|
183
|
+
if (!flag.startsWith("--")) {
|
|
184
|
+
positional.push(flag);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const [name, ...rest] = flag.split("=");
|
|
188
|
+
const inline = rest.length > 0 ? rest.join("=") : undefined;
|
|
189
|
+
if (name === "--json")
|
|
190
|
+
json = true;
|
|
191
|
+
else if (name === "--again")
|
|
192
|
+
again = true;
|
|
193
|
+
else if (name === "--install-hook")
|
|
194
|
+
installHook = true;
|
|
195
|
+
else if (name === "--new")
|
|
196
|
+
fresh = true;
|
|
197
|
+
else if (name === "--record")
|
|
198
|
+
record = true;
|
|
199
|
+
else if (name === "--runner")
|
|
200
|
+
runner = value("--runner", inline);
|
|
201
|
+
else if (name === "--wait")
|
|
202
|
+
wait = true;
|
|
203
|
+
else if (name === "--refresh")
|
|
204
|
+
refresh = true;
|
|
205
|
+
else if (name === "--force")
|
|
206
|
+
force = true;
|
|
207
|
+
else if (name === "--claude-desktop")
|
|
208
|
+
claudeDesktop = true;
|
|
209
|
+
else if (name === "--no-claude-desktop")
|
|
210
|
+
claudeDesktop = false;
|
|
211
|
+
else if (name === "--control-plane")
|
|
212
|
+
controlPlane = value("--control-plane", inline);
|
|
213
|
+
else if (name === "--repo")
|
|
214
|
+
repo = value("--repo", inline);
|
|
215
|
+
else if (name === "--file")
|
|
216
|
+
file = value("--file", inline);
|
|
217
|
+
else if (name === "--repository")
|
|
218
|
+
repositories.push(value("--repository", inline));
|
|
219
|
+
else if (name === "--owner")
|
|
220
|
+
owner = value("--owner", inline);
|
|
221
|
+
else if (name === "--role")
|
|
222
|
+
role = value("--role", inline);
|
|
223
|
+
else if (name === "--create-workspace") {
|
|
224
|
+
createWorkspace = value("--create-workspace", inline).trim();
|
|
225
|
+
if (createWorkspace.length === 0 || createWorkspace.length > CREATE_WORKSPACE_MAX_LENGTH) {
|
|
226
|
+
throw new StoreError("usage", `--create-workspace needs a workspace name of 1 to ${CREATE_WORKSPACE_MAX_LENGTH} characters.`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
else
|
|
230
|
+
throw new StoreError("usage", `Unknown option ${flag}.`);
|
|
231
|
+
}
|
|
232
|
+
// `invite` is the one command that takes a bare word, and the words it takes
|
|
233
|
+
// are email addresses. Anywhere else a bare word is a mistyped flag or a
|
|
234
|
+
// value that lost its flag, and it was refused before this loop learned to
|
|
235
|
+
// collect them, so it is refused here rather than ignored.
|
|
236
|
+
// `affected` is the other one, and the words it takes are paths in the change
|
|
237
|
+
// in front of the person. `prepare` and `status` each take exactly one bare
|
|
238
|
+
// word, the promise id a person copied off a promise page, which is the form
|
|
239
|
+
// both of those commands are told to people and to agents in.
|
|
240
|
+
// `--again` invalidates a packet somebody may be halfway through using, so it
|
|
241
|
+
// belongs to the one command that mints one. Accepted silently elsewhere, it
|
|
242
|
+
// would read as a general "do it anyway" flag.
|
|
243
|
+
if (again && command !== "prepare") {
|
|
244
|
+
throw new StoreError("usage", "--again belongs to prepare: run `balladeer prepare <promise id> --again`.");
|
|
245
|
+
}
|
|
246
|
+
if (positional.length > 0 &&
|
|
247
|
+
command !== "invite" &&
|
|
248
|
+
command !== "affected" &&
|
|
249
|
+
command !== "prepare" &&
|
|
250
|
+
command !== "status") {
|
|
251
|
+
throw new StoreError("usage", `${command} takes no bare arguments: ${positional.join(", ")}.`);
|
|
252
|
+
}
|
|
253
|
+
// `--repository` names one repository everywhere but `setup`, where it names
|
|
254
|
+
// as many as a person wants to add. A second one anywhere else is refused
|
|
255
|
+
// rather than resolved to the last: silently acting on one of two repositories
|
|
256
|
+
// somebody named is worse than saying no.
|
|
257
|
+
// `--refresh` repairs the files setup writes, so it belongs to setup and to
|
|
258
|
+
// nothing else. Accepting it silently elsewhere would let somebody run
|
|
259
|
+
// `status --refresh`, see no error, and believe their install was repaired.
|
|
260
|
+
if (refresh && command !== "setup") {
|
|
261
|
+
throw new StoreError("usage", "--refresh belongs to setup: run `balladeer setup --refresh`.");
|
|
262
|
+
}
|
|
263
|
+
// `--force` says one thing only: set up alongside an earlier Balladeer. Taken
|
|
264
|
+
// silently by another command it would read as a general "do it anyway" flag,
|
|
265
|
+
// which is exactly what nothing else here offers.
|
|
266
|
+
if (force && command !== "setup") {
|
|
267
|
+
throw new StoreError("usage", "--force belongs to setup: run `balladeer setup --force`.");
|
|
268
|
+
}
|
|
269
|
+
// Connecting a chat client is something setup does, so the flag belongs to
|
|
270
|
+
// setup. Accepted silently on `status`, it would let somebody believe they had
|
|
271
|
+
// connected Claude desktop when nothing had written a line.
|
|
272
|
+
if (claudeDesktop !== undefined && command !== "setup") {
|
|
273
|
+
throw new StoreError("usage", "--claude-desktop belongs to setup: run `balladeer setup --claude-desktop`.");
|
|
274
|
+
}
|
|
275
|
+
if (command !== "setup" && repositories.length > 1) {
|
|
276
|
+
throw new StoreError("usage", `${command} acts on one repository. Name --repository once, or run it again for the other.`);
|
|
277
|
+
}
|
|
278
|
+
const chosen = controlPlane ?? process.env.BALLADEER_CONTROL_PLANE?.trim() ?? DEFAULT_CONTROL_PLANE;
|
|
279
|
+
return {
|
|
280
|
+
command,
|
|
281
|
+
subcommand,
|
|
282
|
+
json,
|
|
283
|
+
wait,
|
|
284
|
+
refresh,
|
|
285
|
+
force,
|
|
286
|
+
claudeDesktop,
|
|
287
|
+
repo,
|
|
288
|
+
file,
|
|
289
|
+
repository: repositories[0],
|
|
290
|
+
repositories,
|
|
291
|
+
owner,
|
|
292
|
+
installHook,
|
|
293
|
+
fresh,
|
|
294
|
+
record,
|
|
295
|
+
again,
|
|
296
|
+
runner,
|
|
297
|
+
createWorkspace,
|
|
298
|
+
positional,
|
|
299
|
+
role,
|
|
300
|
+
controlPlane: normalizeControlPlane(chosen),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
export async function main(argv) {
|
|
304
|
+
let parsed;
|
|
305
|
+
try {
|
|
306
|
+
parsed = parseArguments(argv);
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n\n${USAGE}`);
|
|
310
|
+
return 4;
|
|
311
|
+
}
|
|
312
|
+
const write = (text) => void process.stdout.write(text);
|
|
313
|
+
const code = await dispatch(parsed, write);
|
|
314
|
+
// Said once, at the end, on stderr: an agent reading `--json` gets its steps
|
|
315
|
+
// on stdout unchanged, and a person reading a terminal gets the one line that
|
|
316
|
+
// says this copy is behind. It is a nag and never a failure, so it does not
|
|
317
|
+
// touch the exit code.
|
|
318
|
+
const notice = updateNotice();
|
|
319
|
+
if (notice !== undefined)
|
|
320
|
+
process.stderr.write(`${notice}\n`);
|
|
321
|
+
return code;
|
|
322
|
+
}
|
|
323
|
+
async function dispatch(parsed, write) {
|
|
324
|
+
switch (parsed.command) {
|
|
325
|
+
case "explain":
|
|
326
|
+
return runExplain(write, parsed.controlPlane);
|
|
327
|
+
case "setup":
|
|
328
|
+
return runSetup({
|
|
329
|
+
controlPlane: parsed.controlPlane,
|
|
330
|
+
json: parsed.json,
|
|
331
|
+
wait: parsed.wait,
|
|
332
|
+
refresh: parsed.refresh,
|
|
333
|
+
force: parsed.force,
|
|
334
|
+
...(parsed.claudeDesktop === undefined ? {} : { claudeDesktop: parsed.claudeDesktop }),
|
|
335
|
+
...(parsed.repo === undefined ? {} : { repo: parsed.repo }),
|
|
336
|
+
repositories: parsed.repositories,
|
|
337
|
+
...(parsed.createWorkspace === undefined
|
|
338
|
+
? {}
|
|
339
|
+
: { createWorkspace: parsed.createWorkspace }),
|
|
340
|
+
environment: process.env,
|
|
341
|
+
cwd: process.cwd(),
|
|
342
|
+
write,
|
|
343
|
+
});
|
|
344
|
+
case "repositories":
|
|
345
|
+
return runRepositories({
|
|
346
|
+
controlPlane: parsed.controlPlane,
|
|
347
|
+
json: parsed.json,
|
|
348
|
+
environment: process.env,
|
|
349
|
+
cwd: process.cwd(),
|
|
350
|
+
write,
|
|
351
|
+
});
|
|
352
|
+
case "invite": {
|
|
353
|
+
const role = parseRole(parsed.role);
|
|
354
|
+
if (role === undefined) {
|
|
355
|
+
process.stderr.write(`--role takes contributor, viewer, or administrator. It was given "${parsed.role}".\n`);
|
|
356
|
+
return 4;
|
|
357
|
+
}
|
|
358
|
+
return runInvite({
|
|
359
|
+
controlPlane: parsed.controlPlane,
|
|
360
|
+
json: parsed.json,
|
|
361
|
+
emails: parsed.positional,
|
|
362
|
+
role,
|
|
363
|
+
environment: process.env,
|
|
364
|
+
write,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
case "check-seals":
|
|
368
|
+
return runCheckSeals({
|
|
369
|
+
json: parsed.json,
|
|
370
|
+
installHook: parsed.installHook,
|
|
371
|
+
...(parsed.runner === undefined ? {} : { runner: parsed.runner }),
|
|
372
|
+
environment: process.env,
|
|
373
|
+
cwd: process.cwd(),
|
|
374
|
+
write,
|
|
375
|
+
});
|
|
376
|
+
case "touch-map":
|
|
377
|
+
return runTouchMap({
|
|
378
|
+
json: parsed.json,
|
|
379
|
+
environment: process.env,
|
|
380
|
+
cwd: process.cwd(),
|
|
381
|
+
write,
|
|
382
|
+
});
|
|
383
|
+
case "session":
|
|
384
|
+
return runSession({
|
|
385
|
+
controlPlane: parsed.controlPlane,
|
|
386
|
+
json: parsed.json,
|
|
387
|
+
record: parsed.record,
|
|
388
|
+
fresh: parsed.fresh,
|
|
389
|
+
...(parsed.repo === undefined ? {} : { repo: parsed.repo }),
|
|
390
|
+
...(parsed.repository === undefined ? {} : { repository: parsed.repository }),
|
|
391
|
+
environment: process.env,
|
|
392
|
+
cwd: process.cwd(),
|
|
393
|
+
write,
|
|
394
|
+
});
|
|
395
|
+
case "affected":
|
|
396
|
+
return runAffected({
|
|
397
|
+
json: parsed.json,
|
|
398
|
+
paths: parsed.positional,
|
|
399
|
+
cwd: process.cwd(),
|
|
400
|
+
write,
|
|
401
|
+
});
|
|
402
|
+
case "status": {
|
|
403
|
+
// At most one. Two ids on one line is a mistake, and answering for the
|
|
404
|
+
// first of them silently would report a promise nobody asked about.
|
|
405
|
+
if (parsed.positional.length > 1) {
|
|
406
|
+
process.stderr.write("status reads one promise id at a time.\n");
|
|
407
|
+
return 4;
|
|
408
|
+
}
|
|
409
|
+
const promiseId = parsed.positional[0];
|
|
410
|
+
return runStatus({
|
|
411
|
+
controlPlane: parsed.controlPlane,
|
|
412
|
+
json: parsed.json,
|
|
413
|
+
...(promiseId === undefined ? {} : { promiseId }),
|
|
414
|
+
...(parsed.repo === undefined ? {} : { repo: parsed.repo }),
|
|
415
|
+
...(parsed.repository === undefined ? {} : { repository: parsed.repository }),
|
|
416
|
+
environment: process.env,
|
|
417
|
+
cwd: process.cwd(),
|
|
418
|
+
write,
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
case "prepare": {
|
|
422
|
+
// One id, and one only. Preparing two packets from one line would mint
|
|
423
|
+
// two one-time identities and write two files, and reporting the first
|
|
424
|
+
// silently would leave the second promise unprepared with nobody told.
|
|
425
|
+
if (parsed.positional.length !== 1) {
|
|
426
|
+
process.stderr.write("prepare reads one promise id: balladeer prepare <promise id> [--again].\n");
|
|
427
|
+
return 4;
|
|
428
|
+
}
|
|
429
|
+
return runPrepare({
|
|
430
|
+
controlPlane: parsed.controlPlane,
|
|
431
|
+
json: parsed.json,
|
|
432
|
+
promiseId: parsed.positional[0],
|
|
433
|
+
again: parsed.again,
|
|
434
|
+
...(parsed.repo === undefined ? {} : { repo: parsed.repo }),
|
|
435
|
+
...(parsed.repository === undefined ? {} : { repository: parsed.repository }),
|
|
436
|
+
environment: process.env,
|
|
437
|
+
cwd: process.cwd(),
|
|
438
|
+
write,
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
case "propose":
|
|
442
|
+
return runPropose({
|
|
443
|
+
controlPlane: parsed.controlPlane,
|
|
444
|
+
json: parsed.json,
|
|
445
|
+
...(parsed.file === undefined ? {} : { file: parsed.file }),
|
|
446
|
+
...(parsed.repo === undefined ? {} : { repo: parsed.repo }),
|
|
447
|
+
...(parsed.repository === undefined ? {} : { repository: parsed.repository }),
|
|
448
|
+
environment: process.env,
|
|
449
|
+
cwd: process.cwd(),
|
|
450
|
+
write,
|
|
451
|
+
});
|
|
452
|
+
case "discover":
|
|
453
|
+
return runDiscover({
|
|
454
|
+
controlPlane: parsed.controlPlane,
|
|
455
|
+
json: parsed.json,
|
|
456
|
+
...(parsed.file === undefined ? {} : { file: parsed.file }),
|
|
457
|
+
...(parsed.repo === undefined ? {} : { repo: parsed.repo }),
|
|
458
|
+
...(parsed.repository === undefined ? {} : { repository: parsed.repository }),
|
|
459
|
+
...(parsed.owner === undefined ? {} : { owner: parsed.owner }),
|
|
460
|
+
environment: process.env,
|
|
461
|
+
cwd: process.cwd(),
|
|
462
|
+
write,
|
|
463
|
+
});
|
|
464
|
+
case "agent":
|
|
465
|
+
// Rotating revokes every live connection for the repository, which is
|
|
466
|
+
// removal. The approval page tells a person their setup session cannot
|
|
467
|
+
// remove anything, so this command says the same rather than trying and
|
|
468
|
+
// relaying a server refusal the person cannot act on.
|
|
469
|
+
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" +
|
|
470
|
+
`Rotate it in Balladeer under workspace settings at ${parsed.controlPlane}/settings.\n`);
|
|
471
|
+
return 4;
|
|
472
|
+
case "whoami":
|
|
473
|
+
return runWhoami({
|
|
474
|
+
controlPlane: parsed.controlPlane,
|
|
475
|
+
json: parsed.json,
|
|
476
|
+
environment: process.env,
|
|
477
|
+
write,
|
|
478
|
+
});
|
|
479
|
+
case "mcp":
|
|
480
|
+
return runMcp({
|
|
481
|
+
controlPlane: parsed.controlPlane,
|
|
482
|
+
...(parsed.repository === undefined ? {} : { repositoryId: parsed.repository }),
|
|
483
|
+
environment: process.env,
|
|
484
|
+
cwd: process.cwd(),
|
|
485
|
+
stdin: process.stdin,
|
|
486
|
+
write,
|
|
487
|
+
error: (text) => void process.stderr.write(text),
|
|
488
|
+
});
|
|
489
|
+
case "help":
|
|
490
|
+
case "--help":
|
|
491
|
+
case "-h":
|
|
492
|
+
write(USAGE);
|
|
493
|
+
return 0;
|
|
494
|
+
case "--version":
|
|
495
|
+
case "-v":
|
|
496
|
+
write(`${CLI_VERSION}\n`);
|
|
497
|
+
return 0;
|
|
498
|
+
default:
|
|
499
|
+
process.stderr.write(`Unknown command "${parsed.command}".\n\n${USAGE}`);
|
|
500
|
+
return 4;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* The real file behind a path, following symlinks.
|
|
505
|
+
*
|
|
506
|
+
* `npx` and every global install run this program through a
|
|
507
|
+
* `node_modules/.bin/balladeer` symlink, so `process.argv[1]` is the link and
|
|
508
|
+
* not the file Node loaded. `resolve` does not follow a link, so comparing
|
|
509
|
+
* resolved paths made the guard below false for every customer: the process
|
|
510
|
+
* exited 0 having printed nothing, and only a checkout running
|
|
511
|
+
* `node dist/cli.js` ever saw the program run at all.
|
|
512
|
+
*
|
|
513
|
+
* A path that cannot be read back falls through to the resolved form rather
|
|
514
|
+
* than throwing, because a guard that decides whether the program runs must
|
|
515
|
+
* never be the thing that stops it.
|
|
516
|
+
*/
|
|
517
|
+
function programPath(path) {
|
|
518
|
+
const absolute = resolve(path);
|
|
519
|
+
try {
|
|
520
|
+
return realpathSync(absolute);
|
|
521
|
+
}
|
|
522
|
+
catch {
|
|
523
|
+
return absolute;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
// Only when this file is the program, so a test may import `main` without the
|
|
527
|
+
// import itself running a command.
|
|
528
|
+
const entry = process.argv[1];
|
|
529
|
+
if (entry !== undefined && programPath(fileURLToPath(import.meta.url)) === programPath(entry)) {
|
|
530
|
+
process.exitCode = await main(process.argv.slice(2));
|
|
531
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
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: a review link on `localhost:8080`
|
|
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
|
+
/**
|
|
31
|
+
* The one place this command decides where a person reads a proposal.
|
|
32
|
+
*
|
|
33
|
+
* Every review address this command prints, in a receipt, in a discovery run,
|
|
34
|
+
* or in its JSON, is built here, so the address is one edit rather than a hunt
|
|
35
|
+
* and a printed link cannot fall behind the page it names. It matches the
|
|
36
|
+
* server's own `proposal-links` helper: the page is `/proposals`, and
|
|
37
|
+
* the retired address redirects to it permanently, so a link an older copy of
|
|
38
|
+
* this command already printed still lands on it.
|
|
39
|
+
*/
|
|
40
|
+
export declare function proposalReviewLink(controlPlane: string, proposalId: string): string;
|
|
41
|
+
/** The address the whole waiting inbox is read on. */
|
|
42
|
+
export declare function proposalInboxLink(controlPlane: string): string;
|
|
43
|
+
/**
|
|
44
|
+
* One link that opens exactly the proposals named, and nothing else.
|
|
45
|
+
*
|
|
46
|
+
* The ids travel in the query string because the batch is exactly these
|
|
47
|
+
* promises: a link to the whole inbox would also open whatever was already
|
|
48
|
+
* waiting there, and a filter by repository would open a different set
|
|
49
|
+
* tomorrow.
|
|
50
|
+
*/
|
|
51
|
+
export declare function batchProposalReviewLink(controlPlane: string, proposalIds: readonly string[]): string;
|
|
52
|
+
export type RequestOptions = Readonly<{
|
|
53
|
+
method: "GET" | "POST";
|
|
54
|
+
path: string;
|
|
55
|
+
body?: unknown;
|
|
56
|
+
bearer?: string;
|
|
57
|
+
timeoutMs?: number;
|
|
58
|
+
}>;
|
|
59
|
+
/**
|
|
60
|
+
* The one place this command talks to a network.
|
|
61
|
+
*
|
|
62
|
+
* `redirect: "manual"` so no redirect can carry a bearer to a host the person
|
|
63
|
+
* never paired with, and the 426 handshake is decoded here so every caller gets
|
|
64
|
+
* the same actionable refusal rather than a status code.
|
|
65
|
+
*/
|
|
66
|
+
export declare function request<T>(controlPlane: string, options: RequestOptions): Promise<T>;
|