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.
Files changed (72) hide show
  1. package/LICENSE +200 -5
  2. package/README.md +167 -68
  3. package/dist/agent.d.ts +126 -0
  4. package/dist/agent.js +209 -0
  5. package/dist/cli.d.ts +48 -0
  6. package/dist/cli.js +531 -0
  7. package/dist/client.d.ts +66 -0
  8. package/dist/client.js +142 -0
  9. package/dist/commands/affected.d.ts +22 -0
  10. package/dist/commands/affected.js +123 -0
  11. package/dist/commands/check-seals.d.ts +37 -0
  12. package/dist/commands/check-seals.js +289 -0
  13. package/dist/commands/discover.d.ts +68 -0
  14. package/dist/commands/discover.js +403 -0
  15. package/dist/commands/explain.d.ts +35 -0
  16. package/dist/commands/explain.js +90 -0
  17. package/dist/commands/invite.d.ts +24 -0
  18. package/dist/commands/invite.js +198 -0
  19. package/dist/commands/mcp.d.ts +65 -0
  20. package/dist/commands/mcp.js +202 -0
  21. package/dist/commands/prepare.d.ts +74 -0
  22. package/dist/commands/prepare.js +217 -0
  23. package/dist/commands/propose.d.ts +69 -0
  24. package/dist/commands/propose.js +284 -0
  25. package/dist/commands/repositories.d.ts +18 -0
  26. package/dist/commands/repositories.js +185 -0
  27. package/dist/commands/session.d.ts +35 -0
  28. package/dist/commands/session.js +118 -0
  29. package/dist/commands/setup.d.ts +98 -0
  30. package/dist/commands/setup.js +1600 -0
  31. package/dist/commands/status.d.ts +51 -0
  32. package/dist/commands/status.js +542 -0
  33. package/dist/commands/touch-map.d.ts +42 -0
  34. package/dist/commands/touch-map.js +251 -0
  35. package/dist/commands/whoami.d.ts +8 -0
  36. package/dist/commands/whoami.js +80 -0
  37. package/dist/conventions.d.ts +77 -0
  38. package/dist/conventions.js +183 -0
  39. package/dist/copy.d.ts +224 -0
  40. package/dist/copy.js +641 -0
  41. package/dist/currency.d.ts +31 -0
  42. package/dist/currency.js +72 -0
  43. package/dist/desktop-config.d.ts +85 -0
  44. package/dist/desktop-config.js +217 -0
  45. package/dist/gh.d.ts +80 -0
  46. package/dist/gh.js +188 -0
  47. package/dist/git.d.ts +91 -0
  48. package/dist/git.js +226 -0
  49. package/dist/legacy.d.ts +41 -0
  50. package/dist/legacy.js +143 -0
  51. package/dist/local-time.d.ts +66 -0
  52. package/dist/local-time.js +84 -0
  53. package/dist/markers.d.ts +76 -0
  54. package/dist/markers.js +125 -0
  55. package/dist/mcp-config.d.ts +109 -0
  56. package/dist/mcp-config.js +234 -0
  57. package/dist/release.d.ts +55 -0
  58. package/dist/release.js +67 -0
  59. package/dist/repository.d.ts +8 -0
  60. package/dist/repository.js +32 -0
  61. package/dist/seals.d.ts +48 -0
  62. package/dist/seals.js +112 -0
  63. package/dist/session.d.ts +84 -0
  64. package/dist/session.js +135 -0
  65. package/dist/store.d.ts +108 -0
  66. package/dist/store.js +237 -0
  67. package/dist/touch-map.d.ts +241 -0
  68. package/dist/touch-map.js +487 -0
  69. package/dist/wire.d.ts +674 -0
  70. package/dist/wire.js +20 -0
  71. package/package.json +19 -10
  72. package/bin/balladeer.js +0 -161
