@bookedsolid/rea 0.48.1 → 0.49.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.
@@ -0,0 +1,440 @@
1
+ /**
2
+ * `rea init` self-pin (0.49.0).
3
+ *
4
+ * # Problem
5
+ *
6
+ * `rea init` writes hook shims under `.claude/hooks/` that depend on the
7
+ * `@bookedsolid/rea` CLI being resolvable from `node_modules/`. The
8
+ * pre-0.49.0 init flow did NOT add the dep to the consumer's
9
+ * `package.json`, so any fresh clone + `pnpm install` produced a repo
10
+ * where the shims found no CLI and (correctly) refused every Bash
11
+ * call — including the very `pnpm add -D @bookedsolid/rea` that would
12
+ * recover the install. The bash-gate bootstrap allowlist (Fix B) is the
13
+ * paired safety net; this module is the structural fix.
14
+ *
15
+ * # Contract
16
+ *
17
+ * - Caret-pinned (`^<current-CLI-version>`) entry in `devDependencies`.
18
+ * - Lands in workspace ROOT `package.json`. Walks up from `targetDir`
19
+ * until a `package.json` is found; refuses to mutate a parent that
20
+ * the operator did not explicitly target (we only mutate when the
21
+ * FIRST `package.json` we find IS the target).
22
+ * - Existing different version: warn + skip (do NOT mutate). The
23
+ * operator owns their pin.
24
+ * - Idempotent: re-runs are byte-identical when the pin already
25
+ * matches. Detects and preserves indent / EOL / trailing-newline so
26
+ * no spurious diff churn lands in the consumer's repo.
27
+ * - Dogfood short-circuit: when the consumer's `pkg.name` is
28
+ * `@bookedsolid/rea` itself, skip silently — the dogfood install
29
+ * pins the version via the build, not via the manifest.
30
+ *
31
+ * # Why caret
32
+ *
33
+ * A caret pin (`^0.49.0` → satisfies 0.49.x AND 0.50.0+) gives
34
+ * consumers automatic minor-version uptake without breaking when the
35
+ * shim ABI bumps. Major bumps remain a deliberate operator action.
36
+ *
37
+ * # Why warn-and-skip on existing different version
38
+ *
39
+ * Three scenarios where this matters:
40
+ *
41
+ * 1. Operator explicitly pinned an exact version (`"0.48.1"`) for
42
+ * reproducibility — `rea init` overwriting that to caret would
43
+ * silently widen their pin.
44
+ * 2. Operator is running an OLDER `rea init` against a `package.json`
45
+ * that has a NEWER `rea` pin. Downgrading the pin would brick the
46
+ * install once the operator's lockfile resolves against it.
47
+ * 3. Operator deliberately pinned a workspace-relative path (`"workspace:^"`,
48
+ * `"file:../rea"`). Replacing that with a registry pin breaks the
49
+ * monorepo wiring.
50
+ *
51
+ * Warn-and-skip preserves operator intent in all three cases.
52
+ */
53
+ /** Package name we self-pin. */
54
+ export declare const REA_PACKAGE_NAME = "@bookedsolid/rea";
55
+ export interface SelfPinResult {
56
+ /**
57
+ * Outcome of the operation. Single value so the caller can format a
58
+ * one-line console message uniformly.
59
+ *
60
+ * - `'wrote'` — package.json was mutated (new dep added OR an
61
+ * existing matching pin re-serialized identically).
62
+ * - `'bumped'` — `mode: 'upgrade'` only. Existing pin was a
63
+ * managed-caret form on the SAME major as the new
64
+ * CLI but did not admit the new minor (e.g.
65
+ * `^0.49.0` + new CLI `0.50.0` — `^0.49.0` rejects
66
+ * `0.50.0` because pre-1.0 caret behaves like
67
+ * tilde). We re-write the pin to the new caret
68
+ * form. Operator-facing message includes the
69
+ * "bumped from X to Y" delta so the change is
70
+ * visible in the upgrade log.
71
+ * - `'skipped-same'` — the existing pin already matches what we
72
+ * would write (idempotent re-run, byte-identical).
73
+ * - `'skipped-different'` — the existing pin differs from ours and
74
+ * we refuse to mutate (operator owns the pin).
75
+ * - `'skipped-dogfood'` — `pkg.name === '@bookedsolid/rea'`, the
76
+ * self-host case; we never self-pin.
77
+ * - `'skipped-no-package-json'` — no `package.json` in the explicit
78
+ * target directory. P2-4: we never
79
+ * walk upward — invocation from a
80
+ * pkg-less subdir refuses rather
81
+ * than silently mutating the parent.
82
+ * - `'skipped-malformed-package-json'` — `package.json` exists but
83
+ * is not valid JSON or not an
84
+ * object; we refuse to mutate
85
+ * a file we do not understand.
86
+ * - `'skipped-symlink-package-json'` — R10-P2 (codex round 10):
87
+ * `package.json` is a symlink.
88
+ * DRY-RUN only — live mode
89
+ * THROWS rather than returning
90
+ * this. The skip-shape exists
91
+ * so `rea upgrade --dry-run`
92
+ * / `--check` can complete a
93
+ * preview even when the
94
+ * symlink would block the
95
+ * live run.
96
+ */
97
+ action: 'wrote' | 'bumped' | 'skipped-same' | 'skipped-different' | 'skipped-dogfood' | 'skipped-no-package-json' | 'skipped-malformed-package-json' | 'skipped-symlink-package-json';
98
+ /** Absolute path to the package.json we resolved (or null when none found). */
99
+ packageJsonPath: string | null;
100
+ /** Caret-pinned version range we wrote (e.g. `^0.49.0`). Empty when no write happened. */
101
+ pinnedRange: string;
102
+ /** Existing range when the action was `skipped-different`. */
103
+ existingRange?: string;
104
+ /** Operator-facing message (one line, no newline). */
105
+ message: string;
106
+ }
107
+ export interface SelfPinOptions {
108
+ /**
109
+ * Starting directory for the upward `package.json` walk. The walk
110
+ * stops at the first `package.json` found OR at the filesystem root.
111
+ */
112
+ cwd: string;
113
+ /**
114
+ * The currently-running `@bookedsolid/rea` CLI version (e.g.
115
+ * `'0.49.0'`). The written pin is `^<version>`.
116
+ */
117
+ cliVersion: string;
118
+ /**
119
+ * When true, never log to stderr — the caller will surface the result
120
+ * structurally. Used by `rea upgrade` which composes its own output.
121
+ */
122
+ silent?: boolean;
123
+ /**
124
+ * R3-P2 (codex round 3): when true, perform the read + decision
125
+ * logic but skip the on-disk write. The returned `action`
126
+ * discriminant is the SAME as the live run would produce
127
+ * (`'wrote'`, `'bumped'`, `'skipped-same'`, `'skipped-different'`,
128
+ * etc.) so the caller can preview exactly what the live run will
129
+ * do. `message` carries a `would-` prefix on the actions that
130
+ * would have mutated the file (`wrote` / `bumped`) so the caller's
131
+ * console output is unambiguous.
132
+ *
133
+ * Pre-fix, `rea upgrade --dry-run` short-circuited around the entire
134
+ * `selfPinRea` call, hiding the planned self-pin action from the
135
+ * dry-run preview. Operators ran dry-run, saw zero pin-related
136
+ * lines, then ran the live upgrade and got a surprise mutation of
137
+ * their package.json.
138
+ *
139
+ * Default: `false` (write path active — preserves existing behavior
140
+ * for every caller that does not opt in).
141
+ */
142
+ dryRun?: boolean;
143
+ /**
144
+ * Call-site discriminator (P1-1 / codex round 2).
145
+ *
146
+ * - `'init'` (default) — `rea init` semantics: warn-and-skip on every
147
+ * existing-different-version pin. Respects whatever pin the
148
+ * operator already chose; we never overwrite on a fresh install.
149
+ *
150
+ * - `'upgrade'` — `rea upgrade` semantics: when the existing pin is
151
+ * a managed-caret form (a caret pin we previously wrote, with no
152
+ * operator-authored shape laundering) AND the new CLI is on the
153
+ * SAME major as the existing pin AND the existing caret does NOT
154
+ * admit the new CLI version, BUMP the pin to the new caret. This
155
+ * closes the pre-1.0 caret tightness trap: `^0.49.0` does NOT
156
+ * admit `0.50.0` (pre-1.0 caret is npm-spec'd to behave like
157
+ * tilde), so without auto-bump a 0.49.x → 0.50.x upgrade would
158
+ * copy the newer hooks but leave the old CLI pinned, recreating
159
+ * the hook/CLI skew this whole feature exists to prevent.
160
+ *
161
+ * Auto-bump shape gate: existing range matches a strict managed-
162
+ * caret regex (`^\^\d+\.\d+(\.\d+)?(-prerelease)?$`). Anything
163
+ * else (`workspace:*`, `file:..`, git URLs, `next`, exact pins,
164
+ * tildes, complex ranges) is operator-authored and we hands-off.
165
+ * Cross-major bumps (`^0.x` → `1.x` or `^1.x` → `0.x`) are
166
+ * ALSO operator-authored decisions and we hands-off — major
167
+ * changes are meaningful and should not be silent.
168
+ *
169
+ * Default: `'init'`. R13-P1 (codex round 13) update: BOTH the
170
+ * `rea init` and `rea upgrade` call sites now pass `mode:
171
+ * 'upgrade'` explicitly. The R11-P1 preflight (init) +
172
+ * R9-P1 preflight (upgrade) filter out non-managed-caret cases
173
+ * BEFORE `selfPinRea` runs, so the only thing that reaches the
174
+ * write path is either a fresh write OR a managed-caret bump —
175
+ * and `mode: 'upgrade'` is the correct semantics for both.
176
+ *
177
+ * The `'init'` default is preserved for backwards-compat with any
178
+ * external caller of this exported function. New rea-internal
179
+ * call sites should pass `mode: 'upgrade'` explicitly.
180
+ */
181
+ mode?: 'init' | 'upgrade';
182
+ }
183
+ /**
184
+ * Strip a leading UTF-8 BOM (U+FEFF / EF BB BF) from a string, returning the
185
+ * rest. No-op when no BOM is present.
186
+ *
187
+ * Some Windows operators commit `package.json` with a leading BOM; `JSON.parse`
188
+ * rejects it (the spec says JSON.parse must error on a leading BOM). We
189
+ * silently strip it before parse — npm and pnpm both tolerate either form
190
+ * when writing back, so dropping the BOM on save is the simpler, more
191
+ * invariant choice. The alternative (detect-and-preserve) would need an
192
+ * extra field on `FileShape` plus a re-prepend in `serialize`, and the cost
193
+ * (one operator who deliberately wanted a BOM no longer has one) is much
194
+ * lower than the cost of an unrecoverable false-positive
195
+ * `skipped-malformed-package-json` on every BOM-bearing manifest.
196
+ *
197
+ * P3-1 (codex round 1): extracted into a shared helper so the same canonical
198
+ * BOM-strip applies to BOTH `readPackageJson` (the write path used by
199
+ * `selfPinRea`) AND `checkSelfPinDeclaredSync` (the doctor brick-state
200
+ * detector). Pre-extraction, only the write path stripped — doctor would
201
+ * report `fail-malformed` for a BOM-prefixed manifest that self-pin
202
+ * tolerated fine, which is the asymmetric-fix class we explicitly want
203
+ * to defend against.
204
+ */
205
+ export declare function stripUtf8Bom(input: string): string;
206
+ /**
207
+ * Decide whether `rea upgrade` should auto-bump an existing pin to the
208
+ * newly-written caret. Returns `true` only when:
209
+ *
210
+ * 1. `existing` matches the managed-caret shape (we wrote it; it
211
+ * isn't a workspace/file/git/tag/exact pin).
212
+ * 2. `newRange` ALSO matches the managed-caret shape (sanity — the
213
+ * function should never be called with a non-caret target, but
214
+ * the predicate stays self-contained).
215
+ * 3. Both ranges share the same major version. Cross-major bumps
216
+ * are intentional operator decisions and we hands-off.
217
+ * 4. The existing caret does NOT already admit the version the new
218
+ * range would resolve to. We extract the floor version from
219
+ * `newRange` (strip the leading `^`) and ask
220
+ * `semver.satisfies(floor, existing)`:
221
+ *
222
+ * - `^0.49.0` + `^0.49.5` → 0.49.5 satisfies ^0.49.0 → NO bump
223
+ * - `^0.49.0` + `^0.50.0` → 0.50.0 does NOT satisfy → BUMP
224
+ * - `^0.49.0` + `^0.49.1-beta.0` → prerelease does NOT satisfy
225
+ * a non-prerelease range (npm spec) → BUMP (R4-P2)
226
+ * - `^1.0.0` + `^1.5.0` → 1.5.0 satisfies ^1.0.0 → NO bump
227
+ * - `^1.0.0` + `^1.1.0-beta.0` → prerelease does NOT satisfy
228
+ * → BUMP (R4-P2)
229
+ *
230
+ * The pre-fix predicate compared major/minor by hand, which
231
+ * mis-classified prerelease bumps as already-covered. Switching to
232
+ * `semver.satisfies` gives us npm-spec-correct semver behavior in
233
+ * one place.
234
+ */
235
+ export declare function shouldBumpManagedCaret(existing: string, newRange: string): boolean;
236
+ /**
237
+ * R9-P1 (codex round 9 / 0.49.0) — `rea upgrade` blocking-pin check.
238
+ *
239
+ * # Why this exists
240
+ *
241
+ * `rea init` and `rea upgrade` write the consumer's `.rea/policy.yaml`
242
+ * with the new `bootstrap_allowlist:` top-level key (added in 0.49.0).
243
+ * `src/policy/loader.ts::PolicySchema` is `.strict()` — older CLIs
244
+ * (≤ 0.48.x) cannot parse a policy file with that key and throw on
245
+ * load. So if `rea upgrade` writes 0.49 hooks + policy artifacts on
246
+ * top of a `package.json` that still pins an OLD `@bookedsolid/rea`
247
+ * version, the hooks resolve the OLD CLI from `node_modules/` on the
248
+ * next fire and that CLI refuses every payload (policy.yaml strict
249
+ * parse fails). The seemingly-successful upgrade leaves the consumer
250
+ * with non-functional gates.
251
+ *
252
+ * R2-P1-1 closed the managed-caret-bump case (we write the new pin in
253
+ * place). R9-P1 closes the gap for EVERY other shape: workspace:*,
254
+ * file:.., git URLs, dist-tags like `next`, exact pins like `0.48.0`,
255
+ * and managed-caret-cross-major. The fix: abort the upgrade BEFORE
256
+ * any artifacts hit disk when the existing pin would not admit the
257
+ * new CLI version.
258
+ *
259
+ * # Contract
260
+ *
261
+ * Returns a discriminated result:
262
+ *
263
+ * - `kind: 'ok'` — proceed with upgrade. Either:
264
+ * * no existing pin (will write fresh), OR
265
+ * * existing pin admits the new CLI version (semver.satisfies), OR
266
+ * * existing pin is a managed-caret that bumps cleanly (R2-P1-1).
267
+ * - `kind: 'no-pkg-json'` — no package.json in cwd; proceed.
268
+ * `selfPinRea` will return `skipped-no-package-json` later.
269
+ * - `kind: 'malformed-pkg-json'` — same; `selfPinRea` will return
270
+ * `skipped-malformed-package-json` later.
271
+ * - `kind: 'dogfood'` — pkg.name === '@bookedsolid/rea';
272
+ * proceed (dogfood install never mutates the manifest).
273
+ * - `kind: 'block'` — existing pin won't admit the new
274
+ * CLI; the caller MUST abort before writing artifacts.
275
+ * Carries the operator-facing reason string.
276
+ *
277
+ * # Why a separate function?
278
+ *
279
+ * `selfPinRea` is the WRITE-path helper. `checkUpgradeBlockingPin` is
280
+ * a READ-only preflight that gives the caller a yes/no answer before
281
+ * any disk mutation. We do NOT fold the abort logic into `selfPinRea`
282
+ * because:
283
+ * 1. Other callers of `selfPinRea` (rea init) want the warn-and-skip
284
+ * posture, not abort.
285
+ * 2. The upgrade entry needs to run this check BEFORE the canonical
286
+ * file-write loop, well upstream of the existing `selfPinRea`
287
+ * invocation.
288
+ * 3. Keeping the check stateless and read-only makes it testable in
289
+ * isolation without filesystem side effects beyond reading
290
+ * package.json (and even those are bounded — single read).
291
+ */
292
+ export type UpgradeBlockingPinCheckResult = {
293
+ kind: 'ok';
294
+ packageJsonPath: string | null;
295
+ existingRange?: string | undefined;
296
+ } | {
297
+ kind: 'no-pkg-json';
298
+ } | {
299
+ kind: 'malformed-pkg-json';
300
+ packageJsonPath: string;
301
+ } | {
302
+ kind: 'dogfood';
303
+ packageJsonPath: string;
304
+ } | {
305
+ kind: 'block';
306
+ packageJsonPath: string;
307
+ existingRange: string;
308
+ newCliVersion: string;
309
+ newPinnedRange: string;
310
+ reason: string;
311
+ } | {
312
+ kind: 'block-symlink';
313
+ packageJsonPath: string;
314
+ newCliVersion: string;
315
+ newPinnedRange: string;
316
+ reason: string;
317
+ };
318
+ export interface UpgradeBlockingPinCheckOptions {
319
+ cwd: string;
320
+ cliVersion: string;
321
+ /**
322
+ * R11-P1 (codex round 11): which call site is invoking the
323
+ * pre-flight. The check logic is identical for both modes — what
324
+ * changes is the operator-facing message prefix:
325
+ *
326
+ * - `'upgrade'` (default) → `rea upgrade refusing: ...`
327
+ * - `'init'` → `rea init refusing: ...`
328
+ *
329
+ * Both `runInit` and `runUpgrade` write the same 0.49 hooks +
330
+ * policy artifacts, so the skew-creation risk is identical and
331
+ * the pre-flight needs to fire on both surfaces. Pre-R11 the
332
+ * pre-flight was upgrade-only; `rea init` on an existing-install
333
+ * scenario could still leave the bash gates non-functional.
334
+ */
335
+ mode?: 'init' | 'upgrade';
336
+ }
337
+ /**
338
+ * Determine whether the existing `@bookedsolid/rea` pin would block
339
+ * `rea init` / `rea upgrade` from writing 0.49 artifacts safely.
340
+ * See type doc.
341
+ *
342
+ * R11-P1 (codex round 11): the check applies to BOTH `rea init` and
343
+ * `rea upgrade`. The implementation is unchanged; only the
344
+ * operator-facing message prefix varies with `mode`.
345
+ */
346
+ export declare function checkUpgradeBlockingPin(options: UpgradeBlockingPinCheckOptions): Promise<UpgradeBlockingPinCheckResult>;
347
+ /**
348
+ * Idempotent self-pin step. See module header for the full contract.
349
+ */
350
+ export declare function selfPinRea(options: SelfPinOptions): Promise<SelfPinResult>;
351
+ /**
352
+ * `rea doctor` check: FAIL when hook shims are present but no self-pin
353
+ * is declared. This is the "brick state" detector — a fresh clone of a
354
+ * consumer repo whose `.claude/hooks/` exists but whose `package.json`
355
+ * declares no `@bookedsolid/rea` dep is exactly the scenario the bash
356
+ * allowlist (Fix B) recovers from. Doctor surfaces it loudly so the
357
+ * operator knows to run `rea upgrade` (which re-runs the self-pin
358
+ * step) instead of fighting the gates.
359
+ *
360
+ * Returns a discriminated result:
361
+ * - `kind: 'pass'` — hooks + self-pin both present.
362
+ * - `kind: 'pass-no-hooks'` — no `.claude/hooks/` directory; the
363
+ * check is N/A (caller emits an `info`
364
+ * row instead of a check row).
365
+ * - `kind: 'pass-no-pkg'` — no `package.json` in the doctor's
366
+ * target directory (P2-4: no upward
367
+ * walk — doctor reports the absence
368
+ * rather than scanning the parent
369
+ * chain). Doctor treats this as a
370
+ * `warn` not a `fail` because the
371
+ * bootstrap allowlist refuses pkg-less
372
+ * projects anyway.
373
+ * - `kind: 'pass-dogfood'` — `pkg.name === '@bookedsolid/rea'`.
374
+ * - `kind: 'fail'` — hooks present, package.json present,
375
+ * no self-pin declared. Caller emits
376
+ * a `fail` row with the recovery
377
+ * instruction.
378
+ * - `kind: 'fail-malformed'` — package.json exists but is malformed
379
+ * or not an object. Caller emits a
380
+ * `fail` row naming the file.
381
+ * - `kind: 'fail-symlink'` — R10-P2 (codex round 10):
382
+ * package.json is a symlink. Doctor
383
+ * emits a `fail` row mirroring the
384
+ * write path's refusal so operators
385
+ * discover the misconfiguration before
386
+ * running `rea upgrade`.
387
+ */
388
+ export type SelfPinCheckResult = {
389
+ kind: 'pass';
390
+ packageJsonPath: string;
391
+ declaredRange: string;
392
+ declaredIn: 'dependencies' | 'devDependencies';
393
+ } | {
394
+ kind: 'pass-no-hooks';
395
+ } | {
396
+ kind: 'pass-no-pkg';
397
+ hooksDir: string;
398
+ } | {
399
+ kind: 'pass-dogfood';
400
+ packageJsonPath: string;
401
+ } | {
402
+ kind: 'fail';
403
+ packageJsonPath: string;
404
+ hooksDir: string;
405
+ } | {
406
+ kind: 'fail-malformed';
407
+ packageJsonPath: string;
408
+ } | {
409
+ kind: 'fail-symlink';
410
+ packageJsonPath: string;
411
+ reason: string;
412
+ } | {
413
+ kind: 'fail-incompatible';
414
+ packageJsonPath: string;
415
+ declaredRange: string;
416
+ declaredIn: 'dependencies' | 'devDependencies';
417
+ currentCliVersion: string;
418
+ reason: string;
419
+ } | {
420
+ kind: 'fail-non-semver';
421
+ packageJsonPath: string;
422
+ declaredRange: string;
423
+ declaredIn: 'dependencies' | 'devDependencies';
424
+ reason: string;
425
+ };
426
+ export declare function checkSelfPinDeclared(baseDir: string, cliVersion?: string): Promise<SelfPinCheckResult>;
427
+ /**
428
+ * Synchronous variant of {@link checkSelfPinDeclared}. `rea doctor`
429
+ * runs all checks sync; the read+parse cost here is microseconds so
430
+ * the sync form is acceptable.
431
+ *
432
+ * R11-P3 (codex round 11): when `cliVersion` is provided, this check
433
+ * also verifies that the declared range admits the running CLI
434
+ * version (semver.satisfies). Without `cliVersion` the check
435
+ * reverts to presence-only behavior (backwards-compat for callers
436
+ * that don't yet pass the version). The doctor wrapper
437
+ * (`checkSelfPinDeclaredCheck`) passes `getPkgVersion()` so the
438
+ * skew detection always runs in the doctor surface.
439
+ */
440
+ export declare function checkSelfPinDeclaredSync(baseDir: string, cliVersion?: string): SelfPinCheckResult;