balladeer 1.0.0 → 1.0.2
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/README.md +53 -32
- package/dist/agent.d.ts +5 -0
- package/dist/agent.js +13 -1
- package/dist/cli.d.ts +16 -0
- package/dist/cli.js +181 -24
- package/dist/client.d.ts +24 -2
- package/dist/client.js +34 -3
- package/dist/commands/affected.js +8 -7
- package/dist/commands/check-seals.js +4 -4
- package/dist/commands/discover.js +16 -7
- package/dist/commands/explain.d.ts +1 -1
- package/dist/commands/explain.js +1 -1
- package/dist/commands/invite.js +2 -1
- package/dist/commands/prepare.d.ts +74 -0
- package/dist/commands/prepare.js +218 -0
- package/dist/commands/propose.d.ts +10 -0
- package/dist/commands/propose.js +29 -6
- package/dist/commands/repositories.js +1 -0
- package/dist/commands/session.d.ts +35 -0
- package/dist/commands/session.js +131 -0
- package/dist/commands/setup.d.ts +29 -0
- package/dist/commands/setup.js +302 -92
- package/dist/commands/status.d.ts +16 -0
- package/dist/commands/status.js +106 -23
- package/dist/commands/touch-map.js +2 -2
- package/dist/commands/whoami.js +2 -1
- package/dist/conventions.d.ts +9 -1
- package/dist/conventions.js +9 -1
- package/dist/copy.d.ts +83 -7
- package/dist/copy.js +226 -29
- package/dist/desktop-config.d.ts +85 -0
- package/dist/desktop-config.js +217 -0
- package/dist/git.d.ts +15 -0
- package/dist/git.js +23 -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/mcp-config.d.ts +10 -0
- package/dist/mcp-config.js +8 -4
- package/dist/session.d.ts +84 -0
- package/dist/session.js +135 -0
- package/dist/store.d.ts +11 -1
- package/dist/store.js +18 -6
- package/dist/wire.d.ts +95 -4
- package/dist/wire.js +2 -1
- package/package.json +1 -1
package/dist/commands/setup.js
CHANGED
|
@@ -2,14 +2,16 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
2
2
|
import { existsSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import { ClientTooOldError, RefusalError, TransportError, request } from "../client.js";
|
|
5
|
+
import { ClientTooOldError, RefusalError, TransportError, proposalReviewLink, reviewLink, request, } from "../client.js";
|
|
6
6
|
import { writeConventions } from "../conventions.js";
|
|
7
|
-
import { DISCOVERY_PLAYBOOK, JOIN_OR_CREATE } from "../copy.js";
|
|
8
|
-
import { readPublicRepositoryFacts, readRepositoryFacts, runCommand, setRepositoryVariable, } from "../gh.js";
|
|
7
|
+
import { APPROVE_IN_THE_RIGHT_WORKSPACE, DISCOVERY_PLAYBOOK, JOIN_OR_CREATE } from "../copy.js";
|
|
8
|
+
import { ghLogin, readPublicRepositoryFacts, readRepositoryFacts, runCommand, setRepositoryVariable, } from "../gh.js";
|
|
9
9
|
import { CI_BRANCH, commitWorkflowOnBranch, repositoryRoot, workflowOnDefaultBranch, } from "../git.js";
|
|
10
10
|
import { MCP_CONFIG_FILE, currentEntry, entryRepositoryId, mergeMcpConfig, readMcpConfig, stdioEntry, } from "../mcp-config.js";
|
|
11
|
+
import { DESKTOP_CONFIG_FILE, desktopConfigLocation, desktopServerKey, desktopStdioEntry, mergeDesktopConfig, } from "../desktop-config.js";
|
|
11
12
|
import { selectAgent } from "../agent.js";
|
|
12
|
-
import {
|
|
13
|
+
import { findLegacyInstall, legacyMessage } from "../legacy.js";
|
|
14
|
+
import { formatInstant } from "../local-time.js";
|
|
13
15
|
import { checkoutEntryPath, commandLine, runningFromRegistryInstall } from "../release.js";
|
|
14
16
|
import { hostHint, repositoryHint } from "../repository.js";
|
|
15
17
|
import { StoreError, credentialsPath, dropPendingPairing, findPendingPairing, findSession, putAgent, putPendingPairing, putSession, readCredentials, writeCredentials, } from "../store.js";
|
|
@@ -126,7 +128,7 @@ function pairedLines(session) {
|
|
|
126
128
|
` This session may: ${session.scopeMeanings.join(", ")}.`,
|
|
127
129
|
...narrowed,
|
|
128
130
|
` ${REFUSAL_SENTENCE}`,
|
|
129
|
-
` It expires at ${session.expiresAt}. Revoke it any time under Connected sessions in Balladeer.`,
|
|
131
|
+
` It expires at ${formatInstant(session.expiresAt)}. Revoke it any time under Connected sessions in Balladeer.`,
|
|
130
132
|
];
|
|
131
133
|
}
|
|
132
134
|
/**
|
|
@@ -147,8 +149,14 @@ function pendingLines(pending, repository, host, waiting, approvalUri) {
|
|
|
147
149
|
...opening,
|
|
148
150
|
` Open: ${approvalUri}`,
|
|
149
151
|
` The page will show this code: ${pending.userCode}`,
|
|
152
|
+
// A person who belongs to two workspaces approves into whichever one their
|
|
153
|
+
// browser is signed into, and twice that was not the one they meant: the
|
|
154
|
+
// repository was enrolled in the wrong workspace and an agent was connected
|
|
155
|
+
// there. The page now names the workspace and offers the switch, and this
|
|
156
|
+
// line is what makes somebody look at it.
|
|
157
|
+
` ${APPROVE_IN_THE_RIGHT_WORKSPACE}`,
|
|
150
158
|
` Sent to Balladeer so far: a random code, the name "${repository}", and the hostname "${host}". Nothing else.`,
|
|
151
|
-
` This pairing expires at ${pending.expiresAt}.`,
|
|
159
|
+
` This pairing expires at ${formatInstant(pending.expiresAt)}.`,
|
|
152
160
|
" Approve in the browser, then run this command again to finish.",
|
|
153
161
|
"",
|
|
154
162
|
// Printed on the pairing step and nowhere else, because this is the one
|
|
@@ -244,6 +252,16 @@ function namedRepositories(options) {
|
|
|
244
252
|
* a command that blocks for minutes and the pairing would be lost with it.
|
|
245
253
|
*/
|
|
246
254
|
export async function runSetup(options) {
|
|
255
|
+
// First, before the banner and before either path writes a byte: a machine
|
|
256
|
+
// that still carries the July client gets one instruction and this run stops.
|
|
257
|
+
// Refresh is inside the check rather than outside it, because a repair writes
|
|
258
|
+
// the same two files a first setup writes.
|
|
259
|
+
const legacy = options.force === true
|
|
260
|
+
? undefined
|
|
261
|
+
: findLegacyInstall(options.environment, options.controlPlane);
|
|
262
|
+
if (legacy !== undefined) {
|
|
263
|
+
return fail(options, "legacy_balladeer_present", legacyMessage(legacy), 6);
|
|
264
|
+
}
|
|
247
265
|
if (options.refresh === true)
|
|
248
266
|
return runRefresh(options);
|
|
249
267
|
const { controlPlane } = options;
|
|
@@ -365,7 +383,7 @@ export async function runSetup(options) {
|
|
|
365
383
|
return reportStartFailure(options, error);
|
|
366
384
|
}
|
|
367
385
|
if (lapsed !== undefined) {
|
|
368
|
-
say(options, `The earlier code ${lapsed.userCode} expired unapproved at ${lapsed.expiresAt}; here is a new one.`);
|
|
386
|
+
say(options, `The earlier code ${lapsed.userCode} expired unapproved at ${formatInstant(lapsed.expiresAt)}; here is a new one.`);
|
|
369
387
|
emit(options, {
|
|
370
388
|
step: "pair",
|
|
371
389
|
status: "expired",
|
|
@@ -488,7 +506,7 @@ function terminalRefusal(pending, code) {
|
|
|
488
506
|
const again = "Run this command again to start a new one.";
|
|
489
507
|
if (code === "pairing_expired") {
|
|
490
508
|
return {
|
|
491
|
-
message: `Pairing ${pending.userCode} expired at ${pending.expiresAt}. ${again}`,
|
|
509
|
+
message: `Pairing ${pending.userCode} expired at ${formatInstant(pending.expiresAt)}. ${again}`,
|
|
492
510
|
status: "expired",
|
|
493
511
|
};
|
|
494
512
|
}
|
|
@@ -519,7 +537,7 @@ async function waitForApproval(options, credentials, pending, pollIntervalSecond
|
|
|
519
537
|
const sleep = options.sleep ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
|
|
520
538
|
const now = options.now ?? (() => new Date());
|
|
521
539
|
const deadline = now().getTime() + MAX_WAIT_MS;
|
|
522
|
-
say(options, ` Waiting up to ${MAX_WAIT_MS / 60_000} minutes for someone to approve ${pending.userCode} at ${approvalLink(options, pending.verificationUri)}. Press Ctrl+C to stop; the pairing is saved either way, and the code stays approvable until ${pending.expiresAt}.`);
|
|
540
|
+
say(options, ` Waiting up to ${MAX_WAIT_MS / 60_000} minutes for someone to approve ${pending.userCode} at ${approvalLink(options, pending.verificationUri)}. Press Ctrl+C to stop; the pairing is saved either way, and the code stays approvable until ${formatInstant(pending.expiresAt)}.`);
|
|
523
541
|
let interval = pollIntervalSeconds;
|
|
524
542
|
while (now().getTime() < deadline) {
|
|
525
543
|
await sleep(Math.max(interval, 2) * 1000);
|
|
@@ -566,6 +584,7 @@ async function runRemainingSteps(options, credentials, session) {
|
|
|
566
584
|
return transportFailure(options, error.message);
|
|
567
585
|
return fail(options, "setup_state_unreadable", `Balladeer could not report this workspace's setup: ${refusalSentence(error)}. Nothing was changed.`, 5);
|
|
568
586
|
}
|
|
587
|
+
await claimGithubLogin(options, session);
|
|
569
588
|
const chosen = chooseRepositories(namedRepositories(options), options.cwd);
|
|
570
589
|
if (chosen.kind === "mismatch") {
|
|
571
590
|
return fail(options, "repository_mismatch", chosen.message, 4);
|
|
@@ -586,13 +605,64 @@ async function runRemainingSteps(options, credentials, session) {
|
|
|
586
605
|
if (view !== undefined) {
|
|
587
606
|
const step3 = await stepAgent(options, credentials, session, view, name, published);
|
|
588
607
|
view = step3 ?? view;
|
|
589
|
-
view =
|
|
608
|
+
view =
|
|
609
|
+
(options.existingOnly === true
|
|
610
|
+
? reportInviteeCi(options, view)
|
|
611
|
+
: await stepCi(options, session, view, name)) ?? view;
|
|
590
612
|
await stepPromise(options, session, view, published);
|
|
591
613
|
}
|
|
592
614
|
const finalState = await readState(options, session);
|
|
593
615
|
printReceipts(options, session, finalState ?? state, view);
|
|
594
616
|
return 0;
|
|
595
617
|
}
|
|
618
|
+
const ENFORCEMENT_UNAVAILABLE_WARNING = "This repository is not connected to Balladeer enforcement yet. You can continue onboarding and record promises, but Balladeer cannot track whether this repository's promises are enforced until its CI reports an authenticated run.";
|
|
619
|
+
const NO_REPOSITORY_WARNING = "No repository in this workspace is connected for this checkout yet. You can continue onboarding in Balladeer, but a repository must be connected before its promises can be recorded and their enforcement tracked.";
|
|
620
|
+
function warnEnforcementUnavailable(options, message = ENFORCEMENT_UNAVAILABLE_WARNING) {
|
|
621
|
+
say(options, ` WARNING ${message}`);
|
|
622
|
+
emit(options, {
|
|
623
|
+
step: "warning",
|
|
624
|
+
code: "enforcement_unavailable",
|
|
625
|
+
message,
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Hands over the GitHub login this machine is signed in as, so a run this
|
|
630
|
+
* person starts reads as their name rather than as a handle.
|
|
631
|
+
*
|
|
632
|
+
* It sends a login and never a token. `gh` is the person's own tool, already
|
|
633
|
+
* signed in on their own machine, and the one thing read out of it here is the
|
|
634
|
+
* name of the account. Balladeer stores that as a claim: nothing checks it,
|
|
635
|
+
* nothing is granted by it, and the settings page lets the person take it back.
|
|
636
|
+
*
|
|
637
|
+
* Every failure is silent to the run and visible in the receipt. No `gh`, no
|
|
638
|
+
* login, an older control plane that has never heard of this call: setup goes
|
|
639
|
+
* on. A person cannot be stopped from enrolling a repository because a display
|
|
640
|
+
* detail could not be recorded.
|
|
641
|
+
*/
|
|
642
|
+
async function claimGithubLogin(options, session) {
|
|
643
|
+
const login = await ghLogin();
|
|
644
|
+
if (login === undefined) {
|
|
645
|
+
emit(options, { step: "github_login", status: "unavailable" });
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
try {
|
|
649
|
+
const answer = await request(options.controlPlane, {
|
|
650
|
+
method: "POST",
|
|
651
|
+
path: "/api/setup/v1/github-login",
|
|
652
|
+
bearer: session.token,
|
|
653
|
+
body: { login },
|
|
654
|
+
});
|
|
655
|
+
if (answer.claimedLogin === null) {
|
|
656
|
+
emit(options, { step: "github_login", status: "refused" });
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
say(options, `Signed in to GitHub as ${answer.claimedLogin}. Runs you start will carry your name on the promises they protect. Balladeer holds no GitHub token and has not checked this; clear it any time at ${options.controlPlane}/settings#members.\n`);
|
|
660
|
+
emit(options, { step: "github_login", status: "claimed", login: answer.claimedLogin });
|
|
661
|
+
}
|
|
662
|
+
catch {
|
|
663
|
+
emit(options, { step: "github_login", status: "refused" });
|
|
664
|
+
}
|
|
665
|
+
}
|
|
596
666
|
async function readState(options, session) {
|
|
597
667
|
try {
|
|
598
668
|
return await request(options.controlPlane, {
|
|
@@ -641,6 +711,15 @@ async function stepRepository(options, session, state, name, role = "primary") {
|
|
|
641
711
|
});
|
|
642
712
|
return { view };
|
|
643
713
|
}
|
|
714
|
+
if (options.existingOnly === true) {
|
|
715
|
+
const reason = `${name} is not already in ${session.workspaceName}, so this teammate run left repository enrollment unchanged`;
|
|
716
|
+
say(options, enrollmentHeadline(role, `${name} is not connected onboarding continues`));
|
|
717
|
+
say(options, ` ${reason}.`);
|
|
718
|
+
say(options, ` You can keep using Balladeer in the browser. A workspace administrator can connect a repository later; this run will not add one or change CI.`);
|
|
719
|
+
warnEnforcementUnavailable(options, NO_REPOSITORY_WARNING);
|
|
720
|
+
emit(options, { step: "repository", status: "not_connected", repository: name, reason });
|
|
721
|
+
return { view: undefined };
|
|
722
|
+
}
|
|
644
723
|
if (!/^[A-Za-z0-9._-]{1,39}\/[A-Za-z0-9._-]{1,100}$/.test(name) || name === "unknown/unknown") {
|
|
645
724
|
return blockedRepository(options, role, name, "this directory has no GitHub origin remote I could read", "Run this command inside a repository with a GitHub origin, or name one with --repository owner/name.");
|
|
646
725
|
}
|
|
@@ -758,19 +837,67 @@ function blockedRepository(options, role, name, reason, next, publicFacts) {
|
|
|
758
837
|
emit(options, { step: "repository", status: "blocked", repository: name, reason });
|
|
759
838
|
return { view: undefined };
|
|
760
839
|
}
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
840
|
+
const NO_DESKTOP = { lines: [], json: undefined };
|
|
841
|
+
function connectClaudeDesktop(options, repositoryId, repositoryName) {
|
|
842
|
+
if (options.claudeDesktop === false)
|
|
843
|
+
return NO_DESKTOP;
|
|
844
|
+
// Asked for by name, or taken by default. The difference is only whether a
|
|
845
|
+
// machine with no Claude desktop on it hears about it: a person who typed the
|
|
846
|
+
// flag is owed the answer, and a person who typed nothing is not owed a line
|
|
847
|
+
// about an application they never mentioned.
|
|
848
|
+
const asked = options.claudeDesktop === true;
|
|
849
|
+
const location = desktopConfigLocation(options.environment);
|
|
850
|
+
if (location.kind === "unsupported") {
|
|
851
|
+
return asked
|
|
852
|
+
? {
|
|
853
|
+
lines: [` ${location.reason}`],
|
|
854
|
+
json: { status: "unsupported", reason: location.reason },
|
|
855
|
+
}
|
|
856
|
+
: NO_DESKTOP;
|
|
857
|
+
}
|
|
858
|
+
const key = desktopServerKey(repositoryName, repositoryId);
|
|
859
|
+
const entry = desktopStdioEntry(repositoryId, options.environment, options.controlPlane);
|
|
860
|
+
const merged = mergeDesktopConfig(location, key, entry, repositoryId, options.controlPlane);
|
|
861
|
+
if (merged.kind === "absent") {
|
|
862
|
+
return asked
|
|
863
|
+
? { lines: [` ${merged.reason}`], json: { status: "absent", reason: merged.reason } }
|
|
864
|
+
: NO_DESKTOP;
|
|
865
|
+
}
|
|
866
|
+
if (merged.kind === "refused") {
|
|
867
|
+
return {
|
|
868
|
+
lines: [
|
|
869
|
+
` ${merged.reason}`,
|
|
870
|
+
" Merge this block into it yourself:",
|
|
871
|
+
...merged.block.split("\n").map((line) => ` ${line}`),
|
|
872
|
+
],
|
|
873
|
+
json: { status: "refused", reason: merged.reason },
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
// `unknown/unknown` is what the origin parse answers when it read nothing, and
|
|
877
|
+
// reading it back at somebody as though it were a repository name is worse
|
|
878
|
+
// than not naming the repository at all.
|
|
879
|
+
const named = repositoryName === "unknown/unknown" || repositoryName.length === 0
|
|
880
|
+
? "this repository"
|
|
881
|
+
: repositoryName;
|
|
882
|
+
return {
|
|
883
|
+
lines: merged.changed
|
|
884
|
+
? [
|
|
885
|
+
` Also connected Claude desktop chat for ${named}, as ${merged.key} in ${merged.path}. Every other server in that file is untouched and this one carries no credential.`,
|
|
886
|
+
// Said only where something changed. Telling somebody to restart an
|
|
887
|
+
// application over a file nothing wrote to is noise, and noise is how
|
|
888
|
+
// the line that does matter stops being read.
|
|
889
|
+
` Quit Claude desktop completely and open it again: it reads ${DESKTOP_CONFIG_FILE} at startup, so an app that is already running will not see this.`,
|
|
890
|
+
]
|
|
891
|
+
: [
|
|
892
|
+
` Claude desktop chat already runs the current command for ${named} as ${merged.key}; I left ${merged.path} alone.`,
|
|
893
|
+
],
|
|
894
|
+
json: {
|
|
895
|
+
status: merged.changed ? "connected" : "current",
|
|
896
|
+
key: merged.key,
|
|
897
|
+
path: merged.path,
|
|
898
|
+
},
|
|
899
|
+
};
|
|
900
|
+
}
|
|
774
901
|
function hostNextSteps() {
|
|
775
902
|
return [
|
|
776
903
|
` Your agent host reads ${MCP_CONFIG_FILE} when it starts, so the tools appear in its next session rather than in one already running. Nothing here needs a restart: finish setup first.`,
|
|
@@ -801,7 +928,69 @@ async function proveAgent(agent) {
|
|
|
801
928
|
* is not mistaken for this one's.
|
|
802
929
|
*/
|
|
803
930
|
function credentialOnThisMachine(agents, controlPlane, repositoryId) {
|
|
804
|
-
return agents.
|
|
931
|
+
return agents.find((agent) => agent.controlPlane === controlPlane && agent.repositoryId === repositoryId);
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* Reconcile every local host surface whether the bearer was issued now or was
|
|
935
|
+
* already on this machine. A credential is not a project MCP installation:
|
|
936
|
+
* another checkout, a deleted uncommitted file, or an interrupted earlier run
|
|
937
|
+
* can leave the first present and the second absent.
|
|
938
|
+
*/
|
|
939
|
+
async function configureAgentHost(options, repositoryId, name, published) {
|
|
940
|
+
const root = await repositoryRoot(options.cwd);
|
|
941
|
+
const files = [];
|
|
942
|
+
let merged;
|
|
943
|
+
let conventions;
|
|
944
|
+
if (root !== undefined) {
|
|
945
|
+
merged = mergeMcpConfig(root, stdioEntry(repositoryId, published), options.controlPlane);
|
|
946
|
+
if (merged.kind === "written") {
|
|
947
|
+
if (merged.changed)
|
|
948
|
+
files.push(MCP_CONFIG_FILE);
|
|
949
|
+
conventions = writeConventions(root);
|
|
950
|
+
if (conventions.kind === "written" && conventions.changed)
|
|
951
|
+
files.push(conventions.file);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
return {
|
|
955
|
+
root,
|
|
956
|
+
files,
|
|
957
|
+
merged,
|
|
958
|
+
conventions,
|
|
959
|
+
desktop: connectClaudeDesktop(options, repositoryId, name),
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
function reportAgentHost(options, configured, published) {
|
|
963
|
+
if (configured.root === undefined) {
|
|
964
|
+
say(options, ` This directory is not a git work tree, so I wrote no ${MCP_CONFIG_FILE} and no instructions block.`);
|
|
965
|
+
}
|
|
966
|
+
else {
|
|
967
|
+
if (configured.merged?.kind === "refused") {
|
|
968
|
+
say(options, ` ${configured.merged.reason}`);
|
|
969
|
+
say(options, " Merge this block into it yourself:");
|
|
970
|
+
for (const line of configured.merged.block.split("\n"))
|
|
971
|
+
say(options, ` ${line}`);
|
|
972
|
+
}
|
|
973
|
+
if (configured.conventions?.kind === "refused") {
|
|
974
|
+
say(options, ` ${configured.conventions.reason}`);
|
|
975
|
+
say(options, ` Repair the markers in ${configured.conventions.file}, or delete the block between them, then run this command again.`);
|
|
976
|
+
}
|
|
977
|
+
if (configured.merged?.kind === "written") {
|
|
978
|
+
say(options, ` The ${MCP_CONFIG_FILE} entry runs this command's own MCP forwarder for this repository and reads the credential from that store, so there is nothing else to set. This command never prints the credential.`);
|
|
979
|
+
if (published === null && !existsSync(checkoutEntryPath())) {
|
|
980
|
+
say(options, ` That entry runs ${checkoutEntryPath()}, which this checkout has not built yet. Run \`pnpm --filter balladeer build\` once so your agent host can start it.`);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
for (const line of configured.desktop.lines)
|
|
985
|
+
say(options, line);
|
|
986
|
+
for (const line of hostNextSteps())
|
|
987
|
+
say(options, line);
|
|
988
|
+
say(options, published === null
|
|
989
|
+
? ` Setup does not install a global balladeer executable. This checkout's CLI runs as \`${commandLine(null, "<command>")}\` after it is built.`
|
|
990
|
+
: ` Setup does not install a global balladeer executable. Run the CLI as \`${commandLine(published, "<command>")}\`; ${MCP_CONFIG_FILE} invokes that same package automatically.`);
|
|
991
|
+
if (configured.files.length > 0) {
|
|
992
|
+
say(options, ` ${configured.files.join(" and ")} ${configured.files.length === 1 ? "is an uncommitted change" : "are uncommitted changes"} in your working tree. Commit ${configured.files.length === 1 ? "it" : "them"} when you are ready; they carry no secret.`);
|
|
993
|
+
}
|
|
805
994
|
}
|
|
806
995
|
/**
|
|
807
996
|
* Whose credential this step is about: this machine's.
|
|
@@ -826,15 +1015,36 @@ function credentialOnThisMachine(agents, controlPlane, repositoryId) {
|
|
|
826
1015
|
*/
|
|
827
1016
|
async function stepAgent(options, credentials, session, view, name, published) {
|
|
828
1017
|
const heldHere = credentialOnThisMachine(credentials.agents, options.controlPlane, view.id);
|
|
829
|
-
if (heldHere && view.agentConfigured) {
|
|
830
|
-
|
|
1018
|
+
if (heldHere !== undefined && view.agentConfigured) {
|
|
1019
|
+
const configured = await configureAgentHost(options, view.id, name, published);
|
|
1020
|
+
const proof = await proveAgent(heldHere);
|
|
1021
|
+
const repaired = configured.files.length > 0 ? ` repaired ${configured.files.join(" and ")}` : "";
|
|
1022
|
+
say(options, proof.kind === "answered"
|
|
1023
|
+
? `Step 3 of 5 This coding agent is already connected on this machine${repaired}`
|
|
1024
|
+
: `Step 3 of 5 This machine holds an agent credential not yet proven${repaired}`);
|
|
831
1025
|
say(options, ` ${name}'s credential is in ${credentialsPath(options.environment)}, so nothing was issued and nothing was rotated. A credential belongs to the machine it was issued on: another machine runs this command there to get its own.`);
|
|
1026
|
+
if (proof.kind === "answered") {
|
|
1027
|
+
say(options, ` Balladeer answered a get_promise_setup call for ${name} over this connection just now.`);
|
|
1028
|
+
}
|
|
1029
|
+
else {
|
|
1030
|
+
say(options, ` ${proof.reason}. ${proof.nextAction}`);
|
|
1031
|
+
}
|
|
1032
|
+
reportAgentHost(options, configured, published);
|
|
832
1033
|
emit(options, {
|
|
833
1034
|
step: "agent",
|
|
834
|
-
status: "already",
|
|
1035
|
+
status: proof.kind === "answered" ? "already" : "unproven",
|
|
835
1036
|
repositoryId: view.id,
|
|
836
1037
|
storedHere: true,
|
|
837
1038
|
...(view.agentConnectionId === null ? {} : { connectionId: view.agentConnectionId }),
|
|
1039
|
+
files: configured.files,
|
|
1040
|
+
...(proof.kind === "answered"
|
|
1041
|
+
? { provedBy: "get_promise_setup" }
|
|
1042
|
+
: { reason: proof.reason, nextAction: proof.nextAction }),
|
|
1043
|
+
...(configured.merged?.kind === "refused" ? { mcpConfig: configured.merged.reason } : {}),
|
|
1044
|
+
...(configured.conventions?.kind === "refused"
|
|
1045
|
+
? { conventions: configured.conventions.reason }
|
|
1046
|
+
: {}),
|
|
1047
|
+
...(configured.desktop.json === undefined ? {} : { claudeDesktop: configured.desktop.json }),
|
|
838
1048
|
});
|
|
839
1049
|
return undefined;
|
|
840
1050
|
}
|
|
@@ -844,7 +1054,7 @@ async function stepAgent(options, credentials, session, view, name, published) {
|
|
|
844
1054
|
// A credential in this store, and no live connection anywhere for the
|
|
845
1055
|
// repository: the one this machine holds was revoked. Holding it is not being
|
|
846
1056
|
// connected, so this issues rather than reporting the stored entry as working.
|
|
847
|
-
const revokedHere = heldHere && !view.agentConfigured;
|
|
1057
|
+
const revokedHere = heldHere !== undefined && !view.agentConfigured;
|
|
848
1058
|
let packet;
|
|
849
1059
|
try {
|
|
850
1060
|
packet = await request(options.controlPlane, {
|
|
@@ -874,30 +1084,13 @@ async function stepAgent(options, credentials, session, view, name, published) {
|
|
|
874
1084
|
catch (error) {
|
|
875
1085
|
return blockedAgent(options, view, error instanceof StoreError ? error.message : "the credential store could not be written");
|
|
876
1086
|
}
|
|
877
|
-
const
|
|
878
|
-
const files = [];
|
|
879
|
-
let merged;
|
|
880
|
-
let conventions;
|
|
881
|
-
if (root !== undefined) {
|
|
882
|
-
// The stdio forwarder, bound to this repository. It reads the bearer from
|
|
883
|
-
// the credential store this step just wrote, so the connection works from
|
|
884
|
-
// here with nothing further for anyone to set, and the file carries no
|
|
885
|
-
// secret.
|
|
886
|
-
merged = mergeMcpConfig(root, stdioEntry(packet.repositoryId, published), options.controlPlane);
|
|
887
|
-
if (merged.kind === "written") {
|
|
888
|
-
if (merged.changed)
|
|
889
|
-
files.push(MCP_CONFIG_FILE);
|
|
890
|
-
conventions = writeConventions(root);
|
|
891
|
-
if (conventions.kind === "written" && conventions.changed)
|
|
892
|
-
files.push(conventions.file);
|
|
893
|
-
}
|
|
894
|
-
}
|
|
1087
|
+
const configured = await configureAgentHost(options, packet.repositoryId, name, published);
|
|
895
1088
|
// The probe runs after the files, because the files are not what it proves.
|
|
896
1089
|
// The credential, the endpoint, and the frames this command's own forwarder
|
|
897
1090
|
// sends are.
|
|
898
1091
|
const proof = await proveAgent(agent);
|
|
899
|
-
const wrote = merged?.kind === "written" && conventions?.kind === "written"
|
|
900
|
-
? `, ${MCP_CONFIG_FILE} and ${conventions.file} updated`
|
|
1092
|
+
const wrote = configured.merged?.kind === "written" && configured.conventions?.kind === "written"
|
|
1093
|
+
? `, ${MCP_CONFIG_FILE} and ${configured.conventions.file} updated`
|
|
901
1094
|
: "";
|
|
902
1095
|
say(options, proof.kind === "answered"
|
|
903
1096
|
? `Step 3 of 5 Connected this coding agent on this machine credential stored in ${credentialsPath(options.environment)}${wrote}`
|
|
@@ -914,38 +1107,7 @@ async function stepAgent(options, credentials, session, view, name, published) {
|
|
|
914
1107
|
say(options, ` The credential is stored, but ${proof.reason}, so this is not reported as connected.`);
|
|
915
1108
|
say(options, ` ${proof.nextAction}`);
|
|
916
1109
|
}
|
|
917
|
-
|
|
918
|
-
say(options, ` This directory is not a git work tree, so I wrote no ${MCP_CONFIG_FILE} and no instructions block.`);
|
|
919
|
-
}
|
|
920
|
-
else {
|
|
921
|
-
if (merged?.kind === "refused") {
|
|
922
|
-
say(options, ` ${merged.reason}`);
|
|
923
|
-
say(options, " Merge this block into it yourself:");
|
|
924
|
-
for (const line of merged.block.split("\n"))
|
|
925
|
-
say(options, ` ${line}`);
|
|
926
|
-
}
|
|
927
|
-
if (conventions?.kind === "refused") {
|
|
928
|
-
say(options, ` ${conventions.reason}`);
|
|
929
|
-
say(options, ` Repair the markers in ${conventions.file}, or delete the block between them, then run this command again.`);
|
|
930
|
-
}
|
|
931
|
-
if (merged?.kind === "written") {
|
|
932
|
-
// Nothing further is asked of anybody about the credential. The entry runs
|
|
933
|
-
// this command's own forwarder, which reads the bearer out of the store
|
|
934
|
-
// above, so there is no variable to export and no secret to carry by hand.
|
|
935
|
-
say(options, ` The ${MCP_CONFIG_FILE} entry runs this command's own MCP forwarder for this repository and reads the credential from that store, so there is nothing else to set. This command never prints the credential.`);
|
|
936
|
-
if (published === null && !existsSync(checkoutEntryPath())) {
|
|
937
|
-
say(options, ` That entry runs ${checkoutEntryPath()}, which this checkout has not built yet. Run \`pnpm --filter balladeer build\` once so your agent host can start it.`);
|
|
938
|
-
}
|
|
939
|
-
}
|
|
940
|
-
}
|
|
941
|
-
// Said on every ending of this step, including the ones where no file was
|
|
942
|
-
// written: whoever is reading has to configure that host by hand, and they
|
|
943
|
-
// need the same two facts about when a host picks an entry up.
|
|
944
|
-
for (const line of hostNextSteps())
|
|
945
|
-
say(options, line);
|
|
946
|
-
if (files.length > 0) {
|
|
947
|
-
say(options, ` ${files.join(" and ")} ${files.length === 1 ? "is an uncommitted change" : "are uncommitted changes"} in your working tree. Commit ${files.length === 1 ? "it" : "them"} when you are ready; they carry no secret.`);
|
|
948
|
-
}
|
|
1110
|
+
reportAgentHost(options, configured, published);
|
|
949
1111
|
emit(options, {
|
|
950
1112
|
step: "agent",
|
|
951
1113
|
status: proof.kind === "answered" ? "connected" : "unproven",
|
|
@@ -953,12 +1115,15 @@ async function stepAgent(options, credentials, session, view, name, published) {
|
|
|
953
1115
|
connectionId: packet.connectionId,
|
|
954
1116
|
storedHere: true,
|
|
955
1117
|
...(connectedElsewhere ? { alreadyConnectedElsewhere: true } : {}),
|
|
956
|
-
files,
|
|
1118
|
+
files: configured.files,
|
|
957
1119
|
...(proof.kind === "answered"
|
|
958
1120
|
? { provedBy: "get_promise_setup" }
|
|
959
1121
|
: { reason: proof.reason, nextAction: proof.nextAction }),
|
|
960
|
-
...(merged?.kind === "refused" ? { mcpConfig: merged.reason } : {}),
|
|
961
|
-
...(conventions?.kind === "refused"
|
|
1122
|
+
...(configured.merged?.kind === "refused" ? { mcpConfig: configured.merged.reason } : {}),
|
|
1123
|
+
...(configured.conventions?.kind === "refused"
|
|
1124
|
+
? { conventions: configured.conventions.reason }
|
|
1125
|
+
: {}),
|
|
1126
|
+
...(configured.desktop.json === undefined ? {} : { claudeDesktop: configured.desktop.json }),
|
|
962
1127
|
});
|
|
963
1128
|
return undefined;
|
|
964
1129
|
}
|
|
@@ -970,6 +1135,34 @@ function blockedAgent(options, view, reason) {
|
|
|
970
1135
|
return undefined;
|
|
971
1136
|
}
|
|
972
1137
|
/* ----------------------------- Step 4 ------------------------------------ */
|
|
1138
|
+
/**
|
|
1139
|
+
* A teammate run reports CI truth and never becomes repository maintenance.
|
|
1140
|
+
* Adding or updating a workflow, setting repository variables, and opening a
|
|
1141
|
+
* pull request all belong to the administrator/maintainer path.
|
|
1142
|
+
*/
|
|
1143
|
+
function reportInviteeCi(options, view) {
|
|
1144
|
+
if (view.ciConfigured) {
|
|
1145
|
+
say(options, `Step 4 of 5 CI already connected ${view.ciValidatedRunCount} authenticated run${view.ciValidatedRunCount === 1 ? "" : "s"} observed`);
|
|
1146
|
+
emit(options, {
|
|
1147
|
+
step: "ci",
|
|
1148
|
+
status: "connected",
|
|
1149
|
+
repositoryId: view.id,
|
|
1150
|
+
validatedRunCount: view.ciValidatedRunCount,
|
|
1151
|
+
...(view.ciFirstValidatedAt === null ? {} : { firstValidatedAt: view.ciFirstValidatedAt }),
|
|
1152
|
+
});
|
|
1153
|
+
return undefined;
|
|
1154
|
+
}
|
|
1155
|
+
say(options, "Step 4 of 5 CI is not connected teammate onboarding continues");
|
|
1156
|
+
say(options, " This run did not write a workflow, set GitHub variables, push a branch, or open a pull request.");
|
|
1157
|
+
warnEnforcementUnavailable(options);
|
|
1158
|
+
emit(options, {
|
|
1159
|
+
step: "ci",
|
|
1160
|
+
status: "not_changed",
|
|
1161
|
+
repositoryId: view.id,
|
|
1162
|
+
reason: "Teammate setup leaves repository CI unchanged.",
|
|
1163
|
+
});
|
|
1164
|
+
return undefined;
|
|
1165
|
+
}
|
|
973
1166
|
async function stepCi(options, session, view, name) {
|
|
974
1167
|
// Checked on every run, and BEFORE the connected branch below, because an
|
|
975
1168
|
// already-connected repository is exactly the one this happens to. Balladeer
|
|
@@ -986,7 +1179,7 @@ async function stepCi(options, session, view, name) {
|
|
|
986
1179
|
if (!upgrading && view.ciConfigured) {
|
|
987
1180
|
// "Connected" appears on this branch and nowhere else in the command: it is
|
|
988
1181
|
// the one state Balladeer observed rather than recorded.
|
|
989
|
-
say(options, `Step 4 of 5 CI connected authenticated run observed at ${view.ciFirstValidatedAt
|
|
1182
|
+
say(options, `Step 4 of 5 CI connected authenticated run observed at ${view.ciFirstValidatedAt === null ? "an earlier run" : formatInstant(view.ciFirstValidatedAt)}`);
|
|
990
1183
|
emit(options, {
|
|
991
1184
|
step: "ci",
|
|
992
1185
|
status: "connected",
|
|
@@ -1228,7 +1421,7 @@ async function stepPromise(options, session, view, published) {
|
|
|
1228
1421
|
const state = await readState(options, session);
|
|
1229
1422
|
const current = state?.repositories.find((repository) => repository.id === view.id) ?? view;
|
|
1230
1423
|
if (current.firstPromiseId !== null) {
|
|
1231
|
-
const promiseUrl =
|
|
1424
|
+
const promiseUrl = reviewLink(options.controlPlane, `promises/${encodeURIComponent(current.firstPromiseId)}`, session.workspaceId);
|
|
1232
1425
|
say(options, "Step 5 of 5 A named person agreed to the first promise");
|
|
1233
1426
|
say(options, ` ${promiseUrl}`);
|
|
1234
1427
|
say(options, " It stands agreed and is not yet protected. Protection starts on its own the moment a verifier qualifies, once the verifier pull request has merged. Nobody is asked to turn it on.");
|
|
@@ -1241,7 +1434,7 @@ async function stepPromise(options, session, view, published) {
|
|
|
1241
1434
|
return;
|
|
1242
1435
|
}
|
|
1243
1436
|
if (current.latestCandidateId !== null) {
|
|
1244
|
-
const reviewUrl =
|
|
1437
|
+
const reviewUrl = proposalReviewLink(options.controlPlane, current.latestCandidateId, session.workspaceId);
|
|
1245
1438
|
say(options, "Step 5 of 5 A first promise is proposed and waiting for a person");
|
|
1246
1439
|
say(options, ` Review it: ${reviewUrl}`);
|
|
1247
1440
|
say(options, " A named person reads the proposal and clicks Agree. Nothing else can.");
|
|
@@ -1301,10 +1494,12 @@ function printReceipts(options, session, state, view) {
|
|
|
1301
1494
|
// promise the receipt below it said already stood agreed.
|
|
1302
1495
|
const promiseId = current?.firstPromiseId ?? null;
|
|
1303
1496
|
const candidateId = current?.latestCandidateId ?? null;
|
|
1304
|
-
const promiseUrl = promiseId === null
|
|
1497
|
+
const promiseUrl = promiseId === null
|
|
1498
|
+
? undefined
|
|
1499
|
+
: reviewLink(options.controlPlane, `promises/${encodeURIComponent(promiseId)}`, state.workspace.id);
|
|
1305
1500
|
const reviewUrl = promiseId !== null || candidateId === null
|
|
1306
1501
|
? undefined
|
|
1307
|
-
:
|
|
1502
|
+
: proposalReviewLink(options.controlPlane, candidateId, state.workspace.id);
|
|
1308
1503
|
const promiseLink = promiseUrl ?? reviewUrl;
|
|
1309
1504
|
const promiseSentence = promiseId !== null
|
|
1310
1505
|
? "Agreed. A named person agreed to this repository's first promise."
|
|
@@ -1402,11 +1597,14 @@ async function runRefresh(options) {
|
|
|
1402
1597
|
}
|
|
1403
1598
|
const existing = currentEntry(readMcpConfig(root));
|
|
1404
1599
|
let stored;
|
|
1600
|
+
let storedName;
|
|
1405
1601
|
try {
|
|
1406
1602
|
const credentials = readCredentials(options.environment);
|
|
1407
1603
|
const selection = selectAgent(credentials.agents, options.controlPlane, undefined, repositoryHint(options.cwd));
|
|
1408
|
-
if (selection.kind === "agent")
|
|
1604
|
+
if (selection.kind === "agent") {
|
|
1409
1605
|
stored = selection.agent.repositoryId;
|
|
1606
|
+
storedName = selection.agent.repository;
|
|
1607
|
+
}
|
|
1410
1608
|
}
|
|
1411
1609
|
catch {
|
|
1412
1610
|
// A store this machine cannot read is not a reason to refuse the repair:
|
|
@@ -1433,6 +1631,12 @@ async function runRefresh(options) {
|
|
|
1433
1631
|
say(options, conventions.reason);
|
|
1434
1632
|
return failRefresh(options, "conventions_refused", conventions.reason);
|
|
1435
1633
|
}
|
|
1634
|
+
// The third stale thing, repaired by the same command and for the same
|
|
1635
|
+
// reason. A desktop entry naming an interpreter that a node upgrade moved is
|
|
1636
|
+
// a chat client that silently has no Balladeer in it, and nothing on screen
|
|
1637
|
+
// says so. This rewrites it to the interpreter and entry point running right
|
|
1638
|
+
// now, and leaves every other server in that file alone.
|
|
1639
|
+
const desktop = connectClaudeDesktop(options, repositoryId, storedName ?? repositoryHint(options.cwd));
|
|
1436
1640
|
const files = [];
|
|
1437
1641
|
if (merged.changed)
|
|
1438
1642
|
files.push(MCP_CONFIG_FILE);
|
|
@@ -1446,21 +1650,27 @@ async function runRefresh(options) {
|
|
|
1446
1650
|
? ""
|
|
1447
1651
|
: ` (it was v${conventions.previousVersion})`}. Everything outside the markers is untouched.`
|
|
1448
1652
|
: `${conventions.file} already carries conventions v${conventions.version}; I left it alone.`);
|
|
1449
|
-
|
|
1653
|
+
// Said before the closing line, because it names a file outside the working
|
|
1654
|
+
// tree and the closing line is about what to commit. The desktop entry is
|
|
1655
|
+
// never a change anybody commits: it lives in this person's home directory.
|
|
1656
|
+
for (const line of desktop.lines)
|
|
1657
|
+
say(options, line.replace(/^ {2}/, ""));
|
|
1658
|
+
if (files.length === 0 && desktop.json?.status !== "connected") {
|
|
1450
1659
|
say(options, "Nothing to change: this repository is already on the current form.");
|
|
1451
1660
|
}
|
|
1452
|
-
else {
|
|
1661
|
+
else if (files.length > 0) {
|
|
1453
1662
|
say(options, `Commit ${files.join(" and ")} when you are ready.`);
|
|
1454
1663
|
}
|
|
1455
1664
|
emit(options, {
|
|
1456
1665
|
step: "refresh",
|
|
1457
|
-
status: files.length === 0 ? "current" : "updated",
|
|
1666
|
+
status: files.length === 0 && desktop.json?.status !== "connected" ? "current" : "updated",
|
|
1458
1667
|
repositoryId,
|
|
1459
1668
|
files,
|
|
1460
1669
|
conventionsVersion: conventions.version,
|
|
1461
1670
|
...(conventions.previousVersion === undefined
|
|
1462
1671
|
? {}
|
|
1463
1672
|
: { previousConventionsVersion: conventions.previousVersion }),
|
|
1673
|
+
...(desktop.json === undefined ? {} : { claudeDesktop: desktop.json }),
|
|
1464
1674
|
});
|
|
1465
1675
|
return 0;
|
|
1466
1676
|
}
|
|
@@ -18,6 +18,22 @@ export type StatusOptions = Readonly<{
|
|
|
18
18
|
cwd: string;
|
|
19
19
|
write: (text: string) => void;
|
|
20
20
|
}>;
|
|
21
|
+
/**
|
|
22
|
+
* The two postures in which Balladeer is reporting the behavior as holding.
|
|
23
|
+
*
|
|
24
|
+
* Named as the small set rather than as "everything that is not broken",
|
|
25
|
+
* because the states this command actually meets are mostly neither. A promise
|
|
26
|
+
* whose meaning somebody agreed and nobody has checked, one whose required run
|
|
27
|
+
* never arrived, one whose newest evidence aged out, and one whose seal no
|
|
28
|
+
* longer matches all have no failing run behind them, and a reader who takes
|
|
29
|
+
* the absence of a failing run for a pass has been told the one thing this
|
|
30
|
+
* product refuses to say. The published contract this mirrors is `NOT_HOLDING`
|
|
31
|
+
* in `@balladeer/service` and the posture enum in `@balladeer/contracts`; this
|
|
32
|
+
* command carries its own copy because the published package depends on
|
|
33
|
+
* neither, and `tests/cli/promise-status-posture.test.ts` fails if the two
|
|
34
|
+
* drift apart.
|
|
35
|
+
*/
|
|
36
|
+
export declare const HOLDING_POSTURES: ReadonlySet<string>;
|
|
21
37
|
/**
|
|
22
38
|
* What Balladeer looks like right now, read from the server and never from
|
|
23
39
|
* anything this machine remembers.
|