@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/init.js
ADDED
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
import { mkdir, stat } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { BRAMBO_ERROR_CODES, BramboError, isRetiredEntryType, projectionTargetLocation, } from '@skanl/brambo-contracts';
|
|
5
|
+
import { createMemoryLogSink } from '@skanl/brambo-kernel';
|
|
6
|
+
import { ProjectionLedger, groupByKind, runProjection, runRemediation } from '@skanl/brambo-projection';
|
|
7
|
+
import { RegistryStore } from '@skanl/brambo-registry';
|
|
8
|
+
import { EXECUTOR_PROFILES, detectExecutors } from './executors.js';
|
|
9
|
+
// The first composed path brambo has: registry -> projection -> a real
|
|
10
|
+
// executor's real configuration file. Everything here is COMPOSITION. The
|
|
11
|
+
// projection engine, its targets and its ownership ledger are Story 2.8's and
|
|
12
|
+
// are used exactly as they ship: this package decides WHICH targets run and
|
|
13
|
+
// reports what happened, and it is the ledger — never this file — that decides
|
|
14
|
+
// what brambo is allowed to modify.
|
|
15
|
+
//
|
|
16
|
+
// Nothing in this package writes into a vendor's file. The only filesystem write
|
|
17
|
+
// it performs itself is `mkdir` of brambo's OWN directory; `test/guard.test.ts`
|
|
18
|
+
// pins that by asserting the whole of `src/` reaches the filesystem module for
|
|
19
|
+
// nothing beyond `mkdir` and `stat`.
|
|
20
|
+
//
|
|
21
|
+
// On the kernel: a projection write is not an executor action, so AD-10's
|
|
22
|
+
// interception pipeline is not forced in here. What IS taken from the kernel is
|
|
23
|
+
// the Story 1.6 record sink (NFR-4), because "what did brambo write into whose
|
|
24
|
+
// configuration" is the kind of thing that has to be reconstructable afterwards.
|
|
25
|
+
// The record shape is closed and has no free-form slot, so the sink carries THAT
|
|
26
|
+
// each target was projected and whether it succeeded, and the durable ledger
|
|
27
|
+
// carries what was written and where. Both halves are needed and neither is a
|
|
28
|
+
// substitute for the other.
|
|
29
|
+
//
|
|
30
|
+
// ponytail: one record per TARGET attempt, not per entry. The closed record
|
|
31
|
+
// shape has room for an event and a bounded subject and nothing else, so
|
|
32
|
+
// per-entry granularity has nowhere to go without a kernel record-shape change
|
|
33
|
+
// (LOG_RECORD_VERSION exists to carry one). Recorded in deferred-work.md.
|
|
34
|
+
/**
|
|
35
|
+
* Subject PREFIX every projection record is written under. The subject is
|
|
36
|
+
* `${PROJECTION_ACTION_ID}#${targetId}` — bounded by brambo's own constants, so
|
|
37
|
+
* it can never be rejected by the sink's identifier rules the way a file path
|
|
38
|
+
* (unbounded length, arbitrary characters) could be.
|
|
39
|
+
*
|
|
40
|
+
* Exported because a reader of the record stream needs the same string brambo
|
|
41
|
+
* wrote; match with `subject.startsWith(PROJECTION_ACTION_ID + '#')`.
|
|
42
|
+
*/
|
|
43
|
+
export const PROJECTION_ACTION_ID = 'environment.projection';
|
|
44
|
+
/**
|
|
45
|
+
* True when no executor was found — the caller's non-zero-exit condition.
|
|
46
|
+
*
|
|
47
|
+
* Takes the DETECTION, not an `InitResult`: `brambo doctor` has to answer the
|
|
48
|
+
* same question about the same evidence, and two spellings of "did brambo find
|
|
49
|
+
* anything" is how the two commands come to disagree about one machine.
|
|
50
|
+
*/
|
|
51
|
+
export function noExecutorsDetected(result) {
|
|
52
|
+
return result.detected.every((detection) => !detection.present);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Registry identity is `type:id`, but `ProjectionResult.skippedEntryIds` carries
|
|
56
|
+
* BARE ids, so one id can arrive matching several entries — a `tool` named `x`
|
|
57
|
+
* and an `mcp-server` named `x` are two different entries and only the first is
|
|
58
|
+
* skipped. Reporting both reasons would tell the user that an mcp-server which
|
|
59
|
+
* was projected successfully in this very run declares no command, and that
|
|
60
|
+
* reason is the one field a user acts on.
|
|
61
|
+
*
|
|
62
|
+
* So the candidates are narrowed by the property that actually makes an entry
|
|
63
|
+
* skippable, which is the same rule `collectMcpEntries` applies: any kind other
|
|
64
|
+
* than mcp-server, or an mcp-server with no command. Nothing else can be behind
|
|
65
|
+
* a skipped id.
|
|
66
|
+
*
|
|
67
|
+
* A target that KNOWS why says so itself, through `ProjectionResult.skipped`,
|
|
68
|
+
* and its reason wins wherever it is present — the skills target is the first
|
|
69
|
+
* one with reasons of its own ("this skill names a source brambo cannot read"),
|
|
70
|
+
* which no derivation from the registry entry could ever have produced.
|
|
71
|
+
*
|
|
72
|
+
* `skillsHandled` is the other half of the same correction. Once an executor has
|
|
73
|
+
* a verified skills root, "this executor has no native representation for a
|
|
74
|
+
* skill" is FALSE for that executor, so skill entries stop being candidates
|
|
75
|
+
* here; an id left with no candidate at all is dropped from the config row
|
|
76
|
+
* entirely rather than explained by a sentence that is no longer true.
|
|
77
|
+
*/
|
|
78
|
+
function reasonUnprojectable(entries, executorId, skillsHandled) {
|
|
79
|
+
const candidates = entries.filter((entry) => (entry.type !== 'mcp-server' || entry.command === undefined) &&
|
|
80
|
+
!(skillsHandled && entry.type === 'skill'));
|
|
81
|
+
if (candidates.length === 0) {
|
|
82
|
+
// Only when this executor materialises skills can an id legitimately have no
|
|
83
|
+
// candidate left, and then the id belongs to the skills row, not this one.
|
|
84
|
+
if (skillsHandled && entries.length > 0)
|
|
85
|
+
return undefined;
|
|
86
|
+
// Otherwise no entry brambo handed over can explain this id: said plainly
|
|
87
|
+
// rather than guessed, because a reason brambo cannot establish is still a fact.
|
|
88
|
+
return `'${executorId}' reported this entry as unprojectable and the registry holds no entry that explains it`;
|
|
89
|
+
}
|
|
90
|
+
return candidates
|
|
91
|
+
.map((entry) => entry.type === 'mcp-server'
|
|
92
|
+
? `the mcp-server entry declares no command, so there is nothing to render into '${executorId}'`
|
|
93
|
+
: // NO SPEC CITATION IN A SENTENCE A USER READS. This carried
|
|
94
|
+
// `(correction-01 C5)` and doctor printed it verbatim: an internal
|
|
95
|
+
// document name is a fact about brambo's own history, not about the
|
|
96
|
+
// reader's machine, and it is the one part of this line nobody can act
|
|
97
|
+
// on. The citation belongs where the RULE lives, which is the doc
|
|
98
|
+
// comment above and the `unprojectable` finding kind's own.
|
|
99
|
+
`'${executorId}' has no native representation for a ${entry.type} entry`)
|
|
100
|
+
.join('; ');
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The per-entry reasons a target row carries: the target's own words where it
|
|
104
|
+
* had any, and the registry-derived sentence everywhere else.
|
|
105
|
+
*/
|
|
106
|
+
function unprojectableFor(result, byId, executorId, skillsHandled) {
|
|
107
|
+
const stated = new Map((result?.skipped ?? []).map((skip) => [skip.entryId, skip.reason]));
|
|
108
|
+
const rows = [];
|
|
109
|
+
for (const entryId of result?.skippedEntryIds ?? []) {
|
|
110
|
+
const reason = stated.get(entryId) ?? reasonUnprojectable(byId.get(entryId) ?? [], executorId, skillsHandled);
|
|
111
|
+
if (reason !== undefined)
|
|
112
|
+
rows.push({ entryId, reason });
|
|
113
|
+
}
|
|
114
|
+
return rows;
|
|
115
|
+
}
|
|
116
|
+
function scopeUnavailable(detail, cause) {
|
|
117
|
+
return new BramboError(BRAMBO_ERROR_CODES.environmentScopeUnavailable, detail, { cause });
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The trust boundary. `homeDir` and `projectDir` are caller-supplied paths that
|
|
121
|
+
* decide where brambo creates directories and which vendor files it writes, so
|
|
122
|
+
* every one of them is resolved ONCE here and rejected unless it already names a
|
|
123
|
+
* directory.
|
|
124
|
+
*
|
|
125
|
+
* Three failures this closes, all of them observed: `homeDir: ''` — which is
|
|
126
|
+
* exactly `process.env.HOME ?? ''` in a consumer — resolves to the CWD and
|
|
127
|
+
* relocates the machine scope into whatever directory the process happens to be
|
|
128
|
+
* in; `brambo project init ~/typo` built the whole missing tree and wrote a
|
|
129
|
+
* vendor config into it; and `brambo project init ~/repo/.git` would have done
|
|
130
|
+
* the same inside a git directory. Brambo BINDS a project, it does not create one.
|
|
131
|
+
*/
|
|
132
|
+
export async function scopeDirectory(label, value) {
|
|
133
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
134
|
+
throw scopeUnavailable(`${label} must be a non-empty path, but brambo was given ${JSON.stringify(value)}`);
|
|
135
|
+
}
|
|
136
|
+
const resolved = resolve(value);
|
|
137
|
+
let isDirectory;
|
|
138
|
+
try {
|
|
139
|
+
isDirectory = (await stat(resolved)).isDirectory();
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
throw scopeUnavailable(`${label} '${resolved}' cannot be used (${error?.code ?? 'unknown error'}); brambo binds an existing directory and never creates one`, error);
|
|
143
|
+
}
|
|
144
|
+
if (!isDirectory) {
|
|
145
|
+
throw scopeUnavailable(`${label} '${resolved}' is not a directory`);
|
|
146
|
+
}
|
|
147
|
+
return resolved;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* MACHINE SCOPE, AND `'inspect'` HARD-CODED. Two separate deliberate choices:
|
|
151
|
+
*
|
|
152
|
+
* Machine scope, because the builds that wrote these locations had no project
|
|
153
|
+
* scope at all — `brambo project init` arrives in Story 2.7a, after correction-01
|
|
154
|
+
* — so there is no project-scope file that can hold one.
|
|
155
|
+
*
|
|
156
|
+
* `'inspect'` written here rather than threaded from `runScope`'s own `mode`,
|
|
157
|
+
* because `brambo init` must never remove a legacy block: removal is a decision,
|
|
158
|
+
* and this story's whole rule is that a decision is a user's, named one at a
|
|
159
|
+
* time. Passing the caller's mode through would make `brambo init` silently
|
|
160
|
+
* rewrite a vendor file — pinned by a test in `test/remediate.test.ts`.
|
|
161
|
+
*/
|
|
162
|
+
async function legacyFor(detected, homeDir) {
|
|
163
|
+
const present = new Set(detected.filter((detection) => detection.present).map((detection) => detection.executorId));
|
|
164
|
+
const rows = [];
|
|
165
|
+
for (const profile of EXECUTOR_PROFILES) {
|
|
166
|
+
if (!present.has(profile.executorId) || profile.legacyConfig === undefined)
|
|
167
|
+
continue;
|
|
168
|
+
const location = profile.legacyConfig(homeDir);
|
|
169
|
+
let outcome;
|
|
170
|
+
try {
|
|
171
|
+
outcome = await runRemediation({
|
|
172
|
+
remediation: 'discard',
|
|
173
|
+
legacy: { targetId: profile.targetId, rootPath: homeDir, ...location },
|
|
174
|
+
mode: 'inspect',
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
// A file brambo cannot READ is not evidence that litter is in it, and this
|
|
179
|
+
// finding's only resolution is a removal brambo would then be unable to
|
|
180
|
+
// perform — which is the false promise `brambo doctor`'s own Never clause
|
|
181
|
+
// forbids. Silence here, and the two of these three files that a target
|
|
182
|
+
// owns already report the read failure as `target-failed`.
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const change = outcome.changes[0];
|
|
186
|
+
if (outcome.refusal !== undefined) {
|
|
187
|
+
rows.push({
|
|
188
|
+
executorId: profile.executorId,
|
|
189
|
+
targetId: profile.targetId,
|
|
190
|
+
filePath: location.filePath,
|
|
191
|
+
detail: outcome.refusal.message,
|
|
192
|
+
byteDelta: 0,
|
|
193
|
+
refusal: outcome.refusal,
|
|
194
|
+
});
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (change === undefined)
|
|
198
|
+
continue;
|
|
199
|
+
rows.push({
|
|
200
|
+
executorId: profile.executorId,
|
|
201
|
+
targetId: profile.targetId,
|
|
202
|
+
filePath: location.filePath,
|
|
203
|
+
detail: change.detail,
|
|
204
|
+
byteDelta: change.byteDelta,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
return rows;
|
|
208
|
+
}
|
|
209
|
+
export function targetsFor(scope, detected, homeDir, projectDir) {
|
|
210
|
+
const planned = [];
|
|
211
|
+
const skills = [];
|
|
212
|
+
const skipped = [];
|
|
213
|
+
const present = new Set(detected.filter((detection) => detection.present).map((detection) => detection.executorId));
|
|
214
|
+
for (const profile of EXECUTOR_PROFILES) {
|
|
215
|
+
if (!present.has(profile.executorId))
|
|
216
|
+
continue;
|
|
217
|
+
const rootPath = scope === 'machine' ? profile.machineSkills?.(homeDir) : undefined;
|
|
218
|
+
if (rootPath !== undefined && profile.createSkillsTarget !== undefined) {
|
|
219
|
+
skills.push({ profile, target: profile.createSkillsTarget(rootPath) });
|
|
220
|
+
}
|
|
221
|
+
const filePath = scope === 'machine' ? profile.machineConfig(homeDir) : profile.projectConfig?.(projectDir);
|
|
222
|
+
if (filePath === undefined) {
|
|
223
|
+
skipped.push({
|
|
224
|
+
executorId: profile.executorId,
|
|
225
|
+
reason: `'${profile.executorId}' has no project-scope configuration file; brambo will not invent a location it does not read`,
|
|
226
|
+
});
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
planned.push({ profile, target: profile.createTarget(filePath) });
|
|
230
|
+
}
|
|
231
|
+
return { planned, skills, skipped };
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Records without letting a broken sink break the run it is describing — the
|
|
235
|
+
* same containment rule the kernel applies to its own call sites. Brambo's
|
|
236
|
+
* subjects are bounded by construction, so the only way this throws is a hostile
|
|
237
|
+
* sink; the sink's own `dropped` counter remains the loss signal.
|
|
238
|
+
*/
|
|
239
|
+
function recordProjection(log, event, targetId) {
|
|
240
|
+
// No sink means there is no action to record: `diagnose` passes none, because
|
|
241
|
+
// an `action.invoked` for a projection that deliberately never ran would put a
|
|
242
|
+
// projection brambo did not perform into the record stream NFR-4 exists to make
|
|
243
|
+
// reconstructable. `initMachine`/`initProject` always pass one.
|
|
244
|
+
if (log === undefined)
|
|
245
|
+
return;
|
|
246
|
+
try {
|
|
247
|
+
log.record({ event, subject: `${PROJECTION_ACTION_ID}#${targetId}` });
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
// Contained by contract; a diagnostic never aborts what it describes.
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* A thrown error flattened to the two fields a caller acts on. A live `Error`
|
|
255
|
+
* serialises to `{}` for every caller that prints the result.
|
|
256
|
+
*/
|
|
257
|
+
function toTargetFailure(error) {
|
|
258
|
+
const code = error?.code;
|
|
259
|
+
return {
|
|
260
|
+
// Duck-typed on `code`, like the CLI's own `describe()`: the registry throws
|
|
261
|
+
// `BramboError`, but a code that arrived some other way is still the fact.
|
|
262
|
+
code: typeof code === 'string' && code.length > 0
|
|
263
|
+
? code
|
|
264
|
+
: BRAMBO_ERROR_CODES.registryStoreUnavailable,
|
|
265
|
+
message: error instanceof Error ? error.message : String(error),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
/** Brambo's own state directory for a scope root. One spelling, two callers. */
|
|
269
|
+
function bramboDirOf(root) {
|
|
270
|
+
return join(root, '.brambo');
|
|
271
|
+
}
|
|
272
|
+
export function storeFor(scope, homeDir, projectDir) {
|
|
273
|
+
return new RegistryStore(scope === 'machine' ? { homeDir } : { homeDir, projectDir });
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* The command that projects one scope. One spelling, and `doctor.ts` is now a
|
|
277
|
+
* caller too: it named `brambo init` as the exit from a PROJECT's
|
|
278
|
+
* `not-initialised` and `out-of-date`, and that command exits 0 leaving the
|
|
279
|
+
* project exactly as it was.
|
|
280
|
+
*/
|
|
281
|
+
export function projectCommandFor(scope) {
|
|
282
|
+
return scope === 'machine' ? 'brambo init' : 'brambo project init';
|
|
283
|
+
}
|
|
284
|
+
async function takenBy(entry, detected, scope, homeDir, projectDir) {
|
|
285
|
+
const { planned, skills } = targetsFor(scope, detected, homeDir, projectDir);
|
|
286
|
+
const byKind = groupByKind([entry]);
|
|
287
|
+
const executorIds = [];
|
|
288
|
+
// DEDUPED, and that was measured rather than tidiness. The derived sentence
|
|
289
|
+
// names the EXECUTOR, while targets are per-executor-PLUS-surface: with a
|
|
290
|
+
// `.claude.json` and a `.claude/` skills root both present, the same executor
|
|
291
|
+
// is asked twice and a naive collection printed the identical sentence twice.
|
|
292
|
+
// Doctor's rows are per TARGET and can carry two; `EntryDelivery.reasons` is a
|
|
293
|
+
// flat per-executor list and cannot.
|
|
294
|
+
const reasons = new Set();
|
|
295
|
+
// The executors whose skills root brambo VERIFIED at this scope, which is what
|
|
296
|
+
// makes "this executor has no native representation for a skill" false for
|
|
297
|
+
// them -- the same input `unprojectableFor` gives `reasonUnprojectable`.
|
|
298
|
+
const skillsHandled = new Set(skills.map(({ profile }) => profile.executorId));
|
|
299
|
+
/**
|
|
300
|
+
* A target skipped this entry and said nothing. Derive the reason the way
|
|
301
|
+
* doctor already does, from the ONE producer, rather than reporting an absence
|
|
302
|
+
* brambo never measured.
|
|
303
|
+
*
|
|
304
|
+
* `no target said why` at `registry-commands.ts` used to fire here for every
|
|
305
|
+
* mcp-server with no command -- while `brambo doctor`, on the same fixture,
|
|
306
|
+
* printed the exact sentence. The verb that CREATED the state sent the user to
|
|
307
|
+
* a second command for an answer that lives in this module.
|
|
308
|
+
*/
|
|
309
|
+
const explain = (executorId) => {
|
|
310
|
+
const derived = reasonUnprojectable([entry], executorId, skillsHandled.has(executorId));
|
|
311
|
+
// `undefined` is the skills-handled case: the id belongs to another row and
|
|
312
|
+
// this one has nothing true to say. Left unsaid, so the caller's fallback
|
|
313
|
+
// still fires -- an absence brambo DID measure.
|
|
314
|
+
if (derived !== undefined)
|
|
315
|
+
reasons.add(derived);
|
|
316
|
+
};
|
|
317
|
+
for (const { profile, target } of [...planned, ...skills]) {
|
|
318
|
+
if (target.kind === 'materialise') {
|
|
319
|
+
const plan = await target.plan({ entries: byKind, records: [], rootPath: target.rootPath });
|
|
320
|
+
if (plan.entries.some((row) => row.entryId === entry.id)) {
|
|
321
|
+
executorIds.push(profile.executorId);
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
// The target's own words win where it had any -- it knows things no
|
|
325
|
+
// derivation from a registry entry could produce, like a skill whose
|
|
326
|
+
// source cannot be read. The prefix stays HERE and only here, because that
|
|
327
|
+
// text was authored by the target and may not name the executor; the
|
|
328
|
+
// derived sentence already does.
|
|
329
|
+
const own = (plan.skipped ?? []).filter((skip) => skip.entryId === entry.id);
|
|
330
|
+
if (own.length > 0) {
|
|
331
|
+
for (const skip of own)
|
|
332
|
+
reasons.add(`${profile.executorId}: ${skip.reason}`);
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
// AND THIS BRANCH IS THE ONE THE FIRST DIAGNOSIS MISSED. A skills target
|
|
336
|
+
// iterates `entries.skill` alone, so an mcp-server is dropped by it without
|
|
337
|
+
// ever producing a `ProjectionSkip`: for that entry BOTH target kinds were
|
|
338
|
+
// silent, not just merge.
|
|
339
|
+
explain(profile.executorId);
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
const outcome = await target.merge({ entries: byKind, records: [], nativeText: '' });
|
|
343
|
+
if (!(outcome.skippedEntryIds ?? []).includes(entry.id)) {
|
|
344
|
+
executorIds.push(profile.executorId);
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
// A merge outcome carries ids alone (`ProjectionMergeOutcome.skippedEntryIds`),
|
|
348
|
+
// which `contracts/src/projection.ts` names as the defect that forces this
|
|
349
|
+
// derivation. It is EXACT rather than approximate today: `collectMcpEntries`
|
|
350
|
+
// skips exactly the two shapes `reasonUnprojectable` derives, and anything
|
|
351
|
+
// else falls through to its honest "the registry holds no entry that
|
|
352
|
+
// explains it".
|
|
353
|
+
explain(profile.executorId);
|
|
354
|
+
}
|
|
355
|
+
return { executorIds, reasons: [...reasons] };
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* {@link EntryDelivery} for one scope, with the OTHER scope answered too
|
|
359
|
+
* whenever this one takes the entry nowhere.
|
|
360
|
+
*
|
|
361
|
+
* Contained: a target that throws while being asked leaves `undetermined` set
|
|
362
|
+
* rather than propagating, because the caller has already registered the entry
|
|
363
|
+
* and a message is not worth losing a completed write over.
|
|
364
|
+
*/
|
|
365
|
+
export async function deliveryFor(entry, scope, homeDir, projectDir) {
|
|
366
|
+
const command = projectCommandFor(scope);
|
|
367
|
+
let detected;
|
|
368
|
+
try {
|
|
369
|
+
detected = await detectExecutors(homeDir);
|
|
370
|
+
const here = await takenBy(entry, detected, scope, homeDir, projectDir);
|
|
371
|
+
if (here.executorIds.length > 0) {
|
|
372
|
+
return { scope, command, executorIds: here.executorIds, reasons: here.reasons };
|
|
373
|
+
}
|
|
374
|
+
const otherScope = scope === 'machine' ? 'project' : 'machine';
|
|
375
|
+
const there = await takenBy(entry, detected, otherScope, homeDir, projectDir);
|
|
376
|
+
return {
|
|
377
|
+
scope,
|
|
378
|
+
command,
|
|
379
|
+
executorIds: [],
|
|
380
|
+
reasons: here.reasons,
|
|
381
|
+
...(there.executorIds.length === 0
|
|
382
|
+
? {}
|
|
383
|
+
: {
|
|
384
|
+
elsewhere: {
|
|
385
|
+
scope: otherScope,
|
|
386
|
+
command: projectCommandFor(otherScope),
|
|
387
|
+
executorIds: there.executorIds,
|
|
388
|
+
reasons: there.reasons,
|
|
389
|
+
},
|
|
390
|
+
}),
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
catch (error) {
|
|
394
|
+
const failure = toTargetFailure(error);
|
|
395
|
+
return {
|
|
396
|
+
scope,
|
|
397
|
+
command,
|
|
398
|
+
executorIds: [],
|
|
399
|
+
reasons: [],
|
|
400
|
+
undetermined: `${failure.code}: ${failure.message}`,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* The ONLY two writes into brambo's own state that `brambo init` performs: its
|
|
406
|
+
* directory, and the registry store document. They live here — OUTSIDE
|
|
407
|
+
* `runScope`, which `init` and `diagnose` share — so the read-only caller cannot
|
|
408
|
+
* reach them by construction rather than by remembering not to.
|
|
409
|
+
* `test/doctor.test.ts` proves the consequence at byte level.
|
|
410
|
+
*/
|
|
411
|
+
async function prepareScope(scope, root, homeDir, projectDir) {
|
|
412
|
+
const bramboDir = bramboDirOf(root);
|
|
413
|
+
// Brambo's own directory, created by brambo. `recursive` here means "tolerate an
|
|
414
|
+
// existing directory", not "build a tree": the parent was validated as an
|
|
415
|
+
// existing directory above, so the only thing this can still meet is `.brambo`
|
|
416
|
+
// occupied by a FILE, which arrives as a bare doubled EEXIST naming nothing.
|
|
417
|
+
try {
|
|
418
|
+
await mkdir(bramboDir, { recursive: true });
|
|
419
|
+
}
|
|
420
|
+
catch (error) {
|
|
421
|
+
throw scopeUnavailable(`brambo's own state directory '${bramboDir}' cannot be created (${error?.code ?? 'unknown error'})`, error);
|
|
422
|
+
}
|
|
423
|
+
const store = storeFor(scope, homeDir, projectDir);
|
|
424
|
+
try {
|
|
425
|
+
// Materialised through the store itself, so the document's version and shape
|
|
426
|
+
// stay the registry's to define. A corrupt store fails coded here rather than
|
|
427
|
+
// being silently replaced.
|
|
428
|
+
return await store.ensure(scope === 'machine' ? 'global' : 'project');
|
|
429
|
+
}
|
|
430
|
+
finally {
|
|
431
|
+
await store.dispose();
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Registry -> detection -> projection engine, for one scope. `brambo init` and
|
|
436
|
+
* `brambo doctor` are THIS function under the two projection modes and nothing
|
|
437
|
+
* else: same entries, same detection, same targets, same engine call, same drift
|
|
438
|
+
* classification. Two code paths could disagree about what applying would do,
|
|
439
|
+
* and they would disagree exactly when a user is trying to fix something.
|
|
440
|
+
*
|
|
441
|
+
* Every line below either reads, or writes through the projection engine — which
|
|
442
|
+
* under `'inspect'` writes nothing at all.
|
|
443
|
+
*/
|
|
444
|
+
export async function runScope(scope, homeDir, projectDir, log, mode) {
|
|
445
|
+
const store = storeFor(scope, homeDir, projectDir);
|
|
446
|
+
const registryPath = store.storePath(scope === 'machine' ? 'global' : 'project');
|
|
447
|
+
let entries = [];
|
|
448
|
+
const retired = [];
|
|
449
|
+
let registryError;
|
|
450
|
+
try {
|
|
451
|
+
entries = await store.list();
|
|
452
|
+
// Read PER SCOPE as well, rather than filtered out of the merged view above:
|
|
453
|
+
// the merge keeps one row per `type:id` and DROPS the scope that produced
|
|
454
|
+
// it, which is exactly the fact every message about a retired entry needs.
|
|
455
|
+
// Same scopes, in the same order, that `brambo list` walks under each
|
|
456
|
+
// grammar — so `brambo project doctor` reports a global entry against the
|
|
457
|
+
// global document, with the global verb.
|
|
458
|
+
const retiredScopes = scope === 'machine' ? ['global'] : ['global', 'project'];
|
|
459
|
+
for (const candidateScope of retiredScopes) {
|
|
460
|
+
for (const entry of await store.list(candidateScope)) {
|
|
461
|
+
if (!isRetiredEntryType(entry.type))
|
|
462
|
+
continue;
|
|
463
|
+
retired.push({ entry, scope: candidateScope, registryPath: store.storePath(candidateScope) });
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
catch (error) {
|
|
468
|
+
// Brambo's OWN two state files, classified the same way. A corrupt ledger is
|
|
469
|
+
// already a reported finding; a corrupt registry throwing out of the command
|
|
470
|
+
// whose job is diagnosing brambo's state would be the opposite treatment for
|
|
471
|
+
// the same class of fault. `'apply'` still throws: `brambo init` must not
|
|
472
|
+
// project against a registry it cannot read.
|
|
473
|
+
if (mode === 'apply')
|
|
474
|
+
throw error;
|
|
475
|
+
registryError = toTargetFailure(error);
|
|
476
|
+
}
|
|
477
|
+
finally {
|
|
478
|
+
await store.dispose();
|
|
479
|
+
}
|
|
480
|
+
const detected = await detectExecutors(homeDir);
|
|
481
|
+
const { planned, skills, skipped } = targetsFor(scope, detected, homeDir, projectDir);
|
|
482
|
+
const ledger = new ProjectionLedger({ homeDir });
|
|
483
|
+
if (registryError !== undefined) {
|
|
484
|
+
// No engine call at all: every per-target verdict is derived from the
|
|
485
|
+
// registry, so reporting rows computed against an empty one would tell the
|
|
486
|
+
// user brambo is about to delete entries it simply could not read.
|
|
487
|
+
return {
|
|
488
|
+
bramboDir: bramboDirOf(scope === 'machine' ? homeDir : projectDir),
|
|
489
|
+
registryPath,
|
|
490
|
+
ledgerPath: ledger.filePath,
|
|
491
|
+
entryCount: 0,
|
|
492
|
+
detected,
|
|
493
|
+
targets: [],
|
|
494
|
+
skills: [],
|
|
495
|
+
skipped,
|
|
496
|
+
legacy: scope === 'machine' && mode === 'inspect' ? await legacyFor(detected, homeDir) : [],
|
|
497
|
+
warnings: [],
|
|
498
|
+
registryError,
|
|
499
|
+
retired: [],
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
const everyTarget = [...planned, ...skills];
|
|
503
|
+
for (const { target } of everyTarget)
|
|
504
|
+
recordProjection(log, 'action.invoked', target.targetId);
|
|
505
|
+
const run = await runProjection({
|
|
506
|
+
entries: groupByKind(entries),
|
|
507
|
+
targets: everyTarget.map((plan) => plan.target),
|
|
508
|
+
ledger,
|
|
509
|
+
mode,
|
|
510
|
+
});
|
|
511
|
+
// Built from the entries the engine was actually GIVEN: a retired entry is
|
|
512
|
+
// never handed to a target, so it can never be the explanation for an id a
|
|
513
|
+
// target skipped — and an id spelled the same in both vocabularies would
|
|
514
|
+
// otherwise be explained by the entry nobody projected, naming a type brambo no
|
|
515
|
+
// longer declares. `test/init.test.ts` forces exactly that collision.
|
|
516
|
+
const byId = new Map();
|
|
517
|
+
for (const entry of entries) {
|
|
518
|
+
if (isRetiredEntryType(entry.type))
|
|
519
|
+
continue;
|
|
520
|
+
byId.set(entry.id, [...(byId.get(entry.id) ?? []), entry]);
|
|
521
|
+
}
|
|
522
|
+
const results = new Map(run.results.map((result) => [result.targetId, result]));
|
|
523
|
+
const failures = new Map(run.failures.map((failure) => [failure.targetId, failure]));
|
|
524
|
+
// Walked over `planned`, which is catalogue order, so one executor failing can
|
|
525
|
+
// never reshuffle the report — and so a target that BOTH wrote and then failed
|
|
526
|
+
// its ledger update yields ONE row carrying both facts. Two rows, or a row
|
|
527
|
+
// hardcoding `changed: false` for a failure, is how brambo came to report
|
|
528
|
+
// `written: false` for bytes it had already landed.
|
|
529
|
+
const materialising = new Set(skills.map(({ profile }) => profile.executorId));
|
|
530
|
+
const rowFor = ({ profile, target }) => {
|
|
531
|
+
const result = results.get(target.targetId);
|
|
532
|
+
const failure = failures.get(target.targetId);
|
|
533
|
+
recordProjection(log, failure === undefined ? 'action.completed' : 'action.failed', target.targetId);
|
|
534
|
+
return {
|
|
535
|
+
executorId: profile.executorId,
|
|
536
|
+
targetId: target.targetId,
|
|
537
|
+
filePath: projectionTargetLocation(target),
|
|
538
|
+
changed: result?.written ?? false,
|
|
539
|
+
drift: result?.drift ?? [],
|
|
540
|
+
// A config row stops claiming an executor cannot express a SKILL once that
|
|
541
|
+
// executor has a verified skills root: the skills row below is the
|
|
542
|
+
// authority for those ids, and two rows answering for one entry is how a
|
|
543
|
+
// user is told a skill was both materialised and impossible.
|
|
544
|
+
//
|
|
545
|
+
// KNOWN LOSS OF GRANULARITY, and it is deliberate. When the skills target
|
|
546
|
+
// FAILS outright, its row carries the coded error and an empty
|
|
547
|
+
// `unprojectable` list, while this row has already dropped those ids — so
|
|
548
|
+
// no row names the individual skills. The alternative is worse: the config
|
|
549
|
+
// row's only sentence is "this executor has no native representation for a
|
|
550
|
+
// skill", which is false for an executor that has a verified root and
|
|
551
|
+
// merely could not be written to this run. A per-target failure is loud in
|
|
552
|
+
// its own right (`error` on the row, `target-failed` in doctor, non-zero
|
|
553
|
+
// exit), so what is lost is which entries it covered, not the failure.
|
|
554
|
+
unprojectable: unprojectableFor(result, byId, profile.executorId, target.kind !== 'materialise' && materialising.has(profile.executorId)),
|
|
555
|
+
...(failure === undefined
|
|
556
|
+
? {}
|
|
557
|
+
: { error: { code: failure.error.code, message: failure.error.message } }),
|
|
558
|
+
};
|
|
559
|
+
};
|
|
560
|
+
const targets = planned.map(rowFor);
|
|
561
|
+
const skillRows = skills.map(rowFor);
|
|
562
|
+
return {
|
|
563
|
+
bramboDir: bramboDirOf(scope === 'machine' ? homeDir : projectDir),
|
|
564
|
+
registryPath,
|
|
565
|
+
ledgerPath: ledger.filePath,
|
|
566
|
+
entryCount: entries.length,
|
|
567
|
+
detected,
|
|
568
|
+
targets,
|
|
569
|
+
skills: skillRows,
|
|
570
|
+
skipped,
|
|
571
|
+
legacy: scope === 'machine' && mode === 'inspect' ? await legacyFor(detected, homeDir) : [],
|
|
572
|
+
warnings: run.warnings,
|
|
573
|
+
retired,
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
function toTargetProjection(row) {
|
|
577
|
+
// Named field by field, NOT spread. A spread rebuilds the row in the spread's
|
|
578
|
+
// order and silently moved `written` from index 3 to last, after `error`, with
|
|
579
|
+
// both suites green — and this is a documented payload a caller prints.
|
|
580
|
+
// `test/init.test.ts` pins the order.
|
|
581
|
+
return {
|
|
582
|
+
executorId: row.executorId,
|
|
583
|
+
targetId: row.targetId,
|
|
584
|
+
filePath: row.filePath,
|
|
585
|
+
written: row.changed,
|
|
586
|
+
drift: row.drift,
|
|
587
|
+
unprojectable: row.unprojectable,
|
|
588
|
+
// A row carrying no `error` key keeps carrying none: an `error: undefined`
|
|
589
|
+
// in the payload reads to every JSON consumer as a field brambo decided to
|
|
590
|
+
// say nothing about.
|
|
591
|
+
...(row.error === undefined ? {} : { error: row.error }),
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
function toInitResult(scope, registryPath, report) {
|
|
595
|
+
return {
|
|
596
|
+
scope,
|
|
597
|
+
bramboDir: report.bramboDir,
|
|
598
|
+
registryPath,
|
|
599
|
+
ledgerPath: report.ledgerPath,
|
|
600
|
+
entryCount: report.entryCount,
|
|
601
|
+
detected: report.detected,
|
|
602
|
+
targets: report.targets.map(toTargetProjection),
|
|
603
|
+
skills: report.skills.map(toTargetProjection),
|
|
604
|
+
skipped: report.skipped,
|
|
605
|
+
warnings: report.warnings,
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Prepares this machine: brambo's own directory and registry store exist
|
|
610
|
+
* afterwards, and the global registry is projected into every detected
|
|
611
|
+
* executor's own machine-scope configuration.
|
|
612
|
+
*
|
|
613
|
+
* Idempotent. A second run over an unchanged registry writes no vendor byte and
|
|
614
|
+
* reports every target as unchanged.
|
|
615
|
+
*/
|
|
616
|
+
export async function initMachine(options = {}) {
|
|
617
|
+
// Every field read ONCE, here, before the first await. A later read of a
|
|
618
|
+
// caller-controlled object is a TOCTOU hole: an accessor that answers with a
|
|
619
|
+
// temp directory now and the real home directory later would get the real one
|
|
620
|
+
// projected into.
|
|
621
|
+
const { homeDir = homedir(), log } = options;
|
|
622
|
+
const home = await scopeDirectory('the home directory', homeDir);
|
|
623
|
+
const registryPath = await prepareScope('machine', home, home, home);
|
|
624
|
+
const report = await runScope('machine', home, home, log ?? createMemoryLogSink(), 'apply');
|
|
625
|
+
return toInitResult('machine', registryPath, report);
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Binds a project: brambo's own directory and project registry store exist under
|
|
629
|
+
* it afterwards, and the registry it can see — the project's entries over the
|
|
630
|
+
* machine's — is projected into every detected executor that has a project-scope
|
|
631
|
+
* configuration. An executor without one is reported as skipped, never written
|
|
632
|
+
* to somewhere it does not read.
|
|
633
|
+
*/
|
|
634
|
+
export async function initProject(options = {}) {
|
|
635
|
+
const { homeDir = homedir(), projectDir = process.cwd(), log } = options;
|
|
636
|
+
const home = await scopeDirectory('the home directory', homeDir);
|
|
637
|
+
const projectRoot = await scopeDirectory('the project directory', projectDir);
|
|
638
|
+
const registryPath = await prepareScope('project', projectRoot, home, projectRoot);
|
|
639
|
+
const report = await runScope('project', home, projectRoot, log ?? createMemoryLogSink(), 'apply');
|
|
640
|
+
return toInitResult('project', registryPath, report);
|
|
641
|
+
}
|