@skanl/brambo-projection 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 +112 -0
- package/dist/atomic-write.d.ts +3 -0
- package/dist/atomic-write.js +90 -0
- package/dist/config-write.d.ts +49 -0
- package/dist/config-write.js +146 -0
- package/dist/document-fault.d.ts +59 -0
- package/dist/document-fault.js +78 -0
- package/dist/engine.d.ts +69 -0
- package/dist/engine.js +235 -0
- package/dist/formats.d.ts +132 -0
- package/dist/formats.js +1301 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +15 -0
- package/dist/ledger.d.ts +193 -0
- package/dist/ledger.js +552 -0
- package/dist/materialise.d.ts +55 -0
- package/dist/materialise.js +620 -0
- package/dist/remediate.d.ts +65 -0
- package/dist/remediate.js +468 -0
- package/dist/targets/claude-mcp.d.ts +6 -0
- package/dist/targets/claude-mcp.js +36 -0
- package/dist/targets/codex-config.d.ts +6 -0
- package/dist/targets/codex-config.js +27 -0
- package/dist/targets/opencode-config.d.ts +6 -0
- package/dist/targets/opencode-config.js +58 -0
- package/dist/targets/skills.d.ts +22 -0
- package/dist/targets/skills.js +165 -0
- package/package.json +57 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SKANL
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# @skanl/brambo-projection
|
|
2
|
+
|
|
3
|
+
Projects Registry entries into executors' native configuration files, in each
|
|
4
|
+
executor's OWN vocabulary at the location that executor actually reads (port
|
|
5
|
+
defined in `@skanl/brambo-contracts`).
|
|
6
|
+
|
|
7
|
+
- `runProjection` — engine: reads the ownership ledger, then runs each target
|
|
8
|
+
SEQUENTIALLY with per-target failure containment (a failing target never
|
|
9
|
+
affects siblings, and failures surface as typed results — the call does not
|
|
10
|
+
throw). The native file lands before the ledger records it, so a crash
|
|
11
|
+
between the two under-claims rather than over-claims. Single-writer
|
|
12
|
+
assumption: concurrent `runProjection` calls over the same projected file are
|
|
13
|
+
unsupported in v1; a file modified externally between read and write fails
|
|
14
|
+
that target instead of landing stale content.
|
|
15
|
+
- `runRemediation` — the way OUT of a state projection reports and refuses to
|
|
16
|
+
resolve. Four verbs, one subject per call, and the SAME call describes and
|
|
17
|
+
performs (`mode: 'inspect' | 'apply'`, the engine's own switch), so a preview
|
|
18
|
+
cannot disagree with the act:
|
|
19
|
+
- `adopt` — brambo takes ownership of what is at its own location, exactly as
|
|
20
|
+
it is now. It writes only the ledger, and that is not the same as being
|
|
21
|
+
harmless: owning a location is what lets a LATER `runProjection` replace it,
|
|
22
|
+
and on a materialisation root REMOVE it. The description names every path
|
|
23
|
+
that becomes deletable and says which of the two will happen, before the
|
|
24
|
+
claim is written. A tree that is only partly there is claimed as the subset
|
|
25
|
+
that exists, so the ordinary run writes the rest back; an entry that has left
|
|
26
|
+
the registry is claimed from the ledger's own record, and the next run then
|
|
27
|
+
removes the tree. There is no verb that renders one entry outside the merge.
|
|
28
|
+
- `release` — brambo stops claiming a location. The file is not opened.
|
|
29
|
+
- `repair` — brambo rewrites its OWN ledger to hold exactly the records it can
|
|
30
|
+
read; the only write that does not merge, and the only exit from a ledger
|
|
31
|
+
carrying records it cannot use.
|
|
32
|
+
- `discard` — brambo removes its OWN prior output from a vendor file
|
|
33
|
+
(correction-01 C6): a reserved `$.brambo` key whose members are *all* brambo's
|
|
34
|
+
own vocabulary (`version`, `tools`, `mcpServers`, `skills`, `hooks`), or a
|
|
35
|
+
`# BEGIN brambo-managed` block whose sub-keys under `[tools]`/`[skills]` make
|
|
36
|
+
a Codex `config.toml` fail to load under `--strict-config`. A `brambo` key
|
|
37
|
+
holding anything else is somebody's own configuration: not reported, not
|
|
38
|
+
removed. A marker inside a multi-line TOML string is the user's bytes and is
|
|
39
|
+
invisible to the scan.
|
|
40
|
+
|
|
41
|
+
Containment is the materialisation rule unchanged: every path is resolved and
|
|
42
|
+
proven inside the location brambo owns, a link at any depth disqualifies it, and
|
|
43
|
+
a path another surviving claim holds is refused. `discard` checks the REAL
|
|
44
|
+
path, so a junctioned `~/.claude` cannot land the write outside the scope its
|
|
45
|
+
refusal promises. `adopt` builds its claim from the TARGET's plan of what brambo
|
|
46
|
+
would write — never from a directory listing — so a file a user put beside
|
|
47
|
+
brambo's is never swept into a record that could later authorise deleting it,
|
|
48
|
+
and both ledger verbs write ONE entry through `updateEntry` rather than
|
|
49
|
+
replacing a scope from a read they took earlier. A remediation brambo will not
|
|
50
|
+
perform is returned as a coded `BRAMBO_PROJECTION_REMEDIATION_REFUSED`, not
|
|
51
|
+
thrown, because under inspection the refusal is part of the description.
|
|
52
|
+
|
|
53
|
+
`runRemediation` defaults to `mode: 'inspect'` — the opposite of
|
|
54
|
+
`runProjection`, and the same default `brambo remediate` uses, so the
|
|
55
|
+
describe-before-act guarantee is true of the SDK surface and of the command.
|
|
56
|
+
- `ProjectionLedger` — brambo's durable record of what it wrote (target, file,
|
|
57
|
+
native location, content hash), in `~/.brambo/projection-ledger.json`. Brambo
|
|
58
|
+
never marks a vendor's file to prove ownership: a marker has nowhere to live
|
|
59
|
+
in some formats and is an unknown field in others. Writes MERGE one target's
|
|
60
|
+
claims into the on-disk document; an unreadable ledger is reported and left
|
|
61
|
+
alone, never overwritten.
|
|
62
|
+
- `createClaudeMcpTarget` — `mcpServers` in `~/.claude.json` (or `.mcp.json`
|
|
63
|
+
for project scope), `{type:'stdio', command, args}`.
|
|
64
|
+
- `createOpenCodeConfigTarget` — `mcp.<id>` in `opencode.json`,
|
|
65
|
+
`{type:'local', command: argv}`.
|
|
66
|
+
- `createCodexConfigTarget` — `[mcp_servers.<id>]` in `config.toml`, `command`
|
|
67
|
+
and `args` only, so `--strict-config` has nothing to reject.
|
|
68
|
+
|
|
69
|
+
Ledger records, native-entry and drift vocabulary live in `@skanl/brambo-contracts`
|
|
70
|
+
(`src/projection.ts`). Drift is a ledger-versus-disk comparison — `edited`,
|
|
71
|
+
`removed-by-user`, `foreign-collision` — and is always reported, never resolved
|
|
72
|
+
by writing.
|
|
73
|
+
|
|
74
|
+
- `createClaudeSkillsTarget` / `createCodexSkillsTarget` /
|
|
75
|
+
`createOpenCodeSkillsTarget` — materialisation targets: they own a ROOT and
|
|
76
|
+
copy each registry skill's `entryPath` to `<root>/<id>/SKILL.md`. Brambo
|
|
77
|
+
copies; it never authors skill content.
|
|
78
|
+
|
|
79
|
+
## Where skills land, and what each executor really reads
|
|
80
|
+
|
|
81
|
+
Verified by EXECUTION against each installed binary under an injected home
|
|
82
|
+
(`test/skills-discovery.live.test.ts`), not by reading a document.
|
|
83
|
+
|
|
84
|
+
| Executor | Brambo writes | How it was verified |
|
|
85
|
+
| --- | --- | --- |
|
|
86
|
+
| claude-code | `~/.claude/skills/<id>/SKILL.md` | the request claude sends lists it in its own available-skills block |
|
|
87
|
+
| codex | `~/.codex/skills/<id>/SKILL.md` | `codex debug prompt-input` names the file by absolute path |
|
|
88
|
+
| opencode | `~/.config/opencode/skills/<id>/SKILL.md` | `opencode debug skill` reports that exact `location` |
|
|
89
|
+
|
|
90
|
+
**OpenCode reads four roots, and brambo does not write into the one that wins.**
|
|
91
|
+
Measured: `~/.config/opencode/skills`, `~/.config/opencode/skill`,
|
|
92
|
+
`~/.opencode/skills` and `~/.opencode/skill` are all scanned, and with the same
|
|
93
|
+
skill id present in two of them `~/.opencode/skills` silently takes precedence
|
|
94
|
+
while `brambo init` and `brambo doctor` still report brambo's own write as done.
|
|
95
|
+
Brambo writes one root per executor and claims nothing about precedence between
|
|
96
|
+
them. OpenCode additionally scans `~/.claude/skills` and `~/.agents/skills`, so
|
|
97
|
+
a skill brambo materialises for Claude Code is visible to opencode too.
|
|
98
|
+
|
|
99
|
+
**Brambo's registry id is not necessarily the name the executor shows.** Claude
|
|
100
|
+
Code takes the skill's name from the DIRECTORY, while codex and opencode take
|
|
101
|
+
it from the source's frontmatter `name:`. A registry entry whose id differs
|
|
102
|
+
from its source's frontmatter therefore appears under two different names, and
|
|
103
|
+
brambo's ledger, `init` and `doctor` all name the registry id.
|
|
104
|
+
|
|
105
|
+
Entries no target can express are still reported rather than approximated
|
|
106
|
+
(`skippedEntryIds`, plus the target's own reason in `skipped`): a `skill` is not
|
|
107
|
+
expressible by a CONFIG target, and an `mcp-server` with no command has nothing
|
|
108
|
+
to render. A RETIRED type never reaches a target at all — `groupByKind` has no
|
|
109
|
+
bucket for one — so it is neither projected nor reported here; `brambo doctor`
|
|
110
|
+
reports it against the registry document instead. Skills at PROJECT scope are
|
|
111
|
+
reported the same way — materialising into a project is a decision no story has
|
|
112
|
+
taken, so brambo invents no location.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { chmod, lstat, mkdir, realpath, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname, join } from 'node:path';
|
|
4
|
+
import { BRAMBO_ERROR_CODES, BramboError } from '@skanl/brambo-contracts';
|
|
5
|
+
// Atomic persistence for target files: temp file in the same directory, then
|
|
6
|
+
// rename over the target. Atomicity here means reader visibility (readers only
|
|
7
|
+
// ever observe a complete document) and crash-safe replacement — NOT
|
|
8
|
+
// durability: like the Registry store, there is a power-loss window after
|
|
9
|
+
// rename with no fsync; that deferral is tracked as deferred work.
|
|
10
|
+
//
|
|
11
|
+
// The previous file's permissions are copied onto the temp file before the
|
|
12
|
+
// rename so a projection never widens or narrows the target's mode.
|
|
13
|
+
//
|
|
14
|
+
// SYMLINKS ARE FOLLOWED, NEVER REPLACED. `~/.claude.json -> ~/dotfiles/claude.json`
|
|
15
|
+
// is the ordinary way people keep these files in a repo, and rename() over a
|
|
16
|
+
// symlink destroys the link and orphans the source: every later edit in the
|
|
17
|
+
// dotfiles repo goes nowhere, `git status` there shows nothing, and brambo exits
|
|
18
|
+
// 0. So the link is resolved first and the rename lands on the real file. A link
|
|
19
|
+
// that cannot be resolved — dangling, a cycle — is a coded refusal, because the
|
|
20
|
+
// only alternative is to materialise a regular file where the user put a link.
|
|
21
|
+
async function priorMode(path) {
|
|
22
|
+
try {
|
|
23
|
+
return (await stat(path)).mode;
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (error?.code === 'ENOENT')
|
|
27
|
+
return undefined;
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The real file the write must land on. Identity for a regular file or an
|
|
33
|
+
* absent one; the link's destination for a symlink.
|
|
34
|
+
*/
|
|
35
|
+
async function writeTargetOf(path) {
|
|
36
|
+
let link;
|
|
37
|
+
try {
|
|
38
|
+
link = (await lstat(path)).isSymbolicLink();
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (error?.code === 'ENOENT')
|
|
42
|
+
return path;
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
if (!link)
|
|
46
|
+
return path;
|
|
47
|
+
try {
|
|
48
|
+
return await realpath(path);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
const detail = error?.code ?? String(error);
|
|
52
|
+
throw new BramboError(BRAMBO_ERROR_CODES.projectionNativeUnclaimable, `native config file '${path}' is a symlink brambo cannot resolve (${detail}); refusing to replace the link with a regular file`, { cause: error });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async function atomicWrite(path, contents) {
|
|
56
|
+
const target = await writeTargetOf(path);
|
|
57
|
+
const dir = dirname(target);
|
|
58
|
+
await mkdir(dir, { recursive: true });
|
|
59
|
+
const mode = await priorMode(target);
|
|
60
|
+
const tempPath = join(dir, `${basename(target)}.${randomUUID()}.tmp`);
|
|
61
|
+
try {
|
|
62
|
+
// `utf8` applies to the string form alone; a Uint8Array is written verbatim,
|
|
63
|
+
// which is what a materialised file needs — brambo copies bytes it did not
|
|
64
|
+
// author and must not re-encode them.
|
|
65
|
+
await writeFile(tempPath, contents, typeof contents === 'string' ? 'utf8' : undefined);
|
|
66
|
+
if (mode !== undefined)
|
|
67
|
+
await chmod(tempPath, mode);
|
|
68
|
+
await rename(tempPath, target);
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
await unlink(tempPath).catch(() => { });
|
|
72
|
+
// Rethrown RAW, deliberately. `toTargetFailure` in `engine.ts` wraps a raw
|
|
73
|
+
// error as `BRAMBO_PROJECTION_TARGET_FAILED` and passes a `BramboError`
|
|
74
|
+
// through unchanged, so every projection caller already receives a coded
|
|
75
|
+
// failure and `doctor` classifies this state from that code. Coding it here
|
|
76
|
+
// was tried and reverted: it changed the code doctor sees, and the "bare
|
|
77
|
+
// errno reaches a caller" defect it was meant to fix does not exist for any
|
|
78
|
+
// caller that goes through the engine. A caller that does NOT — brambo's own
|
|
79
|
+
// config writer — codes it at its own boundary, where the right vocabulary
|
|
80
|
+
// is a configuration one rather than a projection one.
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
export async function atomicWriteText(path, contents) {
|
|
85
|
+
await atomicWrite(path, contents);
|
|
86
|
+
}
|
|
87
|
+
/** The same discipline for a file brambo COPIES rather than renders. */
|
|
88
|
+
export async function atomicWriteBytes(path, contents) {
|
|
89
|
+
await atomicWrite(path, contents);
|
|
90
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The keys brambo will persist — an ALLOWLIST, not a suggestion.
|
|
3
|
+
*
|
|
4
|
+
* The two literals are spelled here rather than imported: this package cannot
|
|
5
|
+
* reach '@skanl/brambo-adapter-cli' (which owns EXECUTOR_CONFIG_KEY) under AD-2, and
|
|
6
|
+
* importing '@skanl/brambo-contracts' for METHOD_CONFIG_KEY alone would make one of the
|
|
7
|
+
* pair look canonical while the other stayed a literal. Both are duplicated, and
|
|
8
|
+
* this comment is why.
|
|
9
|
+
*
|
|
10
|
+
* Key-agnostic is not unconstrained. A key brambo does not read is a value
|
|
11
|
+
* written once and ignored forever, which is the same defect as a registry type
|
|
12
|
+
* nothing projects: M4.E's rule, applied to configuration. `method` arrived
|
|
13
|
+
* with M5.D, in the same change that taught brambo to read and mount one.
|
|
14
|
+
*/
|
|
15
|
+
export declare const WRITABLE_CONFIG_KEYS: readonly ['executor', 'method'];
|
|
16
|
+
export type WritableConfigKey = (typeof WRITABLE_CONFIG_KEYS)[number];
|
|
17
|
+
export interface ConfigWriteOptions {
|
|
18
|
+
/** `machine` writes the `global` layer's document; `project` writes the project's. */
|
|
19
|
+
readonly scope: 'machine' | 'project';
|
|
20
|
+
readonly homeDir: string;
|
|
21
|
+
/** Required for the project scope; ignored for the machine one. */
|
|
22
|
+
readonly projectDir?: string;
|
|
23
|
+
readonly key: WritableConfigKey;
|
|
24
|
+
readonly value: string;
|
|
25
|
+
}
|
|
26
|
+
export interface ConfigWriteResult {
|
|
27
|
+
readonly filePath: string;
|
|
28
|
+
/** What the key said before, so a caller can tell a change from a no-op. */
|
|
29
|
+
readonly previous: string | undefined;
|
|
30
|
+
/** True when the document did not exist and this call created it. */
|
|
31
|
+
readonly created: boolean;
|
|
32
|
+
}
|
|
33
|
+
/** `<root>/.brambo/config.json` for the scope this call names. */
|
|
34
|
+
export declare function configPathFor(options: Pick<ConfigWriteOptions, 'scope' | 'homeDir' | 'projectDir'>): string;
|
|
35
|
+
/**
|
|
36
|
+
* Sets ONE allowlisted key in brambo's own configuration document.
|
|
37
|
+
*
|
|
38
|
+
* Every other key is carried through untouched: these documents hold the
|
|
39
|
+
* workspace root beside the executor selection, and a writer that serialises
|
|
40
|
+
* only what it was handed silently deletes the rest.
|
|
41
|
+
*
|
|
42
|
+
* The write goes through `@skanl/brambo-projection`'s `atomicWriteText` rather than a
|
|
43
|
+
* local temp-then-rename, because this exact file is the one dotfile managers
|
|
44
|
+
* materialise as a symlink and it is the only writer in this repository that
|
|
45
|
+
* resolves the link instead of replacing it. The cost is that a refusal arrives
|
|
46
|
+
* as a `BRAMBO_PROJECTION_*` code out of a configuration verb; that is recorded
|
|
47
|
+
* in `deferred-work.md` and it is cheaper than a second copy of the symlink rule.
|
|
48
|
+
*/
|
|
49
|
+
export declare function setConfigValue(options: ConfigWriteOptions): Promise<ConfigWriteResult>;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { BRAMBO_ERROR_CODES, BramboError } from '@skanl/brambo-contracts';
|
|
4
|
+
import { atomicWriteText } from './atomic-write.js';
|
|
5
|
+
import { strictFaultLocation } from './document-fault.js';
|
|
6
|
+
// The write half of brambo's OWN configuration. `brambo run --help` has always
|
|
7
|
+
// told the user where the executor selection comes from and never how a value
|
|
8
|
+
// gets there, because nothing in the product wrote one: `packages/session`
|
|
9
|
+
// reads these documents four times and writes them zero. The answer brambo gave
|
|
10
|
+
// a user who wanted a different default was "edit this JSON", which is the one
|
|
11
|
+
// answer the product exists to remove.
|
|
12
|
+
//
|
|
13
|
+
// KEY-AGNOSTIC ON PURPOSE. This is "set one key in brambo's own configuration
|
|
14
|
+
// document", not "set the executor". Story 5.4 persists a `method` selection
|
|
15
|
+
// into the same documents with the same layer semantics and the same symlink
|
|
16
|
+
// hazard, and a second writer is a second place to get the symlink rule wrong.
|
|
17
|
+
// `<homeDir>/.brambo/config.json` is the `global` layer and
|
|
18
|
+
// `<projectDir>/.brambo/config.json` is the `project` layer, resolved by
|
|
19
|
+
// `readExecutorConfigLayers` in `@skanl/brambo-session`.
|
|
20
|
+
//
|
|
21
|
+
// ponytail: `.brambo/config.json` is spelled here rather than imported from
|
|
22
|
+
// `@skanl/brambo-session`, which spells it too and carries the same note. AD-2 forbids
|
|
23
|
+
// the edge, and it would exist only to share two string literals.
|
|
24
|
+
const BRAMBO_STATE_DIR = '.brambo';
|
|
25
|
+
const CONFIG_FILE = 'config.json';
|
|
26
|
+
/**
|
|
27
|
+
* The keys brambo will persist — an ALLOWLIST, not a suggestion.
|
|
28
|
+
*
|
|
29
|
+
* The two literals are spelled here rather than imported: this package cannot
|
|
30
|
+
* reach '@skanl/brambo-adapter-cli' (which owns EXECUTOR_CONFIG_KEY) under AD-2, and
|
|
31
|
+
* importing '@skanl/brambo-contracts' for METHOD_CONFIG_KEY alone would make one of the
|
|
32
|
+
* pair look canonical while the other stayed a literal. Both are duplicated, and
|
|
33
|
+
* this comment is why.
|
|
34
|
+
*
|
|
35
|
+
* Key-agnostic is not unconstrained. A key brambo does not read is a value
|
|
36
|
+
* written once and ignored forever, which is the same defect as a registry type
|
|
37
|
+
* nothing projects: M4.E's rule, applied to configuration. `method` arrived
|
|
38
|
+
* with M5.D, in the same change that taught brambo to read and mount one.
|
|
39
|
+
*/
|
|
40
|
+
export const WRITABLE_CONFIG_KEYS = ['executor', 'method'];
|
|
41
|
+
function unusable(filePath, detail, cause) {
|
|
42
|
+
return new BramboError(BRAMBO_ERROR_CODES.configurationUnusable, `brambo will not write '${filePath}' because ${detail}`, cause === undefined ? undefined : { cause });
|
|
43
|
+
}
|
|
44
|
+
function isRecord(value) {
|
|
45
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The document as it is on disk, or `undefined` when there is none.
|
|
49
|
+
*
|
|
50
|
+
* AD-5: absent and unusable are DIFFERENT answers and this is the only place
|
|
51
|
+
* that can tell them apart. Absent means create; unusable means refuse, and
|
|
52
|
+
* refuse without writing — the alternative is destroying a document brambo could
|
|
53
|
+
* not understand, including one a user is halfway through editing.
|
|
54
|
+
*/
|
|
55
|
+
async function readDocument(filePath) {
|
|
56
|
+
let text;
|
|
57
|
+
try {
|
|
58
|
+
text = await readFile(filePath, 'utf8');
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
const code = error?.code;
|
|
62
|
+
// ENOENT on a DANGLING symlink too — `readFile` follows links. That case is
|
|
63
|
+
// not treated as absent here: `atomicWriteText` resolves the link itself and
|
|
64
|
+
// refuses a dangling one coded, which is the answer that keeps brambo from
|
|
65
|
+
// materialising a regular file where the user put a link.
|
|
66
|
+
if (code === 'ENOENT')
|
|
67
|
+
return undefined;
|
|
68
|
+
throw unusable(filePath, `it could not be read (${code ?? 'unknown error'})`, error);
|
|
69
|
+
}
|
|
70
|
+
let parsed;
|
|
71
|
+
try {
|
|
72
|
+
parsed = JSON.parse(text);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// LOCATED, never quoted (`document-fault.ts`), and no `cause`: passing the
|
|
76
|
+
// error here put its message on every stack that printed this refusal.
|
|
77
|
+
throw unusable(filePath, `it is not valid JSON, and brambo does not overwrite a document it cannot read: ${strictFaultLocation(text)}`);
|
|
78
|
+
}
|
|
79
|
+
if (!isRecord(parsed)) {
|
|
80
|
+
throw unusable(filePath, 'it must hold a JSON object, and brambo does not replace one that does not');
|
|
81
|
+
}
|
|
82
|
+
return parsed;
|
|
83
|
+
}
|
|
84
|
+
/** `<root>/.brambo/config.json` for the scope this call names. */
|
|
85
|
+
export function configPathFor(options) {
|
|
86
|
+
const root = options.scope === 'machine' ? options.homeDir : options.projectDir;
|
|
87
|
+
if (root === undefined || root.trim().length === 0) {
|
|
88
|
+
throw new BramboError(BRAMBO_ERROR_CODES.environmentScopeUnavailable, "a 'project' scope needs the directory of the project whose configuration is being written");
|
|
89
|
+
}
|
|
90
|
+
return join(root, BRAMBO_STATE_DIR, CONFIG_FILE);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Sets ONE allowlisted key in brambo's own configuration document.
|
|
94
|
+
*
|
|
95
|
+
* Every other key is carried through untouched: these documents hold the
|
|
96
|
+
* workspace root beside the executor selection, and a writer that serialises
|
|
97
|
+
* only what it was handed silently deletes the rest.
|
|
98
|
+
*
|
|
99
|
+
* The write goes through `@skanl/brambo-projection`'s `atomicWriteText` rather than a
|
|
100
|
+
* local temp-then-rename, because this exact file is the one dotfile managers
|
|
101
|
+
* materialise as a symlink and it is the only writer in this repository that
|
|
102
|
+
* resolves the link instead of replacing it. The cost is that a refusal arrives
|
|
103
|
+
* as a `BRAMBO_PROJECTION_*` code out of a configuration verb; that is recorded
|
|
104
|
+
* in `deferred-work.md` and it is cheaper than a second copy of the symlink rule.
|
|
105
|
+
*/
|
|
106
|
+
export async function setConfigValue(options) {
|
|
107
|
+
const { key, value } = options;
|
|
108
|
+
if (!WRITABLE_CONFIG_KEYS.includes(key)) {
|
|
109
|
+
// The list is built BEFORE the message rather than interpolated into it: the
|
|
110
|
+
// printed-command scanner reads backtick strings out of shipped source, and
|
|
111
|
+
// a nested template literal reached it as raw `${...}` source text.
|
|
112
|
+
const known = WRITABLE_CONFIG_KEYS.map((writable) => `'${writable}'`).join(', ');
|
|
113
|
+
throw new BramboError(BRAMBO_ERROR_CODES.configurationUnusable, `brambo does not persist a '${key}' setting; it writes ${known}`);
|
|
114
|
+
}
|
|
115
|
+
const filePath = configPathFor(options);
|
|
116
|
+
const existing = await readDocument(filePath);
|
|
117
|
+
const previous = existing?.[key];
|
|
118
|
+
try {
|
|
119
|
+
await atomicWriteText(filePath, `${JSON.stringify({ ...existing, [key]: value }, undefined, 2)}\n`);
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
// Coded HERE rather than inside `atomicWriteText` (AD-7). Every OTHER caller
|
|
123
|
+
// of that writer goes through the projection engine, whose `toTargetFailure`
|
|
124
|
+
// already wraps a raw error as `BRAMBO_PROJECTION_TARGET_FAILED` and which
|
|
125
|
+
// `doctor` classifies from — coding it upstream was tried and it changed the
|
|
126
|
+
// code doctor sees. This caller does not go through the engine, so it codes
|
|
127
|
+
// its own failure, in configuration vocabulary rather than projection's.
|
|
128
|
+
//
|
|
129
|
+
// The commonest cause is a read-only document, and on Windows it is `rename`
|
|
130
|
+
// that refuses (measured: a 0o444 target gives `EPERM: operation not
|
|
131
|
+
// permitted, rename` while the temp write succeeds). The mode is NOT relaxed
|
|
132
|
+
// to get the write through: a file the user made read-only was made
|
|
133
|
+
// read-only on purpose.
|
|
134
|
+
const detail = error?.code;
|
|
135
|
+
// Worded to read after `unusable`'s own "brambo will not write '<path>'
|
|
136
|
+
// because" prefix, which is also why it does not start with the word brambo:
|
|
137
|
+
// a printed string that does is treated as a COMMAND by
|
|
138
|
+
// `packages/cli/test/printed-commands.test.ts` and has to be declared prose.
|
|
139
|
+
throw unusable(filePath, `it could not be replaced (${detail ?? String(error)}), so it is not writable`, error);
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
filePath,
|
|
143
|
+
previous: typeof previous === 'string' ? previous : undefined,
|
|
144
|
+
created: existing === undefined,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { ParseError } from 'jsonc-parser';
|
|
2
|
+
/**
|
|
3
|
+
* ONE rule, in one place, for every document this package parses (Spec M17.A):
|
|
4
|
+
* **no error brambo raises about a document quotes that document's content.**
|
|
5
|
+
*
|
|
6
|
+
* The hazard is not hypothetical and not confined to vendor files. V8's
|
|
7
|
+
* `JSON.parse` puts a window of the SOURCE TEXT in its message for the shapes it
|
|
8
|
+
* cannot give a position for — a stray comma before an array element and a `NaN`
|
|
9
|
+
* literal — and the documents brambo parses are where MCP server arguments live,
|
|
10
|
+
* which is where an API token lives. Measured through the shipped binary at
|
|
11
|
+
* `4232e9c`: a credential planted next to the fault reached stdout from
|
|
12
|
+
* `~/.claude.json` AND from `.brambo/registry.json`.
|
|
13
|
+
*
|
|
14
|
+
* The message cannot be TRIMMED to its location, because the shapes that quote
|
|
15
|
+
* the document are exactly the ones carrying no position. So it is dropped
|
|
16
|
+
* whole, the `cause` goes with it — a cause is reachable from any printed stack,
|
|
17
|
+
* so keeping it would move the leak rather than close it — and brambo derives its
|
|
18
|
+
* own location from `jsonc-parser`'s offsets.
|
|
19
|
+
*
|
|
20
|
+
* ponytail: `@skanl/brambo-registry` carries its own copy of this, because AD-2 forbids
|
|
21
|
+
* the edge that would let it import this one and `@skanl/brambo-contracts` must stay
|
|
22
|
+
* dependency-free for the third-party promise. Ceiling: two copies to keep in
|
|
23
|
+
* step. Upgrade path: a shared dependency-free leaf package, worth it the first
|
|
24
|
+
* time a third package needs it.
|
|
25
|
+
*/
|
|
26
|
+
/** What brambo says when it cannot derive a location — never the parser's text. */
|
|
27
|
+
export declare const FAULT_UNLOCATED = "the fault could not be located";
|
|
28
|
+
/**
|
|
29
|
+
* A byte offset as the 1-based `line:column` a user's editor shows.
|
|
30
|
+
*
|
|
31
|
+
* Offset 0 needs no special case and had one until it was measured: there
|
|
32
|
+
* `lastIndexOf('\n', -1)` is -1, the +1 makes `lineStart` 0, and `''.split('\n')`
|
|
33
|
+
* has length 1 — so the general form already answers `line 1, column 1`.
|
|
34
|
+
*/
|
|
35
|
+
export declare function positionOf(text: string, offset: number): string;
|
|
36
|
+
/**
|
|
37
|
+
* The parser's own CODE plus brambo's `line:column`, from a collected offset.
|
|
38
|
+
*
|
|
39
|
+
* The code (`InvalidSymbol`, `PropertyNameExpected`) is the parser's, not prose
|
|
40
|
+
* brambo invents: it is terser than a sentence, and it is the same word the
|
|
41
|
+
* user's editor and every other jsonc-parser consumer already shows them. It
|
|
42
|
+
* names the FAULT; it never carries a byte of the document.
|
|
43
|
+
*/
|
|
44
|
+
export declare function faultDetail(body: string, error: ParseError | undefined): string;
|
|
45
|
+
/**
|
|
46
|
+
* Re-locates a fault `JSON.parse` refused, using `jsonc-parser`'s OFFSETS — not
|
|
47
|
+
* a second parser, and not a regex over V8's message.
|
|
48
|
+
*
|
|
49
|
+
* The options are strict-JSON semantics exactly: trailing commas are errors (the
|
|
50
|
+
* default) and so are comments, so nearly every document V8 rejects collects an
|
|
51
|
+
* offset here. Where none is collected, the caller still refuses; it just cannot
|
|
52
|
+
* say where.
|
|
53
|
+
*
|
|
54
|
+
* `parseTree` RECURSES, and it was measured throwing `RangeError` on a document
|
|
55
|
+
* nested past ~5000 levels that V8 also rejects — so the throw is reachable from
|
|
56
|
+
* here. It is caught rather than propagated, because losing the location is the
|
|
57
|
+
* documented outcome and losing the CODED error is not.
|
|
58
|
+
*/
|
|
59
|
+
export declare function strictFaultLocation(body: string): string;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { parseTree, printParseErrorCode } from 'jsonc-parser';
|
|
2
|
+
/**
|
|
3
|
+
* ONE rule, in one place, for every document this package parses (Spec M17.A):
|
|
4
|
+
* **no error brambo raises about a document quotes that document's content.**
|
|
5
|
+
*
|
|
6
|
+
* The hazard is not hypothetical and not confined to vendor files. V8's
|
|
7
|
+
* `JSON.parse` puts a window of the SOURCE TEXT in its message for the shapes it
|
|
8
|
+
* cannot give a position for — a stray comma before an array element and a `NaN`
|
|
9
|
+
* literal — and the documents brambo parses are where MCP server arguments live,
|
|
10
|
+
* which is where an API token lives. Measured through the shipped binary at
|
|
11
|
+
* `4232e9c`: a credential planted next to the fault reached stdout from
|
|
12
|
+
* `~/.claude.json` AND from `.brambo/registry.json`.
|
|
13
|
+
*
|
|
14
|
+
* The message cannot be TRIMMED to its location, because the shapes that quote
|
|
15
|
+
* the document are exactly the ones carrying no position. So it is dropped
|
|
16
|
+
* whole, the `cause` goes with it — a cause is reachable from any printed stack,
|
|
17
|
+
* so keeping it would move the leak rather than close it — and brambo derives its
|
|
18
|
+
* own location from `jsonc-parser`'s offsets.
|
|
19
|
+
*
|
|
20
|
+
* ponytail: `@skanl/brambo-registry` carries its own copy of this, because AD-2 forbids
|
|
21
|
+
* the edge that would let it import this one and `@skanl/brambo-contracts` must stay
|
|
22
|
+
* dependency-free for the third-party promise. Ceiling: two copies to keep in
|
|
23
|
+
* step. Upgrade path: a shared dependency-free leaf package, worth it the first
|
|
24
|
+
* time a third package needs it.
|
|
25
|
+
*/
|
|
26
|
+
/** What brambo says when it cannot derive a location — never the parser's text. */
|
|
27
|
+
export const FAULT_UNLOCATED = 'the fault could not be located';
|
|
28
|
+
/**
|
|
29
|
+
* A byte offset as the 1-based `line:column` a user's editor shows.
|
|
30
|
+
*
|
|
31
|
+
* Offset 0 needs no special case and had one until it was measured: there
|
|
32
|
+
* `lastIndexOf('\n', -1)` is -1, the +1 makes `lineStart` 0, and `''.split('\n')`
|
|
33
|
+
* has length 1 — so the general form already answers `line 1, column 1`.
|
|
34
|
+
*/
|
|
35
|
+
export function positionOf(text, offset) {
|
|
36
|
+
const bounded = Math.max(0, Math.min(offset, text.length));
|
|
37
|
+
const lineStart = text.lastIndexOf('\n', bounded - 1) + 1;
|
|
38
|
+
return `line ${text.slice(0, lineStart).split('\n').length}, column ${bounded - lineStart + 1}`;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The parser's own CODE plus brambo's `line:column`, from a collected offset.
|
|
42
|
+
*
|
|
43
|
+
* The code (`InvalidSymbol`, `PropertyNameExpected`) is the parser's, not prose
|
|
44
|
+
* brambo invents: it is terser than a sentence, and it is the same word the
|
|
45
|
+
* user's editor and every other jsonc-parser consumer already shows them. It
|
|
46
|
+
* names the FAULT; it never carries a byte of the document.
|
|
47
|
+
*/
|
|
48
|
+
export function faultDetail(body, error) {
|
|
49
|
+
return error === undefined
|
|
50
|
+
? FAULT_UNLOCATED
|
|
51
|
+
: `${printParseErrorCode(error.error)} at ${positionOf(body, error.offset)}`;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Re-locates a fault `JSON.parse` refused, using `jsonc-parser`'s OFFSETS — not
|
|
55
|
+
* a second parser, and not a regex over V8's message.
|
|
56
|
+
*
|
|
57
|
+
* The options are strict-JSON semantics exactly: trailing commas are errors (the
|
|
58
|
+
* default) and so are comments, so nearly every document V8 rejects collects an
|
|
59
|
+
* offset here. Where none is collected, the caller still refuses; it just cannot
|
|
60
|
+
* say where.
|
|
61
|
+
*
|
|
62
|
+
* `parseTree` RECURSES, and it was measured throwing `RangeError` on a document
|
|
63
|
+
* nested past ~5000 levels that V8 also rejects — so the throw is reachable from
|
|
64
|
+
* here. It is caught rather than propagated, because losing the location is the
|
|
65
|
+
* documented outcome and losing the CODED error is not.
|
|
66
|
+
*/
|
|
67
|
+
export function strictFaultLocation(body) {
|
|
68
|
+
const errors = [];
|
|
69
|
+
try {
|
|
70
|
+
parseTree(body, errors, { disallowComments: true });
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return FAULT_UNLOCATED;
|
|
74
|
+
}
|
|
75
|
+
// The FIRST only. A recovering parser cascades — an unquoted key reports four
|
|
76
|
+
// — and the rest are that one's shadow.
|
|
77
|
+
return faultDetail(body, errors[0]);
|
|
78
|
+
}
|
package/dist/engine.d.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { ProjectionFailure, ProjectionResult, ProjectionTarget, ProjectionWarning, RegistryEntriesByKind, RegistryEntry } from '@skanl/brambo-contracts';
|
|
2
|
+
import type { ProjectionLedger } from './ledger.ts';
|
|
3
|
+
/**
|
|
4
|
+
* The registry as the projection engine reads it: one bucket per DECLARED entry
|
|
5
|
+
* type.
|
|
6
|
+
*
|
|
7
|
+
* Derived from `REGISTRY_ENTRY_TYPES` rather than listed — a second table here
|
|
8
|
+
* is how the engine comes to project a word the contract no longer has, or to
|
|
9
|
+
* miss one it just gained. It is also the boundary that keeps a RETIRED type out
|
|
10
|
+
* of projection: an entry whose type is not declared has no bucket, so it is
|
|
11
|
+
* dropped here and no target is ever asked to express it. The store still reads
|
|
12
|
+
* it, `brambo list` still shows it and `brambo remove` still takes it out.
|
|
13
|
+
*/
|
|
14
|
+
export declare function groupByKind(entries: readonly RegistryEntry[]): RegistryEntriesByKind;
|
|
15
|
+
/**
|
|
16
|
+
* Whether the run is allowed to LAND what it computes.
|
|
17
|
+
*
|
|
18
|
+
* `'inspect'` is the whole of `brambo doctor`: the identical merge, the identical
|
|
19
|
+
* drift classification, the identical ledger read — and neither of the two
|
|
20
|
+
* writes a run performs. A diagnosis computed by a second code path can disagree
|
|
21
|
+
* with what applying would do, and it would disagree exactly when a user is
|
|
22
|
+
* trying to fix something.
|
|
23
|
+
*/
|
|
24
|
+
export type ProjectionMode = 'apply' | 'inspect';
|
|
25
|
+
/**
|
|
26
|
+
* FAIL CLOSED. Not `mode !== 'inspect'`: that writes for `'Inspect'`,
|
|
27
|
+
* `'inspect '`, `'dry-run'` and `null`, and the one thing this field decides is
|
|
28
|
+
* whether brambo writes into files it does not own. A no-op run is visible in its
|
|
29
|
+
* own output; a write into a user's config on the say-so of a typo is not. So
|
|
30
|
+
* both failures are loud. `=== undefined`, not `??`: `null` is a value a caller
|
|
31
|
+
* PASSED, not an omission, and coalescing it into the writing default is the
|
|
32
|
+
* same silent accept this guard exists to remove.
|
|
33
|
+
*
|
|
34
|
+
* Shared by `runProjection` and `runRemediation` so the two commands that can
|
|
35
|
+
* write cannot disagree about what "do not touch this machine" means.
|
|
36
|
+
*/
|
|
37
|
+
export declare function resolveProjectionMode(mode: ProjectionMode | undefined): ProjectionMode;
|
|
38
|
+
export interface RunProjectionOptions {
|
|
39
|
+
readonly entries: RegistryEntriesByKind;
|
|
40
|
+
readonly targets: readonly ProjectionTarget[];
|
|
41
|
+
/** Required: without a ledger brambo cannot know which entries are its own. */
|
|
42
|
+
readonly ledger: ProjectionLedger;
|
|
43
|
+
/**
|
|
44
|
+
* Defaults to `'apply'`. Under `'inspect'` NOTHING is written — not the vendor
|
|
45
|
+
* file, not the ledger — and `ProjectionResult.written` reads as "these bytes
|
|
46
|
+
* WOULD have changed", which is the one field whose sentence the mode alters.
|
|
47
|
+
*/
|
|
48
|
+
readonly mode?: ProjectionMode;
|
|
49
|
+
}
|
|
50
|
+
export interface ProjectionRun {
|
|
51
|
+
readonly results: ProjectionResult[];
|
|
52
|
+
readonly failures: ProjectionFailure[];
|
|
53
|
+
readonly warnings: ProjectionWarning[];
|
|
54
|
+
}
|
|
55
|
+
/** File identity snapshot taken when the native text is read. */
|
|
56
|
+
export interface NativeFileSnapshot {
|
|
57
|
+
readonly mtimeMs: number;
|
|
58
|
+
readonly size: number;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Defense against a read-write race: if the native file changed on disk between
|
|
62
|
+
* the read and the write, the projection is stale and MUST NOT land. An ABSENT
|
|
63
|
+
* snapshot is not "nothing to compare": the merge was computed against an empty
|
|
64
|
+
* document, so the file appearing in the meantime — a vendor CLI creating
|
|
65
|
+
* `~/.claude.json` — is exactly the case where landing would overwrite it
|
|
66
|
+
* wholesale.
|
|
67
|
+
*/
|
|
68
|
+
export declare function hasFileChangedSince(filePath: string, snapshot: NativeFileSnapshot | undefined): Promise<boolean>;
|
|
69
|
+
export declare function runProjection(options: RunProjectionOptions): Promise<ProjectionRun>;
|