@skanl/brambo-environment 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 +29 -0
- package/dist/doctor.d.ts +261 -0
- package/dist/doctor.js +551 -0
- package/dist/executors.d.ts +88 -0
- package/dist/executors.js +109 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +60 -0
- package/dist/ingest.d.ts +60 -0
- package/dist/ingest.js +85 -0
- package/dist/init.d.ts +325 -0
- package/dist/init.js +641 -0
- package/dist/remediate.d.ts +51 -0
- package/dist/remediate.js +140 -0
- package/package.json +56 -0
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
import { access, constants, stat } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import { BRAMBO_ERROR_CODES, REGISTRY_ENTRY_TYPES } from '@skanl/brambo-contracts';
|
|
5
|
+
import { noExecutorsDetected, projectCommandFor, runScope, scopeDirectory } from './init.js';
|
|
6
|
+
/**
|
|
7
|
+
* What `brambo init` WOULD do about each kind — and only ever what brambo can
|
|
8
|
+
* itself perform. A `Record` over the closed kind union, so a kind added without
|
|
9
|
+
* an answer here does not compile: "each finding carries what brambo would do
|
|
10
|
+
* about it" is a type error away from being false, rather than a promise.
|
|
11
|
+
*/
|
|
12
|
+
export const RESOLUTION = {
|
|
13
|
+
edited: "brambo never overwrites an entry that changed since it wrote it; projecting again leaves your edit exactly as it is",
|
|
14
|
+
'removed-by-user': 'brambo never re-adds an entry you deleted; projecting again leaves it absent',
|
|
15
|
+
'foreign-collision': 'brambo never resolves a collision with content its ledger does not claim; projecting again leaves it untouched',
|
|
16
|
+
'not-initialised': "`brambo init` (or `brambo project init`) creates brambo's state here; doctor creates nothing",
|
|
17
|
+
'no-executor': 'brambo projects into configurations that already exist and creates none, so `brambo init` would write nothing here and exits 2',
|
|
18
|
+
'registry-unreadable': 'brambo never replaces a registry document it cannot read; `brambo init` fails on it and projects nothing, so no entry is deleted from any vendor file',
|
|
19
|
+
'registry-version-ahead': 'the document is not damaged: this build is simply older than the one that wrote it, and brambo refuses a format it does not speak rather than reading half of it. `brambo init` fails on it and projects nothing, so no entry is deleted from any vendor file and not one byte of the document is changed',
|
|
20
|
+
'retired-type': 'brambo reads and lists the entry but hands it to no target, so projecting again neither writes nor removes anything for it; brambo never deletes a registry entry by itself, because removing one is a decision and a decision is yours',
|
|
21
|
+
'ledger-damaged': 'brambo leaves the ledger exactly as it is and claims nothing it cannot read; until it is readable again brambo reports its own entries as foreign and touches none of them',
|
|
22
|
+
'projection-warning': 'brambo surfaced this from the projection run and resolves none of it by itself; `brambo init` runs through the same condition',
|
|
23
|
+
'out-of-date': 'projecting makes this location match the registry — for a skills root that can mean REMOVING a tree brambo wrote, not only writing one. Brambo checked the location is writable, which is weaker than a guarantee: an ACL, a mount option or another process holding it can still refuse the write, and on Windows that check sees only the read-only attribute',
|
|
24
|
+
'not-writable': 'brambo cannot write here, so projecting fails on this location and changes nothing rather than half-applying it',
|
|
25
|
+
unprojectable: 'no target can express this entry, so projecting again changes nothing for it; it stays out of this configuration',
|
|
26
|
+
'legacy-block': "`brambo remediate discard --executor <id>` removes exactly this block and leaves every other byte of the file alone; projecting again neither reads nor removes it. Where the detail says brambo will NOT take it, that is the reason, and brambo leaves the file untouched",
|
|
27
|
+
'target-failed': 'projecting again fails the same way for this executor and leaves its file untouched; the other executors are unaffected',
|
|
28
|
+
'worktree-leftover': 'projecting neither reads nor touches a worktree, so `brambo init` would do nothing about this; brambo never sweeps a leftover on its own, because removing a checkout on a run nobody asked to be destructive is exactly what a startup sweep would be',
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* The exit for every state brambo reports — TOTAL over {@link DiagnosisFindingKind},
|
|
32
|
+
* so a finding kind added without one does not compile.
|
|
33
|
+
*
|
|
34
|
+
* IT LIVES IN DOCTOR, beside the kinds, and not beside `remediate` — because the
|
|
35
|
+
* first version put it beside the capability, nothing outside the tests consumed
|
|
36
|
+
* it, and `brambo doctor` went on printing *"brambo never overwrites an entry that
|
|
37
|
+
* changed since it wrote it; projecting again leaves your edit exactly as it is"*
|
|
38
|
+
* for four of the five states this story gave an exit to. The trap was closed in
|
|
39
|
+
* the code and left open on the only surface a user reads. Every `resolution`
|
|
40
|
+
* below is now composed from this record, so the product cannot know an exit the
|
|
41
|
+
* report does not print.
|
|
42
|
+
*/
|
|
43
|
+
export const FINDING_EXITS = {
|
|
44
|
+
edited: {
|
|
45
|
+
by: 'remediation',
|
|
46
|
+
remediations: ['adopt', 'release'],
|
|
47
|
+
detail: "`adopt` takes ownership of what is there now, after which `brambo init` replaces it with the registry's version — that is how you get brambo's version back, and it is a REPLACEMENT of your edit. `release` drops the claim and leaves your edit alone permanently",
|
|
48
|
+
},
|
|
49
|
+
'removed-by-user': {
|
|
50
|
+
by: 'remediation',
|
|
51
|
+
remediations: ['release'],
|
|
52
|
+
detail: '`release` drops the claim, which makes the location free again, so the next `brambo init` writes the entry back. To keep it absent instead, the entry has to leave the registry, which is `brambo remove <type> <id>` (`brambo project remove <type> <id>` for a project-scope entry)',
|
|
53
|
+
},
|
|
54
|
+
'foreign-collision': {
|
|
55
|
+
by: 'remediation',
|
|
56
|
+
remediations: ['adopt', 'release'],
|
|
57
|
+
detail: "`adopt` takes ownership of what occupies brambo's location, exactly as it is now — including brambo's OWN tree left unclaimed by a crash, and a tree that is only PARTLY there, which it claims as the subset that exists. It claims what is THERE and nothing else, so where the location holds nothing brambo can identify there is nothing to claim and `adopt` refuses rather than writing an empty claim. `release` is the exit where the collision comes from a claim brambo holds and cannot use. Where the detail says the VENDOR's document is ambiguous — a location declared twice, a container brambo cannot address — neither verb applies until that is fixed in the file itself, and brambo's own ledger is not involved",
|
|
58
|
+
},
|
|
59
|
+
'ledger-damaged': {
|
|
60
|
+
by: 'remediation',
|
|
61
|
+
remediations: ['repair'],
|
|
62
|
+
detail: "`repair` rewrites brambo's own ledger to hold exactly the records it can read. It describes what it will drop before it drops it, and it touches no vendor file",
|
|
63
|
+
},
|
|
64
|
+
'legacy-block': {
|
|
65
|
+
by: 'remediation',
|
|
66
|
+
remediations: ['discard'],
|
|
67
|
+
detail: "`discard` removes exactly the block a previous brambo build wrote and nothing else. Where the detail says brambo will NOT take it — markers it cannot bound, a key it cannot attribute — that is the reason, brambo leaves the file untouched, and the block has to be removed by hand",
|
|
68
|
+
},
|
|
69
|
+
'not-initialised': {
|
|
70
|
+
by: 'command',
|
|
71
|
+
command: 'brambo init',
|
|
72
|
+
// ONLY WHAT THE OTHER HALF DID NOT SAY. `RESOLUTION['not-initialised']`
|
|
73
|
+
// already names the command and says doctor creates nothing; this said the
|
|
74
|
+
// same sentence again, and the user read both in one line.
|
|
75
|
+
detail: "it writes brambo's own state directory and registry document, and nothing into any executor's configuration",
|
|
76
|
+
},
|
|
77
|
+
'out-of-date': {
|
|
78
|
+
by: 'command',
|
|
79
|
+
command: 'brambo init',
|
|
80
|
+
// THE SAME RULE `not-initialised` CARRIES ABOVE, applied to the sibling that
|
|
81
|
+
// missed it: only what the other half did not say. This used to end "that is
|
|
82
|
+
// `brambo init`", which the caller's scope override cannot reach — the
|
|
83
|
+
// override rewrites the "To leave this state" half and the detail is
|
|
84
|
+
// concatenated raw. Driven at project scope, one resolution said BOTH
|
|
85
|
+
// `brambo project init` and `brambo init`, and a user reading the tail runs
|
|
86
|
+
// the machine command for a project finding.
|
|
87
|
+
//
|
|
88
|
+
// It names no command now, because the half in front of it already prints
|
|
89
|
+
// the right one. What it adds instead is the thing `RESOLUTION` does not
|
|
90
|
+
// say: that this verdict cost the user nothing.
|
|
91
|
+
detail: 'doctor reached it by running the same merge projecting would and writing none of it, so the location is exactly as you left it',
|
|
92
|
+
},
|
|
93
|
+
'no-executor': {
|
|
94
|
+
by: 'outside-brambo',
|
|
95
|
+
// The premise -- that brambo projects into configurations and creates none --
|
|
96
|
+
// belongs to `RESOLUTION` and was restated here for eleven words. This half
|
|
97
|
+
// carries the ACTION, which is the only part the other one cannot give.
|
|
98
|
+
detail: 'Run one of them at least once so a configuration exists to project into; nothing in brambo has to be fixed first',
|
|
99
|
+
},
|
|
100
|
+
'registry-unreadable': {
|
|
101
|
+
by: 'outside-brambo',
|
|
102
|
+
// The refusal and its reason are `RESOLUTION`'s sentence; repeating them
|
|
103
|
+
// here cost eight words before the part a user acts on.
|
|
104
|
+
detail: "Repair or remove that document. Brambo's ownership ledger is a different file and is not involved, so nothing it already claims is at risk while you do",
|
|
105
|
+
},
|
|
106
|
+
// The ONE action, and it is the opposite of the sibling's above. The premise —
|
|
107
|
+
// the document is intact and brambo refuses it whole — is `RESOLUTION`'s
|
|
108
|
+
// sentence; this half carries only what the user does about it, which is the
|
|
109
|
+
// part that other one cannot give.
|
|
110
|
+
'registry-version-ahead': {
|
|
111
|
+
by: 'outside-brambo',
|
|
112
|
+
detail: 'Install a brambo at least as new as the build that wrote it; the detail above names both versions. The document itself needs nothing done to it, and deleting it or editing it back into a shape this older build accepts is how the entries in it get lost',
|
|
113
|
+
},
|
|
114
|
+
'not-writable': {
|
|
115
|
+
by: 'outside-brambo',
|
|
116
|
+
detail: 'brambo cannot grant itself permission; the location has to become writable',
|
|
117
|
+
},
|
|
118
|
+
// A COMMAND, and it has to be: retiring a word from the registry vocabulary
|
|
119
|
+
// while the entries written under it stay unreadable-or-unremovable is the
|
|
120
|
+
// dead end M4.C exists to abolish, reached this time by upgrading. `brambo
|
|
121
|
+
// remove` therefore accepts a retired type even though `brambo add` refuses
|
|
122
|
+
// one, and the finding's own detail names the exact spelling for this entry.
|
|
123
|
+
'retired-type': {
|
|
124
|
+
by: 'command',
|
|
125
|
+
command: 'brambo remove <type> <id>',
|
|
126
|
+
detail: 'the entry is not damaged and the document is not corrupt — brambo simply no longer declares that word. `brambo remove` still accepts a retired type even though `brambo add` refuses one, which is how an entry written by an older build leaves without hand-editing the document',
|
|
127
|
+
},
|
|
128
|
+
// Reclassified OUT of `outside-brambo` by story M4.D, which is the SAFE
|
|
129
|
+
// direction: the M4.C ledger flagged reclassification INTO `outside-brambo` as
|
|
130
|
+
// the move that weakens the totality proof, because it lets a hard state be
|
|
131
|
+
// answered with a plausible sentence. This goes the other way — the sentence
|
|
132
|
+
// is replaced by a command the binary dispatches.
|
|
133
|
+
unprojectable: {
|
|
134
|
+
by: 'command',
|
|
135
|
+
command: 'brambo remove <type> <id>',
|
|
136
|
+
detail: 'this is informational and is never counted as a problem, so nothing has to be done about it. Nothing makes the entry PROJECTABLE — no target can express it — and what `brambo remove <type> <id>` changes is that it stops being reported, because the entry has left the registry. Use `brambo project remove <type> <id>` for an entry registered at a project scope',
|
|
137
|
+
},
|
|
138
|
+
'target-failed': {
|
|
139
|
+
by: 'outside-brambo',
|
|
140
|
+
detail: 'the coded error on the finding names the cause; brambo leaves this executor untouched until it is addressed, and the others are unaffected',
|
|
141
|
+
},
|
|
142
|
+
'projection-warning': {
|
|
143
|
+
by: 'outside-brambo',
|
|
144
|
+
detail: 'a condition the projection run surfaced with no more specific reading than its own code; brambo resolves none of it by itself',
|
|
145
|
+
},
|
|
146
|
+
// A COMMAND, and the same shape `retired-type` reached: the state is fully
|
|
147
|
+
// resolvable and the thing that resolves it is a verb the binary dispatches.
|
|
148
|
+
// Naming the removal here is also what keeps the report and the capability one
|
|
149
|
+
// answer -- the verb re-runs the identical removal the interrupted one was
|
|
150
|
+
// performing, so a leftover cannot be resolved by a second code path that
|
|
151
|
+
// reasons differently from the first.
|
|
152
|
+
'worktree-leftover': {
|
|
153
|
+
by: 'command',
|
|
154
|
+
command: 'brambo workspace remove <id>',
|
|
155
|
+
detail: 'the removal is finished by running it again: the same checks, the same refusals, and the same retirement the interrupted one was performing. It removes only what brambo holds a record for, and it still refuses a tree with modified or untracked files or one whose commit no ref contains -- resuming an interrupted removal is not a licence to skip the checks. Run it with no id to resolve every leftover in the project at once',
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
/** Every kind this remediation is the named exit for. Derived, never listed. */
|
|
159
|
+
export function findingKindsFor(remediation) {
|
|
160
|
+
return Object.keys(FINDING_EXITS).filter((kind) => {
|
|
161
|
+
const exit = FINDING_EXITS[kind];
|
|
162
|
+
return exit.by === 'remediation' && exit.remediations.includes(remediation);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* The exit, as the sentence a user reads. Composed from {@link FINDING_EXITS} so
|
|
167
|
+
* the command the product prints and the command the capability accepts are one
|
|
168
|
+
* string, and a remediation renamed upstream renames itself here.
|
|
169
|
+
*/
|
|
170
|
+
function exitSentence(kind,
|
|
171
|
+
/**
|
|
172
|
+
* WHICH STATE THE EXIT HAS TO LEAVE. `FINDING_EXITS` is a
|
|
173
|
+
* `Record<DiagnosisFindingKind, FindingExit>` with ONE command per kind and no
|
|
174
|
+
* scope axis, so every exit was rendered in the machine grammar. Driven at
|
|
175
|
+
* project scope: `brambo project doctor` reported an `edited` finding and named
|
|
176
|
+
* `brambo remediate adopt`, which exits 1 with
|
|
177
|
+
* `BRAMBO_PROJECTION_REMEDIATION_REFUSED` — brambo never remediates a state it
|
|
178
|
+
* did not just report, and the machine scope reported none. The command that
|
|
179
|
+
* works was never printed.
|
|
180
|
+
*
|
|
181
|
+
* The verb is spelled twice rather than interpolated, because that is what
|
|
182
|
+
* makes the printed-command invariant see a real verb in each — the same shape
|
|
183
|
+
* `retired-type` arrived at 300 lines below, after it printed
|
|
184
|
+
* `brambo project remove` for a global entry.
|
|
185
|
+
*/
|
|
186
|
+
scope, command) {
|
|
187
|
+
const exit = FINDING_EXITS[kind];
|
|
188
|
+
if (exit.by === 'remediation') {
|
|
189
|
+
return `To LEAVE this state, name it: ${exit.remediations
|
|
190
|
+
.map((remediation) => scope === 'machine' ? `\`brambo remediate ${remediation}\`` : `\`brambo project remediate ${remediation}\``)
|
|
191
|
+
.join(' or ')}. ${exit.detail}`;
|
|
192
|
+
}
|
|
193
|
+
// `command` is the SPELLING for this one finding, where the caller holds the
|
|
194
|
+
// concrete values. `FINDING_EXITS` can only declare the shape of the exit --
|
|
195
|
+
// `brambo remove <type> <id>` -- and printing a placeholder at a finding that
|
|
196
|
+
// already knows the type and the id makes the user translate a command brambo
|
|
197
|
+
// could have written out. `unprojectable` still prints the template, because
|
|
198
|
+
// its rows carry an entry id and no type; `retired-type` carries both.
|
|
199
|
+
if (exit.by === 'command')
|
|
200
|
+
return `To leave this state: \`${command ?? exit.command}\`. ${exit.detail}`;
|
|
201
|
+
return `Brambo cannot leave this state itself. ${exit.detail}`;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Which findings the exit code answers for. Total over the same union, for the
|
|
205
|
+
* same reason: a new kind has to be classified deliberately, not inherit
|
|
206
|
+
* "problem" from a default nobody chose.
|
|
207
|
+
*/
|
|
208
|
+
const SEVERITY = {
|
|
209
|
+
edited: 'problem',
|
|
210
|
+
'removed-by-user': 'problem',
|
|
211
|
+
'foreign-collision': 'problem',
|
|
212
|
+
'not-initialised': 'problem',
|
|
213
|
+
'no-executor': 'problem',
|
|
214
|
+
'registry-unreadable': 'problem',
|
|
215
|
+
// A PROBLEM, and the `unprojectable` test is what earns it: the light CAN be
|
|
216
|
+
// got back to green, by installing the build the document was already written
|
|
217
|
+
// for. It also names a condition OF THIS MACHINE — an older brambo in front of
|
|
218
|
+
// a newer document — rather than a standing architectural fact, so it fires on
|
|
219
|
+
// approximately no runs instead of on every one (spec M4.A's test, passed).
|
|
220
|
+
'registry-version-ahead': 'problem',
|
|
221
|
+
// A PROBLEM rather than info, and the exit code is again the reason. The test
|
|
222
|
+
// is whether a user can get the light back to green: `unprojectable` is info
|
|
223
|
+
// because no target can express the entry and deleting one the user
|
|
224
|
+
// deliberately registered is not a fix. Here one command clears it
|
|
225
|
+
// for good, and the entry is one no current build can create — leaving it
|
|
226
|
+
// silent would hide the single visible consequence of an upgrade.
|
|
227
|
+
'retired-type': 'problem',
|
|
228
|
+
'ledger-damaged': 'problem',
|
|
229
|
+
'projection-warning': 'problem',
|
|
230
|
+
'out-of-date': 'problem',
|
|
231
|
+
'not-writable': 'problem',
|
|
232
|
+
// The one INFO kind, and the exit code is the whole reason: no target can
|
|
233
|
+
// express the entry — a `skill` reaching a config target, a `skill` at project
|
|
234
|
+
// scope, an `mcp-server` with no command — so the only way exit 1 here could
|
|
235
|
+
// be got back to 0 is DELETING an entry the user deliberately registered,
|
|
236
|
+
// which is not a fix. Reported in full, never counted as diagnosed. (Story
|
|
237
|
+
// M4.D gave the kind a real exit, `brambo remove`; that changes how it is LEFT,
|
|
238
|
+
// not whether having it is wrong.)
|
|
239
|
+
unprojectable: 'info',
|
|
240
|
+
'target-failed': 'problem',
|
|
241
|
+
// A PROBLEM, and the Codex case is why: a `# BEGIN brambo-managed` block puts
|
|
242
|
+
// foreign sub-keys inside `[tools]` and `[skills]`, so a documented
|
|
243
|
+
// `--strict-config` run fails to load the user's ENTIRE config.toml. It is
|
|
244
|
+
// also fully resolvable, which is what earns a non-zero exit — the exit code
|
|
245
|
+
// is a promise that the light can be got back to green.
|
|
246
|
+
'legacy-block': 'problem',
|
|
247
|
+
// A PROBLEM: a half-removed worktree is a real state of brambo's own store,
|
|
248
|
+
// and one command clears it for good. The `unprojectable` test applies and
|
|
249
|
+
// passes — the light CAN be got back to green — so silence here would hide
|
|
250
|
+
// the one visible consequence of a run that was killed.
|
|
251
|
+
'worktree-leftover': 'problem',
|
|
252
|
+
};
|
|
253
|
+
/**
|
|
254
|
+
* Every kind, derived from a record TypeScript proves total. Exported for the
|
|
255
|
+
* tests that partition the kinds by what a finding of that kind must name — a
|
|
256
|
+
* hand-written list there would fall behind the union silently.
|
|
257
|
+
*/
|
|
258
|
+
export const DIAGNOSIS_FINDING_KINDS = Object.keys(RESOLUTION);
|
|
259
|
+
/**
|
|
260
|
+
* How a projection warning is read. Keyed on the warning's own CODE rather than
|
|
261
|
+
* assumed: the engine seeds warnings from the ledger today, and a second source
|
|
262
|
+
* added upstream would otherwise ship silently wearing the ledger's resolution
|
|
263
|
+
* text. An unmapped code says exactly that instead.
|
|
264
|
+
*/
|
|
265
|
+
const WARNING_KIND = {
|
|
266
|
+
[BRAMBO_ERROR_CODES.projectionLedgerUnavailable]: 'ledger-damaged',
|
|
267
|
+
};
|
|
268
|
+
/**
|
|
269
|
+
* How a failed registry read is read, keyed on the store's own CODE — never on
|
|
270
|
+
* its message text (AD-7). Every code the store can raise that is NOT listed
|
|
271
|
+
* here is a document brambo could not read, which is what the fallback says.
|
|
272
|
+
*/
|
|
273
|
+
const REGISTRY_ERROR_KIND = {
|
|
274
|
+
[BRAMBO_ERROR_CODES.registryStoreVersionMismatch]: 'registry-version-ahead',
|
|
275
|
+
};
|
|
276
|
+
/** True when at least one finding is something wrong — the non-zero condition. */
|
|
277
|
+
export function hasProblem(diagnosis) {
|
|
278
|
+
return diagnosis.findings.some((found) => found.severity === 'problem');
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* `finding`, bound to the scope being diagnosed.
|
|
282
|
+
*
|
|
283
|
+
* A CLOSURE rather than a parameter on all fourteen call sites, and that is the
|
|
284
|
+
* point: every one of them is inside `findingsFor`, which holds
|
|
285
|
+
* `diagnosis.scope`, so binding it once makes a scope-blind exit impossible to
|
|
286
|
+
* write here rather than possible-but-discouraged.
|
|
287
|
+
*/
|
|
288
|
+
function findingIn(scope) {
|
|
289
|
+
return (kind, detail, about = {}, command) => buildFinding(scope, kind, detail, about, command);
|
|
290
|
+
}
|
|
291
|
+
function buildFinding(scope, kind, detail, about = {},
|
|
292
|
+
/** The concrete spelling of a `by: 'command'` exit; see {@link exitSentence}. */
|
|
293
|
+
command) {
|
|
294
|
+
// Two halves, always: what PROJECTING again would do (which for five kinds is
|
|
295
|
+
// "nothing, forever"), and how to LEAVE the state. Shipping only the first is
|
|
296
|
+
// what made this command tell users four remediable states were terminal.
|
|
297
|
+
return {
|
|
298
|
+
kind,
|
|
299
|
+
severity: SEVERITY[kind],
|
|
300
|
+
...about,
|
|
301
|
+
detail,
|
|
302
|
+
resolution: `${RESOLUTION[kind]} — ${exitSentence(kind, scope, command)}`,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
async function isFile(path) {
|
|
306
|
+
return await stat(path).then((stats) => stats.isFile(), () => false);
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Whether `access(W_OK)` is granted at `path` or, when nothing is there yet, at
|
|
310
|
+
* its nearest EXISTING ancestor — three-valued, because "brambo could not
|
|
311
|
+
* determine" must not be reported as "brambo cannot write".
|
|
312
|
+
*/
|
|
313
|
+
async function permitsWrite(path) {
|
|
314
|
+
let candidate = path;
|
|
315
|
+
for (;;) {
|
|
316
|
+
try {
|
|
317
|
+
await access(candidate, constants.W_OK);
|
|
318
|
+
return true;
|
|
319
|
+
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
const code = error?.code;
|
|
322
|
+
// EPERM is the Windows spelling for a read-only file; EACCES the POSIX one.
|
|
323
|
+
if (code === 'EACCES' || code === 'EPERM' || code === 'EROFS')
|
|
324
|
+
return false;
|
|
325
|
+
if (code !== 'ENOENT')
|
|
326
|
+
return undefined;
|
|
327
|
+
const parent = dirname(candidate);
|
|
328
|
+
if (parent === candidate)
|
|
329
|
+
return undefined;
|
|
330
|
+
candidate = parent;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Whether brambo could write at `path` — modelling the write brambo ACTUALLY
|
|
336
|
+
* performs, which is not `open(path, 'w')`. Every byte brambo lands goes through
|
|
337
|
+
* one atomic writer: a temp file created in the target's own directory, then
|
|
338
|
+
* renamed over the target.
|
|
339
|
+
*
|
|
340
|
+
* So the permission that decides the outcome is the DIRECTORY's, on both
|
|
341
|
+
* platforms — the temp file has to be created there, and rename() consults the
|
|
342
|
+
* containing directory, never the mode of the name it replaces. A 0444 target
|
|
343
|
+
* whose directory is writable is replaced without complaint on POSIX. Windows
|
|
344
|
+
* is the exception, and only Windows: rename over a file carrying the read-only
|
|
345
|
+
* attribute fails EPERM there, so that one check is guarded by the platform
|
|
346
|
+
* rather than applied to both. Both halves measured by execution (see the
|
|
347
|
+
* differential rows in `test/doctor.test.ts`).
|
|
348
|
+
*
|
|
349
|
+
* ponytail: `access(W_OK)` is advisory, not a guarantee — on Windows it sees the
|
|
350
|
+
* read-only attribute and not ACLs, and nothing survives another process taking
|
|
351
|
+
* the directory between the check and the write. That is why a positive answer
|
|
352
|
+
* only lets the `out-of-date` resolution say brambo CHECKED, never that it will
|
|
353
|
+
* succeed. Upgrade path: none worth having; a trial write is exactly the thing
|
|
354
|
+
* this command may not do.
|
|
355
|
+
*
|
|
356
|
+
* ponytail: a SYMLINKED target is probed at the link's own directory, not at the
|
|
357
|
+
* directory of the file `realpath` resolves to, which is where the writer lands
|
|
358
|
+
* it. Ceiling accepted deliberately: `realpath` is not one of the four fs verbs
|
|
359
|
+
* this package is allowed to import (`test/guard.test.ts`), and the answer is
|
|
360
|
+
* advisory either way. Upgrade path: the engine reports the resolved write
|
|
361
|
+
* target alongside the row, and this probes that.
|
|
362
|
+
*/
|
|
363
|
+
async function writableLocation(path) {
|
|
364
|
+
if (process.platform === 'win32' && (await isFile(path))) {
|
|
365
|
+
const target = await permitsWrite(path);
|
|
366
|
+
if (target !== true)
|
|
367
|
+
return target;
|
|
368
|
+
}
|
|
369
|
+
return await permitsWrite(dirname(path));
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Everything wrong with one scope, in the order a reader needs it: brambo's own
|
|
373
|
+
* state first (a machine with nothing initialised explains every other row),
|
|
374
|
+
* then per target in catalogue order.
|
|
375
|
+
*/
|
|
376
|
+
async function findingsFor(diagnosis, registryError, retired, worktreeLeftovers) {
|
|
377
|
+
// Bound once, so no exit below can be rendered in the wrong grammar.
|
|
378
|
+
const finding = findingIn(diagnosis.scope);
|
|
379
|
+
const findings = [];
|
|
380
|
+
if (registryError !== undefined) {
|
|
381
|
+
findings.push(finding(REGISTRY_ERROR_KIND[registryError.code] ?? 'registry-unreadable', `${registryError.code}: ${registryError.message}`, { filePath: diagnosis.registryPath }));
|
|
382
|
+
}
|
|
383
|
+
else if (!(await isFile(diagnosis.registryPath))) {
|
|
384
|
+
// The REGISTRY DOCUMENT is the initialised signal, not brambo's directory:
|
|
385
|
+
// the ledger creates `<home>/.brambo` on its own first write, so one
|
|
386
|
+
// `brambo project init` anywhere would otherwise make the machine scope read
|
|
387
|
+
// as initialised forever — on the ordinary path, not an exotic one.
|
|
388
|
+
findings.push(finding('not-initialised', `brambo has no registry document at '${diagnosis.registryPath}'`, { filePath: diagnosis.registryPath },
|
|
389
|
+
// The override this parameter was built for. `FINDING_EXITS` holds
|
|
390
|
+
// `brambo init`, and at project scope that command exits 0 and leaves the
|
|
391
|
+
// project uninitialised — driven, then asserted by running what doctor
|
|
392
|
+
// printed and asking doctor again.
|
|
393
|
+
projectCommandFor(diagnosis.scope)));
|
|
394
|
+
}
|
|
395
|
+
for (const row of retired) {
|
|
396
|
+
// Both halves come from the ROW, never from the scope being diagnosed: the
|
|
397
|
+
// verb is the grammar that reaches the document the entry is actually in,
|
|
398
|
+
// and `filePath` is that document. `brambo project doctor` reads the global
|
|
399
|
+
// registry too, so deriving either from `diagnosis.scope` printed
|
|
400
|
+
// `brambo project remove <id>` for a global entry -- a command that exits 1,
|
|
401
|
+
// against a project document that does not hold it. The two spellings are
|
|
402
|
+
// separate literals so the printed-command invariant sees a real verb in
|
|
403
|
+
// each, and the concrete command goes to the EXIT sentence rather than into
|
|
404
|
+
// this detail, so the rendered line states the fact once and the command once.
|
|
405
|
+
const { entry } = row;
|
|
406
|
+
const removeCommand = row.scope === 'global'
|
|
407
|
+
? `brambo remove ${entry.type} ${entry.id}`
|
|
408
|
+
: `brambo project remove ${entry.type} ${entry.id}`;
|
|
409
|
+
findings.push(finding('retired-type', `'${entry.id}' is a '${entry.type}' entry in the ${row.scope} registry, and '${entry.type}' is a type brambo no longer declares (it has ${REGISTRY_ENTRY_TYPES.join(', ')}); no target will ever take it`, { filePath: row.registryPath, entryId: entry.id }, removeCommand));
|
|
410
|
+
}
|
|
411
|
+
for (const leftover of worktreeLeftovers) {
|
|
412
|
+
// The command is SPELLED OUT with this leftover's own id rather than left as
|
|
413
|
+
// the `<id>` template the exit declares, for the reason `retired-type`'s
|
|
414
|
+
// block gives: a finding that already knows the id makes the user translate
|
|
415
|
+
// a command brambo could have written out.
|
|
416
|
+
findings.push(finding('worktree-leftover', leftover.detail, { filePath: leftover.path }, `brambo workspace remove ${leftover.id}`));
|
|
417
|
+
}
|
|
418
|
+
if (noExecutorsDetected(diagnosis)) {
|
|
419
|
+
// `brambo init` exits 2 on exactly this state, so a doctor that called it
|
|
420
|
+
// clean would certify an environment the very next command refuses.
|
|
421
|
+
findings.push(finding('no-executor', `no configuration was found for any executor brambo knows (${diagnosis.detected.map((detection) => detection.executorId).join(', ')})`));
|
|
422
|
+
}
|
|
423
|
+
for (const block of diagnosis.legacy) {
|
|
424
|
+
findings.push(finding('legacy-block', block.detail, {
|
|
425
|
+
executorId: block.executorId,
|
|
426
|
+
filePath: block.filePath,
|
|
427
|
+
}));
|
|
428
|
+
}
|
|
429
|
+
for (const warning of diagnosis.warnings) {
|
|
430
|
+
findings.push(finding(WARNING_KIND[warning.code] ?? 'projection-warning', `${warning.code}: ${warning.detail}`, {
|
|
431
|
+
...(WARNING_KIND[warning.code] === 'ledger-damaged' ? { filePath: diagnosis.ledgerPath } : {}),
|
|
432
|
+
}));
|
|
433
|
+
}
|
|
434
|
+
// Brambo's own ledger is written for EVERY target a run produces a result for,
|
|
435
|
+
// changed or not, so an unwritable ledger fails a run that would otherwise be
|
|
436
|
+
// a no-op — and inspection cannot discover that by failing, because the write
|
|
437
|
+
// it would fail on is the one this mode skips.
|
|
438
|
+
if (diagnosis.targets.length + diagnosis.skills.length > 0 &&
|
|
439
|
+
(await writableLocation(diagnosis.ledgerPath)) === false) {
|
|
440
|
+
findings.push(finding('not-writable', `brambo's own ownership ledger '${diagnosis.ledgerPath}' is not writable, which fails every target of a run, not only the ones that would change`, { filePath: diagnosis.ledgerPath }));
|
|
441
|
+
}
|
|
442
|
+
// Config files first, then skills roots — the same catalogue order both
|
|
443
|
+
// arrays already carry, so one executor's two surfaces read together.
|
|
444
|
+
for (const target of [...diagnosis.targets, ...diagnosis.skills]) {
|
|
445
|
+
const tree = diagnosis.skills.includes(target);
|
|
446
|
+
const at = { executorId: target.executorId, filePath: target.filePath };
|
|
447
|
+
if (target.error !== undefined) {
|
|
448
|
+
findings.push(finding('target-failed', `${target.error.code}: ${target.error.message}`, at));
|
|
449
|
+
}
|
|
450
|
+
if (target.wouldWrite) {
|
|
451
|
+
// Reported as one or the other, never both: `out-of-date` promises a write
|
|
452
|
+
// brambo would perform, and at a location brambo cannot write that promise
|
|
453
|
+
// is false forever — which is the one thing this command may not say.
|
|
454
|
+
//
|
|
455
|
+
// A skills ROOT is probed as itself rather than through its parent: the
|
|
456
|
+
// writer creates the root when it is absent, so the nearest existing
|
|
457
|
+
// ancestor is what decides, and `permitsWrite` walks up to find it. A
|
|
458
|
+
// config file is probed through its DIRECTORY, because that is where the
|
|
459
|
+
// temp-file-then-rename actually lands.
|
|
460
|
+
const writable = tree ? await permitsWrite(target.filePath) : await writableLocation(target.filePath);
|
|
461
|
+
// ABSENT IS NOT DIFFERENT, and saying so is AD-5 applied to this command's
|
|
462
|
+
// own sentence. Driven before this: a project that had never been
|
|
463
|
+
// projected reported "the bytes in '<path>' differ from what projecting
|
|
464
|
+
// would produce" — a byte comparison brambo did not perform, about a file
|
|
465
|
+
// with no bytes — and the CONTROL, a file that really was there and really
|
|
466
|
+
// differed, produced a byte-identical sentence. Two states, one report,
|
|
467
|
+
// and the one brambo invented is the commoner of the two.
|
|
468
|
+
//
|
|
469
|
+
// `ProjectionResult` carries `written`/`byteDelta` and no presence, and
|
|
470
|
+
// threading one through would change a published contract for a sentence.
|
|
471
|
+
// A `stat` here instead: this branch already probes the filesystem for
|
|
472
|
+
// writability, so the answer costs one more syscall on a path it is
|
|
473
|
+
// holding anyway.
|
|
474
|
+
const present = await stat(target.filePath).then(() => true, () => false);
|
|
475
|
+
findings.push(writable === false
|
|
476
|
+
? finding('not-writable', `brambo would rewrite '${target.filePath}' and the location is not writable`, at)
|
|
477
|
+
: finding('out-of-date', present
|
|
478
|
+
? tree
|
|
479
|
+
? `the skills brambo materialises under '${target.filePath}' differ from what projecting would produce`
|
|
480
|
+
: `the bytes in '${target.filePath}' differ from what projecting would produce`
|
|
481
|
+
: tree
|
|
482
|
+
? `'${target.filePath}' does not exist yet, so nothing of what projecting would materialise is there`
|
|
483
|
+
: `'${target.filePath}' does not exist yet, so nothing of what projecting would write is there`, at,
|
|
484
|
+
// Same override, same reason as `not-initialised`: this exit is
|
|
485
|
+
// "project again", and at project scope `brambo init` is not that.
|
|
486
|
+
projectCommandFor(diagnosis.scope)));
|
|
487
|
+
}
|
|
488
|
+
for (const entry of target.drift) {
|
|
489
|
+
findings.push(finding(entry.kind, entry.detail, { ...at, location: entry.location, entryId: entry.entryId }));
|
|
490
|
+
}
|
|
491
|
+
for (const entry of target.unprojectable) {
|
|
492
|
+
findings.push(finding('unprojectable', entry.reason, { ...at, entryId: entry.entryId }));
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
return findings;
|
|
496
|
+
}
|
|
497
|
+
function toDiagnosisTarget(row) {
|
|
498
|
+
return {
|
|
499
|
+
executorId: row.executorId,
|
|
500
|
+
targetId: row.targetId,
|
|
501
|
+
filePath: row.filePath,
|
|
502
|
+
wouldWrite: row.changed,
|
|
503
|
+
drift: row.drift,
|
|
504
|
+
unprojectable: row.unprojectable,
|
|
505
|
+
...(row.error === undefined ? {} : { error: row.error }),
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Diagnoses one scope and writes nothing at all.
|
|
510
|
+
*
|
|
511
|
+
* A clean environment yields no findings; anything wrong yields at least one
|
|
512
|
+
* with `severity: 'problem'`, which is what lets a script branch on it.
|
|
513
|
+
* Reporting stops at the scope the caller named: doctor never goes looking for
|
|
514
|
+
* other projects brambo has bound.
|
|
515
|
+
*/
|
|
516
|
+
export async function diagnose(options = {}) {
|
|
517
|
+
// Every field read ONCE, here, before the first await — the same TOCTOU rule
|
|
518
|
+
// `initMachine` follows, and it bites harder here: an accessor that answered
|
|
519
|
+
// with a temp directory now and the real home directory later would have the
|
|
520
|
+
// real one diagnosed under a promise that nothing would be touched.
|
|
521
|
+
const { homeDir = homedir(), projectDir, scope = 'machine', worktreeLeftovers = [] } = options;
|
|
522
|
+
const home = await scopeDirectory('the home directory', homeDir);
|
|
523
|
+
// Resolved and validated only when it is the scope being diagnosed. `brambo
|
|
524
|
+
// doctor` must not fail on a working directory it was never asked about — and
|
|
525
|
+
// `process.cwd()` THROWS when the process's directory has been deleted, which
|
|
526
|
+
// is exactly the kind of machine this command gets run on.
|
|
527
|
+
const root = scope === 'machine'
|
|
528
|
+
? home
|
|
529
|
+
: await scopeDirectory('the project directory', projectDir ?? process.cwd());
|
|
530
|
+
// No log sink: nothing is invoked, so nothing is recorded as invoked.
|
|
531
|
+
const report = await runScope(scope, home, root, undefined, 'inspect');
|
|
532
|
+
const body = {
|
|
533
|
+
scope,
|
|
534
|
+
bramboDir: report.bramboDir,
|
|
535
|
+
registryPath: report.registryPath,
|
|
536
|
+
ledgerPath: report.ledgerPath,
|
|
537
|
+
entryCount: report.entryCount,
|
|
538
|
+
detected: report.detected,
|
|
539
|
+
// Named field by field rather than spread, so this payload's key order is
|
|
540
|
+
// authored and pinned instead of inherited from a rest object.
|
|
541
|
+
targets: report.targets.map(toDiagnosisTarget),
|
|
542
|
+
skills: report.skills.map(toDiagnosisTarget),
|
|
543
|
+
legacy: report.legacy,
|
|
544
|
+
skipped: report.skipped,
|
|
545
|
+
warnings: report.warnings,
|
|
546
|
+
};
|
|
547
|
+
return {
|
|
548
|
+
...body,
|
|
549
|
+
findings: await findingsFor(body, report.registryError, report.retired, worktreeLeftovers),
|
|
550
|
+
};
|
|
551
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { ProjectionConfigTarget, ProjectionMaterialiseTarget } from '@skanl/brambo-contracts';
|
|
2
|
+
import type { FileFormat, NativeMcpRead } from '@skanl/brambo-projection';
|
|
3
|
+
/**
|
|
4
|
+
* One filesystem location consulted for an executor, and what was found.
|
|
5
|
+
*
|
|
6
|
+
* `exists` is deliberately THREE-valued. Collapsing "brambo could not look" into
|
|
7
|
+
* "absent" makes the no-executor exit tell a user that nothing is installed
|
|
8
|
+
* when the truth is that a permission error, a dangling link or an ELOOP stopped
|
|
9
|
+
* the check — and it fails in the direction that hides a config brambo would
|
|
10
|
+
* otherwise have written to.
|
|
11
|
+
*/
|
|
12
|
+
export interface EvidencePath {
|
|
13
|
+
readonly path: string;
|
|
14
|
+
/** true present · false definitively absent · undefined could not determine. */
|
|
15
|
+
readonly exists: boolean | undefined;
|
|
16
|
+
/** errno behind an `undefined` verdict; absent otherwise. */
|
|
17
|
+
readonly error?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface ExecutorDetection {
|
|
20
|
+
readonly executorId: string;
|
|
21
|
+
/** The projection target this executor is served by, present or not. */
|
|
22
|
+
readonly targetId: string;
|
|
23
|
+
/** True only where an evidence path was OBSERVED to exist. Nothing else sets it. */
|
|
24
|
+
readonly present: boolean;
|
|
25
|
+
readonly evidence: readonly EvidencePath[];
|
|
26
|
+
}
|
|
27
|
+
export interface ExecutorProfile {
|
|
28
|
+
readonly executorId: string;
|
|
29
|
+
readonly targetId: string;
|
|
30
|
+
/** Consulted in order; any hit makes the executor present. */
|
|
31
|
+
readonly evidencePaths: (homeDir: string) => readonly string[];
|
|
32
|
+
readonly machineConfig: (homeDir: string) => string;
|
|
33
|
+
/** Absent when the vendor has no verified project-scope configuration. */
|
|
34
|
+
readonly projectConfig: ((projectDir: string) => string) | undefined;
|
|
35
|
+
readonly createTarget: (filePath: string) => ProjectionConfigTarget;
|
|
36
|
+
/**
|
|
37
|
+
* The READ direction of the same file `createTarget` writes into (M11.A D2).
|
|
38
|
+
*
|
|
39
|
+
* A closure over this executor's own trait record, exactly like `createTarget`
|
|
40
|
+
* above, rather than the trait record itself: `brambo ingest` needs the entries
|
|
41
|
+
* a vendor's document declares and nothing else about the format, and the
|
|
42
|
+
* document's format, container key and entry shape all stay where D1 put them.
|
|
43
|
+
* `undefined` from it means the file is absent, which is not an error (AD-5).
|
|
44
|
+
*/
|
|
45
|
+
readonly readMcpEntries: (filePath: string) => Promise<NativeMcpRead | undefined>;
|
|
46
|
+
/**
|
|
47
|
+
* The skills root brambo has VERIFIED this executor reads, or `undefined`.
|
|
48
|
+
*
|
|
49
|
+
* Undefined is the honest answer, not a gap: an executor whose skills
|
|
50
|
+
* location brambo has not proven by running the real binary reports its skills
|
|
51
|
+
* unprojectable, exactly as before this story. There is no project-scope
|
|
52
|
+
* entry here at all for the same reason — materialising into a project scope
|
|
53
|
+
* is Ask-First in this story's Boundaries, so `brambo project init` reports
|
|
54
|
+
* skills unprojectable rather than inventing a second location.
|
|
55
|
+
*/
|
|
56
|
+
readonly machineSkills: ((homeDir: string) => string) | undefined;
|
|
57
|
+
readonly skillsTargetId: string | undefined;
|
|
58
|
+
readonly createSkillsTarget: ((rootPath: string) => ProjectionMaterialiseTarget) | undefined;
|
|
59
|
+
/**
|
|
60
|
+
* Where a PREVIOUS brambo build wrote brambo's own vocabulary, and in which
|
|
61
|
+
* format — correction-01 C6.
|
|
62
|
+
*
|
|
63
|
+
* These are not locations brambo writes; they are locations brambo has to be
|
|
64
|
+
* able to CLEAN. Stories 2.2 and 2.3 put a reserved `$.brambo` key into the
|
|
65
|
+
* JSON family and a `# BEGIN brambo-managed` block into Codex's TOML, none of
|
|
66
|
+
* which any executor reads and the Codex one of which stops the user's whole
|
|
67
|
+
* `config.toml` from loading under `--strict-config`. A machine that ran one
|
|
68
|
+
* of those builds still has the litter, and the corrected build cannot produce
|
|
69
|
+
* it — so it is reported and removed rather than merged around.
|
|
70
|
+
*
|
|
71
|
+
* MACHINE SCOPE ONLY, and that is a measurement rather than an omission: the
|
|
72
|
+
* builds that wrote these had no project scope at all (`brambo project init`
|
|
73
|
+
* arrives in Story 2.7a, after correction-01), so there is no project-scope
|
|
74
|
+
* location that can hold one.
|
|
75
|
+
*/
|
|
76
|
+
readonly legacyConfig: ((homeDir: string) => {
|
|
77
|
+
filePath: string;
|
|
78
|
+
fileFormat: FileFormat;
|
|
79
|
+
}) | undefined;
|
|
80
|
+
}
|
|
81
|
+
export declare const EXECUTOR_PROFILES: readonly ExecutorProfile[];
|
|
82
|
+
/**
|
|
83
|
+
* Every executor brambo knows about, whether it was found, and the exact paths
|
|
84
|
+
* consulted for each. Returns the FULL catalogue on purpose: a run that detects
|
|
85
|
+
* nothing has to be able to tell the user what was looked for and where, and a
|
|
86
|
+
* list that omitted the misses could not.
|
|
87
|
+
*/
|
|
88
|
+
export declare function detectExecutors(homeDir: string): Promise<ExecutorDetection[]>;
|