package/dist/client.js ADDED
@@ -0,0 +1,142 @@
1
+ import { noteServerVersion } from "./currency.js";
2
+ import { commandLine } from "./release.js";
3
+ import { CLIENT_HEADER, CLIENT_HEADER_VALUE } from "./wire.js";
4
+ export class TransportError extends Error {
5
+ controlPlane;
6
+ constructor(controlPlane, reason) {
7
+ super(reason);
8
+ this.name = "TransportError";
9
+ this.controlPlane = controlPlane;
10
+ }
11
+ }
12
+ export class ClientTooOldError extends Error {
13
+ update;
14
+ controlPlane;
15
+ constructor(controlPlane, update) {
16
+ super(`This copy of balladeer is too old for ${controlPlane}. Run: ${update}`);
17
+ this.name = "ClientTooOldError";
18
+ this.controlPlane = controlPlane;
19
+ this.update = update;
20
+ }
21
+ }
22
+ export class RefusalError extends Error {
23
+ code;
24
+ status;
25
+ constructor(status, refusal) {
26
+ super(refusal.message ?? refusal.error);
27
+ this.name = "RefusalError";
28
+ this.code = refusal.error;
29
+ this.status = status;
30
+ }
31
+ }
32
+ /**
33
+ * A link this command prints, always built from the address it paired with.
34
+ *
35
+ * Behind a platform proxy a server can resolve a link against the machine it is
36
+ * running on rather than against the address customers use, and the first
37
+ * production `propose` printed exactly that: a review link on `localhost:8080`
38
+ * as the place to go and agree. A command that prints such a link is worse than
39
+ * one that prints none, because the person tries it.
40
+ *
41
+ * So no link a server sends is ever printed. The proposal path answers with an
42
+ * identifier and this builds the address, which is why there is no parameter
43
+ * here for a server's own link to arrive through.
44
+ */
45
+ export function reviewLink(controlPlane, path) {
46
+ return new URL(path, ensureTrailing(controlPlane)).toString();
47
+ }
48
+ /**
49
+ * The one place this command decides where a person reads a proposal.
50
+ *
51
+ * Every review address this command prints, in a receipt, in a discovery run,
52
+ * or in its JSON, is built here, so the address is one edit rather than a hunt
53
+ * and a printed link cannot fall behind the page it names. It matches the
54
+ * server's own `proposal-links` helper: the page is `/proposals`, and
55
+ * the retired address redirects to it permanently, so a link an older copy of
56
+ * this command already printed still lands on it.
57
+ */
58
+ export function proposalReviewLink(controlPlane, proposalId) {
59
+ return reviewLink(controlPlane, `proposals/${encodeURIComponent(proposalId)}`);
60
+ }
61
+ /** The address the whole waiting inbox is read on. */
62
+ export function proposalInboxLink(controlPlane) {
63
+ return reviewLink(controlPlane, "proposals");
64
+ }
65
+ /**
66
+ * One link that opens exactly the proposals named, and nothing else.
67
+ *
68
+ * The ids travel in the query string because the batch is exactly these
69
+ * promises: a link to the whole inbox would also open whatever was already
70
+ * waiting there, and a filter by repository would open a different set
71
+ * tomorrow.
72
+ */
73
+ export function batchProposalReviewLink(controlPlane, proposalIds) {
74
+ return reviewLink(controlPlane, `proposals/review?ids=${proposalIds.join(",")}`);
75
+ }
76
+ function ensureTrailing(controlPlane) {
77
+ return controlPlane.endsWith("/") ? controlPlane : `${controlPlane}/`;
78
+ }
79
+ /** Bounded, so a server's body can never become pages of terminal output. */
80
+ function boundedReason(error) {
81
+ if (!(error instanceof Error))
82
+ return "unknown transport failure";
83
+ return error.message.trim().slice(0, 200) || error.name;
84
+ }
85
+ /**
86
+ * The one place this command talks to a network.
87
+ *
88
+ * `redirect: "manual"` so no redirect can carry a bearer to a host the person
89
+ * never paired with, and the 426 handshake is decoded here so every caller gets
90
+ * the same actionable refusal rather than a status code.
91
+ */
92
+ export async function request(controlPlane, options) {
93
+ const url = new URL(options.path, `${controlPlane}/`);
94
+ const headers = {
95
+ accept: "application/json",
96
+ [CLIENT_HEADER]: CLIENT_HEADER_VALUE,
97
+ };
98
+ if (options.body !== undefined)
99
+ headers["content-type"] = "application/json";
100
+ if (options.bearer)
101
+ headers.authorization = `Bearer ${options.bearer}`;
102
+ let response;
103
+ try {
104
+ response = await fetch(url, {
105
+ method: options.method,
106
+ headers,
107
+ redirect: "manual",
108
+ signal: AbortSignal.timeout(options.timeoutMs ?? 20_000),
109
+ ...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
110
+ });
111
+ }
112
+ catch (error) {
113
+ throw new TransportError(controlPlane, boundedReason(error));
114
+ }
115
+ // Read before anything branches on the status, so a refusal teaches this copy
116
+ // that it is old just as a success does. A client whose call failed is the
117
+ // client most likely to be the one that needs updating.
118
+ noteServerVersion(response.headers);
119
+ if (response.status >= 300 && response.status < 400) {
120
+ throw new TransportError(controlPlane, `unexpected redirect (${response.status})`);
121
+ }
122
+ let payload;
123
+ try {
124
+ payload = await response.json();
125
+ }
126
+ catch {
127
+ payload = undefined;
128
+ }
129
+ if (response.status === 426) {
130
+ const refusal = (payload ?? {});
131
+ // The server's own remedy first, because it is the one that knows whether
132
+ // the package is published. A dist-tag would be the wrong fallback: it
133
+ // resolves to whatever the registry holds under that name today, which is
134
+ // not this program.
135
+ throw new ClientTooOldError(controlPlane, refusal.update ?? commandLine(null, "setup"));
136
+ }
137
+ if (!response.ok) {
138
+ const refusal = (payload ?? { error: `http_${response.status}` });
139
+ throw new RefusalError(response.status, refusal);
140
+ }
141
+ return payload;
142
+ }
@@ -0,0 +1,22 @@
1
+ export type AffectedOptions = Readonly<{
2
+ cwd: string;
3
+ json: boolean;
4
+ paths: readonly string[];
5
+ write: (text: string) => void;
6
+ }>;
7
+ /** The path as the map spells it: repository-relative, forward slashes. */
8
+ export declare function normalizePath(root: string, cwd: string, given: string): string;
9
+ /**
10
+ * Which promises a change touches, answered from the map and from nothing else.
11
+ *
12
+ * The point of answering here rather than asking Balladeer is that the question
13
+ * contains the answer's evidence: the file names of the change in front of the
14
+ * person. Sending them anywhere to find out which promises they touch would
15
+ * hand a vendor the shape of a repository it has promised never to see. So this
16
+ * reads one local file, and it says so.
17
+ *
18
+ * A stale row is still printed. The map's answer for a promise whose verifier
19
+ * has since changed is the last true thing anybody measured, and hiding it
20
+ * would leave a coder believing their change touches nothing.
21
+ */
22
+ export declare function runAffected(options: AffectedOptions): Promise<number>;
@@ -0,0 +1,123 @@
1
+ import { isAbsolute, relative, resolve, sep } from "node:path";
2
+ import { TOUCH_MAP_FILE, affectedPaths, readLocalPackages, readTouchMap, verifierDigest, } from "../touch-map.js";
3
+ import { formatInstant } from "../local-time.js";
4
+ import {} from "../wire.js";
5
+ import { promiseRoot } from "./touch-map.js";
6
+ /** How many promises one path lists before the report says how many more. */
7
+ const NAMED_LIMIT = 20;
8
+ /** The path as the map spells it: repository-relative, forward slashes. */
9
+ export function normalizePath(root, cwd, given) {
10
+ const absolute = isAbsolute(given) ? given : resolve(cwd, given);
11
+ const inside = relative(root, absolute);
12
+ if (inside === "" || inside.startsWith("..") || isAbsolute(inside))
13
+ return given.replace(/\\/g, "/").replace(/^\.\//, "");
14
+ return inside.split(sep).join("/");
15
+ }
16
+ /**
17
+ * Which promises a change touches, answered from the map and from nothing else.
18
+ *
19
+ * The point of answering here rather than asking Balladeer is that the question
20
+ * contains the answer's evidence: the file names of the change in front of the
21
+ * person. Sending them anywhere to find out which promises they touch would
22
+ * hand a vendor the shape of a repository it has promised never to see. So this
23
+ * reads one local file, and it says so.
24
+ *
25
+ * A stale row is still printed. The map's answer for a promise whose verifier
26
+ * has since changed is the last true thing anybody measured, and hiding it
27
+ * would leave a coder believing their change touches nothing.
28
+ */
29
+ export async function runAffected(options) {
30
+ const emit = (step) => {
31
+ if (options.json)
32
+ options.write(`${JSON.stringify(step)}\n`);
33
+ };
34
+ const say = (text) => {
35
+ if (!options.json)
36
+ options.write(`${text}\n`);
37
+ };
38
+ if (options.paths.length === 0) {
39
+ const message = "Name at least one path: balladeer affected src/orders/total.ts";
40
+ if (options.json)
41
+ emit({ step: "error", reason: "usage", message, changed: false, exitCode: 4 });
42
+ else
43
+ options.write(`${message}\n`);
44
+ return 4;
45
+ }
46
+ const root = await promiseRoot(options.cwd);
47
+ if (root === undefined) {
48
+ const message = "This is not a git repository and there are no promise packages here, so there is no map to read. Run it from inside your checkout.";
49
+ if (options.json)
50
+ emit({ step: "error", reason: "not_a_repository", message, changed: false, exitCode: 4 });
51
+ else
52
+ options.write(`${message}\n`);
53
+ return 4;
54
+ }
55
+ const reading = readTouchMap(root);
56
+ if (reading.kind !== "map") {
57
+ const message = reading.kind === "absent"
58
+ ? `There is no touch map in this checkout yet. Build one with: balladeer touch-map`
59
+ : `${TOUCH_MAP_FILE} could not be read as a touch map. Build it again with: balladeer touch-map`;
60
+ emit({ step: "affected", mapped: false, paths: [], changed: false });
61
+ say(message);
62
+ // Zero, because having no map is not a fault in the change somebody is
63
+ // making. A command that failed here would break every hook it was put in.
64
+ return 0;
65
+ }
66
+ const packages = readLocalPackages(root);
67
+ const details = new Map(packages.map((pkg) => [pkg.promiseId, pkg]));
68
+ const current = new Map(packages.map((pkg) => [pkg.promiseId, verifierDigest(root, pkg)]));
69
+ const paths = options.paths.map((given) => normalizePath(root, options.cwd, given));
70
+ const answers = affectedPaths(reading.map, paths, current, details);
71
+ const touched = answers.filter((answer) => answer.promises.length > 0);
72
+ const stale = answers.reduce((count, answer) => count + answer.promises.filter((promise) => promise.stale).length, 0);
73
+ emit({
74
+ step: "affected",
75
+ mapped: true,
76
+ generatedAt: reading.map.generatedAt,
77
+ ...(reading.map.truncated ? { truncated: true } : {}),
78
+ paths: answers.map((answer) => ({
79
+ path: answer.path,
80
+ promises: answer.promises.slice(0, NAMED_LIMIT).map((promise) => ({
81
+ promiseId: promise.promiseId,
82
+ stale: promise.stale,
83
+ ...(promise.title === undefined ? {} : { title: promise.title }),
84
+ ...(promise.claim === undefined ? {} : { claim: promise.claim }),
85
+ })),
86
+ })),
87
+ changed: false,
88
+ });
89
+ if (touched.length === 0) {
90
+ say(`No promise in this map ran any of those files. The map was built ${formatInstant(reading.map.generatedAt)}; a promise sealed since then is not in it.`);
91
+ return 0;
92
+ }
93
+ for (const answer of answers) {
94
+ if (answer.promises.length === 0)
95
+ continue;
96
+ say(answer.path);
97
+ for (const promise of answer.promises.slice(0, NAMED_LIMIT)) {
98
+ say(` ${promise.promiseId}${promise.title ? `: ${promise.title}` : ""}`);
99
+ if (promise.claim !== undefined)
100
+ say(` ${promise.claim}`);
101
+ if (promise.stale)
102
+ say(" Stale: this promise's verifier has changed since the map was built.");
103
+ }
104
+ if (answer.promises.length > NAMED_LIMIT)
105
+ say(` and ${answer.promises.length - NAMED_LIMIT} more, not listed here.`);
106
+ }
107
+ // Named and absent is an answer, and a silent omission is not. A person who
108
+ // listed four files and read two of them back cannot tell whether the other
109
+ // two are untouched or were dropped.
110
+ const untouched = answers.filter((answer) => answer.promises.length === 0);
111
+ if (untouched.length > 0) {
112
+ say("");
113
+ say(`No promise in this map ran: ${untouched.map((answer) => answer.path).join(", ")}`);
114
+ }
115
+ say("");
116
+ if (stale > 0) {
117
+ say(stale === 1
118
+ ? "One answer above is stale. Run balladeer touch-map to measure it again."
119
+ : `${stale} of those answers are stale. Run balladeer touch-map to measure them again.`);
120
+ }
121
+ say("This came from your own checkout. Balladeer was not asked, and was told nothing.");
122
+ return 0;
123
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Where the pinned runner sits once setup's instructions have been followed.
3
+ *
4
+ * Setup tells an agent to check the enrolled attestor release out beside the
5
+ * repository, at exactly the commit CI pins, and to run its prebuilt entry
6
+ * point. This command runs that same binary rather than deciding for itself
7
+ * whether a seal holds: the runner's answer is the one CI will give, and a
8
+ * second opinion computed here would eventually disagree with it.
9
+ */
10
+ export declare const PINNED_RUNNER = "../balladeer-attestor/release/continuity-runner/cli.js";
11
+ export type CheckSealsOptions = Readonly<{
12
+ cwd: string;
13
+ json: boolean;
14
+ /** Write the optional pre-push hook instead of checking anything. */
15
+ installHook: boolean;
16
+ /** Where the pinned runner is, when it is not in the usual place. */
17
+ runner?: string;
18
+ environment: NodeJS.ProcessEnv;
19
+ write: (text: string) => void;
20
+ }>;
21
+ /**
22
+ * The one command a coder runs before they push, and the one they never notice
23
+ * when nothing is wrong.
24
+ *
25
+ * A seal is broken by an ordinary edit, and the person who breaks one almost
26
+ * never meant to. Finding out in CI costs the promise's owner an afternoon of
27
+ * qualifying it again; finding out here costs a `git checkout` of one file. So
28
+ * this says nothing at all when the change is ordinary, and when it is not it
29
+ * names the promise, the person who agreed it, and the page they agreed it on,
30
+ * because those are what the coder needs to decide whether to back the edit out
31
+ * or to finish the repair.
32
+ *
33
+ * It never decides for itself whether a seal holds. It works out which promises
34
+ * this push touches and asks the pinned runner about each one, so its verdict
35
+ * and the protected check's verdict come from the same program.
36
+ */
37
+ export declare function runCheckSeals(options: CheckSealsOptions): Promise<number>;
@@ -0,0 +1,289 @@
1
+ import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { runCommand } from "../gh.js";
4
+ import { repositoryRoot } from "../git.js";
5
+ import { promiseForPath, sealedPromises } from "../seals.js";
6
+ import {} from "../wire.js";
7
+ /**
8
+ * Where the pinned runner sits once setup's instructions have been followed.
9
+ *
10
+ * Setup tells an agent to check the enrolled attestor release out beside the
11
+ * repository, at exactly the commit CI pins, and to run its prebuilt entry
12
+ * point. This command runs that same binary rather than deciding for itself
13
+ * whether a seal holds: the runner's answer is the one CI will give, and a
14
+ * second opinion computed here would eventually disagree with it.
15
+ */
16
+ export const PINNED_RUNNER = "../balladeer-attestor/release/continuity-runner/cli.js";
17
+ /** The exit code `run-one-target` uses for a broken seal, and only for that. */
18
+ const SEAL_BROKEN_EXIT = 4;
19
+ /** A verifier gets this long before the check gives up on it. */
20
+ const RUNNER_TIMEOUT_MS = 120_000;
21
+ /** How many broken seals are named before the report says how many more. */
22
+ const NAMED_LIMIT = 20;
23
+ /** How many of one promise's changed files are listed under it. */
24
+ const CHANGED_SHOWN = 10;
25
+ /**
26
+ * Every path this push would carry that the person has not pushed yet.
27
+ *
28
+ * Two questions, because a coder is in one of two states when this runs. Before
29
+ * the commit, the change is staged; after it, the change is committed and the
30
+ * remote has never seen it. Both are asked, and the union is what a push would
31
+ * take. Unstaged edits are deliberately not included: a file being edited right
32
+ * now is not a change anybody is pushing, and a check that went red on every
33
+ * keystroke would be turned off within the hour.
34
+ */
35
+ async function pendingPaths(root) {
36
+ const paths = new Set();
37
+ const staged = await runCommand("git", ["-C", root, "diff", "--cached", "--name-only"]);
38
+ if (staged.ok)
39
+ for (const line of staged.stdout.split("\n"))
40
+ if (line.trim())
41
+ paths.add(line);
42
+ // `@{upstream}` names the branch this one pushes to. A branch that has never
43
+ // been pushed has none, and then every commit not on any remote is pending,
44
+ // which is what the second form answers.
45
+ const upstream = await runCommand("git", [
46
+ "-C",
47
+ root,
48
+ "rev-parse",
49
+ "--abbrev-ref",
50
+ "--symbolic-full-name",
51
+ "@{upstream}",
52
+ ]);
53
+ if (upstream.ok) {
54
+ const committed = await runCommand("git", [
55
+ "-C",
56
+ root,
57
+ "diff",
58
+ "--name-only",
59
+ `${upstream.stdout.trim()}...HEAD`,
60
+ ]);
61
+ if (committed.ok)
62
+ for (const line of committed.stdout.split("\n"))
63
+ if (line.trim())
64
+ paths.add(line);
65
+ return [...paths].sort();
66
+ }
67
+ // No upstream: take every commit that sits on no remote branch at all.
68
+ const unpushed = await runCommand("git", [
69
+ "-C",
70
+ root,
71
+ "log",
72
+ "--format=%H",
73
+ "--branches",
74
+ "--not",
75
+ "--remotes",
76
+ ]);
77
+ const commits = unpushed.ok
78
+ ? unpushed.stdout.split("\n").filter((line) => line.trim() !== "")
79
+ : [];
80
+ const oldest = commits[commits.length - 1];
81
+ if (oldest !== undefined) {
82
+ const changed = await runCommand("git", [
83
+ "-C",
84
+ root,
85
+ "diff",
86
+ "--name-only",
87
+ `${oldest}^`,
88
+ "HEAD",
89
+ ]);
90
+ // A repository whose first commit is unpushed has no `^` to diff against,
91
+ // so everything it holds is what it would push.
92
+ const all = changed.ok
93
+ ? changed
94
+ : await runCommand("git", ["-C", root, "ls-tree", "-r", "--name-only", "HEAD"]);
95
+ if (all.ok)
96
+ for (const line of all.stdout.split("\n"))
97
+ if (line.trim())
98
+ paths.add(line);
99
+ }
100
+ return [...paths].sort();
101
+ }
102
+ /**
103
+ * The one command a coder runs before they push, and the one they never notice
104
+ * when nothing is wrong.
105
+ *
106
+ * A seal is broken by an ordinary edit, and the person who breaks one almost
107
+ * never meant to. Finding out in CI costs the promise's owner an afternoon of
108
+ * qualifying it again; finding out here costs a `git checkout` of one file. So
109
+ * this says nothing at all when the change is ordinary, and when it is not it
110
+ * names the promise, the person who agreed it, and the page they agreed it on,
111
+ * because those are what the coder needs to decide whether to back the edit out
112
+ * or to finish the repair.
113
+ *
114
+ * It never decides for itself whether a seal holds. It works out which promises
115
+ * this push touches and asks the pinned runner about each one, so its verdict
116
+ * and the protected check's verdict come from the same program.
117
+ */
118
+ export async function runCheckSeals(options) {
119
+ const emit = (step) => {
120
+ if (options.json)
121
+ options.write(`${JSON.stringify(step)}\n`);
122
+ };
123
+ const say = (text) => {
124
+ if (!options.json)
125
+ options.write(`${text}\n`);
126
+ };
127
+ const root = await repositoryRoot(options.cwd);
128
+ if (root === undefined) {
129
+ const message = "This is not a git repository, so there is no change to check. Run it from inside your checkout.";
130
+ if (options.json)
131
+ emit({ step: "error", reason: "not_a_repository", message, changed: false, exitCode: 4 });
132
+ else
133
+ options.write(`${message}\n`);
134
+ return 4;
135
+ }
136
+ if (options.installHook)
137
+ return installPrePushHook(root, options, emit, say);
138
+ const sealed = new Map(sealedPromises(root).map((promise) => [promise.promiseId, promise]));
139
+ if (sealed.size === 0)
140
+ return 0;
141
+ const paths = await pendingPaths(root);
142
+ const touched = new Map();
143
+ for (const path of paths) {
144
+ const promiseId = promiseForPath(path);
145
+ if (promiseId === undefined || !sealed.has(promiseId))
146
+ continue;
147
+ const changed = touched.get(promiseId) ?? [];
148
+ changed.push(path);
149
+ touched.set(promiseId, changed);
150
+ }
151
+ if (touched.size === 0)
152
+ return 0;
153
+ const runner = options.runner ?? PINNED_RUNNER;
154
+ // Resolved against the repository root, so the documented relative default
155
+ // and an absolute path a person passed both land where they mean.
156
+ const runnerPath = resolve(root, runner);
157
+ if (!existsSync(runnerPath)) {
158
+ // Loud, and still zero. Whether a seal holds is the pinned runner's answer
159
+ // and nobody else's, so with no runner here this has no verdict to give; it
160
+ // says which promises went unchecked rather than implying they are fine.
161
+ const names = [...touched.keys()].sort().join(", ");
162
+ const message = `This change touches sealed files for ${names}, and the pinned Balladeer runner is not at ` +
163
+ `${runner}, so nothing was checked. Check the enrolled attestor release out beside this ` +
164
+ `repository, or pass --runner with the path to its cli.js.`;
165
+ emit({ step: "error", reason: "runner_unavailable", message, changed: false, exitCode: 0 });
166
+ say(message);
167
+ return 0;
168
+ }
169
+ const head = await runCommand("git", ["-C", root, "rev-parse", "HEAD"]);
170
+ const sourceSha = head.ok ? head.stdout.trim() : "";
171
+ const broken = [];
172
+ for (const promiseId of [...touched.keys()].sort()) {
173
+ const promise = sealed.get(promiseId);
174
+ const answer = await runCommand(process.execPath, [runnerPath, "run-one-target", promise.packageFile], {
175
+ cwd: root,
176
+ timeoutMs: RUNNER_TIMEOUT_MS,
177
+ // The runner refuses to produce a result without an exact commit to
178
+ // attribute it to. This is a local question about the tree in front of
179
+ // it, so the commit it is standing on is the honest answer.
180
+ env: { ...options.environment, BALLADEER_SOURCE_SHA: sourceSha },
181
+ });
182
+ // Only a broken seal. A refuted behavior, a verifier that could not run, and
183
+ // a runner that fell over are all somebody's problem, and none of them is
184
+ // this command's: a check that blocked a push for any of them would be
185
+ // turned off, and then it would catch nothing at all.
186
+ if (answer.code !== SEAL_BROKEN_EXIT)
187
+ continue;
188
+ broken.push({ promise, changedPaths: (touched.get(promiseId) ?? []).slice(0, CHANGED_SHOWN) });
189
+ }
190
+ if (broken.length === 0)
191
+ return 0;
192
+ emit({
193
+ step: "seals",
194
+ broken: broken.length,
195
+ promises: broken.slice(0, NAMED_LIMIT).map((entry) => ({
196
+ promiseId: entry.promise.promiseId,
197
+ sealedPath: entry.promise.sealedPath,
198
+ ...(entry.promise.title === undefined ? {} : { title: entry.promise.title }),
199
+ ...(entry.promise.owner === undefined ? {} : { owner: entry.promise.owner }),
200
+ ...(entry.promise.promiseUrl === undefined ? {} : { promiseUrl: entry.promise.promiseUrl }),
201
+ changedPaths: [...entry.changedPaths],
202
+ resealWith: resealCommand(promiseFile(entry.promise), entry.promise, runner),
203
+ })),
204
+ changed: false,
205
+ });
206
+ say(broken.length === 1
207
+ ? "Pushing this would break one promise's seal."
208
+ : `Pushing this would break the seals of ${broken.length} promises.`);
209
+ for (const entry of broken.slice(0, NAMED_LIMIT)) {
210
+ say("");
211
+ say(` ${entry.promise.promiseId}${entry.promise.title ? `: ${entry.promise.title}` : ""}`);
212
+ say(` Sealed files: ${entry.promise.sealedPath}`);
213
+ if (entry.promise.owner !== undefined)
214
+ say(` Agreed by: ${entry.promise.owner}`);
215
+ if (entry.promise.promiseUrl !== undefined)
216
+ say(` Read it: ${entry.promise.promiseUrl}`);
217
+ for (const path of entry.changedPaths)
218
+ say(` Changed here: ${path}`);
219
+ }
220
+ if (broken.length > NAMED_LIMIT) {
221
+ say("");
222
+ say(` and ${broken.length - NAMED_LIMIT} more, not listed here.`);
223
+ }
224
+ say("");
225
+ say("If you did not mean to touch these files, put them back and push again. If you were repairing");
226
+ say("a verifier, the repair is not finished until the promise is sealed again with the pinned runner:");
227
+ say("");
228
+ for (const entry of broken.slice(0, NAMED_LIMIT)) {
229
+ say(` ${resealCommand(promiseFile(entry.promise), entry.promise, runner)}`);
230
+ }
231
+ say("");
232
+ say("Then a named person qualifies the promise again before it protects anything.");
233
+ return 1;
234
+ }
235
+ /** The draft the reseal reads, which the scaffold wrote beside the package. */
236
+ function promiseFile(promise) {
237
+ return `.continuity/drafts/${promise.promiseId}.json`;
238
+ }
239
+ /**
240
+ * The exact line that reseals one promise.
241
+ *
242
+ * The seal command refuses to overwrite an existing package on purpose, so the
243
+ * old one is removed in the same line rather than left for the coder to find
244
+ * out about from a refusal.
245
+ */
246
+ function resealCommand(draftFile, promise, runner) {
247
+ return `rm ${promise.packageFile} && node ${runner} seal ${draftFile} ${promise.packageFile}`;
248
+ }
249
+ /**
250
+ * The optional hook, written only when somebody asks for it by name.
251
+ *
252
+ * Setup offers this and never installs it. A tool that writes into somebody's
253
+ * `.git/hooks` without being asked has taken over a file the person may already
254
+ * be using, and the first they would know of it is a push that stopped for a
255
+ * reason they cannot find.
256
+ */
257
+ function installPrePushHook(root, options, emit, say) {
258
+ const hookPath = join(root, ".git", "hooks", "pre-push");
259
+ if (existsSync(hookPath)) {
260
+ const message = `${hookPath} already exists, so nothing was written. Add this line to it yourself if you ` +
261
+ "want the check: balladeer check-seals";
262
+ emit({ step: "error", reason: "hook_exists", message, changed: false, exitCode: 4 });
263
+ say(message);
264
+ return 4;
265
+ }
266
+ const script = [
267
+ "#!/bin/sh",
268
+ "# Installed by: balladeer check-seals --install-hook",
269
+ "# It stops a push that would break a promise's seal, and says nothing otherwise.",
270
+ "# Delete this file to remove it.",
271
+ "balladeer check-seals || exit 1",
272
+ "",
273
+ ].join("\n");
274
+ try {
275
+ mkdirSync(dirname(hookPath), { recursive: true });
276
+ writeFileSync(hookPath, script, { encoding: "utf8", flag: "wx" });
277
+ chmodSync(hookPath, 0o755);
278
+ }
279
+ catch (error) {
280
+ const message = `That hook could not be written: ${error instanceof Error ? error.message : String(error)}`;
281
+ emit({ step: "error", reason: "hook_unwritable", message, changed: false, exitCode: 4 });
282
+ say(message);
283
+ return 4;
284
+ }
285
+ emit({ step: "hook", path: ".git/hooks/pre-push", changed: true });
286
+ say("Written: .git/hooks/pre-push. Every push from this checkout now stops if it would break a promise's seal.");
287
+ say("Delete that file to remove it. It is yours, and Balladeer never rewrites it.");
288
+ return 0;
289
+ }