@skanl/brambo-cli 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +208 -0
- package/dist/bin/brambo.d.ts +2 -0
- package/dist/bin/brambo.js +9 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +1 -0
- package/dist/src/registry-commands.d.ts +94 -0
- package/dist/src/registry-commands.js +612 -0
- package/dist/src/run.d.ts +26 -0
- package/dist/src/run.js +1157 -0
- package/dist/src/swap-command.d.ts +28 -0
- package/dist/src/swap-command.js +225 -0
- package/package.json +59 -0
package/dist/src/run.js
ADDED
|
@@ -0,0 +1,1157 @@
|
|
|
1
|
+
import { BRAMBO_VERSION, REGISTRY_ENTRY_TYPES, REMEDIATION_KINDS, REMOVABLE_ENTRY_TYPES, diagnose, hasProblem, initMachine, initProject, noExecutorsDetected, remediate, } from '@skanl/brambo-environment';
|
|
2
|
+
import { createLogSink, inspectLocalWorkspaces, inspectWorktrees, readExecutorConfigLayers, readUsageReports, recordUsageObservation, removeLocalWorkspace, removeWorktree, runSession, worktreeStateDir, } from '@skanl/brambo-session';
|
|
3
|
+
import { isRegistryVerb, runExportCommand, runImportCommand, runIngestCommand, runRegistryCommand, verbAt, } from './registry-commands.js';
|
|
4
|
+
import { SWAP_NOUNS, runSwap } from './swap-command.js';
|
|
5
|
+
// The synopsis' type lists are DERIVED, never typed out. They were four literal
|
|
6
|
+
// `<tool|skill|mcp-server|profile>` spellings, and retiring `tool` left every one
|
|
7
|
+
// of them advertising a word the binary refuses — the exact CLI-side table M4.D
|
|
8
|
+
// forbade, hiding in help text. `brambo remove` also takes a retired type, which
|
|
9
|
+
// is why its list is the removable vocabulary and `add`'s is the declared one.
|
|
10
|
+
const ADD_TYPES = `<${REGISTRY_ENTRY_TYPES.join('|')}>`;
|
|
11
|
+
const REMOVE_TYPES = `<${REMOVABLE_ENTRY_TYPES.join('|')}>`;
|
|
12
|
+
export const USAGE = [
|
|
13
|
+
'usage: brambo run [--executor <id>] [--trace] "<prompt>"',
|
|
14
|
+
` brambo add ${ADD_TYPES} <id> [--command <c>] [--entry-path <p>] [--arg <a>]...`,
|
|
15
|
+
` brambo project add ${ADD_TYPES} <id> [directory] [--command <c>] [--entry-path <p>] [--arg <a>]...`,
|
|
16
|
+
` brambo remove ${REMOVE_TYPES} <id>`,
|
|
17
|
+
` brambo project remove ${REMOVE_TYPES} <id> [directory]`,
|
|
18
|
+
' brambo list',
|
|
19
|
+
' brambo project list [directory]',
|
|
20
|
+
' brambo export <path>',
|
|
21
|
+
' brambo import <path>',
|
|
22
|
+
' brambo ingest [--dry-run]',
|
|
23
|
+
' brambo init',
|
|
24
|
+
' brambo project init [directory]',
|
|
25
|
+
' brambo doctor',
|
|
26
|
+
' brambo project doctor [directory]',
|
|
27
|
+
' brambo status',
|
|
28
|
+
' brambo workspace remove [<id>]',
|
|
29
|
+
' brambo remediate <adopt|release|repair|discard> [--executor <id>] [--entry <id>] [--apply]',
|
|
30
|
+
' brambo project remediate <adopt|release|repair|discard> [directory] [--executor <id>] [--entry <id>] [--apply]',
|
|
31
|
+
` brambo swap <${SWAP_NOUNS.join('|')}> <id>`,
|
|
32
|
+
` brambo project swap <${SWAP_NOUNS.join('|')}> <id> [directory]`,
|
|
33
|
+
' brambo --help',
|
|
34
|
+
' brambo --version',
|
|
35
|
+
'',
|
|
36
|
+
'run Runs <prompt> through the selected executor inside a workspace under .brambo/workspaces.',
|
|
37
|
+
' --executor <id> Overrides the configured selection; --executor=<id> also works.',
|
|
38
|
+
' Without it the selection comes from <project>/.brambo/config.json, then',
|
|
39
|
+
' ~/.brambo/config.json, then the built-in default. The selection and the layer',
|
|
40
|
+
' that decided it are reported on stderr.',
|
|
41
|
+
' It overrides a configuration brambo can READ; a document that exists and',
|
|
42
|
+
' cannot be used still fails, because running a different agent than the one',
|
|
43
|
+
' configured is the failure this selection exists to remove.',
|
|
44
|
+
' --trace Writes the action waterfall to stderr as it happens: one line per',
|
|
45
|
+
' intercepted action, with its cost when the pipeline estimated one.',
|
|
46
|
+
' stdout stays the result envelope, so a piped run is unaffected.',
|
|
47
|
+
'add Puts ONE entry in the registry and projects nothing; it names the command that does.',
|
|
48
|
+
' --command <c> The executable an mcp-server runs.',
|
|
49
|
+
' --entry-path <p> A skill entry file, or the directory holding one.',
|
|
50
|
+
' --arg <a> One argument for an mcp-server, repeatable, order preserved.',
|
|
51
|
+
' -- Ends the options, so an id that begins with a dash can still be named.',
|
|
52
|
+
' Which of these a type accepts is the registry contract\'s answer, not this',
|
|
53
|
+
' binding\'s: a field that does not belong on the type is refused coded.',
|
|
54
|
+
'remove Takes ONE entry out of the registry by type and id. An entry that was not there',
|
|
55
|
+
' is said out loud and exits non-zero. It also takes a type brambo has RETIRED, so an',
|
|
56
|
+
' entry written by an older build has an exit through the product rather than by hand.',
|
|
57
|
+
'list Every registered entry with its type, id and the scope it came from. An empty',
|
|
58
|
+
' registry is a result, not a failure, and exits 0.',
|
|
59
|
+
'export Writes the machine registry to <path> as a portable artifact, so an environment can',
|
|
60
|
+
' move to another device. Machine scope only: an agent entry dies with its process and',
|
|
61
|
+
' a project entry names a directory the destination does not have.',
|
|
62
|
+
' An entry carrying anything that looks like a credential is LEFT OUT rather than',
|
|
63
|
+
' redacted, and each one is named in the output with the field that stopped it, so what',
|
|
64
|
+
' did not travel is a task you can see instead of a gap you discover later.',
|
|
65
|
+
'import Installs a bundle from <path> into this machine and re-projects into every detected',
|
|
66
|
+
' executor, so a new device is set up by one command. An entry whose type and id are',
|
|
67
|
+
' already registered here is TAKEN OVER and said out loud; entries the bundle could not',
|
|
68
|
+
' carry are listed as work left for you. A bundle written by a newer brambo is refused',
|
|
69
|
+
' by name, and nothing is written until the whole document has been read and checked.',
|
|
70
|
+
'ingest Puts the skills AND the MCP servers already on this machine into the registry, so it holds',
|
|
71
|
+
' something without one command per entry. It reads only the skills roots and the executor',
|
|
72
|
+
' configs brambo has VERIFIED each executor reads, and never a skill or a server brambo wrote',
|
|
73
|
+
' there itself: re-ingesting its own output would make the registry a copy of its own',
|
|
74
|
+
' projection. Purely ADDITIVE: an entry whose source is gone is left exactly where it is,',
|
|
75
|
+
' and nothing is ever removed. A directory that holds no skill, a server with no command to',
|
|
76
|
+
' run, and a name that cannot be a registry id are each named and skipped, never renamed.',
|
|
77
|
+
' A server carrying more than an mcp-server entry can hold is ingested for its command and',
|
|
78
|
+
' arguments, and the keys that stayed behind are named with the file they stayed in.',
|
|
79
|
+
' --dry-run Report exactly what would be ingested and write nothing. Same call, same answer.',
|
|
80
|
+
"init Prepares this machine and projects the registry into every detected executor's own config.",
|
|
81
|
+
'project init Binds a project and projects into every detected executor that has a project-scope config.',
|
|
82
|
+
'doctor Reports what init would change and every problem brambo can see. Writes nothing.',
|
|
83
|
+
'project doctor The same report for a project, matching what project init would do.',
|
|
84
|
+
'status Reports the usage each executor published the last time brambo ran it: the windows',
|
|
85
|
+
' that executor NAMES, with its own utilisation and reset values, and the instant the',
|
|
86
|
+
' reading was taken. It invokes no executor and writes nothing — a report that spent',
|
|
87
|
+
' the quota it reports on would be unusable on the day you most need it, so the run',
|
|
88
|
+
' that already paid for the reading is the one that records it. An executor that',
|
|
89
|
+
' publishes no usage surface, and one brambo has not run yet, each say so with their',
|
|
90
|
+
' own reason; neither is ever shown as a zero.',
|
|
91
|
+
"swap Writes the selection into brambo's own config so later runs use it, and reports the",
|
|
92
|
+
' layer that actually decides. Writing the machine document while the project one',
|
|
93
|
+
' names something else changes nothing a run will do, and swap says so rather than',
|
|
94
|
+
' reporting a success it did not deliver. A selection brambo cannot honour is refused',
|
|
95
|
+
' before a byte is written.',
|
|
96
|
+
' executor <id> One of the adapter ids; the refusal lists the ids there are.',
|
|
97
|
+
' method <spec> A MODULE SPECIFIER — a relative path or a package name — not an id into a store,',
|
|
98
|
+
' because brambo has no installed-methods list. It is LOADED and validated before',
|
|
99
|
+
' it is written, so a broken one fails while you can still fix it. The next',
|
|
100
|
+
' session mounts it; a method changed on disk takes effect on the next run, never',
|
|
101
|
+
' inside a running one.',
|
|
102
|
+
'workspace remove Takes back a workspace brambo made, in the project it is run in. BOTH shipped',
|
|
103
|
+
' providers write under the same .brambo/workspaces root, so both stores are asked:',
|
|
104
|
+
' the local one, which is what runs when nothing selects otherwise, and the',
|
|
105
|
+
' git-worktree one. Their ids are disjoint, so an <id> routes to whichever store',
|
|
106
|
+
' holds a record for it; with none it FINISHES the removals an interrupted run',
|
|
107
|
+
' left half-done and reports everything else it found, removing none of it.',
|
|
108
|
+
' It removes only what brambo holds an ownership record for: a directory shaped',
|
|
109
|
+
' exactly like one of brambos own, with no record, is reported and never touched.',
|
|
110
|
+
' Every workspace made before brambo kept these records has none, so it is named',
|
|
111
|
+
' and left alone -- guessing ownership from a directory name is the one thing this',
|
|
112
|
+
' verb must never do.',
|
|
113
|
+
' For a worktree it also refuses a tree with modified or untracked files in gits',
|
|
114
|
+
' own words, and refuses a tree whose commit no ref contains -- git removes that',
|
|
115
|
+
' one silently and the work would be reachable from nothing. There is no override:',
|
|
116
|
+
' a user who wants to destroy unreachable work has git for that. A removed id is',
|
|
117
|
+
' retired for good and is never issued to another workspace.',
|
|
118
|
+
'remediate Leaves ONE state doctor reported, named by the user. Describes and writes nothing',
|
|
119
|
+
' unless --apply is given; nothing is ever remediated automatically or in bulk.',
|
|
120
|
+
" adopt Brambo claims what is at its own location, exactly as it is. No vendor byte is written;",
|
|
121
|
+
' `brambo init` then converges it. The exit from a foreign collision and from an edit.',
|
|
122
|
+
' release Brambo stops claiming a location. The file is not read, not written, not looked at.',
|
|
123
|
+
" repair Brambo rewrites its OWN ownership ledger to hold exactly the records it can read.",
|
|
124
|
+
" discard Brambo removes its OWN prior output from a vendor file (correction-01 C6).",
|
|
125
|
+
' --executor <id> / --entry <id> Narrow the finding; required whenever more than one matches.',
|
|
126
|
+
' --apply Perform it. Without this the same call only describes what it would change.',
|
|
127
|
+
'',
|
|
128
|
+
'Exit codes: 0 ok · 1 failed/cancelled · 2 usage/environment error.',
|
|
129
|
+
'For init, a target that failed to project exits 1; detecting no executor at all exits 2.',
|
|
130
|
+
'For doctor, a finding that is a problem exits 1; a clean environment exits 0.',
|
|
131
|
+
'For status, 0 whenever a report could be produced; 2 only when none could be.',
|
|
132
|
+
'For workspace remove, a refusal or an id brambo does not own exits 1; nothing to do exits 0.',
|
|
133
|
+
].join('\n');
|
|
134
|
+
/**
|
|
135
|
+
* The synopsis block: every line up to the first blank one.
|
|
136
|
+
*
|
|
137
|
+
* Derived, not a line COUNT. It was `slice(0, 6)`, and adding two subcommands to
|
|
138
|
+
* the synopsis silently truncated it for six pre-existing usage-error paths —
|
|
139
|
+
* they stopped printing `brambo --help` and advertised `brambo remediate` without
|
|
140
|
+
* `brambo project remediate`. A count is a constant that has to be maintained in
|
|
141
|
+
* a second place every time the block grows; the blank line maintains itself.
|
|
142
|
+
*/
|
|
143
|
+
const DEFAULT_USAGE = USAGE.split('\n').slice(0, USAGE.split('\n').indexOf('')).join('\n');
|
|
144
|
+
/**
|
|
145
|
+
* Process-level signal wiring, which is why it lives in the binary's package and
|
|
146
|
+
* not in the session: a library that registers SIGINT handlers takes them from
|
|
147
|
+
* whatever host embedded it. The session takes the registration as a seam and
|
|
148
|
+
* this is the CLI's answer to it.
|
|
149
|
+
*/
|
|
150
|
+
function defaultInterruptRegistration(handler) {
|
|
151
|
+
process.once('SIGINT', handler);
|
|
152
|
+
process.once('SIGTERM', handler);
|
|
153
|
+
return () => {
|
|
154
|
+
process.off('SIGINT', handler);
|
|
155
|
+
process.off('SIGTERM', handler);
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* The first option-looking token, recognized or not. Matching on a single `-`
|
|
160
|
+
* rather than `--` is the point: `brambo project init -f` used to fall through as
|
|
161
|
+
* a POSITIONAL and create a directory literally named `-f`.
|
|
162
|
+
*/
|
|
163
|
+
function optionToken(tokens) {
|
|
164
|
+
return tokens.find((token) => token.startsWith('-'));
|
|
165
|
+
}
|
|
166
|
+
function isHelp(token) {
|
|
167
|
+
return token === '--help' || token === '-h';
|
|
168
|
+
}
|
|
169
|
+
/** Index of the `--` option terminator, or the length when there is none. */
|
|
170
|
+
function terminatorAt(tokens) {
|
|
171
|
+
const at = tokens.indexOf('--');
|
|
172
|
+
return at === -1 ? tokens.length : at;
|
|
173
|
+
}
|
|
174
|
+
export async function runBrambo(argv, options = {}) {
|
|
175
|
+
const out = options.stdout ?? ((line) => console.log(line));
|
|
176
|
+
const err = options.stderr ?? ((line) => console.error(line));
|
|
177
|
+
if (argv[0] === '--help' || argv[0] === '-h') {
|
|
178
|
+
out(USAGE);
|
|
179
|
+
return 0;
|
|
180
|
+
}
|
|
181
|
+
if (argv[0] === '--version' || argv[0] === '-v') {
|
|
182
|
+
out(BRAMBO_VERSION);
|
|
183
|
+
return 0;
|
|
184
|
+
}
|
|
185
|
+
if (isRegistryVerb(argv[0])) {
|
|
186
|
+
return await runRegistry(argv[0], argv.slice(1), 'machine', out, err, options);
|
|
187
|
+
}
|
|
188
|
+
if (argv[0] === 'export') {
|
|
189
|
+
if (isHelp(argv[1])) {
|
|
190
|
+
out(USAGE);
|
|
191
|
+
return 0;
|
|
192
|
+
}
|
|
193
|
+
return await runExport(argv.slice(1), out, err, options);
|
|
194
|
+
}
|
|
195
|
+
if (argv[0] === 'import') {
|
|
196
|
+
if (isHelp(argv[1])) {
|
|
197
|
+
out(USAGE);
|
|
198
|
+
return 0;
|
|
199
|
+
}
|
|
200
|
+
return await runImport(argv.slice(1), out, err, options);
|
|
201
|
+
}
|
|
202
|
+
if (argv[0] === 'ingest') {
|
|
203
|
+
return await runIngest(argv.slice(1), out, err, options);
|
|
204
|
+
}
|
|
205
|
+
if (argv[0] === 'init') {
|
|
206
|
+
return await runInit(argv.slice(1), out, err, 0, options.homeDir, (homeDir) => initMachine({ homeDir }));
|
|
207
|
+
}
|
|
208
|
+
if (argv[0] === 'doctor') {
|
|
209
|
+
// No directory: the machine scope has one, and it is the home directory.
|
|
210
|
+
return await runDoctor(argv.slice(1), out, err, 0, () => diagnose({ homeDir: options.homeDir, scope: 'machine' }));
|
|
211
|
+
}
|
|
212
|
+
if (argv[0] === 'status') {
|
|
213
|
+
return await runStatus(argv.slice(1), out, err, () => readUsageReports({ homeDir: options.homeDir }));
|
|
214
|
+
}
|
|
215
|
+
if (argv[0] === 'workspace') {
|
|
216
|
+
return await runWorkspace(argv.slice(1), out, err, options);
|
|
217
|
+
}
|
|
218
|
+
if (argv[0] === 'remediate') {
|
|
219
|
+
return await runRemediate(argv.slice(1), out, err, 1, (selector) => remediate({ ...selector, homeDir: options.homeDir, scope: 'machine' }));
|
|
220
|
+
}
|
|
221
|
+
if (argv[0] === 'swap') {
|
|
222
|
+
if (isHelp(argv[1])) {
|
|
223
|
+
out(USAGE);
|
|
224
|
+
return 0;
|
|
225
|
+
}
|
|
226
|
+
return await runSwap(argv.slice(1), 'machine', err, DEFAULT_USAGE, options);
|
|
227
|
+
}
|
|
228
|
+
if (argv[0] === 'project') {
|
|
229
|
+
if (isHelp(argv[1])) {
|
|
230
|
+
out(USAGE);
|
|
231
|
+
return 0;
|
|
232
|
+
}
|
|
233
|
+
if (argv[1] === 'doctor') {
|
|
234
|
+
return await runDoctor(argv.slice(2), out, err, 1, async (directory) => {
|
|
235
|
+
// Worktrees are PROJECT state: `runSession` puts them under the project's
|
|
236
|
+
// own `.brambo/workspaces`, so the machine scope has none to report and
|
|
237
|
+
// this is the only doctor that looks. The list is discovered here and
|
|
238
|
+
// handed in because `@skanl/brambo-environment` may not import a workspace
|
|
239
|
+
// implementation (spec M16.A, D4 and the environment guard test).
|
|
240
|
+
const projectDir = directory ?? options.cwd ?? process.cwd();
|
|
241
|
+
const inspection = await inspectWorktrees(worktreeStateDir(projectDir));
|
|
242
|
+
return await diagnose({
|
|
243
|
+
homeDir: options.homeDir,
|
|
244
|
+
scope: 'project',
|
|
245
|
+
projectDir,
|
|
246
|
+
worktreeLeftovers: inspection.interrupted.map((leftover) => ({
|
|
247
|
+
id: leftover.id,
|
|
248
|
+
path: leftover.path,
|
|
249
|
+
detail: leftover.detail,
|
|
250
|
+
})),
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
if (argv[1] === 'remediate') {
|
|
255
|
+
return await runRemediate(argv.slice(2), out, err, 2, (selector, directory) => remediate({
|
|
256
|
+
...selector,
|
|
257
|
+
homeDir: options.homeDir,
|
|
258
|
+
scope: 'project',
|
|
259
|
+
projectDir: directory ?? options.cwd,
|
|
260
|
+
}));
|
|
261
|
+
}
|
|
262
|
+
if (argv[1] === 'swap') {
|
|
263
|
+
// Handled HERE and not inside `runSwap`, mirroring the machine branch
|
|
264
|
+
// above. The printed-command invariant dispatches every verb path with a
|
|
265
|
+
// help flag and requires exit 0; without this the flag was read as the
|
|
266
|
+
// NOUN and the project scope exited 2. The invariant caught it, which is
|
|
267
|
+
// the second real defect it has found in this story.
|
|
268
|
+
if (isHelp(argv[2])) {
|
|
269
|
+
out(USAGE);
|
|
270
|
+
return 0;
|
|
271
|
+
}
|
|
272
|
+
return await runSwap(argv.slice(2), 'project', err, DEFAULT_USAGE, options);
|
|
273
|
+
}
|
|
274
|
+
if (isRegistryVerb(argv[1])) {
|
|
275
|
+
return await runRegistry(argv[1], argv.slice(2), 'project', out, err, options);
|
|
276
|
+
}
|
|
277
|
+
if (argv[1] !== 'init') {
|
|
278
|
+
err(DEFAULT_USAGE);
|
|
279
|
+
return 2;
|
|
280
|
+
}
|
|
281
|
+
return await runInit(argv.slice(2), out, err, 1, options.homeDir, (homeDir, directory) => initProject({ homeDir, projectDir: directory ?? options.cwd }));
|
|
282
|
+
}
|
|
283
|
+
if (argv[0] !== 'run') {
|
|
284
|
+
err(DEFAULT_USAGE);
|
|
285
|
+
return 2;
|
|
286
|
+
}
|
|
287
|
+
const runTokens = argv.slice(1);
|
|
288
|
+
// The only help in the binary that used to REFUSE: `brambo run --help` exited 2
|
|
289
|
+
// with "unrecognized option", and `brambo run -h` spawned a real, billed agent
|
|
290
|
+
// with the prompt `-h`. `run` is now the one subcommand with a flag, so its
|
|
291
|
+
// own usage block is the natural thing to ask for.
|
|
292
|
+
if (isRunHelp(runTokens)) {
|
|
293
|
+
out(USAGE);
|
|
294
|
+
return 0;
|
|
295
|
+
}
|
|
296
|
+
const parsed = parseRunTokens(runTokens);
|
|
297
|
+
if ('usageError' in parsed) {
|
|
298
|
+
err(parsed.usageError);
|
|
299
|
+
err(DEFAULT_USAGE);
|
|
300
|
+
return 2;
|
|
301
|
+
}
|
|
302
|
+
const { prompt, executorId, trace } = parsed;
|
|
303
|
+
if (prompt.length === 0) {
|
|
304
|
+
err(DEFAULT_USAGE);
|
|
305
|
+
return 2;
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
308
|
+
// The two capability calls, in order, with nothing between them the CLI
|
|
309
|
+
// decided: reading brambo's documents is `@skanl/brambo-session`'s answer, and so is
|
|
310
|
+
// the run. The layers are handed FORWARD rather than resolved here so the
|
|
311
|
+
// documents are read once and the KERNEL's configuration is the one that
|
|
312
|
+
// decides — the CLI holds no kernel and composes nothing (Story M3.B).
|
|
313
|
+
const configLayers = await readExecutorConfigLayers({
|
|
314
|
+
executorId,
|
|
315
|
+
homeDir: options.homeDir,
|
|
316
|
+
projectDir: options.cwd,
|
|
317
|
+
});
|
|
318
|
+
// Only under `--trace`. `state.dropped` counts failures of the write the
|
|
319
|
+
// CALLER supplied, so with no write there is nothing that can fail and an
|
|
320
|
+
// unconditional sink would carry a counter that is structurally zero.
|
|
321
|
+
const log = trace ? createLogSink((record) => err(renderLogRecord(record))) : undefined;
|
|
322
|
+
// What the executor said about its own quota during THIS run (M15.A, D7).
|
|
323
|
+
// Captured rather than written from inside the adapter: the adapter has no
|
|
324
|
+
// business knowing where brambo's home directory is, and the write must not
|
|
325
|
+
// happen until the run is over.
|
|
326
|
+
let observed;
|
|
327
|
+
const envelope = await runSession({
|
|
328
|
+
prompt,
|
|
329
|
+
log,
|
|
330
|
+
configLayers,
|
|
331
|
+
adapterOptions: {
|
|
332
|
+
...options.adapterOptions,
|
|
333
|
+
// CHAINED, not replaced. `adapterOptions` is a caller-supplied seam, and
|
|
334
|
+
// a spread that overwrote one of its callbacks would silently take the
|
|
335
|
+
// reading away from a host that had asked for it — the CLI stealing a
|
|
336
|
+
// hook it does not own.
|
|
337
|
+
onUsageObservation: (report) => {
|
|
338
|
+
observed = report;
|
|
339
|
+
options.adapterOptions?.onUsageObservation?.(report);
|
|
340
|
+
},
|
|
341
|
+
},
|
|
342
|
+
cwd: options.cwd,
|
|
343
|
+
createAdapter: options.createAdapter,
|
|
344
|
+
createProvider: options.createProvider,
|
|
345
|
+
onInterrupt: options.onInterrupt ?? defaultInterruptRegistration,
|
|
346
|
+
// Which agent is about to produce the output, said BEFORE anything is
|
|
347
|
+
// constructed, exactly where the old `resolveExecutor` call said it.
|
|
348
|
+
onSelection: (selection) => reportSelection(selection, options.createAdapter !== undefined, err),
|
|
349
|
+
// A configuration key brambo read and could not use. Reported, never fatal:
|
|
350
|
+
// one forward-looking key in `~/.brambo/config.json` used to fail every run
|
|
351
|
+
// on the machine, and silence would have been the other wrong answer.
|
|
352
|
+
onWarning: (message) => err(message),
|
|
353
|
+
});
|
|
354
|
+
// Before the envelope, and required rather than defensive: `SessionOptions`
|
|
355
|
+
// states the caller owns the sink they pass, DRAINING INCLUDED, so a write
|
|
356
|
+
// still in flight is neither written nor counted until this resolves. It
|
|
357
|
+
// also puts the whole trace on stderr before the result reaches stdout,
|
|
358
|
+
// which is the order a human reads them in.
|
|
359
|
+
if (log !== undefined) {
|
|
360
|
+
await log.drain();
|
|
361
|
+
// A trace the user asked for and did not fully get. Only reachable when
|
|
362
|
+
// stderr itself refused a line (a closed pipe), which is exactly the case
|
|
363
|
+
// where silence would be indistinguishable from a quiet run.
|
|
364
|
+
if (log.state.dropped > 0)
|
|
365
|
+
err(`trace: ${log.state.dropped} record(s) could not be written`);
|
|
366
|
+
}
|
|
367
|
+
// Printed AFTER cleanup now, where the old inline composition printed before
|
|
368
|
+
// it. Deliberate, and the trade is worth naming: output can no longer be
|
|
369
|
+
// interleaved with a half-torn-down workspace, but a HUNG release or dispose
|
|
370
|
+
// withholds the envelope entirely, where before it had already been written.
|
|
371
|
+
// `contained()` catches throws, not hangs — restoring the old order would
|
|
372
|
+
// mean the CLI holding the workspace lifecycle again, which is the whole
|
|
373
|
+
// thing this story removed. A cleanup timeout belongs in the session, and is
|
|
374
|
+
// filed with the other AbortSignal-policy work in deferred-work.md.
|
|
375
|
+
//
|
|
376
|
+
// The run that produced the reading is the one that records it, so `brambo
|
|
377
|
+
// status` never has to spend quota to report on quota (D7). Best-effort by
|
|
378
|
+
// design and said out loud when it fails: a bookkeeping write that could not
|
|
379
|
+
// land must not turn a run that SUCCEEDED into a failed one, and silence
|
|
380
|
+
// would leave `status` reporting a stale reading with nothing to explain it.
|
|
381
|
+
if (observed !== undefined) {
|
|
382
|
+
try {
|
|
383
|
+
await recordUsageObservation(observed, { homeDir: options.homeDir });
|
|
384
|
+
}
|
|
385
|
+
catch (error) {
|
|
386
|
+
err(`the usage reading from this run could not be recorded: ${describe(error)}`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
// Inside the try on purpose: a payload that cannot be serialised is an
|
|
390
|
+
// environment failure (exit 2), not an uncaught throw out of the binary.
|
|
391
|
+
out(JSON.stringify(envelope, null, 2));
|
|
392
|
+
return envelope.status === 'ok' ? 0 : 1;
|
|
393
|
+
}
|
|
394
|
+
catch (error) {
|
|
395
|
+
err(describe(error));
|
|
396
|
+
return 2;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Which agent is about to produce the output, and what decided it — a swap you
|
|
401
|
+
* cannot see is not one you can trust. On stderr, so stdout stays exactly the
|
|
402
|
+
* envelope JSON a caller pipes into a parser, and BEFORE the run so it still
|
|
403
|
+
* reaches the user when the run then fails or hangs.
|
|
404
|
+
*
|
|
405
|
+
* Two cases, because they are different claims:
|
|
406
|
+
* - brambo selected and brambo ran it: report the selection.
|
|
407
|
+
* - a host supplied its own adapter: brambo selected nothing, so an unqualified
|
|
408
|
+
* selection line would be false. Silence is right for an IMPLICIT selection
|
|
409
|
+
* — and wrong for an explicit one, where the user typed `--executor codex`,
|
|
410
|
+
* brambo resolved it, and something else then ran. That gets said out loud.
|
|
411
|
+
*
|
|
412
|
+
* `createAdapter` is an SDK/test seam with no argv spelling, so every actual
|
|
413
|
+
* invocation of the binary takes the first branch.
|
|
414
|
+
*/
|
|
415
|
+
function reportSelection(selection, overridden, err) {
|
|
416
|
+
const line = `executor: ${selection.executorId} (selected by the '${selection.layer}' layer)`;
|
|
417
|
+
if (!overridden) {
|
|
418
|
+
err(line);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
if (selection.layer === 'invocation')
|
|
422
|
+
err(`${line} — overridden by the host-supplied adapter`);
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Help for `brambo run`. `--help` anywhere, because every other `--` token is
|
|
426
|
+
* already a usage error and so cannot be prompt text; `-h` only when it is the
|
|
427
|
+
* WHOLE argument list, because a single dash is legitimate inside a prompt and
|
|
428
|
+
* `brambo run explain -h` must stay a prompt.
|
|
429
|
+
*/
|
|
430
|
+
function isRunHelp(tokens) {
|
|
431
|
+
return tokens.includes('--help') || (tokens.length === 1 && tokens[0] === '-h');
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* `brambo run`'s argv: an optional `--executor <id>` (or `--executor=<id>`) and
|
|
435
|
+
* the prompt words. Every other `--` token stays a usage error, and a SINGLE
|
|
436
|
+
* dash still falls through as prompt text, which is what `brambo run` has always
|
|
437
|
+
* done — a prompt is free text and `-x` is a legitimate part of one.
|
|
438
|
+
*/
|
|
439
|
+
function parseRunTokens(tokens) {
|
|
440
|
+
const EXECUTOR_FLAG = '--executor';
|
|
441
|
+
const TRACE_FLAG = '--trace';
|
|
442
|
+
const words = [];
|
|
443
|
+
let executorId;
|
|
444
|
+
let trace = false;
|
|
445
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
446
|
+
const token = tokens[index];
|
|
447
|
+
if (token === undefined)
|
|
448
|
+
continue;
|
|
449
|
+
// Above the `--` refusal below, and it takes NO value: `--trace=verbose`
|
|
450
|
+
// stays `unrecognized option`, because the thing after the `=` would have to
|
|
451
|
+
// mean something, and the session already narrows the stream to `action.*`
|
|
452
|
+
// — a second filter here would be a filter for one event family.
|
|
453
|
+
if (token === TRACE_FLAG) {
|
|
454
|
+
trace = true;
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
if (token === EXECUTOR_FLAG) {
|
|
458
|
+
const value = tokens[index + 1];
|
|
459
|
+
// A following option is not a value: `brambo run --executor --help` must be
|
|
460
|
+
// a usage error, not a run of an executor named '--help'.
|
|
461
|
+
if (value === undefined || value.length === 0 || value.startsWith('-')) {
|
|
462
|
+
return { usageError: `option '${EXECUTOR_FLAG}' requires an executor id` };
|
|
463
|
+
}
|
|
464
|
+
executorId = value;
|
|
465
|
+
index += 1;
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
if (token.startsWith(`${EXECUTOR_FLAG}=`)) {
|
|
469
|
+
const value = token.slice(EXECUTOR_FLAG.length + 1);
|
|
470
|
+
// The SAME guard as the two-token form: `--executor=-x` reached the
|
|
471
|
+
// catalogue while `--executor -x` was refused, which is two answers to one
|
|
472
|
+
// question.
|
|
473
|
+
if (value.length === 0 || value.startsWith('-')) {
|
|
474
|
+
return { usageError: `option '${EXECUTOR_FLAG}' requires an executor id` };
|
|
475
|
+
}
|
|
476
|
+
executorId = value;
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
if (token.startsWith('--'))
|
|
480
|
+
return { usageError: `unrecognized option '${token}'` };
|
|
481
|
+
words.push(token);
|
|
482
|
+
}
|
|
483
|
+
return { prompt: words.join(' ').trim(), executorId, trace };
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* One record, one line, for a human watching a run happen. Fields in the order
|
|
487
|
+
* the kernel writes them, each present only when the record carries it.
|
|
488
|
+
*
|
|
489
|
+
* It formats and decides nothing — the whole of cordis's `ConsoleExporter` is
|
|
490
|
+
* `console.log(this.render(message))` over a renderer just like this one. `at`
|
|
491
|
+
* is left out on purpose: the record carries a wall clock for whoever PERSISTS
|
|
492
|
+
* the stream, but ordering is `seq`, and a timestamp on a line scrolling past
|
|
493
|
+
* live is noise that carries no order the number does not already carry.
|
|
494
|
+
*/
|
|
495
|
+
export function renderLogRecord(record) {
|
|
496
|
+
const parts = [`[${record.seq}]`, record.event, record.subject];
|
|
497
|
+
if (record.service !== undefined)
|
|
498
|
+
parts.push(`service=${record.service}`);
|
|
499
|
+
if (record.code !== undefined)
|
|
500
|
+
parts.push(record.code);
|
|
501
|
+
if (record.cost !== undefined)
|
|
502
|
+
parts.push(`cost=${record.cost}`);
|
|
503
|
+
return parts.join(' ');
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Argv validation shared by every subcommand that takes at most one directory.
|
|
507
|
+
* Returns the exit code when the tokens were help or a usage error, and
|
|
508
|
+
* `undefined` when they are usable — one rule, so `doctor` cannot drift into
|
|
509
|
+
* accepting an option `init` rejects.
|
|
510
|
+
*/
|
|
511
|
+
function usageOutcome(tokens, maxPositionals, out, err,
|
|
512
|
+
/** What the one positional IS, so the refusal names the thing that was wrong. */
|
|
513
|
+
positional = 'directory') {
|
|
514
|
+
const flagToken = optionToken(tokens);
|
|
515
|
+
if (isHelp(flagToken)) {
|
|
516
|
+
// The usage block advertises these subcommands, so asking it for help on one
|
|
517
|
+
// of them cannot be the one thing it refuses to do.
|
|
518
|
+
out(USAGE);
|
|
519
|
+
return 0;
|
|
520
|
+
}
|
|
521
|
+
if (flagToken !== undefined) {
|
|
522
|
+
err(`unrecognized option '${flagToken}'`);
|
|
523
|
+
err(DEFAULT_USAGE);
|
|
524
|
+
return 2;
|
|
525
|
+
}
|
|
526
|
+
if (tokens.length > maxPositionals) {
|
|
527
|
+
err(maxPositionals === 0
|
|
528
|
+
? `unexpected argument '${tokens[0]}'`
|
|
529
|
+
: `unexpected argument '${tokens[maxPositionals]}'; at most one ${positional} may be given`);
|
|
530
|
+
err(DEFAULT_USAGE);
|
|
531
|
+
return 2;
|
|
532
|
+
}
|
|
533
|
+
return undefined;
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* The whole of what `brambo workspace remove` is: reject bad argv, call the two
|
|
537
|
+
* workspace capabilities in `@skanl/brambo-session`, print what they did, map it to an
|
|
538
|
+
* exit code. Every fact printed is a capability's — the CLI removes nothing,
|
|
539
|
+
* checks nothing and classifies nothing.
|
|
540
|
+
*
|
|
541
|
+
* ONE noun, and it is honest about what it will not do (spec M16.A D6, M27.A).
|
|
542
|
+
* With an id it removes that workspace; with none it finishes the removals an
|
|
543
|
+
* interrupted run left half-done, through the SAME call — a sweep that resolved
|
|
544
|
+
* a leftover some other way would be a second answer to the question the removal
|
|
545
|
+
* already answers. Everything it will not remove is REPORTED with the reason,
|
|
546
|
+
* never skipped in silence and never counted as a success.
|
|
547
|
+
*
|
|
548
|
+
* IT ASKS BOTH STORES, NEVER THE SELECTED PROVIDER (spec M27.A, D4). A project
|
|
549
|
+
* that switched `workspace.provider` has leftovers of both kinds, and a verb
|
|
550
|
+
* that asked only the current selection would strand the other forever. The ids
|
|
551
|
+
* are disjoint by construction — `w-<n>` versus a v4 UUID — so a named id routes
|
|
552
|
+
* unambiguously: whichever store holds a record for it wins, and neither holding
|
|
553
|
+
* one is `unknown`. The local store is asked FIRST because `local` is the
|
|
554
|
+
* default provider, and the worktree store is asked ONLY when the local one
|
|
555
|
+
* disclaims the id, so exactly one destructive call is ever made.
|
|
556
|
+
*/
|
|
557
|
+
async function runWorkspace(tokens, out, err, options) {
|
|
558
|
+
if (isHelp(tokens[0])) {
|
|
559
|
+
out(USAGE);
|
|
560
|
+
return 0;
|
|
561
|
+
}
|
|
562
|
+
if (tokens[0] !== 'remove') {
|
|
563
|
+
err(tokens[0] === undefined
|
|
564
|
+
? 'brambo workspace needs a noun: remove'
|
|
565
|
+
: `brambo workspace has no '${tokens[0]}' noun; it has: remove`);
|
|
566
|
+
err(DEFAULT_USAGE);
|
|
567
|
+
return 2;
|
|
568
|
+
}
|
|
569
|
+
const rest = tokens.slice(1);
|
|
570
|
+
const usage = usageOutcome(rest, 1, out, err, 'workspace id');
|
|
571
|
+
if (usage !== undefined)
|
|
572
|
+
return usage;
|
|
573
|
+
// The project the binary is running in, exactly as `brambo run` reads it: a
|
|
574
|
+
// worktree lives under that project's own state directory, and the path is
|
|
575
|
+
// asked for rather than spelled here so the remover cannot look somewhere
|
|
576
|
+
// other than where the run wrote.
|
|
577
|
+
const stateDir = worktreeStateDir(options.cwd ?? process.cwd());
|
|
578
|
+
try {
|
|
579
|
+
const named = rest[0];
|
|
580
|
+
const outcomes = [];
|
|
581
|
+
let worktrees;
|
|
582
|
+
let workspaces;
|
|
583
|
+
if (named === undefined) {
|
|
584
|
+
worktrees = await inspectWorktrees(stateDir);
|
|
585
|
+
// The other store's own footprint, ASKED FOR rather than spelled here: the
|
|
586
|
+
// two providers share this root, and `trees`/`records` are the worktree
|
|
587
|
+
// store's, not local leftovers. It narrows the report only — a removal is
|
|
588
|
+
// still record-gated in both stores.
|
|
589
|
+
workspaces = await inspectLocalWorkspaces(stateDir, { ignore: worktrees.storeDirectories });
|
|
590
|
+
// Only the worktree store has an interrupted state to finish. A local
|
|
591
|
+
// removal is one `rm -rf` of the directory that holds its own proof, so a
|
|
592
|
+
// process killed mid-removal leaves either the whole workspace or none of
|
|
593
|
+
// it — there is no half-state for a sweep to resolve.
|
|
594
|
+
for (const leftover of worktrees.interrupted) {
|
|
595
|
+
outcomes.push(await removeWorktree(stateDir, leftover.id));
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
else {
|
|
599
|
+
const local = await removeLocalWorkspace(stateDir, named);
|
|
600
|
+
if (local.kind === 'unknown') {
|
|
601
|
+
const worktree = await removeWorktree(stateDir, named);
|
|
602
|
+
// Both stores disclaim it. Report the answer that at least found a
|
|
603
|
+
// directory, so the refusal names the path the user typed at instead of
|
|
604
|
+
// repeating the id back at them.
|
|
605
|
+
outcomes.push(worktree.kind === 'unknown' && local.path !== undefined ? local : worktree);
|
|
606
|
+
}
|
|
607
|
+
else {
|
|
608
|
+
outcomes.push(local);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
out(JSON.stringify({
|
|
612
|
+
stateDir,
|
|
613
|
+
outcomes,
|
|
614
|
+
...(worktrees === undefined || workspaces === undefined
|
|
615
|
+
? {}
|
|
616
|
+
: {
|
|
617
|
+
stores: {
|
|
618
|
+
local: { claimed: workspaces.claimed, unclaimed: workspaces.unclaimed },
|
|
619
|
+
'git-worktree': { claimed: worktrees.claimed, unclaimed: worktrees.unclaimed },
|
|
620
|
+
},
|
|
621
|
+
}),
|
|
622
|
+
}, null, 2));
|
|
623
|
+
for (const outcome of outcomes)
|
|
624
|
+
err(formatOutcome(outcome));
|
|
625
|
+
if (worktrees !== undefined && workspaces !== undefined) {
|
|
626
|
+
// Reported and never removed (D2/E3/E5/E8): what makes a workspace brambo's
|
|
627
|
+
// is the ownership record, so a directory without one is somebody else's
|
|
628
|
+
// however exactly it is shaped like brambo's. The local store says WHY in
|
|
629
|
+
// its own words — a record that is missing and one that is present and
|
|
630
|
+
// unusable are different facts and must not share a sentence.
|
|
631
|
+
for (const directory of workspaces.unclaimed) {
|
|
632
|
+
err(`unclaimed: ${directory.id} (${directory.path}): ${directory.detail}`);
|
|
633
|
+
}
|
|
634
|
+
for (const directory of worktrees.unclaimed) {
|
|
635
|
+
err(`unclaimed: ${directory.id} (${directory.path}): brambo holds no ownership record for this directory, so it is not brambo's to remove and nothing here will remove it`);
|
|
636
|
+
}
|
|
637
|
+
// The healthy claims, said out loud: a sweep that printed nothing about
|
|
638
|
+
// them would look like it had considered and rejected them.
|
|
639
|
+
for (const workspace of workspaces.claimed) {
|
|
640
|
+
err(`claimed: ${workspace.id} (${workspace.path}): brambo claims this workspace and no removal was asked for; name its id to remove it`);
|
|
641
|
+
}
|
|
642
|
+
for (const worktree of worktrees.claimed) {
|
|
643
|
+
err(`claimed: ${worktree.id} (${worktree.path}): brambo claims this worktree and no removal was asked for; name its id to remove it`);
|
|
644
|
+
}
|
|
645
|
+
const found = worktrees.unclaimed.length +
|
|
646
|
+
worktrees.claimed.length +
|
|
647
|
+
workspaces.unclaimed.length +
|
|
648
|
+
workspaces.claimed.length;
|
|
649
|
+
if (outcomes.length === 0 && found === 0) {
|
|
650
|
+
err(`nothing to remove: brambo holds no workspaces under '${stateDir}'`);
|
|
651
|
+
}
|
|
652
|
+
else if (outcomes.length === 0) {
|
|
653
|
+
err('nothing to resolve: no workspace removal was left unfinished in this project');
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
// A refusal is not a success, and neither is an id brambo does not own —
|
|
657
|
+
// reporting either as 0 would tell a script the workspace is gone.
|
|
658
|
+
return outcomes.some((outcome) => outcome.kind === 'refused' || outcome.kind === 'unknown')
|
|
659
|
+
? 1
|
|
660
|
+
: 0;
|
|
661
|
+
}
|
|
662
|
+
catch (error) {
|
|
663
|
+
err(describe(error));
|
|
664
|
+
return 2;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* One outcome on one line, with the coded reason whenever nothing was removed.
|
|
669
|
+
*
|
|
670
|
+
* ONE formatter for both stores, and it needs no branch: `LocalWorkspaceOutcome`
|
|
671
|
+
* is `WorktreeOutcome`'s shape with a narrower `kind` and no `repoPath`, which
|
|
672
|
+
* is what spec M27.A asks the removal pair to match so the printing cannot drift
|
|
673
|
+
* between the two.
|
|
674
|
+
*/
|
|
675
|
+
function formatOutcome(outcome) {
|
|
676
|
+
const about = outcome.path === undefined ? outcome.id : `${outcome.id} (${outcome.path})`;
|
|
677
|
+
return `${outcome.kind}: ${about}: ${outcome.error === undefined ? outcome.detail : describe(outcome.error)}`;
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* The whole of what `brambo doctor` and `brambo project doctor` are: reject bad
|
|
681
|
+
* argv, call the capability in `@skanl/brambo-environment`, print its diagnosis, map
|
|
682
|
+
* findings to an exit code. Every fact printed is the capability's — the CLI
|
|
683
|
+
* classifies nothing, decides nothing about drift, and writes nothing.
|
|
684
|
+
*/
|
|
685
|
+
async function runDoctor(tokens, out, err, maxPositionals, capability) {
|
|
686
|
+
const usage = usageOutcome(tokens, maxPositionals, out, err);
|
|
687
|
+
if (usage !== undefined)
|
|
688
|
+
return usage;
|
|
689
|
+
try {
|
|
690
|
+
const diagnosis = await capability(tokens[0]);
|
|
691
|
+
out(JSON.stringify(diagnosis, null, 2));
|
|
692
|
+
for (const found of diagnosis.findings)
|
|
693
|
+
err(formatFinding(found));
|
|
694
|
+
// The same two facts `brambo init` prints and findings have no room for: an
|
|
695
|
+
// executor with no location for this scope is not a problem, and a path
|
|
696
|
+
// brambo could not CHECK is not evidence that nothing is installed.
|
|
697
|
+
for (const skip of diagnosis.skipped)
|
|
698
|
+
err(`${skip.executorId}: nothing would be projected: ${skip.reason}`);
|
|
699
|
+
const undetermined = undeterminedEvidence(diagnosis.detected);
|
|
700
|
+
if (undetermined !== undefined)
|
|
701
|
+
err(undetermined);
|
|
702
|
+
// Severity, not count. Every target failing still exits 1 rather than 2:
|
|
703
|
+
// doctor DID look — it enumerated the executors, read the ledger and
|
|
704
|
+
// produced a per-target verdict — and 2 is reserved for the cases where no
|
|
705
|
+
// diagnosis exists to print at all (a scope directory it cannot use).
|
|
706
|
+
return hasProblem(diagnosis) ? 1 : 0;
|
|
707
|
+
}
|
|
708
|
+
catch (error) {
|
|
709
|
+
err(describe(error));
|
|
710
|
+
return 2;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* The whole of what `brambo status` is: reject bad argv, call the capability,
|
|
715
|
+
* print what it read, exit 0.
|
|
716
|
+
*
|
|
717
|
+
* It takes no directory and no flag because it has no scope to narrow and no
|
|
718
|
+
* work to authorise — every fact in it is a reading some earlier RUN already
|
|
719
|
+
* paid for, and the capability neither invokes an executor nor opens a store for
|
|
720
|
+
* writing (D6/D7). `brambo status` on a machine that has never run anything is
|
|
721
|
+
* therefore instant, offline, and free.
|
|
722
|
+
*
|
|
723
|
+
* The exit code follows `doctor`'s convention rather than inventing a third: 0
|
|
724
|
+
* whenever a report could be produced — an all-absence report is still a report,
|
|
725
|
+
* and absence here is an answer — and 2 only when none could be. There is no 1,
|
|
726
|
+
* because a utilisation is not a verdict brambo gets to fail on.
|
|
727
|
+
*
|
|
728
|
+
* Said out loud rather than implied: with today's capability there is no input
|
|
729
|
+
* that reaches that 2. Every row is derivable from the shipped catalogue, and a
|
|
730
|
+
* stored reading brambo cannot read is reported as absence rather than raised —
|
|
731
|
+
* MEASURED in `test/status.test.ts`, which drives an unreadable home directory
|
|
732
|
+
* and still gets 0. The `catch` is the same uncaught-throw guard every other
|
|
733
|
+
* binding in this file carries, kept so a future capability that CAN fail
|
|
734
|
+
* reaches the user as an exit code instead of a stack trace.
|
|
735
|
+
*/
|
|
736
|
+
async function runStatus(tokens, out, err, capability) {
|
|
737
|
+
const usage = usageOutcome(tokens, 0, out, err);
|
|
738
|
+
if (usage !== undefined)
|
|
739
|
+
return usage;
|
|
740
|
+
try {
|
|
741
|
+
const reports = await capability();
|
|
742
|
+
out(JSON.stringify(reports, null, 2));
|
|
743
|
+
for (const report of reports)
|
|
744
|
+
err(formatUsageReport(report));
|
|
745
|
+
return 0;
|
|
746
|
+
}
|
|
747
|
+
catch (error) {
|
|
748
|
+
err(describe(error));
|
|
749
|
+
return 2;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* One executor per line, for a human reading stderr while stdout is piped.
|
|
754
|
+
*
|
|
755
|
+
* The vendor's own window NAMES and the vendor's own numbers, printed as they
|
|
756
|
+
* were read (D5): no averaging across windows, no "N% remaining", and no reset
|
|
757
|
+
* instant rendered as a countdown that is already wrong by the time it is on
|
|
758
|
+
* screen. `observedAt` is printed beside them because a utilisation is only
|
|
759
|
+
* true as of its reading, and a report that hides its age lies with a straight
|
|
760
|
+
* face — the reader, not brambo, decides whether an hour-old reading is stale.
|
|
761
|
+
*
|
|
762
|
+
* An absence prints its CODE next to its sentence, for the same reason findings
|
|
763
|
+
* do: the code is what a script routes on (AD-7), the sentence is for the human.
|
|
764
|
+
*/
|
|
765
|
+
function formatUsageReport(report) {
|
|
766
|
+
if (report.kind === 'absent')
|
|
767
|
+
return `${report.executorId}: ${report.reason}: ${report.detail}`;
|
|
768
|
+
const windows = report.windows
|
|
769
|
+
.map((window) => `${window.name} utilization=${window.utilization} resetsAt=${window.resetsAt}`)
|
|
770
|
+
.join(' · ');
|
|
771
|
+
return `${report.executorId}: observed at ${report.observedAt}: ${windows}`;
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* `brambo remediate`'s argv: the verb, and the two narrowing flags.
|
|
775
|
+
*
|
|
776
|
+
* `--apply` is a FLAG rather than the default, and that asymmetry with `brambo
|
|
777
|
+
* init` is the point: a projection converges a machine a user asked brambo to
|
|
778
|
+
* manage, while a remediation changes who owns what. Describing it first is the
|
|
779
|
+
* frozen requirement, so the plain form describes and the flag performs.
|
|
780
|
+
*/
|
|
781
|
+
function parseRemediateTokens(tokens, maxPositionals) {
|
|
782
|
+
let remediation;
|
|
783
|
+
let executorId;
|
|
784
|
+
let entryId;
|
|
785
|
+
let directory;
|
|
786
|
+
let apply = false;
|
|
787
|
+
const named = {
|
|
788
|
+
'--executor': (value) => {
|
|
789
|
+
executorId = value;
|
|
790
|
+
},
|
|
791
|
+
'--entry': (value) => {
|
|
792
|
+
entryId = value;
|
|
793
|
+
},
|
|
794
|
+
};
|
|
795
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
796
|
+
const token = tokens[index];
|
|
797
|
+
if (token === undefined)
|
|
798
|
+
continue;
|
|
799
|
+
if (token === '--apply') {
|
|
800
|
+
apply = true;
|
|
801
|
+
continue;
|
|
802
|
+
}
|
|
803
|
+
const equals = Object.keys(named).find((flag) => token.startsWith(`${flag}=`));
|
|
804
|
+
if (equals !== undefined) {
|
|
805
|
+
const value = token.slice(equals.length + 1);
|
|
806
|
+
// The SAME guard as the two-token form below: `--entry=-x` reaching the
|
|
807
|
+
// selector while `--entry -x` is refused would be two answers to one
|
|
808
|
+
// question, which is the shape `--executor` was already fixed for.
|
|
809
|
+
if (value.length === 0 || value.startsWith('-'))
|
|
810
|
+
return { usageError: `option '${equals}' requires a value` };
|
|
811
|
+
named[equals](value);
|
|
812
|
+
continue;
|
|
813
|
+
}
|
|
814
|
+
if (named[token] !== undefined) {
|
|
815
|
+
const value = tokens[index + 1];
|
|
816
|
+
if (value === undefined || value.length === 0 || value.startsWith('-')) {
|
|
817
|
+
return { usageError: `option '${token}' requires a value` };
|
|
818
|
+
}
|
|
819
|
+
named[token](value);
|
|
820
|
+
index += 1;
|
|
821
|
+
continue;
|
|
822
|
+
}
|
|
823
|
+
if (token.startsWith('-'))
|
|
824
|
+
return { usageError: `unrecognized option '${token}'` };
|
|
825
|
+
if (remediation === undefined) {
|
|
826
|
+
if (!REMEDIATION_KINDS.includes(token)) {
|
|
827
|
+
return { usageError: `unknown remediation '${token}'; brambo has ${REMEDIATION_KINDS.join(', ')}` };
|
|
828
|
+
}
|
|
829
|
+
remediation = token;
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
832
|
+
// The project form takes a directory after the verb, exactly like `brambo
|
|
833
|
+
// project init [directory]` and `brambo project doctor [directory]`; the
|
|
834
|
+
// machine form has one scope and takes none.
|
|
835
|
+
if (maxPositionals === 2 && directory === undefined) {
|
|
836
|
+
directory = token;
|
|
837
|
+
continue;
|
|
838
|
+
}
|
|
839
|
+
return { usageError: `unexpected argument '${token}'` };
|
|
840
|
+
}
|
|
841
|
+
if (remediation === undefined) {
|
|
842
|
+
// `maxPositionals` IS the scope here — the project form takes a directory
|
|
843
|
+
// and the machine form takes none, which the comment above already says.
|
|
844
|
+
// Reading it is what stops this sentence naming a verb that acts on the
|
|
845
|
+
// other registry.
|
|
846
|
+
return {
|
|
847
|
+
usageError: `${verbAt(maxPositionals === 2 ? 'project' : 'machine', 'remediate')} needs a remediation: ${REMEDIATION_KINDS.join(', ')}`,
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
return { remediation, executorId, entryId, directory, apply };
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
853
|
+
* The whole of what `brambo remediate` is: reject bad argv, call the capability,
|
|
854
|
+
* print what it described or did, map the outcome to an exit code. The CLI
|
|
855
|
+
* selects no finding, classifies no state and writes nothing — even the sentence
|
|
856
|
+
* describing a change is the capability's, computed by the code that performs it.
|
|
857
|
+
*/
|
|
858
|
+
async function runRemediate(tokens, out, err, maxPositionals, capability) {
|
|
859
|
+
// `--help` ANYWHERE, like `brambo run`: every other `--` token here is already
|
|
860
|
+
// a usage error, so it cannot be anything else. Matching only the FIRST option
|
|
861
|
+
// token made `brambo remediate adopt --apply --help` a usage error while
|
|
862
|
+
// `--help --apply` printed help, which is two answers to one question.
|
|
863
|
+
if (tokens.some((token) => isHelp(token))) {
|
|
864
|
+
out(USAGE);
|
|
865
|
+
return 0;
|
|
866
|
+
}
|
|
867
|
+
const parsed = parseRemediateTokens(tokens, maxPositionals);
|
|
868
|
+
if ('usageError' in parsed) {
|
|
869
|
+
err(parsed.usageError);
|
|
870
|
+
err(DEFAULT_USAGE);
|
|
871
|
+
return 2;
|
|
872
|
+
}
|
|
873
|
+
try {
|
|
874
|
+
const report = await capability({
|
|
875
|
+
remediation: parsed.remediation,
|
|
876
|
+
...(parsed.executorId === undefined ? {} : { executorId: parsed.executorId }),
|
|
877
|
+
...(parsed.entryId === undefined ? {} : { entryId: parsed.entryId }),
|
|
878
|
+
mode: parsed.apply ? 'apply' : 'inspect',
|
|
879
|
+
}, parsed.directory);
|
|
880
|
+
// The full diagnosis is deliberately NOT printed on stdout here: the payload
|
|
881
|
+
// a caller pipes is the remediation, and `brambo doctor` is the command whose
|
|
882
|
+
// payload is the diagnosis. Named field by field rather than rest-spread, so
|
|
883
|
+
// this payload's key order is authored and pinned instead of inherited.
|
|
884
|
+
out(JSON.stringify({
|
|
885
|
+
scope: report.scope,
|
|
886
|
+
remediation: report.remediation,
|
|
887
|
+
mode: report.mode,
|
|
888
|
+
...(report.finding === undefined ? {} : { finding: report.finding }),
|
|
889
|
+
...(report.outcome === undefined ? {} : { outcome: report.outcome }),
|
|
890
|
+
...(report.refusal === undefined ? {} : { refusal: report.refusal }),
|
|
891
|
+
candidates: report.candidates,
|
|
892
|
+
}, null, 2));
|
|
893
|
+
const refusal = report.refusal ?? report.outcome?.refusal;
|
|
894
|
+
if (refusal !== undefined) {
|
|
895
|
+
err(`${refusal.code}: ${refusal.message}`);
|
|
896
|
+
for (const candidate of report.candidates)
|
|
897
|
+
err(formatFinding(candidate));
|
|
898
|
+
return 1;
|
|
899
|
+
}
|
|
900
|
+
const changes = report.outcome?.changes ?? [];
|
|
901
|
+
if (changes.length === 0) {
|
|
902
|
+
err(`${parsed.remediation}: nothing to change — the state this resolves is already gone`);
|
|
903
|
+
return 0;
|
|
904
|
+
}
|
|
905
|
+
for (const change of changes) {
|
|
906
|
+
err(`${report.mode === 'apply' ? 'changed' : 'would change'}: ${change.subject} ${change.action} ${change.path} (${change.byteDelta} byte(s)): ${change.detail}`);
|
|
907
|
+
}
|
|
908
|
+
if (report.mode !== 'apply')
|
|
909
|
+
err('nothing was written; re-run with --apply to perform it');
|
|
910
|
+
return 0;
|
|
911
|
+
}
|
|
912
|
+
catch (error) {
|
|
913
|
+
err(describe(error));
|
|
914
|
+
return 2;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
/**
|
|
918
|
+
* The registry verbs, bound the same way every other command is: help, then the
|
|
919
|
+
* capability, then an exit code — with the thrown-error case mapped by the same
|
|
920
|
+
* `describe()` the rest of the binding uses, so `BRAMBO_REGISTRY_CONTENTION` and
|
|
921
|
+
* `BRAMBO_REGISTRY_INVALID_ENTRY` reach the user carrying their codes.
|
|
922
|
+
*
|
|
923
|
+
* `--help` ANYWHERE, like `brambo run` and `brambo remediate`: every other `--`
|
|
924
|
+
* token these verbs do not know is already a usage error, so it cannot be
|
|
925
|
+
* anything else — and `brambo add skill x --entry-path ./s.md --help` printing
|
|
926
|
+
* usage while `--help --entry-path ./s.md` refuses would be two answers to one
|
|
927
|
+
* question.
|
|
928
|
+
*/
|
|
929
|
+
/**
|
|
930
|
+
* The import verb: install, then RE-PROJECT, in that order.
|
|
931
|
+
*
|
|
932
|
+
* FR-22 is one sentence with two verbs and the order is not free — projecting
|
|
933
|
+
* before the entries are in place would project the registry the machine had a
|
|
934
|
+
* moment ago. The projection half is `initMachine`, the same capability `init`
|
|
935
|
+
* runs, reported through the same `reportInitOutcome`.
|
|
936
|
+
*
|
|
937
|
+
* One JSON object on stdout, with the projection nested. Two would be two
|
|
938
|
+
* documents for a consumer that reasonably calls JSON.parse on the whole stream.
|
|
939
|
+
*/
|
|
940
|
+
async function runImport(tokens, out, err, options) {
|
|
941
|
+
try {
|
|
942
|
+
const installed = await runImportCommand(tokens, {
|
|
943
|
+
out,
|
|
944
|
+
err,
|
|
945
|
+
defaultUsage: DEFAULT_USAGE,
|
|
946
|
+
homeDir: options.homeDir,
|
|
947
|
+
cwd: options.cwd,
|
|
948
|
+
});
|
|
949
|
+
if (typeof installed === 'number')
|
|
950
|
+
return installed;
|
|
951
|
+
const projection = await initMachine({ homeDir: installed.homeDir });
|
|
952
|
+
out(JSON.stringify({ ...installed, projection }, null, 2));
|
|
953
|
+
// Said on stderr too, because a user who ran a command wants the manual work
|
|
954
|
+
// without parsing JSON for it. An entry that could not travel is absent,
|
|
955
|
+
// named, and theirs to re-add — brambo does not guess at what the secret was.
|
|
956
|
+
for (const entry of installed.pending) {
|
|
957
|
+
err(
|
|
958
|
+
// The `id` arm names NO id, because there the id is the credential —
|
|
959
|
+
// and it still has to say what to do, since the entry is intact in the
|
|
960
|
+
// source machine's registry and re-adding it is hand work.
|
|
961
|
+
entry.field === 'id'
|
|
962
|
+
? `pending: one ${entry.type} was not exported because its own id carried a credential, so brambo cannot name it here; the source machine's registry still holds it and it has to be re-added by hand`
|
|
963
|
+
: `pending: ${entry.type} '${entry.id}' was not exported (its ${entry.field} carried a credential)`);
|
|
964
|
+
}
|
|
965
|
+
for (const entry of installed.replaced) {
|
|
966
|
+
err(`replaced: ${entry.type} '${entry.id}' was already registered here`);
|
|
967
|
+
}
|
|
968
|
+
return reportInitOutcome(projection, err);
|
|
969
|
+
}
|
|
970
|
+
catch (error) {
|
|
971
|
+
err(describe(error));
|
|
972
|
+
return 2;
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
/**
|
|
976
|
+
* The export verb. Its own wrapper rather than a RegistryVerb: it takes no
|
|
977
|
+
* entry, no type and no directory, so the registry grammar has nothing to parse
|
|
978
|
+
* for it, and there is no project-scoped spelling to offer — a project's
|
|
979
|
+
* entries name a directory the destination machine does not have.
|
|
980
|
+
*/
|
|
981
|
+
async function runExport(tokens, out, err, options) {
|
|
982
|
+
try {
|
|
983
|
+
return await runExportCommand(tokens, {
|
|
984
|
+
out,
|
|
985
|
+
err,
|
|
986
|
+
defaultUsage: DEFAULT_USAGE,
|
|
987
|
+
homeDir: options.homeDir,
|
|
988
|
+
cwd: options.cwd,
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
catch (error) {
|
|
992
|
+
err(describe(error));
|
|
993
|
+
return 2;
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
/**
|
|
997
|
+
* `brambo ingest` — the registry filling itself from what is already installed.
|
|
998
|
+
*
|
|
999
|
+
* The same wrapper shape as `runExport`: help, then one capability call, then
|
|
1000
|
+
* the coded failure as exit 2. `--help` ANYWHERE, like `brambo remediate`, so
|
|
1001
|
+
* `brambo ingest --dry-run --help` and `--help --dry-run` cannot be two answers
|
|
1002
|
+
* to one question.
|
|
1003
|
+
*/
|
|
1004
|
+
async function runIngest(tokens, out, err, options) {
|
|
1005
|
+
if (tokens.some((token) => isHelp(token))) {
|
|
1006
|
+
out(USAGE);
|
|
1007
|
+
return 0;
|
|
1008
|
+
}
|
|
1009
|
+
try {
|
|
1010
|
+
return await runIngestCommand(tokens, {
|
|
1011
|
+
out,
|
|
1012
|
+
err,
|
|
1013
|
+
defaultUsage: DEFAULT_USAGE,
|
|
1014
|
+
homeDir: options.homeDir,
|
|
1015
|
+
cwd: options.cwd,
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
catch (error) {
|
|
1019
|
+
err(describe(error));
|
|
1020
|
+
return 2;
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
async function runRegistry(verb, tokens, scope, out, err, options) {
|
|
1024
|
+
// Help ANYWHERE, but only BEFORE the `--` terminator: past it every token is
|
|
1025
|
+
// an id, and an entry may legitimately be called `--help`.
|
|
1026
|
+
if (tokens.slice(0, terminatorAt(tokens)).some((token) => isHelp(token))) {
|
|
1027
|
+
out(USAGE);
|
|
1028
|
+
return 0;
|
|
1029
|
+
}
|
|
1030
|
+
try {
|
|
1031
|
+
return await runRegistryCommand(verb, tokens, scope, {
|
|
1032
|
+
out,
|
|
1033
|
+
err,
|
|
1034
|
+
defaultUsage: DEFAULT_USAGE,
|
|
1035
|
+
homeDir: options.homeDir,
|
|
1036
|
+
cwd: options.cwd,
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
catch (error) {
|
|
1040
|
+
err(describe(error));
|
|
1041
|
+
return 2;
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* The paths brambo could not check, as one line, or nothing when there are none.
|
|
1046
|
+
* Shared with `brambo init` because "nothing is installed" and "brambo could not
|
|
1047
|
+
* look" are different claims in both commands, and only one of them is ever true.
|
|
1048
|
+
*/
|
|
1049
|
+
function undeterminedEvidence(detected) {
|
|
1050
|
+
const undetermined = detected
|
|
1051
|
+
.flatMap((detection) => detection.evidence)
|
|
1052
|
+
.filter((item) => item.exists === undefined);
|
|
1053
|
+
if (undetermined.length === 0)
|
|
1054
|
+
return undefined;
|
|
1055
|
+
const paths = undetermined.map((item) => `${item.path} (${item.error ?? 'unknown error'})`).join(', ');
|
|
1056
|
+
// One line, deliberately: a printed string that WRAPS is invisible to the
|
|
1057
|
+
// printed-command invariant, which cannot scan across a newline.
|
|
1058
|
+
return `brambo could not determine whether these exist, so this is not evidence that nothing is installed: ${paths}`;
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* One finding on one line, naming everything it is about. A finding a user
|
|
1062
|
+
* cannot act on is not one, so the executor, the file, the native location and
|
|
1063
|
+
* the entry are printed whenever the capability supplied them — and the
|
|
1064
|
+
* resolution, which is what re-projecting would do about it.
|
|
1065
|
+
*/
|
|
1066
|
+
function formatFinding(found) {
|
|
1067
|
+
const about = [found.executorId, found.filePath, found.location, found.entryId]
|
|
1068
|
+
.filter((part) => part !== undefined)
|
|
1069
|
+
.join(' · ');
|
|
1070
|
+
// The severity is printed, not only acted on: a reader who sees a line on
|
|
1071
|
+
// stderr and an exit code of 0 has to be able to see why the two agree.
|
|
1072
|
+
return `${found.severity}: ${found.kind}${about === '' ? '' : ` (${about})`}: ${found.detail} — ${found.resolution}`;
|
|
1073
|
+
}
|
|
1074
|
+
/**
|
|
1075
|
+
* The whole of what `brambo init` and `brambo project init` are: reject bad argv,
|
|
1076
|
+
* call the capability in `@skanl/brambo-environment`, print its result, map it to an
|
|
1077
|
+
* exit code. Every fact printed is produced by the capability — the CLI adds no
|
|
1078
|
+
* detection, no projection and no interpretation of its own.
|
|
1079
|
+
*/
|
|
1080
|
+
async function runInit(tokens, out, err, maxPositionals, homeDir, capability) {
|
|
1081
|
+
const usage = usageOutcome(tokens, maxPositionals, out, err);
|
|
1082
|
+
if (usage !== undefined)
|
|
1083
|
+
return usage;
|
|
1084
|
+
try {
|
|
1085
|
+
const result = await capability(homeDir, tokens[0]);
|
|
1086
|
+
out(JSON.stringify(result, null, 2));
|
|
1087
|
+
return reportInitOutcome(result, err);
|
|
1088
|
+
}
|
|
1089
|
+
catch (error) {
|
|
1090
|
+
err(describe(error));
|
|
1091
|
+
return 2;
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
/**
|
|
1095
|
+
* The stderr and the exit code an `InitResult` implies, SHARED by `init` and by
|
|
1096
|
+
* `import`.
|
|
1097
|
+
*
|
|
1098
|
+
* Import re-projects (FR-22), so it produces the same result object from the
|
|
1099
|
+
* same capability — and a second copy of this mapping is how two commands come
|
|
1100
|
+
* to disagree about one outcome. A script branching on `brambo import` must not
|
|
1101
|
+
* have to learn a second meaning for exit 1.
|
|
1102
|
+
*/
|
|
1103
|
+
function reportInitOutcome(result, err) {
|
|
1104
|
+
reportDiagnostics(result, err);
|
|
1105
|
+
if (noExecutorsDetected(result)) {
|
|
1106
|
+
// The JSON already lists every executor and every path consulted; these
|
|
1107
|
+
// lines are the same facts for a human reading stderr — including the paths
|
|
1108
|
+
// brambo could NOT check, because "nothing is installed" and "brambo could not
|
|
1109
|
+
// look" are different claims and only one is true here.
|
|
1110
|
+
const evidence = result.detected.flatMap((detection) => detection.evidence);
|
|
1111
|
+
err(`no executor configuration was found under any of: ${evidence
|
|
1112
|
+
.filter((item) => item.exists === false)
|
|
1113
|
+
.map((item) => item.path)
|
|
1114
|
+
.join(', ')}`);
|
|
1115
|
+
const undetermined = undeterminedEvidence(result.detected);
|
|
1116
|
+
if (undetermined !== undefined)
|
|
1117
|
+
err(undetermined);
|
|
1118
|
+
return 2;
|
|
1119
|
+
}
|
|
1120
|
+
const failed = [...result.targets, ...result.skills].filter((target) => target.error !== undefined);
|
|
1121
|
+
for (const target of failed)
|
|
1122
|
+
err(`${target.executorId}: ${target.error?.code}: ${target.error?.message}`);
|
|
1123
|
+
return failed.length > 0 ? 1 : 0;
|
|
1124
|
+
}
|
|
1125
|
+
/**
|
|
1126
|
+
* Drift, unprojectable entries, executors with no location for this scope, and
|
|
1127
|
+
* the ledger's own warnings all leave the exit code at 0, because none of them
|
|
1128
|
+
* is a failed run. Printed anyway, because a run where every entry drifted — or
|
|
1129
|
+
* where brambo LOST its ownership records, which is what a ledger warning says —
|
|
1130
|
+
* is otherwise indistinguishable from success in a script that only reads the
|
|
1131
|
+
* exit code and stderr.
|
|
1132
|
+
*/
|
|
1133
|
+
function reportDiagnostics(result, err) {
|
|
1134
|
+
for (const warning of result.warnings)
|
|
1135
|
+
err(`${warning.code}: ${warning.detail}`);
|
|
1136
|
+
for (const target of [...result.targets, ...result.skills]) {
|
|
1137
|
+
for (const entry of target.drift) {
|
|
1138
|
+
err(`${target.executorId}: drift (${entry.kind}) at ${entry.location}: ${entry.detail}`);
|
|
1139
|
+
}
|
|
1140
|
+
for (const entry of target.unprojectable) {
|
|
1141
|
+
err(`${target.executorId}: '${entry.entryId}' was not projected: ${entry.reason}`);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
for (const skip of result.skipped)
|
|
1145
|
+
err(`${skip.executorId}: nothing was projected: ${skip.reason}`);
|
|
1146
|
+
}
|
|
1147
|
+
function describe(error) {
|
|
1148
|
+
// Duck-typed on `code` rather than `instanceof BramboError`: AD-1 forbids the
|
|
1149
|
+
// kernel from importing the contracts package, so `BramboKernelError` is a
|
|
1150
|
+
// DISJOINT hierarchy — an instanceof check against either one silently drops
|
|
1151
|
+
// the other's code, and a budget refusal is precisely the case whose code the
|
|
1152
|
+
// user needs. It also leaves this package importing nothing but the two
|
|
1153
|
+
// consumer-tier capability packages.
|
|
1154
|
+
const code = error?.code;
|
|
1155
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1156
|
+
return typeof code === 'string' && code.length > 0 ? `${code}: ${message}` : message;
|
|
1157
|
+
}
|