@bookedsolid/rea 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.husky/pre-push +15 -18
- package/README.md +41 -1
- package/dist/cli/doctor.d.ts +19 -4
- package/dist/cli/doctor.js +172 -5
- package/dist/cli/index.js +9 -1
- package/dist/cli/init.js +93 -7
- package/dist/cli/install/pre-push.d.ts +335 -0
- package/dist/cli/install/pre-push.js +2818 -0
- package/dist/cli/serve.d.ts +64 -0
- package/dist/cli/serve.js +270 -2
- package/dist/cli/status.d.ts +90 -0
- package/dist/cli/status.js +399 -0
- package/dist/cli/utils.d.ts +4 -0
- package/dist/cli/utils.js +4 -0
- package/dist/gateway/circuit-breaker.d.ts +17 -0
- package/dist/gateway/circuit-breaker.js +32 -3
- package/dist/gateway/downstream-pool.d.ts +2 -1
- package/dist/gateway/downstream-pool.js +2 -2
- package/dist/gateway/downstream.d.ts +39 -3
- package/dist/gateway/downstream.js +73 -14
- package/dist/gateway/log.d.ts +122 -0
- package/dist/gateway/log.js +334 -0
- package/dist/gateway/middleware/audit.d.ts +10 -1
- package/dist/gateway/middleware/audit.js +26 -1
- package/dist/gateway/middleware/blocked-paths.d.ts +0 -9
- package/dist/gateway/middleware/blocked-paths.js +439 -67
- package/dist/gateway/middleware/injection.d.ts +218 -13
- package/dist/gateway/middleware/injection.js +433 -51
- package/dist/gateway/middleware/kill-switch.d.ts +10 -1
- package/dist/gateway/middleware/kill-switch.js +20 -1
- package/dist/gateway/observability/metrics.d.ts +125 -0
- package/dist/gateway/observability/metrics.js +321 -0
- package/dist/gateway/server.d.ts +19 -0
- package/dist/gateway/server.js +99 -15
- package/dist/policy/loader.d.ts +13 -0
- package/dist/policy/loader.js +28 -0
- package/dist/policy/profiles.d.ts +13 -0
- package/dist/policy/profiles.js +12 -0
- package/dist/policy/types.d.ts +28 -0
- package/dist/registry/fingerprint.d.ts +73 -0
- package/dist/registry/fingerprint.js +81 -0
- package/dist/registry/fingerprints-store.d.ts +62 -0
- package/dist/registry/fingerprints-store.js +111 -0
- package/dist/registry/interpolate.d.ts +58 -0
- package/dist/registry/interpolate.js +121 -0
- package/dist/registry/loader.d.ts +2 -2
- package/dist/registry/loader.js +22 -1
- package/dist/registry/tofu-gate.d.ts +41 -0
- package/dist/registry/tofu-gate.js +189 -0
- package/dist/registry/tofu.d.ts +111 -0
- package/dist/registry/tofu.js +173 -0
- package/dist/registry/types.d.ts +9 -1
- package/package.json +1 -1
- package/profiles/bst-internal-no-codex.yaml +5 -0
- package/profiles/bst-internal.yaml +7 -0
- package/scripts/tarball-smoke.sh +197 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TOFU classifier — the G7 gate between `.rea/registry.yaml` and the
|
|
3
|
+
* downstream pool.
|
|
4
|
+
*
|
|
5
|
+
* For each server declared in the registry, classify as:
|
|
6
|
+
*
|
|
7
|
+
* - `first-seen` — no entry in `.rea/fingerprints.json`. Record the
|
|
8
|
+
* fingerprint, surface a LOUD block to the operator, allow the server
|
|
9
|
+
* to connect. This is the TOFU trust-on-first-use decision; the
|
|
10
|
+
* loudness is deliberate so a silent poisoning at first install is
|
|
11
|
+
* still visible in stderr / audit / logs.
|
|
12
|
+
*
|
|
13
|
+
* - `unchanged` — fingerprint matches the stored value. Proceed normally.
|
|
14
|
+
*
|
|
15
|
+
* - `drifted` — fingerprint differs from the stored value. Refuse to
|
|
16
|
+
* connect the server unless `REA_ACCEPT_DRIFT` names it for a single
|
|
17
|
+
* boot. The rest of the gateway stays up — other servers remain
|
|
18
|
+
* available, the upstream client just sees a smaller catalog.
|
|
19
|
+
*
|
|
20
|
+
* The audit entry, log line, and stderr block are emitted by the caller
|
|
21
|
+
* (the gateway startup sequence). This module is pure classification plus
|
|
22
|
+
* store updates; keeping it side-effect-free makes it unit-testable
|
|
23
|
+
* without stubbing the filesystem or the audit chain.
|
|
24
|
+
*/
|
|
25
|
+
import { fingerprintServer } from './fingerprint.js';
|
|
26
|
+
import { FINGERPRINT_STORE_VERSION } from './fingerprints-store.js';
|
|
27
|
+
function parseAcceptDrift(raw) {
|
|
28
|
+
if (raw === undefined || raw.trim() === '')
|
|
29
|
+
return new Set();
|
|
30
|
+
return new Set(raw
|
|
31
|
+
.split(',')
|
|
32
|
+
.map((s) => s.trim())
|
|
33
|
+
.filter((s) => s.length > 0));
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Classify every server in `servers` against the loaded `store`. Pure:
|
|
37
|
+
* does not read or write the filesystem. Returns one classification per
|
|
38
|
+
* server in the same order.
|
|
39
|
+
*
|
|
40
|
+
* ## Rename-with-removal defense (scope: narrow)
|
|
41
|
+
*
|
|
42
|
+
* A name-only lookup is not enough when an attacker rewrites a
|
|
43
|
+
* previously trusted entry AND changes its `name` at the same time: the
|
|
44
|
+
* stored lookup would miss and the tampered entry would land as benign
|
|
45
|
+
* `first-seen`. To close THAT specific shape we compute a rename signal
|
|
46
|
+
* at boot time:
|
|
47
|
+
*
|
|
48
|
+
* - `disappeared = stored_names - registry_names`
|
|
49
|
+
* - `appeared = registry_names - stored_names`
|
|
50
|
+
*
|
|
51
|
+
* If BOTH sets are non-empty in the same boot, at least one declared
|
|
52
|
+
* entry has been renamed-with-removal (stored entry vanished, a new name
|
|
53
|
+
* showed up). In that case every entry in `appeared` is promoted from
|
|
54
|
+
* `first-seen` to `drifted`: the operator MUST explicitly accept it via
|
|
55
|
+
* `REA_ACCEPT_DRIFT=<new-name>` before it connects.
|
|
56
|
+
*
|
|
57
|
+
* ### What this defense does NOT catch
|
|
58
|
+
*
|
|
59
|
+
* If the attacker leaves the old trusted entry in the registry (e.g.
|
|
60
|
+
* flipped `enabled: false`, or left untouched as a decoy) and ADDS a
|
|
61
|
+
* tampered entry under a new name, `disappeared` stays empty, the
|
|
62
|
+
* set-difference heuristic does not fire, and the new entry lands as
|
|
63
|
+
* `first-seen`. That is **not a bypass** of the TOFU contract — it is
|
|
64
|
+
* structurally identical to `operator added a new MCP server`, which
|
|
65
|
+
* TOFU intentionally allows with a LOUD stderr banner demanding the
|
|
66
|
+
* operator's attention. The first-seen banner is the enforcement
|
|
67
|
+
* mechanism for that shape; the rename-with-removal heuristic above is
|
|
68
|
+
* strictly additional coverage for the harder-to-notice shape where the
|
|
69
|
+
* old entry disappears at the same moment the new one arrives.
|
|
70
|
+
*
|
|
71
|
+
* Genuinely additive installs (new entry appended with no concurrent
|
|
72
|
+
* removal) also remain `first-seen` and get the usual LOUD banner.
|
|
73
|
+
*
|
|
74
|
+
* This is strictly additive over the name-based lookup — a name-matched
|
|
75
|
+
* drift (same name, changed config) still classifies as `drifted` via
|
|
76
|
+
* the primary path.
|
|
77
|
+
*
|
|
78
|
+
* The fingerprint itself already includes `server.name` (see
|
|
79
|
+
* `fingerprint.ts` canonicalization), so an attacker cannot make a
|
|
80
|
+
* renamed entry's fingerprint coincide with a stored one under a
|
|
81
|
+
* different name. That means a cross-name fingerprint match would never
|
|
82
|
+
* happen in practice — the set-difference heuristic above is what
|
|
83
|
+
* actually defends the rename-with-removal shape.
|
|
84
|
+
*/
|
|
85
|
+
export function classifyServers(servers, store, opts = {}) {
|
|
86
|
+
const bypass = parseAcceptDrift(opts.acceptDrift);
|
|
87
|
+
const registryNames = new Set(servers.map((s) => s.name));
|
|
88
|
+
const storedNames = new Set(Object.keys(store.servers));
|
|
89
|
+
const disappeared = new Set();
|
|
90
|
+
for (const n of storedNames) {
|
|
91
|
+
if (!registryNames.has(n))
|
|
92
|
+
disappeared.add(n);
|
|
93
|
+
}
|
|
94
|
+
const appeared = new Set();
|
|
95
|
+
for (const n of registryNames) {
|
|
96
|
+
if (!storedNames.has(n))
|
|
97
|
+
appeared.add(n);
|
|
98
|
+
}
|
|
99
|
+
// A rename is only plausible when something vanished AND something new
|
|
100
|
+
// appeared in the same boot. Pure additions (disappeared empty) are
|
|
101
|
+
// still benign first-seen.
|
|
102
|
+
const renameDetected = disappeared.size > 0 && appeared.size > 0;
|
|
103
|
+
// When a rename is detected, surface one disappeared stored fingerprint
|
|
104
|
+
// so the drift-block banner and audit entry have a concrete `stored`
|
|
105
|
+
// value to display. The choice is deterministic (first in insertion
|
|
106
|
+
// order from the store object) and documented as representative rather
|
|
107
|
+
// than authoritative — the operator's job is to compare the new entry
|
|
108
|
+
// against .rea/fingerprints.json by hand.
|
|
109
|
+
const representativeStored = renameDetected && disappeared.size > 0
|
|
110
|
+
? store.servers[[...disappeared][0]]
|
|
111
|
+
: undefined;
|
|
112
|
+
return servers.map((s) => {
|
|
113
|
+
const current = fingerprintServer(s);
|
|
114
|
+
const stored = store.servers[s.name];
|
|
115
|
+
if (stored === undefined) {
|
|
116
|
+
if (renameDetected && appeared.has(s.name)) {
|
|
117
|
+
// Rename-then-tamper defense: promote to drifted. Operator must
|
|
118
|
+
// REA_ACCEPT_DRIFT the new name to let it connect.
|
|
119
|
+
const c = {
|
|
120
|
+
server: s.name,
|
|
121
|
+
verdict: 'drifted',
|
|
122
|
+
current,
|
|
123
|
+
bypassed: bypass.has(s.name),
|
|
124
|
+
};
|
|
125
|
+
if (representativeStored !== undefined)
|
|
126
|
+
c.stored = representativeStored;
|
|
127
|
+
return c;
|
|
128
|
+
}
|
|
129
|
+
return { server: s.name, verdict: 'first-seen', current, bypassed: false };
|
|
130
|
+
}
|
|
131
|
+
if (stored === current) {
|
|
132
|
+
return { server: s.name, verdict: 'unchanged', current, stored, bypassed: false };
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
server: s.name,
|
|
136
|
+
verdict: 'drifted',
|
|
137
|
+
current,
|
|
138
|
+
stored,
|
|
139
|
+
bypassed: bypass.has(s.name),
|
|
140
|
+
};
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Merge classifications into an updated store. Applies the TOFU rule:
|
|
145
|
+
*
|
|
146
|
+
* - `first-seen` → add the current fingerprint.
|
|
147
|
+
* - `unchanged` → keep the existing value (no-op).
|
|
148
|
+
* - `drifted` → if bypassed, overwrite with the current fingerprint
|
|
149
|
+
* (operator has authorized the update); otherwise keep
|
|
150
|
+
* the stored value (drift persists across restart until
|
|
151
|
+
* explicitly accepted).
|
|
152
|
+
*
|
|
153
|
+
* Does not prune entries for servers that were removed from the registry —
|
|
154
|
+
* that decision is the operator's, and silently dropping fingerprints
|
|
155
|
+
* would let an attacker rename-then-reinstall a server to reset TOFU state.
|
|
156
|
+
*/
|
|
157
|
+
export function updateStore(store, classifications) {
|
|
158
|
+
const next = {
|
|
159
|
+
version: FINGERPRINT_STORE_VERSION,
|
|
160
|
+
servers: { ...store.servers },
|
|
161
|
+
};
|
|
162
|
+
for (const c of classifications) {
|
|
163
|
+
if (c.verdict === 'first-seen') {
|
|
164
|
+
next.servers[c.server] = c.current;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (c.verdict === 'drifted' && c.bypassed) {
|
|
168
|
+
next.servers[c.server] = c.current;
|
|
169
|
+
}
|
|
170
|
+
// unchanged / drifted-no-bypass → leave store untouched for this server
|
|
171
|
+
}
|
|
172
|
+
return next;
|
|
173
|
+
}
|
package/dist/registry/types.d.ts
CHANGED
|
@@ -11,7 +11,15 @@ export interface RegistryServer {
|
|
|
11
11
|
command: string;
|
|
12
12
|
/** Arguments passed to the spawned child process. */
|
|
13
13
|
args: string[];
|
|
14
|
-
/**
|
|
14
|
+
/**
|
|
15
|
+
* Environment variables merged onto the child process env. Values may
|
|
16
|
+
* reference rea-serve's own `process.env` via `${VAR}` syntax — e.g.
|
|
17
|
+
* `{ BOT_TOKEN: '${DISCORD_BOT_TOKEN}' }`. Only the curly-brace form is
|
|
18
|
+
* supported; no `$VAR`, no defaults, no command substitution. If a
|
|
19
|
+
* referenced var is unset at spawn time the affected server fails to
|
|
20
|
+
* start (the rest of the gateway still comes up). See
|
|
21
|
+
* `registry/interpolate.ts` for the full grammar and contract.
|
|
22
|
+
*/
|
|
15
23
|
env: Record<string, string>;
|
|
16
24
|
/**
|
|
17
25
|
* Optional opt-in list of operator-env var names to forward into the child.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bookedsolid/rea",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Agentic governance layer for Claude Code — policy enforcement, hook-based safety gates, audit logging, and Codex-integrated adversarial review for AI-assisted projects",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Booked Solid Technology <oss@bookedsolid.tech> (https://bookedsolid.tech)",
|
|
@@ -32,6 +32,11 @@ blocked_paths:
|
|
|
32
32
|
- SECURITY.md
|
|
33
33
|
- THREAT_MODEL.md
|
|
34
34
|
notification_channel: ""
|
|
35
|
+
# G9: Booked-internal consumers retain the stricter 0.2.x posture — a single
|
|
36
|
+
# literal injection match at write/destructive tier denies (does not merely
|
|
37
|
+
# warn). External profiles inherit the schema default `false`.
|
|
38
|
+
injection:
|
|
39
|
+
suspicious_blocks_writes: true
|
|
35
40
|
context_protection:
|
|
36
41
|
delegate_to_subagent:
|
|
37
42
|
- pnpm run build
|
|
@@ -15,6 +15,13 @@ blocked_paths:
|
|
|
15
15
|
- SECURITY.md
|
|
16
16
|
- THREAT_MODEL.md
|
|
17
17
|
notification_channel: ""
|
|
18
|
+
# G9: Booked-internal consumers retain the stricter 0.2.x posture — a single
|
|
19
|
+
# literal injection match at write/destructive tier denies (does not merely
|
|
20
|
+
# warn). External profiles (open-source, client-engagement, minimal, lit-wc)
|
|
21
|
+
# inherit the schema default `false` so upgrading 0.2.x consumers are not
|
|
22
|
+
# silently tightened.
|
|
23
|
+
injection:
|
|
24
|
+
suspicious_blocks_writes: true
|
|
18
25
|
context_protection:
|
|
19
26
|
delegate_to_subagent:
|
|
20
27
|
- pnpm run build
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# tarball-smoke.sh — exercise the packed @bookedsolid/rea tarball end-to-end
|
|
3
|
+
# in an isolated tempdir. Catches packaging regressions (missing files from
|
|
4
|
+
# `files:`, broken exports map, shebang / chmod issues on `bin`, postinstall
|
|
5
|
+
# failures, dependency resolution drift) BEFORE the tarball reaches npm.
|
|
6
|
+
#
|
|
7
|
+
# Must be run from the repo root. Assumes `dist/` has already been built.
|
|
8
|
+
#
|
|
9
|
+
# Runs under CI on every PR and on every push to main; also recommended as a
|
|
10
|
+
# manual gate before hand-authorizing a Changesets release PR merge.
|
|
11
|
+
#
|
|
12
|
+
# ## Developer-run negative probe (optional, not in CI)
|
|
13
|
+
#
|
|
14
|
+
# To verify the tree-equality asserts actually fail loud on a missing shipped
|
|
15
|
+
# file, temporarily drop `commands/` or `.husky/` from `package.json#files[]`,
|
|
16
|
+
# run this script, and confirm it exits non-zero at the install-surface diff
|
|
17
|
+
# step. Revert the `files:` edit before committing. CI does not run this probe
|
|
18
|
+
# because it would mutate package.json.
|
|
19
|
+
#
|
|
20
|
+
# Exit codes:
|
|
21
|
+
# 0 — smoke passed
|
|
22
|
+
# 1 — preflight failure (missing dist, pack failed)
|
|
23
|
+
# 2 — smoke assertion failure (bin missing, init/doctor failed, exports broken)
|
|
24
|
+
set -euo pipefail
|
|
25
|
+
|
|
26
|
+
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
27
|
+
cd "$REPO_ROOT"
|
|
28
|
+
|
|
29
|
+
if [ ! -d "dist" ]; then
|
|
30
|
+
echo "[smoke] FAIL — dist/ not found. Run 'pnpm build' first." >&2
|
|
31
|
+
exit 1
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
PACK_DIR="$(mktemp -d -t rea-smoke-pack-XXXXXX)"
|
|
35
|
+
SMOKE_DIR="$(mktemp -d -t rea-smoke-install-XXXXXX)"
|
|
36
|
+
DIFF_TMP="$(mktemp -t rea-smoke-diff-XXXXXX)"
|
|
37
|
+
cleanup() { rm -rf -- "$PACK_DIR" "$SMOKE_DIR" 2>/dev/null || true; rm -f "$DIFF_TMP"; }
|
|
38
|
+
# EXIT alone misses Ctrl-C / TERM / HUP during local runs, leaving
|
|
39
|
+
# /tmp/rea-smoke-* tempdirs behind. Trap the interrupt signals too.
|
|
40
|
+
trap cleanup EXIT HUP INT TERM
|
|
41
|
+
|
|
42
|
+
echo "[smoke] pack → $PACK_DIR"
|
|
43
|
+
pnpm pack --pack-destination "$PACK_DIR" >/dev/null
|
|
44
|
+
TARBALL="$(ls "$PACK_DIR"/bookedsolid-rea-*.tgz | head -1)"
|
|
45
|
+
if [ -z "$TARBALL" ] || [ ! -f "$TARBALL" ]; then
|
|
46
|
+
echo "[smoke] FAIL — pnpm pack produced no tarball in $PACK_DIR" >&2
|
|
47
|
+
exit 1
|
|
48
|
+
fi
|
|
49
|
+
echo "[smoke] tarball: $(basename "$TARBALL") ($(wc -c < "$TARBALL" | awk '{printf "%.0f KB\n", $1/1024}'))"
|
|
50
|
+
|
|
51
|
+
echo "[smoke] install in $SMOKE_DIR"
|
|
52
|
+
cd "$SMOKE_DIR"
|
|
53
|
+
npm init -y >/dev/null
|
|
54
|
+
npm install --no-audit --no-fund --loglevel=error "$TARBALL"
|
|
55
|
+
|
|
56
|
+
# Drop the temp package.json + lockfile that `npm init -y` + `npm install`
|
|
57
|
+
# wrote. The tempdir must look like a fresh consumer project (no package.json)
|
|
58
|
+
# so `rea init` exercises the same code path a brand-new consumer hits.
|
|
59
|
+
rm -f package.json package-lock.json
|
|
60
|
+
git init -q
|
|
61
|
+
|
|
62
|
+
echo "[smoke] rea --version"
|
|
63
|
+
VERSION_OUT="$(./node_modules/.bin/rea --version)"
|
|
64
|
+
# Pass the repo-root package.json path via argv to avoid interpolating it
|
|
65
|
+
# into a JS string literal — paths with apostrophes, backslashes, or `${...}`
|
|
66
|
+
# expansions would otherwise break the require() call.
|
|
67
|
+
EXPECTED_VERSION="$(node -p "require(process.argv[1]).version" "$REPO_ROOT/package.json")"
|
|
68
|
+
if [ "$VERSION_OUT" != "$EXPECTED_VERSION" ]; then
|
|
69
|
+
echo "[smoke] FAIL — rea --version returned '$VERSION_OUT', expected '$EXPECTED_VERSION'" >&2
|
|
70
|
+
exit 2
|
|
71
|
+
fi
|
|
72
|
+
echo "[smoke] → $VERSION_OUT"
|
|
73
|
+
|
|
74
|
+
echo "[smoke] rea --help"
|
|
75
|
+
./node_modules/.bin/rea --help >/dev/null
|
|
76
|
+
|
|
77
|
+
echo "[smoke] rea init --yes --profile open-source"
|
|
78
|
+
./node_modules/.bin/rea init --yes --profile open-source
|
|
79
|
+
|
|
80
|
+
# Verify the installed layout matches what init claims it wrote.
|
|
81
|
+
for expected in .rea/policy.yaml .rea/registry.yaml .claude/settings.json CLAUDE.md .rea/install-manifest.json; do
|
|
82
|
+
if [ ! -f "$expected" ]; then
|
|
83
|
+
echo "[smoke] FAIL — rea init did not create $expected" >&2
|
|
84
|
+
exit 2
|
|
85
|
+
fi
|
|
86
|
+
done
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# Install-surface tree-equality asserts.
|
|
90
|
+
#
|
|
91
|
+
# Prior versions counted `.claude/agents/*.md` and `.claude/hooks/*.sh` and
|
|
92
|
+
# never verified `.claude/commands/` or the shipped `.husky/pre-push`. A
|
|
93
|
+
# tarball that dropped either surface still passed. We now diff sorted file
|
|
94
|
+
# lists: any missing OR extra file fails loud and names the delta.
|
|
95
|
+
#
|
|
96
|
+
# Surfaces under test:
|
|
97
|
+
# 1. .claude/agents/ ↔ repo agents/*.md (flat)
|
|
98
|
+
# 2. .claude/hooks/ ↔ repo hooks/**/*.sh (flat + _lib/)
|
|
99
|
+
# 3. .claude/commands/ ↔ repo commands/*.md (flat)
|
|
100
|
+
# 4. node_modules/.../.husky ↔ repo .husky/{commit-msg,pre-push}
|
|
101
|
+
#
|
|
102
|
+
# The `.husky/` check targets the package tree under node_modules because
|
|
103
|
+
# `rea init` only copies `.husky/*` into the consumer when `.husky/` already
|
|
104
|
+
# exists there. On a fresh consumer (this smoke's default), the hooks live as
|
|
105
|
+
# `.git/hooks/{commit-msg,pre-push}` via the fallback installers. What must
|
|
106
|
+
# ALWAYS be true is that the tarball itself ships the `.husky/` source of
|
|
107
|
+
# truth — without it, husky-using consumers get nothing.
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
assert_tree_equal() {
|
|
111
|
+
# $1 — label for error messages
|
|
112
|
+
# $2 — file listing of the source tree (one relative path per line)
|
|
113
|
+
# $3 — file listing of the installed tree (one relative path per line)
|
|
114
|
+
local label="$1" src="$2" dst="$3"
|
|
115
|
+
if [ -z "$src" ] || [ -z "$dst" ]; then
|
|
116
|
+
printf '[smoke] FAIL — empty file listing for %s\n' "$label" >&2
|
|
117
|
+
exit 2
|
|
118
|
+
fi
|
|
119
|
+
if ! diff -u <(printf '%s\n' "$src" | sort -u) <(printf '%s\n' "$dst" | sort -u) > "$DIFF_TMP" 2>&1; then
|
|
120
|
+
echo "[smoke] FAIL — $label differs between source tree and installed tree:" >&2
|
|
121
|
+
cat "$DIFF_TMP" >&2
|
|
122
|
+
exit 2
|
|
123
|
+
fi
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
# 1. agents — flat listing of *.md
|
|
127
|
+
AGENTS_SRC="$(cd "$REPO_ROOT/agents" && find . -maxdepth 1 -type f -name '*.md' | sed 's|^\./||')"
|
|
128
|
+
AGENTS_DST="$(cd "$SMOKE_DIR/.claude/agents" && find . -maxdepth 1 -type f -name '*.md' | sed 's|^\./||')"
|
|
129
|
+
assert_tree_equal ".claude/agents tree" "$AGENTS_SRC" "$AGENTS_DST"
|
|
130
|
+
|
|
131
|
+
# 2. hooks — recursive listing of *.sh (walks hooks/_lib/ too)
|
|
132
|
+
HOOKS_SRC="$(cd "$REPO_ROOT/hooks" && find . -type f -name '*.sh' | sed 's|^\./||')"
|
|
133
|
+
HOOKS_DST="$(cd "$SMOKE_DIR/.claude/hooks" && find . -type f -name '*.sh' | sed 's|^\./||')"
|
|
134
|
+
assert_tree_equal ".claude/hooks tree" "$HOOKS_SRC" "$HOOKS_DST"
|
|
135
|
+
|
|
136
|
+
# 3. commands — flat listing of *.md
|
|
137
|
+
COMMANDS_SRC="$(cd "$REPO_ROOT/commands" && find . -maxdepth 1 -type f -name '*.md' | sed 's|^\./||')"
|
|
138
|
+
COMMANDS_DST="$(cd "$SMOKE_DIR/.claude/commands" && find . -maxdepth 1 -type f -name '*.md' | sed 's|^\./||')"
|
|
139
|
+
assert_tree_equal ".claude/commands tree" "$COMMANDS_SRC" "$COMMANDS_DST"
|
|
140
|
+
|
|
141
|
+
# 4. husky — explicit pre-push + commit-msg existence inside the package
|
|
142
|
+
# tree under node_modules. `rea init` does not copy these into a fresh
|
|
143
|
+
# consumer's root, so we check the shipped copy directly. If either file
|
|
144
|
+
# is missing from the tarball, husky-using consumers silently get zero
|
|
145
|
+
# enforcement on their next `pnpm install`.
|
|
146
|
+
#
|
|
147
|
+
# Executable-bit check is intentionally NOT asserted here: npm's tarball
|
|
148
|
+
# format strips the group/other execute bits from non-`bin:` files on
|
|
149
|
+
# install, so the shipped file lives at mode 0644. What matters is that
|
|
150
|
+
# the installers in commit-msg.ts and pre-push.ts use these as templates
|
|
151
|
+
# and chmod the destination (.git/hooks/... or .husky/...) themselves.
|
|
152
|
+
HUSKY_PKG_DIR="$SMOKE_DIR/node_modules/@bookedsolid/rea/.husky"
|
|
153
|
+
for husky_file in commit-msg pre-push; do
|
|
154
|
+
path="$HUSKY_PKG_DIR/$husky_file"
|
|
155
|
+
if [ ! -f "$path" ]; then
|
|
156
|
+
echo "[smoke] FAIL — tarball missing .husky/$husky_file (expected at $path)" >&2
|
|
157
|
+
exit 2
|
|
158
|
+
fi
|
|
159
|
+
done
|
|
160
|
+
|
|
161
|
+
# On a fresh consumer (no pre-existing .husky/), rea installs the fallback
|
|
162
|
+
# pre-push + commit-msg into .git/hooks/. Assert that path landed too — it is
|
|
163
|
+
# the enforcement surface for this smoke's simulated consumer.
|
|
164
|
+
for git_hook in commit-msg pre-push; do
|
|
165
|
+
path="$SMOKE_DIR/.git/hooks/$git_hook"
|
|
166
|
+
if [ ! -f "$path" ]; then
|
|
167
|
+
echo "[smoke] FAIL — .git/hooks/$git_hook missing after rea init" >&2
|
|
168
|
+
exit 2
|
|
169
|
+
fi
|
|
170
|
+
if [ ! -x "$path" ]; then
|
|
171
|
+
echo "[smoke] FAIL — .git/hooks/$git_hook is not executable" >&2
|
|
172
|
+
exit 2
|
|
173
|
+
fi
|
|
174
|
+
done
|
|
175
|
+
|
|
176
|
+
AGENT_COUNT="$(printf '%s\n' "$AGENTS_DST" | grep -c . || true)"
|
|
177
|
+
HOOK_COUNT="$(printf '%s\n' "$HOOKS_DST" | grep -c . || true)"
|
|
178
|
+
COMMAND_COUNT="$(printf '%s\n' "$COMMANDS_DST" | grep -c . || true)"
|
|
179
|
+
echo "[smoke] → $AGENT_COUNT agents, $HOOK_COUNT hooks, $COMMAND_COUNT commands, .husky/{commit-msg,pre-push} shipped, .git/hooks/{commit-msg,pre-push} installed"
|
|
180
|
+
|
|
181
|
+
echo "[smoke] rea doctor"
|
|
182
|
+
./node_modules/.bin/rea doctor
|
|
183
|
+
|
|
184
|
+
# Verify every declared public export resolves. If the exports map points at a
|
|
185
|
+
# file that didn't ship in `files:`, this is where we catch it.
|
|
186
|
+
echo "[smoke] resolve exports"
|
|
187
|
+
node --input-type=module -e "
|
|
188
|
+
import('@bookedsolid/rea').then(m => { if (typeof m !== 'object') { console.error('bad root export'); process.exit(2); } });
|
|
189
|
+
import('@bookedsolid/rea/policy').then(m => { if (!m) { console.error('bad /policy export'); process.exit(2); } });
|
|
190
|
+
import('@bookedsolid/rea/middleware').then(m => { if (!m) { console.error('bad /middleware export'); process.exit(2); } });
|
|
191
|
+
import('@bookedsolid/rea/audit').then(m => {
|
|
192
|
+
if (typeof m.appendAuditRecord !== 'function') { console.error('audit.appendAuditRecord not a function'); process.exit(2); }
|
|
193
|
+
});
|
|
194
|
+
"
|
|
195
|
+
echo "[smoke] → root, /policy, /middleware, /audit all resolve"
|
|
196
|
+
|
|
197
|
+
echo "[smoke] PASS"
|