balladeer 1.0.1 → 1.0.3
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 +75 -41
- package/dist/agent.d.ts +5 -0
- package/dist/agent.js +13 -1
- package/dist/cli.d.ts +4 -0
- package/dist/cli.js +78 -24
- package/dist/client.d.ts +2 -2
- package/dist/client.js +7 -4
- package/dist/codex-config.d.ts +7 -0
- package/dist/codex-config.js +159 -0
- package/dist/commands/affected.js +6 -6
- package/dist/commands/check-seals.js +4 -4
- package/dist/commands/discover.js +5 -4
- package/dist/commands/prepare.js +2 -1
- package/dist/commands/propose.js +5 -4
- package/dist/commands/repositories.js +1 -0
- package/dist/commands/session.js +17 -4
- package/dist/commands/setup.d.ts +8 -0
- package/dist/commands/setup.js +215 -88
- package/dist/commands/status.js +41 -18
- package/dist/commands/touch-map.js +2 -2
- package/dist/conventions.d.ts +2 -1
- package/dist/conventions.js +2 -2
- package/dist/copy.d.ts +8 -8
- package/dist/copy.js +60 -39
- package/dist/store.d.ts +3 -1
- package/dist/store.js +3 -3
- package/dist/wire.d.ts +9 -4
- package/dist/wire.js +2 -1
- package/package.json +4 -1
package/dist/commands/setup.js
CHANGED
|
@@ -2,17 +2,17 @@ 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, proposalReviewLink, request, } from "../client.js";
|
|
5
|
+
import { ClientTooOldError, RefusalError, TransportError, proposalReviewLink, reviewLink, request, } from "../client.js";
|
|
6
|
+
import { CODEX_CONFIG_FILE, hasManagedCodexEntry, mergeCodexConfig, readCodexEntry, } from "../codex-config.js";
|
|
6
7
|
import { writeConventions } from "../conventions.js";
|
|
7
8
|
import { APPROVE_IN_THE_RIGHT_WORKSPACE, DISCOVERY_PLAYBOOK, JOIN_OR_CREATE } from "../copy.js";
|
|
8
9
|
import { ghLogin, readPublicRepositoryFacts, readRepositoryFacts, runCommand, setRepositoryVariable, } from "../gh.js";
|
|
9
10
|
import { CI_BRANCH, commitWorkflowOnBranch, repositoryRoot, workflowOnDefaultBranch, } from "../git.js";
|
|
10
|
-
import { MCP_CONFIG_FILE, currentEntry, entryRepositoryId, mergeMcpConfig, readMcpConfig, stdioEntry, } from "../mcp-config.js";
|
|
11
|
+
import { MCP_CONFIG_FILE, currentEntry, entryRepositoryId, isOurEntry, mergeMcpConfig, readMcpConfig, stdioEntry, } from "../mcp-config.js";
|
|
11
12
|
import { DESKTOP_CONFIG_FILE, desktopConfigLocation, desktopServerKey, desktopStdioEntry, mergeDesktopConfig, } from "../desktop-config.js";
|
|
12
13
|
import { selectAgent } from "../agent.js";
|
|
13
14
|
import { findLegacyInstall, legacyMessage } from "../legacy.js";
|
|
14
15
|
import { formatInstant } from "../local-time.js";
|
|
15
|
-
import { CONVENTIONS_VERSION } from "../conventions.js";
|
|
16
16
|
import { checkoutEntryPath, commandLine, runningFromRegistryInstall } from "../release.js";
|
|
17
17
|
import { hostHint, repositoryHint } from "../repository.js";
|
|
18
18
|
import { StoreError, credentialsPath, dropPendingPairing, findPendingPairing, findSession, putAgent, putPendingPairing, putSession, readCredentials, writeCredentials, } from "../store.js";
|
|
@@ -268,6 +268,7 @@ export async function runSetup(options) {
|
|
|
268
268
|
const { controlPlane } = options;
|
|
269
269
|
const now = options.now ?? (() => new Date());
|
|
270
270
|
say(options, `Balladeer setup, version ${CLI_VERSION}.\n`);
|
|
271
|
+
say(options, `Agent client: ${options.client === "codex" ? "Codex" : "Claude"}. Use --client codex or --client claude to choose.`);
|
|
271
272
|
emit(options, { step: "explain", version: CLI_VERSION });
|
|
272
273
|
say(options, explainText(controlPlane));
|
|
273
274
|
const chosen = chooseRepositories(namedRepositories(options), options.cwd);
|
|
@@ -289,6 +290,21 @@ export async function runSetup(options) {
|
|
|
289
290
|
catch (error) {
|
|
290
291
|
return storeFailure(options, error);
|
|
291
292
|
}
|
|
293
|
+
// Workspace choice is explicit. Preserve all agent connections and other
|
|
294
|
+
// origins; persist the session replacement only when a new pairing starts.
|
|
295
|
+
const workspaceChoice = options.createWorkspace !== undefined
|
|
296
|
+
? `create:${carriedName}`
|
|
297
|
+
: options.chooseWorkspace === true
|
|
298
|
+
? "choose"
|
|
299
|
+
: undefined;
|
|
300
|
+
if (workspaceChoice !== undefined) {
|
|
301
|
+
credentials = {
|
|
302
|
+
...credentials,
|
|
303
|
+
sessions: credentials.sessions.filter((entry) => entry.controlPlane !== controlPlane),
|
|
304
|
+
pendingPairings: credentials.pendingPairings.filter((entry) => entry.controlPlane !== controlPlane || entry.workspaceChoice === workspaceChoice),
|
|
305
|
+
};
|
|
306
|
+
say(options, "Choose the workspace in the new browser pairing. Existing agent connections remain saved.");
|
|
307
|
+
}
|
|
292
308
|
// A still-valid session carries straight on to the steps that need it.
|
|
293
309
|
const stored = findSession(credentials, controlPlane);
|
|
294
310
|
if (stored && Date.parse(stored.expiresAt) > now().getTime()) {
|
|
@@ -376,6 +392,7 @@ export async function runSetup(options) {
|
|
|
376
392
|
verificationUri: started.verificationUriComplete,
|
|
377
393
|
expiresAt: started.expiresAt,
|
|
378
394
|
startedAt: now().toISOString(),
|
|
395
|
+
...(workspaceChoice === undefined ? {} : { workspaceChoice }),
|
|
379
396
|
};
|
|
380
397
|
credentials = putPendingPairing(credentials, pending);
|
|
381
398
|
writeCredentials(credentials, options.environment);
|
|
@@ -606,13 +623,26 @@ async function runRemainingSteps(options, credentials, session) {
|
|
|
606
623
|
if (view !== undefined) {
|
|
607
624
|
const step3 = await stepAgent(options, credentials, session, view, name, published);
|
|
608
625
|
view = step3 ?? view;
|
|
609
|
-
view =
|
|
626
|
+
view =
|
|
627
|
+
(options.existingOnly === true
|
|
628
|
+
? reportInviteeCi(options, view)
|
|
629
|
+
: await stepCi(options, session, view, name)) ?? view;
|
|
610
630
|
await stepPromise(options, session, view, published);
|
|
611
631
|
}
|
|
612
632
|
const finalState = await readState(options, session);
|
|
613
633
|
printReceipts(options, session, finalState ?? state, view);
|
|
614
634
|
return 0;
|
|
615
635
|
}
|
|
636
|
+
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.";
|
|
637
|
+
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.";
|
|
638
|
+
function warnEnforcementUnavailable(options, message = ENFORCEMENT_UNAVAILABLE_WARNING) {
|
|
639
|
+
say(options, ` WARNING ${message}`);
|
|
640
|
+
emit(options, {
|
|
641
|
+
step: "warning",
|
|
642
|
+
code: "enforcement_unavailable",
|
|
643
|
+
message,
|
|
644
|
+
});
|
|
645
|
+
}
|
|
616
646
|
/**
|
|
617
647
|
* Hands over the GitHub login this machine is signed in as, so a run this
|
|
618
648
|
* person starts reads as their name rather than as a handle.
|
|
@@ -699,6 +729,15 @@ async function stepRepository(options, session, state, name, role = "primary") {
|
|
|
699
729
|
});
|
|
700
730
|
return { view };
|
|
701
731
|
}
|
|
732
|
+
if (options.existingOnly === true) {
|
|
733
|
+
const reason = `${name} is not already in ${session.workspaceName}, so this teammate run left repository enrollment unchanged`;
|
|
734
|
+
say(options, enrollmentHeadline(role, `${name} is not connected onboarding continues`));
|
|
735
|
+
say(options, ` ${reason}.`);
|
|
736
|
+
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.`);
|
|
737
|
+
warnEnforcementUnavailable(options, NO_REPOSITORY_WARNING);
|
|
738
|
+
emit(options, { step: "repository", status: "not_connected", repository: name, reason });
|
|
739
|
+
return { view: undefined };
|
|
740
|
+
}
|
|
702
741
|
if (!/^[A-Za-z0-9._-]{1,39}\/[A-Za-z0-9._-]{1,100}$/.test(name) || name === "unknown/unknown") {
|
|
703
742
|
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.");
|
|
704
743
|
}
|
|
@@ -818,7 +857,7 @@ function blockedRepository(options, role, name, reason, next, publicFacts) {
|
|
|
818
857
|
}
|
|
819
858
|
const NO_DESKTOP = { lines: [], json: undefined };
|
|
820
859
|
function connectClaudeDesktop(options, repositoryId, repositoryName) {
|
|
821
|
-
if (options.claudeDesktop === false)
|
|
860
|
+
if (options.claudeDesktop === false || options.client === "codex")
|
|
822
861
|
return NO_DESKTOP;
|
|
823
862
|
// Asked for by name, or taken by default. The difference is only whether a
|
|
824
863
|
// machine with no Claude desktop on it hears about it: a person who typed the
|
|
@@ -877,11 +916,28 @@ function connectClaudeDesktop(options, repositoryId, repositoryName) {
|
|
|
877
916
|
},
|
|
878
917
|
};
|
|
879
918
|
}
|
|
880
|
-
function
|
|
919
|
+
function hostConfigFile(options) {
|
|
920
|
+
return options.client === "codex" ? CODEX_CONFIG_FILE : MCP_CONFIG_FILE;
|
|
921
|
+
}
|
|
922
|
+
function mergeHostConfig(options, root, repositoryId, published) {
|
|
923
|
+
const entry = stdioEntry(repositoryId, published);
|
|
924
|
+
return options.client === "codex"
|
|
925
|
+
? mergeCodexConfig(root, { ...entry, args: [...entry.args, "--control-plane", options.controlPlane] }, options.controlPlane)
|
|
926
|
+
: mergeMcpConfig(root, entry, options.controlPlane);
|
|
927
|
+
}
|
|
928
|
+
function writeHostConventions(options, root) {
|
|
929
|
+
return writeConventions(root, options.client === "codex" ? { file: "AGENTS.md" } : {});
|
|
930
|
+
}
|
|
931
|
+
function hostNextSteps(options) {
|
|
932
|
+
if (options.client === "codex")
|
|
933
|
+
return [
|
|
934
|
+
" Codex project configuration is ready. Open this repository in Codex and trust the project if appropriate; Codex ignores project configuration in untrusted projects.",
|
|
935
|
+
" Start a fresh Codex session to load .codex/config.toml and AGENTS.md, then check that Balladeer's tools are available. The connection probe does not prove your Codex host loaded them.",
|
|
936
|
+
];
|
|
881
937
|
return [
|
|
882
|
-
` Your agent host reads ${
|
|
938
|
+
` Your agent host reads ${hostConfigFile(options)} 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.`,
|
|
883
939
|
" Claude Code asks you to approve a project MCP server the first time it sees one, so approve balladeer when it asks. In a session already running, /mcp lists the servers and reconnects them.",
|
|
884
|
-
"
|
|
940
|
+
" For Codex, run setup --client codex --refresh to configure this project's Codex MCP and AGENTS.md, then review project trust and start a fresh Codex session.",
|
|
885
941
|
];
|
|
886
942
|
}
|
|
887
943
|
/**
|
|
@@ -907,7 +963,69 @@ async function proveAgent(agent) {
|
|
|
907
963
|
* is not mistaken for this one's.
|
|
908
964
|
*/
|
|
909
965
|
function credentialOnThisMachine(agents, controlPlane, repositoryId) {
|
|
910
|
-
return agents.
|
|
966
|
+
return agents.find((agent) => agent.controlPlane === controlPlane && agent.repositoryId === repositoryId);
|
|
967
|
+
}
|
|
968
|
+
/**
|
|
969
|
+
* Reconcile every local host surface whether the bearer was issued now or was
|
|
970
|
+
* already on this machine. A credential is not a project MCP installation:
|
|
971
|
+
* another checkout, a deleted uncommitted file, or an interrupted earlier run
|
|
972
|
+
* can leave the first present and the second absent.
|
|
973
|
+
*/
|
|
974
|
+
async function configureAgentHost(options, repositoryId, name, published) {
|
|
975
|
+
const root = await repositoryRoot(options.cwd);
|
|
976
|
+
const files = [];
|
|
977
|
+
let merged;
|
|
978
|
+
let conventions;
|
|
979
|
+
if (root !== undefined) {
|
|
980
|
+
merged = mergeHostConfig(options, root, repositoryId, published);
|
|
981
|
+
if (merged.kind === "written") {
|
|
982
|
+
if (merged.changed)
|
|
983
|
+
files.push(hostConfigFile(options));
|
|
984
|
+
conventions = writeHostConventions(options, root);
|
|
985
|
+
if (conventions.kind === "written" && conventions.changed)
|
|
986
|
+
files.push(conventions.file);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
return {
|
|
990
|
+
root,
|
|
991
|
+
files,
|
|
992
|
+
merged,
|
|
993
|
+
conventions,
|
|
994
|
+
desktop: connectClaudeDesktop(options, repositoryId, name),
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
function reportAgentHost(options, configured, published) {
|
|
998
|
+
if (configured.root === undefined) {
|
|
999
|
+
say(options, ` This directory is not a git work tree, so I wrote no ${hostConfigFile(options)} and no instructions block.`);
|
|
1000
|
+
}
|
|
1001
|
+
else {
|
|
1002
|
+
if (configured.merged?.kind === "refused") {
|
|
1003
|
+
say(options, ` ${configured.merged.reason}`);
|
|
1004
|
+
say(options, " Merge this block into it yourself:");
|
|
1005
|
+
for (const line of configured.merged.block.split("\n"))
|
|
1006
|
+
say(options, ` ${line}`);
|
|
1007
|
+
}
|
|
1008
|
+
if (configured.conventions?.kind === "refused") {
|
|
1009
|
+
say(options, ` ${configured.conventions.reason}`);
|
|
1010
|
+
say(options, ` Repair the markers in ${configured.conventions.file}, or delete the block between them, then run this command again.`);
|
|
1011
|
+
}
|
|
1012
|
+
if (configured.merged?.kind === "written") {
|
|
1013
|
+
say(options, ` The ${hostConfigFile(options)} 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.`);
|
|
1014
|
+
if (published === null && !existsSync(checkoutEntryPath())) {
|
|
1015
|
+
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.`);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
for (const line of configured.desktop.lines)
|
|
1020
|
+
say(options, line);
|
|
1021
|
+
for (const line of hostNextSteps(options))
|
|
1022
|
+
say(options, line);
|
|
1023
|
+
say(options, published === null
|
|
1024
|
+
? ` Setup does not install a global balladeer executable. This checkout's CLI runs as \`${commandLine(null, "<command>")}\` after it is built.`
|
|
1025
|
+
: ` Setup does not install a global balladeer executable. Run the CLI as \`${commandLine(published, "<command>")}\`; ${hostConfigFile(options)} invokes that same package automatically.`);
|
|
1026
|
+
if (configured.files.length > 0) {
|
|
1027
|
+
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.`);
|
|
1028
|
+
}
|
|
911
1029
|
}
|
|
912
1030
|
/**
|
|
913
1031
|
* Whose credential this step is about: this machine's.
|
|
@@ -932,15 +1050,36 @@ function credentialOnThisMachine(agents, controlPlane, repositoryId) {
|
|
|
932
1050
|
*/
|
|
933
1051
|
async function stepAgent(options, credentials, session, view, name, published) {
|
|
934
1052
|
const heldHere = credentialOnThisMachine(credentials.agents, options.controlPlane, view.id);
|
|
935
|
-
if (heldHere && view.agentConfigured) {
|
|
936
|
-
|
|
1053
|
+
if (heldHere !== undefined && view.agentConfigured) {
|
|
1054
|
+
const configured = await configureAgentHost(options, view.id, name, published);
|
|
1055
|
+
const proof = await proveAgent(heldHere);
|
|
1056
|
+
const repaired = configured.files.length > 0 ? ` repaired ${configured.files.join(" and ")}` : "";
|
|
1057
|
+
say(options, proof.kind === "answered"
|
|
1058
|
+
? `Step 3 of 5 This coding agent is already connected on this machine${repaired}`
|
|
1059
|
+
: `Step 3 of 5 This machine holds an agent credential not yet proven${repaired}`);
|
|
937
1060
|
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.`);
|
|
1061
|
+
if (proof.kind === "answered") {
|
|
1062
|
+
say(options, ` Balladeer answered a get_promise_setup call for ${name} over this connection just now.`);
|
|
1063
|
+
}
|
|
1064
|
+
else {
|
|
1065
|
+
say(options, ` ${proof.reason}. ${proof.nextAction}`);
|
|
1066
|
+
}
|
|
1067
|
+
reportAgentHost(options, configured, published);
|
|
938
1068
|
emit(options, {
|
|
939
1069
|
step: "agent",
|
|
940
|
-
status: "already",
|
|
1070
|
+
status: proof.kind === "answered" ? "already" : "unproven",
|
|
941
1071
|
repositoryId: view.id,
|
|
942
1072
|
storedHere: true,
|
|
943
1073
|
...(view.agentConnectionId === null ? {} : { connectionId: view.agentConnectionId }),
|
|
1074
|
+
files: configured.files,
|
|
1075
|
+
...(proof.kind === "answered"
|
|
1076
|
+
? { provedBy: "get_promise_setup" }
|
|
1077
|
+
: { reason: proof.reason, nextAction: proof.nextAction }),
|
|
1078
|
+
...(configured.merged?.kind === "refused" ? { mcpConfig: configured.merged.reason } : {}),
|
|
1079
|
+
...(configured.conventions?.kind === "refused"
|
|
1080
|
+
? { conventions: configured.conventions.reason }
|
|
1081
|
+
: {}),
|
|
1082
|
+
...(configured.desktop.json === undefined ? {} : { claudeDesktop: configured.desktop.json }),
|
|
944
1083
|
});
|
|
945
1084
|
return undefined;
|
|
946
1085
|
}
|
|
@@ -950,7 +1089,7 @@ async function stepAgent(options, credentials, session, view, name, published) {
|
|
|
950
1089
|
// A credential in this store, and no live connection anywhere for the
|
|
951
1090
|
// repository: the one this machine holds was revoked. Holding it is not being
|
|
952
1091
|
// connected, so this issues rather than reporting the stored entry as working.
|
|
953
|
-
const revokedHere = heldHere && !view.agentConfigured;
|
|
1092
|
+
const revokedHere = heldHere !== undefined && !view.agentConfigured;
|
|
954
1093
|
let packet;
|
|
955
1094
|
try {
|
|
956
1095
|
packet = await request(options.controlPlane, {
|
|
@@ -980,30 +1119,13 @@ async function stepAgent(options, credentials, session, view, name, published) {
|
|
|
980
1119
|
catch (error) {
|
|
981
1120
|
return blockedAgent(options, view, error instanceof StoreError ? error.message : "the credential store could not be written");
|
|
982
1121
|
}
|
|
983
|
-
const
|
|
984
|
-
const files = [];
|
|
985
|
-
let merged;
|
|
986
|
-
let conventions;
|
|
987
|
-
if (root !== undefined) {
|
|
988
|
-
// The stdio forwarder, bound to this repository. It reads the bearer from
|
|
989
|
-
// the credential store this step just wrote, so the connection works from
|
|
990
|
-
// here with nothing further for anyone to set, and the file carries no
|
|
991
|
-
// secret.
|
|
992
|
-
merged = mergeMcpConfig(root, stdioEntry(packet.repositoryId, published), options.controlPlane);
|
|
993
|
-
if (merged.kind === "written") {
|
|
994
|
-
if (merged.changed)
|
|
995
|
-
files.push(MCP_CONFIG_FILE);
|
|
996
|
-
conventions = writeConventions(root);
|
|
997
|
-
if (conventions.kind === "written" && conventions.changed)
|
|
998
|
-
files.push(conventions.file);
|
|
999
|
-
}
|
|
1000
|
-
}
|
|
1122
|
+
const configured = await configureAgentHost(options, packet.repositoryId, name, published);
|
|
1001
1123
|
// The probe runs after the files, because the files are not what it proves.
|
|
1002
1124
|
// The credential, the endpoint, and the frames this command's own forwarder
|
|
1003
1125
|
// sends are.
|
|
1004
1126
|
const proof = await proveAgent(agent);
|
|
1005
|
-
const wrote = merged?.kind === "written" && conventions?.kind === "written"
|
|
1006
|
-
? `, ${
|
|
1127
|
+
const wrote = configured.merged?.kind === "written" && configured.conventions?.kind === "written"
|
|
1128
|
+
? `, ${hostConfigFile(options)} and ${configured.conventions.file} updated`
|
|
1007
1129
|
: "";
|
|
1008
1130
|
say(options, proof.kind === "answered"
|
|
1009
1131
|
? `Step 3 of 5 Connected this coding agent on this machine credential stored in ${credentialsPath(options.environment)}${wrote}`
|
|
@@ -1020,45 +1142,7 @@ async function stepAgent(options, credentials, session, view, name, published) {
|
|
|
1020
1142
|
say(options, ` The credential is stored, but ${proof.reason}, so this is not reported as connected.`);
|
|
1021
1143
|
say(options, ` ${proof.nextAction}`);
|
|
1022
1144
|
}
|
|
1023
|
-
|
|
1024
|
-
say(options, ` This directory is not a git work tree, so I wrote no ${MCP_CONFIG_FILE} and no instructions block.`);
|
|
1025
|
-
}
|
|
1026
|
-
else {
|
|
1027
|
-
if (merged?.kind === "refused") {
|
|
1028
|
-
say(options, ` ${merged.reason}`);
|
|
1029
|
-
say(options, " Merge this block into it yourself:");
|
|
1030
|
-
for (const line of merged.block.split("\n"))
|
|
1031
|
-
say(options, ` ${line}`);
|
|
1032
|
-
}
|
|
1033
|
-
if (conventions?.kind === "refused") {
|
|
1034
|
-
say(options, ` ${conventions.reason}`);
|
|
1035
|
-
say(options, ` Repair the markers in ${conventions.file}, or delete the block between them, then run this command again.`);
|
|
1036
|
-
}
|
|
1037
|
-
if (merged?.kind === "written") {
|
|
1038
|
-
// Nothing further is asked of anybody about the credential. The entry runs
|
|
1039
|
-
// this command's own forwarder, which reads the bearer out of the store
|
|
1040
|
-
// above, so there is no variable to export and no secret to carry by hand.
|
|
1041
|
-
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.`);
|
|
1042
|
-
if (published === null && !existsSync(checkoutEntryPath())) {
|
|
1043
|
-
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.`);
|
|
1044
|
-
}
|
|
1045
|
-
}
|
|
1046
|
-
}
|
|
1047
|
-
// The chat client, connected from the same credential this step just stored.
|
|
1048
|
-
// It is not conditional on a git work tree, because the desktop app has no
|
|
1049
|
-
// notion of a project and its file lives in this person's home directory
|
|
1050
|
-
// rather than in the repository.
|
|
1051
|
-
const desktop = connectClaudeDesktop(options, packet.repositoryId, name);
|
|
1052
|
-
for (const line of desktop.lines)
|
|
1053
|
-
say(options, line);
|
|
1054
|
-
// Said on every ending of this step, including the ones where no file was
|
|
1055
|
-
// written: whoever is reading has to configure that host by hand, and they
|
|
1056
|
-
// need the same two facts about when a host picks an entry up.
|
|
1057
|
-
for (const line of hostNextSteps())
|
|
1058
|
-
say(options, line);
|
|
1059
|
-
if (files.length > 0) {
|
|
1060
|
-
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.`);
|
|
1061
|
-
}
|
|
1145
|
+
reportAgentHost(options, configured, published);
|
|
1062
1146
|
emit(options, {
|
|
1063
1147
|
step: "agent",
|
|
1064
1148
|
status: proof.kind === "answered" ? "connected" : "unproven",
|
|
@@ -1066,13 +1150,15 @@ async function stepAgent(options, credentials, session, view, name, published) {
|
|
|
1066
1150
|
connectionId: packet.connectionId,
|
|
1067
1151
|
storedHere: true,
|
|
1068
1152
|
...(connectedElsewhere ? { alreadyConnectedElsewhere: true } : {}),
|
|
1069
|
-
files,
|
|
1153
|
+
files: configured.files,
|
|
1070
1154
|
...(proof.kind === "answered"
|
|
1071
1155
|
? { provedBy: "get_promise_setup" }
|
|
1072
1156
|
: { reason: proof.reason, nextAction: proof.nextAction }),
|
|
1073
|
-
...(merged?.kind === "refused" ? { mcpConfig: merged.reason } : {}),
|
|
1074
|
-
...(conventions?.kind === "refused"
|
|
1075
|
-
|
|
1157
|
+
...(configured.merged?.kind === "refused" ? { mcpConfig: configured.merged.reason } : {}),
|
|
1158
|
+
...(configured.conventions?.kind === "refused"
|
|
1159
|
+
? { conventions: configured.conventions.reason }
|
|
1160
|
+
: {}),
|
|
1161
|
+
...(configured.desktop.json === undefined ? {} : { claudeDesktop: configured.desktop.json }),
|
|
1076
1162
|
});
|
|
1077
1163
|
return undefined;
|
|
1078
1164
|
}
|
|
@@ -1084,6 +1170,34 @@ function blockedAgent(options, view, reason) {
|
|
|
1084
1170
|
return undefined;
|
|
1085
1171
|
}
|
|
1086
1172
|
/* ----------------------------- Step 4 ------------------------------------ */
|
|
1173
|
+
/**
|
|
1174
|
+
* A teammate run reports CI truth and never becomes repository maintenance.
|
|
1175
|
+
* Adding or updating a workflow, setting repository variables, and opening a
|
|
1176
|
+
* pull request all belong to the administrator/maintainer path.
|
|
1177
|
+
*/
|
|
1178
|
+
function reportInviteeCi(options, view) {
|
|
1179
|
+
if (view.ciConfigured) {
|
|
1180
|
+
say(options, `Step 4 of 5 CI already connected ${view.ciValidatedRunCount} authenticated run${view.ciValidatedRunCount === 1 ? "" : "s"} observed`);
|
|
1181
|
+
emit(options, {
|
|
1182
|
+
step: "ci",
|
|
1183
|
+
status: "connected",
|
|
1184
|
+
repositoryId: view.id,
|
|
1185
|
+
validatedRunCount: view.ciValidatedRunCount,
|
|
1186
|
+
...(view.ciFirstValidatedAt === null ? {} : { firstValidatedAt: view.ciFirstValidatedAt }),
|
|
1187
|
+
});
|
|
1188
|
+
return undefined;
|
|
1189
|
+
}
|
|
1190
|
+
say(options, "Step 4 of 5 CI is not connected teammate onboarding continues");
|
|
1191
|
+
say(options, " This run did not write a workflow, set GitHub variables, push a branch, or open a pull request.");
|
|
1192
|
+
warnEnforcementUnavailable(options);
|
|
1193
|
+
emit(options, {
|
|
1194
|
+
step: "ci",
|
|
1195
|
+
status: "not_changed",
|
|
1196
|
+
repositoryId: view.id,
|
|
1197
|
+
reason: "Teammate setup leaves repository CI unchanged.",
|
|
1198
|
+
});
|
|
1199
|
+
return undefined;
|
|
1200
|
+
}
|
|
1087
1201
|
async function stepCi(options, session, view, name) {
|
|
1088
1202
|
// Checked on every run, and BEFORE the connected branch below, because an
|
|
1089
1203
|
// already-connected repository is exactly the one this happens to. Balladeer
|
|
@@ -1342,7 +1456,7 @@ async function stepPromise(options, session, view, published) {
|
|
|
1342
1456
|
const state = await readState(options, session);
|
|
1343
1457
|
const current = state?.repositories.find((repository) => repository.id === view.id) ?? view;
|
|
1344
1458
|
if (current.firstPromiseId !== null) {
|
|
1345
|
-
const promiseUrl =
|
|
1459
|
+
const promiseUrl = reviewLink(options.controlPlane, `promises/${encodeURIComponent(current.firstPromiseId)}`, session.workspaceId);
|
|
1346
1460
|
say(options, "Step 5 of 5 A named person agreed to the first promise");
|
|
1347
1461
|
say(options, ` ${promiseUrl}`);
|
|
1348
1462
|
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.");
|
|
@@ -1355,7 +1469,7 @@ async function stepPromise(options, session, view, published) {
|
|
|
1355
1469
|
return;
|
|
1356
1470
|
}
|
|
1357
1471
|
if (current.latestCandidateId !== null) {
|
|
1358
|
-
const reviewUrl = proposalReviewLink(options.controlPlane, current.latestCandidateId);
|
|
1472
|
+
const reviewUrl = proposalReviewLink(options.controlPlane, current.latestCandidateId, session.workspaceId);
|
|
1359
1473
|
say(options, "Step 5 of 5 A first promise is proposed and waiting for a person");
|
|
1360
1474
|
say(options, ` Review it: ${reviewUrl}`);
|
|
1361
1475
|
say(options, " A named person reads the proposal and clicks Agree. Nothing else can.");
|
|
@@ -1415,10 +1529,12 @@ function printReceipts(options, session, state, view) {
|
|
|
1415
1529
|
// promise the receipt below it said already stood agreed.
|
|
1416
1530
|
const promiseId = current?.firstPromiseId ?? null;
|
|
1417
1531
|
const candidateId = current?.latestCandidateId ?? null;
|
|
1418
|
-
const promiseUrl = promiseId === null
|
|
1532
|
+
const promiseUrl = promiseId === null
|
|
1533
|
+
? undefined
|
|
1534
|
+
: reviewLink(options.controlPlane, `promises/${encodeURIComponent(promiseId)}`, state.workspace.id);
|
|
1419
1535
|
const reviewUrl = promiseId !== null || candidateId === null
|
|
1420
1536
|
? undefined
|
|
1421
|
-
: proposalReviewLink(options.controlPlane, candidateId);
|
|
1537
|
+
: proposalReviewLink(options.controlPlane, candidateId, state.workspace.id);
|
|
1422
1538
|
const promiseLink = promiseUrl ?? reviewUrl;
|
|
1423
1539
|
const promiseSentence = promiseId !== null
|
|
1424
1540
|
? "Agreed. A named person agreed to this repository's first promise."
|
|
@@ -1508,13 +1624,21 @@ export function refreshPublishedForm(existing) {
|
|
|
1508
1624
|
* changed. Run twice it changes nothing the second time, which is what makes it
|
|
1509
1625
|
* safe to put in a nag an agent will act on without asking.
|
|
1510
1626
|
*/
|
|
1511
|
-
async function runRefresh(
|
|
1627
|
+
async function runRefresh(input) {
|
|
1628
|
+
let options = input;
|
|
1512
1629
|
say(options, `Balladeer setup --refresh, version ${CLI_VERSION}.\n`);
|
|
1513
1630
|
const root = await repositoryRoot(options.cwd);
|
|
1514
1631
|
if (root === undefined) {
|
|
1515
1632
|
return failRefresh(options, "not_a_repository", "This directory is not a git work tree, so there is no repository configuration to refresh.");
|
|
1516
1633
|
}
|
|
1517
|
-
|
|
1634
|
+
if (options.client === undefined && hasManagedCodexEntry(root, options.controlPlane)) {
|
|
1635
|
+
if (isOurEntry(currentEntry(readMcpConfig(root)), options.controlPlane)) {
|
|
1636
|
+
return failRefresh(options, "client_ambiguous", "Both Codex and Claude have Balladeer project entries. Run setup --client codex --refresh or setup --client claude --refresh. No files were changed.");
|
|
1637
|
+
}
|
|
1638
|
+
options = { ...options, client: "codex" };
|
|
1639
|
+
say(options, "Using Codex for refresh because this repository has only its managed Balladeer entry.");
|
|
1640
|
+
}
|
|
1641
|
+
const existing = options.client === "codex" ? readCodexEntry(root) : currentEntry(readMcpConfig(root));
|
|
1518
1642
|
let stored;
|
|
1519
1643
|
let storedName;
|
|
1520
1644
|
try {
|
|
@@ -1535,9 +1659,9 @@ async function runRefresh(options) {
|
|
|
1535
1659
|
// that never paired can still repair a checkout somebody else set up.
|
|
1536
1660
|
const repositoryId = stored ?? entryRepositoryId(existing);
|
|
1537
1661
|
if (repositoryId === undefined) {
|
|
1538
|
-
return failRefresh(options, "not_connected", `Nothing here names a repository to refresh: this machine holds no Balladeer credential for it and ${
|
|
1662
|
+
return failRefresh(options, "not_connected", `Nothing here names a repository to refresh: this machine holds no Balladeer credential for it and ${hostConfigFile(options)} has no balladeer entry. Run \`${commandLine(null, "setup")}\` to connect it.`);
|
|
1539
1663
|
}
|
|
1540
|
-
const merged =
|
|
1664
|
+
const merged = mergeHostConfig(options, root, repositoryId, refreshPublishedForm(existing));
|
|
1541
1665
|
if (merged.kind === "refused") {
|
|
1542
1666
|
say(options, merged.reason);
|
|
1543
1667
|
say(options, "Merge this block into it yourself:");
|
|
@@ -1545,7 +1669,7 @@ async function runRefresh(options) {
|
|
|
1545
1669
|
say(options, ` ${line}`);
|
|
1546
1670
|
return failRefresh(options, "mcp_config_refused", merged.reason);
|
|
1547
1671
|
}
|
|
1548
|
-
const conventions =
|
|
1672
|
+
const conventions = writeHostConventions(options, root);
|
|
1549
1673
|
if (conventions.kind === "refused") {
|
|
1550
1674
|
say(options, conventions.reason);
|
|
1551
1675
|
return failRefresh(options, "conventions_refused", conventions.reason);
|
|
@@ -1558,12 +1682,12 @@ async function runRefresh(options) {
|
|
|
1558
1682
|
const desktop = connectClaudeDesktop(options, repositoryId, storedName ?? repositoryHint(options.cwd));
|
|
1559
1683
|
const files = [];
|
|
1560
1684
|
if (merged.changed)
|
|
1561
|
-
files.push(
|
|
1685
|
+
files.push(hostConfigFile(options));
|
|
1562
1686
|
if (conventions.changed)
|
|
1563
1687
|
files.push(conventions.file);
|
|
1564
1688
|
say(options, merged.changed
|
|
1565
|
-
? `Rewrote the balladeer entry in ${
|
|
1566
|
-
: `${
|
|
1689
|
+
? `Rewrote the balladeer entry in ${hostConfigFile(options)}. Every other entry in that file is untouched.`
|
|
1690
|
+
: `${hostConfigFile(options)} already names the current command; I left it alone.`);
|
|
1567
1691
|
say(options, conventions.changed
|
|
1568
1692
|
? `Rewrote the Balladeer block in ${conventions.file} to conventions v${conventions.version}${conventions.previousVersion === undefined
|
|
1569
1693
|
? ""
|
|
@@ -1574,6 +1698,9 @@ async function runRefresh(options) {
|
|
|
1574
1698
|
// never a change anybody commits: it lives in this person's home directory.
|
|
1575
1699
|
for (const line of desktop.lines)
|
|
1576
1700
|
say(options, line.replace(/^ {2}/, ""));
|
|
1701
|
+
if (options.client === "codex")
|
|
1702
|
+
for (const line of hostNextSteps(options))
|
|
1703
|
+
say(options, line);
|
|
1577
1704
|
if (files.length === 0 && desktop.json?.status !== "connected") {
|
|
1578
1705
|
say(options, "Nothing to change: this repository is already on the current form.");
|
|
1579
1706
|
}
|
package/dist/commands/status.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { callAgentTool, noAgentCredentialSentence, selectAgent, structuredString, } from "../agent.js";
|
|
2
|
-
import { ClientTooOldError, RefusalError, TransportError, proposalReviewLink, request, } from "../client.js";
|
|
1
|
+
import { callAgentTool, noAgentCredentialSentence, reportAgentEnforcementWarning, selectAgent, structuredString, } from "../agent.js";
|
|
2
|
+
import { ClientTooOldError, RefusalError, TransportError, proposalReviewLink, reviewLink, request, } from "../client.js";
|
|
3
3
|
import { repositoryRoot } from "../git.js";
|
|
4
4
|
import { MARKER_OBSERVATION_LIMITS, observeMissingMarkers, staleMarkerRow, } from "../markers.js";
|
|
5
5
|
import { formatInstant } from "../local-time.js";
|
|
@@ -7,7 +7,7 @@ import { commandLine } from "../release.js";
|
|
|
7
7
|
import { repositoryHint } from "../repository.js";
|
|
8
8
|
import { SEALED_PATHS_SHOWN, sealedPromises } from "../seals.js";
|
|
9
9
|
import { StoreError, findSession, readCredentials } from "../store.js";
|
|
10
|
-
import {} from "../wire.js";
|
|
10
|
+
import { CLI_INVOCATION } from "../wire.js";
|
|
11
11
|
/**
|
|
12
12
|
* The bounded code for "this machine holds no credential for that repository".
|
|
13
13
|
* It is its own code rather than the session's: an agent branching on
|
|
@@ -104,7 +104,23 @@ async function reportBalladeer(options) {
|
|
|
104
104
|
}
|
|
105
105
|
const here = options.repo ?? repositoryHint(options.cwd);
|
|
106
106
|
const selection = selectAgent(credentials.agents, options.controlPlane, options.repository, here);
|
|
107
|
-
const
|
|
107
|
+
const storedSession = findSession(credentials, options.controlPlane);
|
|
108
|
+
const repositoryScoped = options.repo !== undefined ||
|
|
109
|
+
options.repository !== undefined ||
|
|
110
|
+
here !== "unknown/unknown" ||
|
|
111
|
+
(await repositoryRoot(options.cwd)) !== undefined;
|
|
112
|
+
const hasBoundSession = typeof storedSession?.workspaceId === "string" && storedSession.workspaceId.trim().length > 0;
|
|
113
|
+
// A setup session belongs to one workspace, not to every repository whose
|
|
114
|
+
// agent happens to be stored on this machine. Establish that relationship
|
|
115
|
+
// before sending its bearer or fetching its workspace-wide context.
|
|
116
|
+
const session = hasBoundSession &&
|
|
117
|
+
(!repositoryScoped ||
|
|
118
|
+
(selection.kind === "agent" && selection.agent.workspaceId === storedSession.workspaceId))
|
|
119
|
+
? storedSession
|
|
120
|
+
: undefined;
|
|
121
|
+
const missingWorkspace = () => sayWorkspaceUnavailable(emit, say, storedSession === undefined ? "no_stored_setup_session" : "setup_session_scope_unverified", storedSession === undefined
|
|
122
|
+
? noSessionSentence(options.controlPlane)
|
|
123
|
+
: "An optional workspace view was skipped because the saved setup session could not be tied to this request. No workspace read was sent.");
|
|
108
124
|
// The credential this machine holds is what this repository is read over, so
|
|
109
125
|
// its absence is the first thing said, and it is said in its own words. What
|
|
110
126
|
// the founder got instead was the workspace half's refusal, "Balladeer refused
|
|
@@ -120,21 +136,13 @@ async function reportBalladeer(options) {
|
|
|
120
136
|
else {
|
|
121
137
|
options.write(`${message}\n`);
|
|
122
138
|
}
|
|
123
|
-
|
|
124
|
-
sayWorkspaceUnavailable(emit, say, "no_stored_setup_session", noSessionSentence(options.controlPlane));
|
|
125
|
-
}
|
|
126
|
-
else {
|
|
127
|
-
// A different question, over a different credential, so it is still
|
|
128
|
-
// answered underneath. Its outcome never becomes this run's exit code:
|
|
129
|
-
// this repository went unreported whatever the workspace read did.
|
|
130
|
-
await reportWorkspace(options, session, here, emit, say, true);
|
|
131
|
-
}
|
|
139
|
+
missingWorkspace();
|
|
132
140
|
return 4;
|
|
133
141
|
}
|
|
134
142
|
if (selection.kind === "refused" && session === undefined) {
|
|
135
143
|
// Nothing stored can answer anything, so this names the form that always
|
|
136
144
|
// works: the one this copy was run as.
|
|
137
|
-
return fail("nothing_stored", `${selection.reason} No Balladeer setup session is stored for ${options.controlPlane} either. Run: ${commandLine(null, "setup")}`, 4);
|
|
145
|
+
return fail("nothing_stored", `${selection.reason} ${storedSession === undefined ? `No Balladeer setup session is stored for ${options.controlPlane} either.` : "The stored setup session was not read because its workspace could not be established for this request."} Run: ${commandLine(null, "setup")}`, 4);
|
|
138
146
|
}
|
|
139
147
|
// One promise, by id: a different question, answered over the same connection
|
|
140
148
|
// and on its own. Somebody who pasted the repair line wants what the failing
|
|
@@ -150,9 +158,13 @@ async function reportBalladeer(options) {
|
|
|
150
158
|
let reported = false;
|
|
151
159
|
if (selection.kind === "agent") {
|
|
152
160
|
reported = await reportThisRepository(options, selection.agent, emit, say);
|
|
161
|
+
// A revoked or mismatched repository connection must not become a
|
|
162
|
+
// successful repository report through a different credential.
|
|
163
|
+
if (!reported && repositoryScoped)
|
|
164
|
+
return 4;
|
|
153
165
|
}
|
|
154
166
|
if (session === undefined) {
|
|
155
|
-
|
|
167
|
+
missingWorkspace();
|
|
156
168
|
return reported ? 0 : 4;
|
|
157
169
|
}
|
|
158
170
|
return reportWorkspace(options, session, here, emit, say, reported);
|
|
@@ -167,6 +179,7 @@ async function reportBalladeer(options) {
|
|
|
167
179
|
*/
|
|
168
180
|
async function reportThisRepository(options, agent, emit, say) {
|
|
169
181
|
const call = await callAgentTool(agent, "get_promise_setup", {});
|
|
182
|
+
reportAgentEnforcementWarning(call, options);
|
|
170
183
|
if (call.kind !== "result") {
|
|
171
184
|
say(`This repository could not be read over its Balladeer agent connection: ${reason(call)}`);
|
|
172
185
|
return false;
|
|
@@ -277,6 +290,7 @@ async function reportStaleMarkers(options, agent, emit, say) {
|
|
|
277
290
|
*/
|
|
278
291
|
async function reportOnePromise(options, promiseId, agent, emit, say) {
|
|
279
292
|
const call = await callAgentTool(agent, "get_promise", { promiseId });
|
|
293
|
+
reportAgentEnforcementWarning(call, options);
|
|
280
294
|
if (call.kind !== "result") {
|
|
281
295
|
const message = `${promiseId} could not be read over this repository's Balladeer agent connection: ${reason(call)}`;
|
|
282
296
|
emit({ step: "promise", status: "unreadable", promiseId, message });
|
|
@@ -409,7 +423,7 @@ async function reportSealedPaths(options, emit, say) {
|
|
|
409
423
|
say(` ${promise.sealedPath}${promise.title === undefined ? "" : ` ${promise.title}`}`);
|
|
410
424
|
if (sealed.length > shown.length)
|
|
411
425
|
say(` and ${sealed.length - shown.length} more, not listed here.`);
|
|
412
|
-
say(
|
|
426
|
+
say(` Run \`${CLI_INVOCATION} check-seals\` before you push to find out whether your change broke one.`);
|
|
413
427
|
}
|
|
414
428
|
function reason(call) {
|
|
415
429
|
switch (call.kind) {
|
|
@@ -476,6 +490,15 @@ async function reportWorkspace(options, session, here, emit, say, alreadyReporte
|
|
|
476
490
|
}
|
|
477
491
|
return failOutside(options, emit, code, "Balladeer could not report this workspace.", 5);
|
|
478
492
|
}
|
|
493
|
+
if (state?.workspace?.id !== session.workspaceId) {
|
|
494
|
+
sayWorkspaceUnavailable(emit, say, "setup_workspace_mismatch", "The setup session answered for a different workspace, so none of its workspace context was reported.");
|
|
495
|
+
return alreadyReported ? 0 : 4;
|
|
496
|
+
}
|
|
497
|
+
const scopedReviewLink = (url) => {
|
|
498
|
+
const scoped = new URL(url);
|
|
499
|
+
scoped.searchParams.set("workspaceId", state.workspace.id);
|
|
500
|
+
return scoped.toString();
|
|
501
|
+
};
|
|
479
502
|
const active = state.repositories.filter((repository) => repository.status === "active");
|
|
480
503
|
emit({
|
|
481
504
|
step: "receipt",
|
|
@@ -525,10 +548,10 @@ async function reportWorkspace(options, session, here, emit, say, alreadyReporte
|
|
|
525
548
|
// awaiting them, and a rejected proposal is listed nowhere at all.
|
|
526
549
|
say(` Promises: ${repository.promiseCount} agreed, ${repository.candidateCount} proposed and awaiting a person.`);
|
|
527
550
|
if (repository.firstPromiseId !== null) {
|
|
528
|
-
say(` First promise: ${options.controlPlane
|
|
551
|
+
say(` First promise: ${scopedReviewLink(reviewLink(options.controlPlane, `promises/${encodeURIComponent(repository.firstPromiseId)}`))}`);
|
|
529
552
|
}
|
|
530
553
|
if (repository.latestCandidateId !== null) {
|
|
531
|
-
say(` Newest proposal: ${proposalReviewLink(options.controlPlane, repository.latestCandidateId)}`);
|
|
554
|
+
say(` Newest proposal: ${scopedReviewLink(proposalReviewLink(options.controlPlane, repository.latestCandidateId))}`);
|
|
532
555
|
}
|
|
533
556
|
}
|
|
534
557
|
return 0;
|
|
@@ -4,7 +4,7 @@ import { dirname, join, resolve } from "node:path";
|
|
|
4
4
|
import { runCommand } from "../gh.js";
|
|
5
5
|
import { repositoryRoot } from "../git.js";
|
|
6
6
|
import { CONTINUITY_DIRECTORY, TOUCH_MAP_FILE, buildTouchMap, executedPaths, mappablePath, measurableWithNode, readCoverageDirectory, readLocalPackages, repositoryPath, serializeTouchMap, touchMapBody, touchMapEnvironment, verifierCwd, verifierDigest, } from "../touch-map.js";
|
|
7
|
-
import {} from "../wire.js";
|
|
7
|
+
import { CLI_INVOCATION } from "../wire.js";
|
|
8
8
|
/** A verifier gets this long before the measurement gives up on it. */
|
|
9
9
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
10
10
|
/** However long a package asks for, no verifier holds this command longer. */
|
|
@@ -214,7 +214,7 @@ export async function runTouchMap(options) {
|
|
|
214
214
|
say(` and ${map.unmapped.length - NAMED_LIMIT} more, not listed here.`);
|
|
215
215
|
}
|
|
216
216
|
say("");
|
|
217
|
-
say(
|
|
217
|
+
say(`Ask it which promises a change touches with: ${CLI_INVOCATION} affected <paths...>`);
|
|
218
218
|
return 0;
|
|
219
219
|
}
|
|
220
220
|
/**
|