balladeer 0.0.4 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +200 -5
- package/README.md +154 -68
- package/dist/agent.d.ts +126 -0
- package/dist/agent.js +209 -0
- package/dist/cli.d.ts +34 -0
- package/dist/cli.js +392 -0
- package/dist/client.d.ts +44 -0
- package/dist/client.js +114 -0
- package/dist/commands/affected.d.ts +22 -0
- package/dist/commands/affected.js +122 -0
- package/dist/commands/check-seals.d.ts +37 -0
- package/dist/commands/check-seals.js +289 -0
- package/dist/commands/discover.d.ts +68 -0
- package/dist/commands/discover.js +395 -0
- package/dist/commands/explain.d.ts +35 -0
- package/dist/commands/explain.js +90 -0
- package/dist/commands/invite.d.ts +24 -0
- package/dist/commands/invite.js +197 -0
- package/dist/commands/mcp.d.ts +65 -0
- package/dist/commands/mcp.js +202 -0
- package/dist/commands/propose.d.ts +59 -0
- package/dist/commands/propose.js +262 -0
- package/dist/commands/repositories.d.ts +18 -0
- package/dist/commands/repositories.js +185 -0
- package/dist/commands/setup.d.ts +75 -0
- package/dist/commands/setup.js +1471 -0
- package/dist/commands/status.d.ts +35 -0
- package/dist/commands/status.js +482 -0
- package/dist/commands/touch-map.d.ts +42 -0
- package/dist/commands/touch-map.js +251 -0
- package/dist/commands/whoami.d.ts +8 -0
- package/dist/commands/whoami.js +79 -0
- package/dist/conventions.d.ts +69 -0
- package/dist/conventions.js +175 -0
- package/dist/copy.d.ts +148 -0
- package/dist/copy.js +459 -0
- package/dist/currency.d.ts +31 -0
- package/dist/currency.js +72 -0
- package/dist/gh.d.ts +80 -0
- package/dist/gh.js +188 -0
- package/dist/git.d.ts +76 -0
- package/dist/git.js +203 -0
- package/dist/markers.d.ts +76 -0
- package/dist/markers.js +125 -0
- package/dist/mcp-config.d.ts +99 -0
- package/dist/mcp-config.js +230 -0
- package/dist/release.d.ts +55 -0
- package/dist/release.js +67 -0
- package/dist/repository.d.ts +8 -0
- package/dist/repository.js +32 -0
- package/dist/seals.d.ts +48 -0
- package/dist/seals.js +112 -0
- package/dist/store.d.ts +98 -0
- package/dist/store.js +225 -0
- package/dist/touch-map.d.ts +241 -0
- package/dist/touch-map.js +487 -0
- package/dist/wire.d.ts +588 -0
- package/dist/wire.js +20 -0
- package/package.json +19 -10
- package/bin/balladeer.js +0 -136
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { runCommand } from "../gh.js";
|
|
5
|
+
import { repositoryRoot } from "../git.js";
|
|
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";
|
|
8
|
+
/** A verifier gets this long before the measurement gives up on it. */
|
|
9
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
10
|
+
/** However long a package asks for, no verifier holds this command longer. */
|
|
11
|
+
const MAXIMUM_TIMEOUT_MS = 300_000;
|
|
12
|
+
/** How many promises a plain-text summary names before it says how many more. */
|
|
13
|
+
const NAMED_LIMIT = 20;
|
|
14
|
+
/**
|
|
15
|
+
* Where the promises are, which is not always the git root.
|
|
16
|
+
*
|
|
17
|
+
* A repository keeps its promises at its top level, so the git root is the
|
|
18
|
+
* answer almost every time. It is not the answer when somebody is standing in a
|
|
19
|
+
* sub-tree that carries its own `.continuity/` directory, which is how this
|
|
20
|
+
* repository's own dogfood packages are laid out, and running there and mapping
|
|
21
|
+
* the outer tree instead would answer a question nobody asked.
|
|
22
|
+
*/
|
|
23
|
+
export async function promiseRoot(cwd) {
|
|
24
|
+
const chosen = existsSync(join(cwd, CONTINUITY_DIRECTORY)) ? cwd : await repositoryRoot(cwd);
|
|
25
|
+
if (chosen === undefined)
|
|
26
|
+
return undefined;
|
|
27
|
+
// Resolved through its symlinks, because V8 reports the real path of every
|
|
28
|
+
// script it recorded. A checkout reached through a link would otherwise
|
|
29
|
+
// measure correctly and attribute nothing, which reads as a repository where
|
|
30
|
+
// no promise touches anything.
|
|
31
|
+
try {
|
|
32
|
+
return realpathSync(chosen);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return chosen;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Measure one promise: run its verifier against this working tree, under
|
|
40
|
+
* coverage, and keep the list of repository files it executed.
|
|
41
|
+
*
|
|
42
|
+
* It runs the verifier's own declared entry point rather than the pinned
|
|
43
|
+
* runner. The runner hands its child a closed environment on purpose, and
|
|
44
|
+
* `NODE_V8_COVERAGE` is not in it, so a measurement taken through the runner
|
|
45
|
+
* would come back empty every time. The invocation is the package's own, so
|
|
46
|
+
* what runs here is what CI runs; only the environment differs, by the one
|
|
47
|
+
* variable that turns the recorder on.
|
|
48
|
+
*/
|
|
49
|
+
async function measure(root, pkg, environment) {
|
|
50
|
+
if (pkg.target.executable === "" || pkg.target.args.length === 0) {
|
|
51
|
+
return {
|
|
52
|
+
promiseId: pkg.promiseId,
|
|
53
|
+
reason: "no_verifier_target",
|
|
54
|
+
detail: `${pkg.promiseId} declares no runnable verifier target in ${pkg.packageFile}, so nothing could be measured.`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
if (!measurableWithNode(pkg.target)) {
|
|
58
|
+
return {
|
|
59
|
+
promiseId: pkg.promiseId,
|
|
60
|
+
reason: "unsupported_executable",
|
|
61
|
+
detail: `${pkg.promiseId} runs ${pkg.target.executable}, and this release measures Node verifiers ` +
|
|
62
|
+
"only. Other languages come later; until then its path markers answer for it.",
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
const cwd = verifierCwd(root, pkg.target);
|
|
66
|
+
if (cwd === undefined) {
|
|
67
|
+
return {
|
|
68
|
+
promiseId: pkg.promiseId,
|
|
69
|
+
reason: "cwd_outside_repository",
|
|
70
|
+
detail: `${pkg.promiseId} runs its verifier outside this repository, so it was not measured.`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const scratch = mkdtempSync(join(tmpdir(), "balladeer-touch-map-"));
|
|
74
|
+
try {
|
|
75
|
+
const coverage = join(scratch, "coverage");
|
|
76
|
+
mkdirSync(coverage, { recursive: true });
|
|
77
|
+
const budget = Math.min(pkg.target.timeoutMs ?? DEFAULT_TIMEOUT_MS, MAXIMUM_TIMEOUT_MS);
|
|
78
|
+
const started = Date.now();
|
|
79
|
+
const answer = await runCommand(process.execPath, [...pkg.target.args], {
|
|
80
|
+
cwd,
|
|
81
|
+
timeoutMs: budget,
|
|
82
|
+
// Cast, not a widening: this is a closed allow-list by construction, and
|
|
83
|
+
// the ambient environment type insists on names a verifier must not get.
|
|
84
|
+
env: touchMapEnvironment(environment, coverage, join(scratch, "result.json")),
|
|
85
|
+
});
|
|
86
|
+
// A verifier that outlived the budget was stopped by this command, and
|
|
87
|
+
// saying "could not be run (exit 1)" would send somebody hunting for a crash
|
|
88
|
+
// that never happened. The elapsed time is what tells the two apart, because
|
|
89
|
+
// a killed process reports an ordinary non-zero exit.
|
|
90
|
+
const outOfTime = !answer.ok && Date.now() - started >= budget;
|
|
91
|
+
const executed = new Set();
|
|
92
|
+
for (const document of readCoverageDirectory(coverage))
|
|
93
|
+
for (const path of executedPaths(root, document))
|
|
94
|
+
executed.add(path);
|
|
95
|
+
// Whether the verifier ran is answered by whether V8 recorded the verifier
|
|
96
|
+
// itself, not by its exit code. A verifier that reports a refutation exits
|
|
97
|
+
// non-zero and has still told us exactly which files it read; one that died
|
|
98
|
+
// on a missing import exits non-zero and has told us nothing, and the two
|
|
99
|
+
// must not be reported as the same thing.
|
|
100
|
+
if (!ranAtAll(root, cwd, pkg, executed)) {
|
|
101
|
+
return {
|
|
102
|
+
promiseId: pkg.promiseId,
|
|
103
|
+
reason: outOfTime ? "timed_out" : answer.ok ? "no_coverage" : "did_not_run",
|
|
104
|
+
detail: outOfTime
|
|
105
|
+
? `${pkg.promiseId} was still running after ${Math.round(budget / 1000)} seconds and was stopped, so it is not in the map.`
|
|
106
|
+
: answer.ok
|
|
107
|
+
? `${pkg.promiseId} ran and left no coverage of its own verifier behind, so nothing could be attributed to it.`
|
|
108
|
+
: `${pkg.promiseId} could not be run here (exit ${answer.code ?? "none"}), so it is not in the map.`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
promiseId: pkg.promiseId,
|
|
113
|
+
verifierDigest: verifierDigest(root, pkg),
|
|
114
|
+
// A promise whose verifier reads only its own sealed tree maps no files,
|
|
115
|
+
// and that is a true answer rather than a failure: nothing else in the
|
|
116
|
+
// repository is what it checks.
|
|
117
|
+
paths: [...executed].filter(mappablePath).sort(),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
function isCoverage(value) {
|
|
125
|
+
return "paths" in value;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Whether the verifier itself is among the scripts V8 recorded.
|
|
129
|
+
*
|
|
130
|
+
* The package names the program to run, so the honest test of "did it run" is
|
|
131
|
+
* whether that program appears in the coverage. Where the invocation's first
|
|
132
|
+
* argument is not a file in this repository, which no package the scaffold
|
|
133
|
+
* writes produces, any recorded repository file stands in for it rather than
|
|
134
|
+
* refusing to answer.
|
|
135
|
+
*/
|
|
136
|
+
function ranAtAll(root, cwd, pkg, executed) {
|
|
137
|
+
const entry = pkg.target.args[0];
|
|
138
|
+
const path = entry === undefined ? undefined : repositoryPath(root, resolve(cwd, entry));
|
|
139
|
+
if (path === undefined)
|
|
140
|
+
return executed.size > 0;
|
|
141
|
+
return executed.has(path);
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* The command that turns "which files does this verifier read" from a guess
|
|
145
|
+
* into a measurement.
|
|
146
|
+
*
|
|
147
|
+
* It runs every sealed promise in this checkout under V8's coverage recorder
|
|
148
|
+
* and writes what each one executed to `.continuity/touch-map.json`. The file
|
|
149
|
+
* stays on the customer's disk. Nothing here posts it, and the map is a list of
|
|
150
|
+
* a customer's own file names, which is exactly the thing the boundary copy
|
|
151
|
+
* promises Balladeer never receives.
|
|
152
|
+
*/
|
|
153
|
+
export async function runTouchMap(options) {
|
|
154
|
+
const emit = (step) => {
|
|
155
|
+
if (options.json)
|
|
156
|
+
options.write(`${JSON.stringify(step)}\n`);
|
|
157
|
+
};
|
|
158
|
+
const say = (text) => {
|
|
159
|
+
if (!options.json)
|
|
160
|
+
options.write(`${text}\n`);
|
|
161
|
+
};
|
|
162
|
+
const root = await promiseRoot(options.cwd);
|
|
163
|
+
if (root === undefined) {
|
|
164
|
+
const message = "This is not a git repository and there are no promise packages here, so there is nothing to map. Run it from inside your checkout.";
|
|
165
|
+
if (options.json)
|
|
166
|
+
emit({ step: "error", reason: "not_a_repository", message, changed: false, exitCode: 4 });
|
|
167
|
+
else
|
|
168
|
+
options.write(`${message}\n`);
|
|
169
|
+
return 4;
|
|
170
|
+
}
|
|
171
|
+
const packages = readLocalPackages(root);
|
|
172
|
+
if (packages.length === 0) {
|
|
173
|
+
const message = "This checkout holds no sealed promise packages, so there is nothing to map yet. A promise gets one when somebody seals its verifier.";
|
|
174
|
+
emit({ step: "touch_map", promises: 0, files: 0, unmapped: 0, bytes: 0, changed: false });
|
|
175
|
+
say(message);
|
|
176
|
+
return 0;
|
|
177
|
+
}
|
|
178
|
+
const covered = [];
|
|
179
|
+
const unmapped = [];
|
|
180
|
+
for (const pkg of packages) {
|
|
181
|
+
const answer = await measure(root, pkg, options.environment);
|
|
182
|
+
if (isCoverage(answer))
|
|
183
|
+
covered.push(answer);
|
|
184
|
+
else
|
|
185
|
+
unmapped.push(answer);
|
|
186
|
+
}
|
|
187
|
+
const now = options.now?.() ?? new Date();
|
|
188
|
+
const map = buildTouchMap({ generatedAt: now.toISOString(), covered, unmapped });
|
|
189
|
+
const written = writeTouchMap(root, map);
|
|
190
|
+
emit({
|
|
191
|
+
step: "touch_map",
|
|
192
|
+
promises: map.promises.length,
|
|
193
|
+
files: map.files.length,
|
|
194
|
+
unmapped: map.unmapped.length,
|
|
195
|
+
bytes: written.bytes,
|
|
196
|
+
changed: written.changed,
|
|
197
|
+
...(map.truncated ? { truncated: true } : {}),
|
|
198
|
+
});
|
|
199
|
+
say(`Mapped ${map.promises.length} ${map.promises.length === 1 ? "promise" : "promises"} across ` +
|
|
200
|
+
`${map.files.length} ${map.files.length === 1 ? "file" : "files"}, in ${TOUCH_MAP_FILE} ` +
|
|
201
|
+
`(${written.bytes} bytes).`);
|
|
202
|
+
say("This file stays here. Balladeer is never sent it.");
|
|
203
|
+
if (map.truncated) {
|
|
204
|
+
say("It hit this release's size cap, so it is a partial map. Treat a file it does not name as unanswered rather than as untouched.");
|
|
205
|
+
}
|
|
206
|
+
if (map.unmapped.length > 0) {
|
|
207
|
+
say("");
|
|
208
|
+
say(map.unmapped.length === 1
|
|
209
|
+
? "One promise could not be measured, so nothing is mapped to it:"
|
|
210
|
+
: `${map.unmapped.length} promises could not be measured, so nothing is mapped to them:`);
|
|
211
|
+
for (const entry of map.unmapped.slice(0, NAMED_LIMIT))
|
|
212
|
+
say(` ${entry.detail}`);
|
|
213
|
+
if (map.unmapped.length > NAMED_LIMIT)
|
|
214
|
+
say(` and ${map.unmapped.length - NAMED_LIMIT} more, not listed here.`);
|
|
215
|
+
}
|
|
216
|
+
say("");
|
|
217
|
+
say("Ask it which promises a change touches with: balladeer affected <paths...>");
|
|
218
|
+
return 0;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Write the map, and leave the file alone when nothing about it changed.
|
|
222
|
+
*
|
|
223
|
+
* The timestamp is the only field that moves on every run, so comparing the
|
|
224
|
+
* document without it is what makes a second run a no-op. A command that
|
|
225
|
+
* rewrote a byte of a committed file every time it was asked a question would
|
|
226
|
+
* put a diff in front of somebody who changed nothing.
|
|
227
|
+
*/
|
|
228
|
+
export function writeTouchMap(root, map) {
|
|
229
|
+
const path = join(root, TOUCH_MAP_FILE);
|
|
230
|
+
const serialized = serializeTouchMap(map);
|
|
231
|
+
let existing;
|
|
232
|
+
try {
|
|
233
|
+
existing = readFileSync(path, "utf8");
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
existing = undefined;
|
|
237
|
+
}
|
|
238
|
+
if (existing !== undefined) {
|
|
239
|
+
try {
|
|
240
|
+
const before = JSON.parse(existing);
|
|
241
|
+
if (touchMapBody(before) === touchMapBody(map))
|
|
242
|
+
return { changed: false, bytes: Buffer.byteLength(existing, "utf8") };
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
// An unreadable map is replaced rather than kept.
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
249
|
+
writeFileSync(path, serialized, "utf8");
|
|
250
|
+
return { changed: true, bytes: Buffer.byteLength(serialized, "utf8") };
|
|
251
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type WhoamiOptions = Readonly<{
|
|
2
|
+
controlPlane: string;
|
|
3
|
+
json: boolean;
|
|
4
|
+
environment: NodeJS.ProcessEnv;
|
|
5
|
+
write: (text: string) => void;
|
|
6
|
+
}>;
|
|
7
|
+
/** Reads the session's standing from the server, never from the stored copy. */
|
|
8
|
+
export declare function runWhoami(options: WhoamiOptions): Promise<number>;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { ClientTooOldError, RefusalError, TransportError, request } from "../client.js";
|
|
2
|
+
import { StoreError, dropSession, findSession, readCredentials, writeCredentials, } from "../store.js";
|
|
3
|
+
import { commandLine } from "../release.js";
|
|
4
|
+
import {} from "../wire.js";
|
|
5
|
+
function emit(options, step) {
|
|
6
|
+
if (options.json)
|
|
7
|
+
options.write(`${JSON.stringify(step)}\n`);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* The same sentence a person reads, in the shape `--json` promised. Without it,
|
|
11
|
+
* the machine-readable mode answers a failure with prose on the stream it said
|
|
12
|
+
* would carry objects only.
|
|
13
|
+
*/
|
|
14
|
+
function fail(options, reason, message, exitCode, changed = false) {
|
|
15
|
+
if (options.json)
|
|
16
|
+
emit(options, { step: "error", reason, message, changed, exitCode });
|
|
17
|
+
else
|
|
18
|
+
options.write(`${message}\n`);
|
|
19
|
+
return exitCode;
|
|
20
|
+
}
|
|
21
|
+
function noSession(options, changed = false) {
|
|
22
|
+
return fail(options, "no_stored_session", `No Balladeer session is stored for ${options.controlPlane}. Run: ${commandLine(null, "setup")}`, 4, changed);
|
|
23
|
+
}
|
|
24
|
+
/** Reads the session's standing from the server, never from the stored copy. */
|
|
25
|
+
export async function runWhoami(options) {
|
|
26
|
+
let credentials;
|
|
27
|
+
try {
|
|
28
|
+
credentials = readCredentials(options.environment);
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
return fail(options, error instanceof StoreError ? error.code : "credential_store_unusable", error instanceof StoreError ? error.message : String(error), 4);
|
|
32
|
+
}
|
|
33
|
+
const stored = findSession(credentials, options.controlPlane);
|
|
34
|
+
if (!stored)
|
|
35
|
+
return noSession(options);
|
|
36
|
+
try {
|
|
37
|
+
const answer = await request(options.controlPlane, {
|
|
38
|
+
method: "GET",
|
|
39
|
+
path: "/api/pair/session",
|
|
40
|
+
bearer: stored.token,
|
|
41
|
+
});
|
|
42
|
+
const session = answer.session;
|
|
43
|
+
if (options.json) {
|
|
44
|
+
emit(options, { step: "whoami", session });
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
options.write([
|
|
48
|
+
`Signed in as ${session.membershipDisplayName}, workspace "${session.workspaceName}" (${session.workspaceSlug}).`,
|
|
49
|
+
`Role: ${session.role}.`,
|
|
50
|
+
`This session may: ${session.scopeMeanings.join(", ")}.`,
|
|
51
|
+
`It expires at ${session.expiresAt}.`,
|
|
52
|
+
"",
|
|
53
|
+
].join("\n"));
|
|
54
|
+
}
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
if (error instanceof ClientTooOldError) {
|
|
59
|
+
return fail(options, "client_too_old", error.message, 3);
|
|
60
|
+
}
|
|
61
|
+
if (error instanceof TransportError) {
|
|
62
|
+
return fail(options, "control_plane_unreachable", `Balladeer could not be reached at ${options.controlPlane}: ${error.message}. Nothing was changed.`, 5);
|
|
63
|
+
}
|
|
64
|
+
if (error instanceof RefusalError) {
|
|
65
|
+
// Expired, revoked, or the membership behind it is gone. The stored copy
|
|
66
|
+
// is now a lie, so it is deleted rather than left to mislead the next run.
|
|
67
|
+
let changed = false;
|
|
68
|
+
try {
|
|
69
|
+
writeCredentials(dropSession(credentials, options.controlPlane), options.environment);
|
|
70
|
+
changed = true;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// Reporting the truth matters more than tidying the file.
|
|
74
|
+
}
|
|
75
|
+
return noSession(options, changed);
|
|
76
|
+
}
|
|
77
|
+
return fail(options, "session_unreadable", "Balladeer could not read this session. Nothing was changed.", 5);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fence, which never changes again.
|
|
3
|
+
*
|
|
4
|
+
* The first version of this file spelled the marker
|
|
5
|
+
* `<!-- balladeer:conventions:start v1 -->`, so the version travelled in the one
|
|
6
|
+
* string the lookup matches on. Bumping the block therefore bumped the marker,
|
|
7
|
+
* the lookup for the old marker missed, and the next run appended a second block
|
|
8
|
+
* below the stale one instead of replacing it: the customer's committed file
|
|
9
|
+
* grew a copy per release, each of them contradicting the last.
|
|
10
|
+
*
|
|
11
|
+
* The version now lives in a managed-by line inside the fence, where a refresh
|
|
12
|
+
* can read it and the lookup never depends on it. The marker a version 1 install
|
|
13
|
+
* wrote is still recognised, so a repository onboarded before this change is
|
|
14
|
+
* migrated in place rather than given a second block of its own.
|
|
15
|
+
*/
|
|
16
|
+
export declare const CONVENTIONS_START = "<!-- balladeer:conventions:start -->";
|
|
17
|
+
export declare const CONVENTIONS_END = "<!-- balladeer:conventions:end -->";
|
|
18
|
+
/**
|
|
19
|
+
* The version of the text between the markers. It is written into the block and
|
|
20
|
+
* read back out of it, and nothing about finding the block depends on it.
|
|
21
|
+
*/
|
|
22
|
+
export declare const CONVENTIONS_VERSION = 10;
|
|
23
|
+
export declare function managedByLine(version: number): string;
|
|
24
|
+
/**
|
|
25
|
+
* Which version of the block a file already carries, or nothing when it carries
|
|
26
|
+
* no Balladeer block at all.
|
|
27
|
+
*
|
|
28
|
+
* A version 1 install has no managed-by line, so its version is read from the
|
|
29
|
+
* marker it was written with. A block with neither is one somebody hand-copied,
|
|
30
|
+
* and treating it as version 0 refreshes it rather than leaving it to rot.
|
|
31
|
+
*/
|
|
32
|
+
export declare function installedConventionsVersion(contents: string): number | undefined;
|
|
33
|
+
export type ConventionsResult = Readonly<{
|
|
34
|
+
kind: "written";
|
|
35
|
+
file: string;
|
|
36
|
+
changed: boolean;
|
|
37
|
+
version: number;
|
|
38
|
+
/** What the file carried before this run, when it carried a block. */
|
|
39
|
+
previousVersion?: number;
|
|
40
|
+
}> | Readonly<{
|
|
41
|
+
kind: "refused";
|
|
42
|
+
file: string;
|
|
43
|
+
reason: string;
|
|
44
|
+
}>;
|
|
45
|
+
/**
|
|
46
|
+
* Writes the marker-fenced conventions block into the repository's agent
|
|
47
|
+
* instructions file, so a future session reads the team's promises before
|
|
48
|
+
* planning without being told to.
|
|
49
|
+
*
|
|
50
|
+
* The markers are the whole discipline. Everything outside them is somebody
|
|
51
|
+
* else's file and is copied through byte for byte; everything between them is
|
|
52
|
+
* replaced. Running the command twice therefore leaves the file identical, which
|
|
53
|
+
* is what makes it safe to run on every setup.
|
|
54
|
+
*
|
|
55
|
+
* A file whose markers are damaged is refused rather than appended to. One start
|
|
56
|
+
* with no end, an end with no start, a pair in the wrong order, or two of either
|
|
57
|
+
* are all states this command cannot repair without guessing where somebody's
|
|
58
|
+
* own writing begins, and guessing wrong means either deleting their text or
|
|
59
|
+
* leaving a second block behind. The person is told which file and what is wrong
|
|
60
|
+
* with it.
|
|
61
|
+
*
|
|
62
|
+
* Project-scoped and committed by the developer, never a file in their home
|
|
63
|
+
* directory: the promises belong to this repository, and a teammate who clones
|
|
64
|
+
* it should get the same instructions.
|
|
65
|
+
*/
|
|
66
|
+
export declare function writeConventions(repositoryRoot: string, options?: Readonly<{
|
|
67
|
+
version?: number;
|
|
68
|
+
body?: string;
|
|
69
|
+
}>): ConventionsResult;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { closeSync, existsSync, fsyncSync, openSync, readFileSync, renameSync, unlinkSync, writeSync, } from "node:fs";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { CONVENTIONS_BLOCK } from "./copy.js";
|
|
5
|
+
/**
|
|
6
|
+
* The fence, which never changes again.
|
|
7
|
+
*
|
|
8
|
+
* The first version of this file spelled the marker
|
|
9
|
+
* `<!-- balladeer:conventions:start v1 -->`, so the version travelled in the one
|
|
10
|
+
* string the lookup matches on. Bumping the block therefore bumped the marker,
|
|
11
|
+
* the lookup for the old marker missed, and the next run appended a second block
|
|
12
|
+
* below the stale one instead of replacing it: the customer's committed file
|
|
13
|
+
* grew a copy per release, each of them contradicting the last.
|
|
14
|
+
*
|
|
15
|
+
* The version now lives in a managed-by line inside the fence, where a refresh
|
|
16
|
+
* can read it and the lookup never depends on it. The marker a version 1 install
|
|
17
|
+
* wrote is still recognised, so a repository onboarded before this change is
|
|
18
|
+
* migrated in place rather than given a second block of its own.
|
|
19
|
+
*/
|
|
20
|
+
export const CONVENTIONS_START = "<!-- balladeer:conventions:start -->";
|
|
21
|
+
export const CONVENTIONS_END = "<!-- balladeer:conventions:end -->";
|
|
22
|
+
/**
|
|
23
|
+
* The version of the text between the markers. It is written into the block and
|
|
24
|
+
* read back out of it, and nothing about finding the block depends on it.
|
|
25
|
+
*/
|
|
26
|
+
export const CONVENTIONS_VERSION = 10;
|
|
27
|
+
/** Both spellings: the stable marker, and the versioned one version 1 wrote. */
|
|
28
|
+
const START_MARKER = /<!-- balladeer:conventions:start(?: v(\d{1,4}))? -->/g;
|
|
29
|
+
const END_MARKER = /<!-- balladeer:conventions:end -->/g;
|
|
30
|
+
const MANAGED_BY = /Managed by Balladeer \(conventions v(\d{1,4})\)/;
|
|
31
|
+
const CANDIDATE_FILES = ["CLAUDE.md", "AGENTS.md"];
|
|
32
|
+
export function managedByLine(version) {
|
|
33
|
+
return (`Managed by Balladeer (conventions v${version}). Everything between the markers is replaced ` +
|
|
34
|
+
`whenever setup runs in this repository, so put your own notes outside them.`);
|
|
35
|
+
}
|
|
36
|
+
function fenced(version, body) {
|
|
37
|
+
return `${CONVENTIONS_START}\n${managedByLine(version)}\n\n${body.trim()}\n${CONVENTIONS_END}\n`;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Which version of the block a file already carries, or nothing when it carries
|
|
41
|
+
* no Balladeer block at all.
|
|
42
|
+
*
|
|
43
|
+
* A version 1 install has no managed-by line, so its version is read from the
|
|
44
|
+
* marker it was written with. A block with neither is one somebody hand-copied,
|
|
45
|
+
* and treating it as version 0 refreshes it rather than leaving it to rot.
|
|
46
|
+
*/
|
|
47
|
+
export function installedConventionsVersion(contents) {
|
|
48
|
+
const start = [...contents.matchAll(START_MARKER)][0];
|
|
49
|
+
if (start === undefined)
|
|
50
|
+
return undefined;
|
|
51
|
+
const managed = MANAGED_BY.exec(contents.slice(start.index));
|
|
52
|
+
if (managed?.[1] !== undefined)
|
|
53
|
+
return Number(managed[1]);
|
|
54
|
+
return start[1] === undefined ? 0 : Number(start[1]);
|
|
55
|
+
}
|
|
56
|
+
function writeAtomically(path, contents) {
|
|
57
|
+
const temporary = join(dirname(path), `.conventions.${randomBytes(8).toString("hex")}.tmp`);
|
|
58
|
+
let descriptor;
|
|
59
|
+
try {
|
|
60
|
+
descriptor = openSync(temporary, "wx", 0o644);
|
|
61
|
+
writeSync(descriptor, contents);
|
|
62
|
+
fsyncSync(descriptor);
|
|
63
|
+
closeSync(descriptor);
|
|
64
|
+
descriptor = undefined;
|
|
65
|
+
renameSync(temporary, path);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (descriptor !== undefined) {
|
|
69
|
+
try {
|
|
70
|
+
closeSync(descriptor);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// The unlink below is the cleanup that matters.
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
unlinkSync(temporary);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// The temporary file may never have been created.
|
|
81
|
+
}
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Writes the marker-fenced conventions block into the repository's agent
|
|
87
|
+
* instructions file, so a future session reads the team's promises before
|
|
88
|
+
* planning without being told to.
|
|
89
|
+
*
|
|
90
|
+
* The markers are the whole discipline. Everything outside them is somebody
|
|
91
|
+
* else's file and is copied through byte for byte; everything between them is
|
|
92
|
+
* replaced. Running the command twice therefore leaves the file identical, which
|
|
93
|
+
* is what makes it safe to run on every setup.
|
|
94
|
+
*
|
|
95
|
+
* A file whose markers are damaged is refused rather than appended to. One start
|
|
96
|
+
* with no end, an end with no start, a pair in the wrong order, or two of either
|
|
97
|
+
* are all states this command cannot repair without guessing where somebody's
|
|
98
|
+
* own writing begins, and guessing wrong means either deleting their text or
|
|
99
|
+
* leaving a second block behind. The person is told which file and what is wrong
|
|
100
|
+
* with it.
|
|
101
|
+
*
|
|
102
|
+
* Project-scoped and committed by the developer, never a file in their home
|
|
103
|
+
* directory: the promises belong to this repository, and a teammate who clones
|
|
104
|
+
* it should get the same instructions.
|
|
105
|
+
*/
|
|
106
|
+
export function writeConventions(repositoryRoot, options = {}) {
|
|
107
|
+
const version = options.version ?? CONVENTIONS_VERSION;
|
|
108
|
+
const existingName = CANDIDATE_FILES.find((name) => existsSync(join(repositoryRoot, name)));
|
|
109
|
+
const name = existingName ?? "CLAUDE.md";
|
|
110
|
+
const path = join(repositoryRoot, name);
|
|
111
|
+
const block = fenced(version, options.body ?? CONVENTIONS_BLOCK);
|
|
112
|
+
let existing = "";
|
|
113
|
+
try {
|
|
114
|
+
existing = readFileSync(path, "utf8");
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
writeAtomically(path, block);
|
|
118
|
+
return { kind: "written", file: name, changed: true, version };
|
|
119
|
+
}
|
|
120
|
+
const starts = [...existing.matchAll(START_MARKER)];
|
|
121
|
+
const ends = [...existing.matchAll(END_MARKER)];
|
|
122
|
+
const damaged = markerDamage(starts.length, ends.length, starts[0]?.index, ends[0]?.index);
|
|
123
|
+
if (damaged !== undefined) {
|
|
124
|
+
return {
|
|
125
|
+
kind: "refused",
|
|
126
|
+
file: name,
|
|
127
|
+
reason: `${name} ${damaged}, so I could not tell where Balladeer's block ends and yours begins; I did not change it.`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
const start = starts[0];
|
|
131
|
+
const end = ends[0];
|
|
132
|
+
let next;
|
|
133
|
+
let previousVersion;
|
|
134
|
+
if (start !== undefined && end !== undefined) {
|
|
135
|
+
previousVersion = installedConventionsVersion(existing);
|
|
136
|
+
const after = end.index + CONVENTIONS_END.length;
|
|
137
|
+
next = `${existing.slice(0, start.index)}${block}${existing.slice(existing[after] === "\n" ? after + 1 : after)}`;
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
const separator = existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
|
|
141
|
+
next = `${existing}${separator}${block}`;
|
|
142
|
+
}
|
|
143
|
+
if (next === existing) {
|
|
144
|
+
return {
|
|
145
|
+
kind: "written",
|
|
146
|
+
file: name,
|
|
147
|
+
changed: false,
|
|
148
|
+
version,
|
|
149
|
+
...(previousVersion === undefined ? {} : { previousVersion }),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
writeAtomically(path, next);
|
|
153
|
+
return {
|
|
154
|
+
kind: "written",
|
|
155
|
+
file: name,
|
|
156
|
+
changed: true,
|
|
157
|
+
version,
|
|
158
|
+
...(previousVersion === undefined ? {} : { previousVersion }),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
/** What is wrong with the markers, said the way a person would fix it. */
|
|
162
|
+
function markerDamage(startCount, endCount, startAt, endAt) {
|
|
163
|
+
if (startCount > 1 || endCount > 1)
|
|
164
|
+
return "carries more than one Balladeer conventions block";
|
|
165
|
+
if (startCount === 1 && endCount === 0) {
|
|
166
|
+
return "has a Balladeer conventions start marker with no end marker";
|
|
167
|
+
}
|
|
168
|
+
if (startCount === 0 && endCount === 1) {
|
|
169
|
+
return "has a Balladeer conventions end marker with no start marker";
|
|
170
|
+
}
|
|
171
|
+
if (startAt !== undefined && endAt !== undefined && endAt < startAt) {
|
|
172
|
+
return "has its Balladeer conventions markers in the wrong order";
|
|
173
|
+
}
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|