@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.
- package/THREAT_MODEL.md +70 -0
- package/dist/cli/doctor.d.ts +1 -0
- package/dist/cli/doctor.js +241 -0
- package/dist/cli/init.d.ts +12 -0
- package/dist/cli/init.js +161 -0
- package/dist/cli/install/self-pin.d.ts +440 -0
- package/dist/cli/install/self-pin.js +853 -0
- package/dist/cli/upgrade.js +134 -0
- package/dist/policy/loader.d.ts +13 -0
- package/dist/policy/loader.js +36 -0
- package/dist/policy/profiles.d.ts +13 -0
- package/dist/policy/profiles.js +12 -0
- package/dist/policy/types.d.ts +38 -0
- package/hooks/_lib/bootstrap-allowlist.sh +1075 -0
- package/hooks/blocked-paths-bash-gate.sh +35 -12
- package/hooks/protected-paths-bash-gate.sh +30 -12
- package/package.json +3 -1
- package/profiles/bst-internal-no-codex.yaml +4 -0
- package/profiles/bst-internal.yaml +28 -0
- package/profiles/client-engagement.yaml +9 -0
- package/profiles/lit-wc.yaml +6 -0
- package/profiles/minimal.yaml +11 -0
- package/profiles/open-source-no-codex.yaml +4 -0
- package/profiles/open-source.yaml +11 -0
package/THREAT_MODEL.md
CHANGED
|
@@ -495,6 +495,76 @@ Ref: `hooks/settings-protection.sh:86-336`, `.claude/hooks/settings-protection.s
|
|
|
495
495
|
|
|
496
496
|
---
|
|
497
497
|
|
|
498
|
+
### 5.23 Bootstrap Allowlist (0.49.0)
|
|
499
|
+
|
|
500
|
+
**Threat:** Pre-0.49.0, `rea init` wrote `.claude/hooks/blocked-paths-bash-gate.sh` and `.claude/hooks/protected-paths-bash-gate.sh` shims that depend on the `@bookedsolid/rea` CLI being resolvable from `node_modules/`. The init flow did NOT add the dep to the consumer's `package.json`. Any fresh clone of a consumer repo + `pnpm install` produced a brick state where the shims found no CLI and refused 100% of `Bash` calls — including the very `pnpm add -D @bookedsolid/rea` that would recover the install. Hot consumer paths (helixir, BST clients) routinely tripped this whenever a fresh checkout landed.
|
|
501
|
+
|
|
502
|
+
Two paired fixes ship in 0.49.0. The structural fix is `rea init` self-pin (`src/cli/install/self-pin.ts`, §5.23.1 below). The bootstrap allowlist is the safety net that recovers consumers who upgraded to a hooks-shipping `rea init` but were NEVER on the self-pinned init flow.
|
|
503
|
+
|
|
504
|
+
**Scope:** The bootstrap allowlist is a narrow CLI-missing recovery surface. It permits a small set of legitimate-shape PM invocations (bare `pnpm install` / `npm ci` / `yarn install`; `pnpm add -D @bookedsolid/rea` and its npm/yarn equivalents in BARE form) to flow when the CLI is unreachable AND `package.json` already declares `@bookedsolid/rea`. The precondition exists to differentiate consumers who have already opted into `@bookedsolid/rea` (post-`rea init`) from arbitrary repos. It is NOT a defense against agent-initiated package.json mutations — a path-based blocklist on `package.json` cannot distinguish agent writes from PM-tool writes, so that surface is explicitly out of scope here.
|
|
505
|
+
|
|
506
|
+
**Mitigations:**
|
|
507
|
+
|
|
508
|
+
- `hooks/_lib/bootstrap-allowlist.sh` exposes `bootstrap_allowlist_check`. The blocked-paths and protected-paths Bash gates consult it ONLY when (a) the CLI is unreachable AND (b) the substring scan said "this payload looks like it would write a protected/blocked path." When the allowlist returns "allow", the shim exits 0 BEFORE the CLI-missing banner fires.
|
|
509
|
+
- Allowlist is ALWAYS-ON by default (`policy.bootstrap_allowlist.enabled: true`). No env-var ever participates in the decision — `REA_BOOTSTRAP_ALLOW=1`, `REA_FORCE_BOOTSTRAP=*`, etc. are inert. Operators who want to opt out set `policy.bootstrap_allowlist.enabled: false`.
|
|
510
|
+
- Precondition: `<project>/package.json` exists, parses as a strict JSON object, and declares `@bookedsolid/rea` under `dependencies` OR `devDependencies` (NOT `optionalDependencies`, NOT `peerDependencies`, NOT `pnpm.overrides`). Without this declaration, the allowlist refuses every payload. The precondition is "consumer has run `rea init`, so a self-pin already exists" — not a defense against an attacker forging the declaration.
|
|
511
|
+
- The argv shape allowlist is FIXED in the helper, not consumer-mutable from policy. Recognised shapes are precisely:
|
|
512
|
+
- pnpm: `pnpm install`, `pnpm i`, with optional `--frozen-lockfile` / `--no-frozen-lockfile`; `pnpm add -D @bookedsolid/rea` (bare); `pnpm add --save-dev @bookedsolid/rea` (bare).
|
|
513
|
+
- npm: `npm install`, `npm i`, `npm ci`; `npm install -D @bookedsolid/rea` (bare); `npm install --save-dev @bookedsolid/rea` (bare).
|
|
514
|
+
- yarn: `yarn`, `yarn install`; `yarn add -D @bookedsolid/rea` (bare); `yarn add --dev @bookedsolid/rea` (bare).
|
|
515
|
+
- corepack: `corepack enable`, `corepack enable {pnpm,yarn,npm}`, `corepack prepare {pnpm,yarn,npm}@<ver> --activate`.
|
|
516
|
+
|
|
517
|
+
Bun, `pnpm fetch`, `--global`/`-g`, any `--registry=` override, and any `--ignore-scripts` flag are deliberately OUT of the allowlist for 0.49.0 — adding any of them would broaden the contract beyond "recover the brick state."
|
|
518
|
+
|
|
519
|
+
**R6-P2 (codex round 6):** `@bookedsolid/rea` is accepted ONLY in its bare form. Version-pinned shapes `@bookedsolid/rea@<anything>` are REFUSED — this includes dist-tags (`@latest`, `@next`), exact versions (`@0.48.0`), and semver ranges (`@^0.50.0`). Version selection is `rea init` (caret pin at install time) and `rea upgrade` (managed-caret bump, audited via the TS path) territory; the Bash-tier bootstrap path must not allow a CLI-missing session to retarget the trusted gate binary by pinning a chosen version. `corepack prepare pnpm@<ver> --activate` retains the version slot because that selects the package-manager binary (out-of-scope for rea's gate trust), but the rea package spec itself is locked to bare.
|
|
520
|
+
- Multi-segment payloads refuse: the gate's `cmd-segments.sh` segmentation runs ahead of the allowlist, and the helper itself re-counts segments as defense in depth. Any `&&`, `;`, `||`, `|`, newline, or backgrounding refuses.
|
|
521
|
+
- argv[0] basename match is exact-string with no slashes — `./pnpm install`, `/usr/local/bin/pnpm install`, `pnpm/x install` all refuse. The character class for argv[0] is `[A-Za-z0-9._-]`.
|
|
522
|
+
- The package-spec match for `@bookedsolid/rea` is the BARE form only (R6-P2). All versioned forms `@bookedsolid/rea@<ver>` refuse — dist-tags, exact versions, semver ranges, shell metacharacters, command substitutions, and empty `@`-suffixes all hit the refuse branch uniformly.
|
|
523
|
+
- Quoted argv forms (`pnpm "install"`, `'pnpm' install`) refuse-fallthrough. This is a defense feature — a quoted token does NOT match the bare-string shape lists, so any attacker laundering through quotes hits the refuse branch.
|
|
524
|
+
- IFS leakage is defeated by `local IFS=$' \t\n'` declared inside `bootstrap_allowlist_check`. A hostile parent shell that sets `IFS=:` (or worse) cannot reshape the splitter.
|
|
525
|
+
- The `$proj` directory is realpath-resolved via `cd "$CLAUDE_PROJECT_DIR" && pwd -P` before the allowlist sees it. A hostile workspace whose `CLAUDE_PROJECT_DIR` symlinks outside the actual project does not get the allowlist sandbox: the hashing + audit-emit still references the resolved path, NOT the symlink.
|
|
526
|
+
- Every `allow` verdict emits a `rea.bash.bootstrap_allow` audit record through the hash-chained log. Audit fields are fixed-shape: `shim`, `pm`, `argv_shape`, `argv_segments_sha256`, `package_json_sha256`, `package_json_declares_rea`, `declared_version_range` (truncated to 16 chars), `cli_resolution: "missing"`, `policy_enabled`. If `node` is unavailable, or the audit append fails (read-only `.rea/`, hasher missing, disk full), the allowlist REFUSES rather than allowing silently — every allow MUST be auditable.
|
|
527
|
+
- **R19-P1 (codex round 19):** `package.json` is in `blocked_paths` on the `bst-internal` and `bst-internal-no-codex` profiles (and the dogfood `.rea/policy.yaml`). This narrows the Edit/Write-tier forge surface specifically — an L1 Bash session cannot first `Edit` `package.json` to ADD a `@bookedsolid/rea` declaration and then route an otherwise-disallowed PM command through the allowlist's "consumer already declares `@bookedsolid/rea`" precondition. The blocklist gates Write/Edit/MultiEdit/NotebookEdit tool calls via the existing path-based machinery (`settings-protection.sh`, `blocked-paths-enforcer.sh`); it does NOT gate PM-induced writes to the manifest (those are explicitly out of scope, below). External profiles inherit `enabled: true` from the schema default but do NOT add `package.json` to their blocked_paths list (consumers may need to edit package.json freely; the `bst-internal` posture is the strict one).
|
|
528
|
+
|
|
529
|
+
**Trust boundary:** The bootstrap allowlist is reachable only when the CLI is unreachable. The CLI-present path through both gates flows through the full Node-binary scanner (`src/hooks/bash-scanner/`). Once `pnpm install` completes and `node_modules/@bookedsolid/rea/dist/cli/index.js` resolves, every subsequent Bash call is scanned by the canonical scanner — the allowlist no longer participates.
|
|
530
|
+
|
|
531
|
+
**Out of scope (explicit non-claim):**
|
|
532
|
+
|
|
533
|
+
- The allowlist does NOT prevent an attacker who can already write Bash payloads from invoking PM commands that mutate `package.json`. A path-based blocklist on `package.json` (re-added by R19-P1 above) defends against AGENT writes only — it cannot distinguish PM-tool writes from agent writes because the PM is invoked by Bash and the manifest mutation happens inside the PM process, not in argv that a static analyzer can see reliably. Defending against agent-initiated PM mutations requires a different abstraction (process-tree-aware policy, per-tool capability tokens, or operator-confirmed PM invocations) — none of which ship in 0.49.0. The R6-onward "manifest-write static analyzer" surface that would have attempted to close this gap was deliberately removed in R17 (scope-cut) because a path-based blocklist is the wrong abstraction for PM-process introspection. The 0.49.0 contract is solely: "recover the brick state without requiring the operator to disable hooks, AND gate agent-initiated Edit/Write to the manifest."
|
|
534
|
+
|
|
535
|
+
**Residual risks:**
|
|
536
|
+
|
|
537
|
+
- **CLOSED in R6-P2:** Pre-R6 the allowlist accepted `@bookedsolid/rea@<version>` and let a CLI-missing session forge a downgrade attack against a known-vulnerable historical rea version. R6-P2 strips all version-pinned forms from the allowlist; only the bare `@bookedsolid/rea` spec is permitted. Version selection lives in `rea init` (caret pin at install) and `rea upgrade` (managed-caret bump under audit). A CLI-missing Bash session can no longer retarget the trusted gate binary.
|
|
538
|
+
- An attacker who controls a npm registry mirror could publish a malicious `@bookedsolid/rea` once the allowlist runs the install. Mitigation: npm OIDC provenance is verified by `rea init` post-install (out of scope here). Operators on a hostile mirror are exposed regardless of the allowlist. R6-P2 reduces the attack surface because the version range is bounded by the consumer's existing self-pin — the mirror cannot choose which version to ship; the consumer's caret pin does.
|
|
539
|
+
- The `corepack prepare pnpm@<ver> --activate` shape allows arbitrary `<ver>` strings (within the 64-char `[A-Za-z0-9._^~+\-]` charset). This selects the package-MANAGER binary, not rea itself; consumers on a hostile pnpm version have other problems.
|
|
540
|
+
- The allowlist's policy-enabled probe relies on a narrow inline YAML regex (block-form scan, flow-form match). A malformed or pathologically-shaped `bootstrap_allowlist:` block reads as "unparseable" → schema default = enabled. This is intentional: a corrupted policy MUST NOT silently strip the bootstrap recovery path. The full zod schema validation at CLI load time catches typos / wrong types at the canonical boundary.
|
|
541
|
+
|
|
542
|
+
Ref: `hooks/_lib/bootstrap-allowlist.sh`, `hooks/blocked-paths-bash-gate.sh`, `hooks/protected-paths-bash-gate.sh`, `src/policy/loader.ts` (`BootstrapAllowlistPolicySchema`), `__tests__/hooks/bootstrap-allowlist/`.
|
|
543
|
+
|
|
544
|
+
#### 5.23.1 `rea init` self-pin (paired structural fix)
|
|
545
|
+
|
|
546
|
+
**Threat:** Same brick state described in §5.23. The bootstrap allowlist recovers consumers who get stuck; self-pin prevents them from getting stuck in the first place.
|
|
547
|
+
|
|
548
|
+
**Mitigations:**
|
|
549
|
+
|
|
550
|
+
- `rea init` writes `@bookedsolid/rea` as a `^<cli-version>` caret pin in the consumer's `devDependencies`. Workspace-root semantics: the upward walk finds the FIRST `package.json` from the invocation dir and stops; sub-package installs land at the sub-package, not the workspace root.
|
|
551
|
+
- Idempotent: a re-run produces byte-identical `package.json`. Indent, EOL (LF vs CRLF), and trailing-newline are sniffed from the existing file and preserved.
|
|
552
|
+
- Existing different version → warn + skip. The operator's pin is not mutated (covers exact-version reproducibility pins, workspace-relative paths, older-or-newer pins that disagree with the running CLI).
|
|
553
|
+
- Dogfood short-circuit: `pkg.name === '@bookedsolid/rea'` skips silently.
|
|
554
|
+
- `rea doctor` runs `checkSelfPinDeclared` and emits a `fail` row when `.claude/hooks/` is installed but no pin is declared. The recovery instruction names `rea upgrade` (which re-runs the self-pin step). This is the brick-state detector.
|
|
555
|
+
- `rea upgrade` re-runs `selfPinRea` so legacy installs that pre-date 0.49.0 self-heal on the next upgrade. Same warn-and-skip posture on existing different version.
|
|
556
|
+
|
|
557
|
+
**Trust boundary:** Self-pin only writes the `package.json` entry. It does not invoke any package manager, does not run lifecycle scripts, does not modify the lockfile. The consumer's next `pnpm install` is what materializes the install — same trust posture as a manual `pnpm add -D @bookedsolid/rea` would have.
|
|
558
|
+
|
|
559
|
+
**Residual risks:**
|
|
560
|
+
|
|
561
|
+
- An operator who runs `rea init` in a repo whose `package.json` is owned by a parent workspace will get a pin in the parent (the closest package.json found by the upward walk). This matches the architect's locked decision. Operators with per-package install discipline should run `rea init` from each sub-package directory after seeding a sub-package package.json.
|
|
562
|
+
- An operator who pinned a workspace-relative path (`workspace:^`, `file:../rea`) gets `skipped-different` on every re-run. This is the correct behavior — the operator owns the pin. The doctor's `pass` detection accepts any non-empty string in dependencies/devDependencies as a valid pin (including workspace specs).
|
|
563
|
+
|
|
564
|
+
Ref: `src/cli/install/self-pin.ts`, `src/cli/init.ts`, `src/cli/upgrade.ts`, `src/cli/doctor.ts` (`checkSelfPinDeclaredCheck`), `__tests__/cli/self-pin.test.ts`.
|
|
565
|
+
|
|
566
|
+
---
|
|
567
|
+
|
|
498
568
|
## 6. Residual Risks and Open Issues
|
|
499
569
|
|
|
500
570
|
| Risk | Severity | Status / Tracking |
|
package/dist/cli/doctor.d.ts
CHANGED
|
@@ -29,6 +29,7 @@ export declare function checkFingerprintStore(baseDir: string): Promise<CheckRes
|
|
|
29
29
|
*/
|
|
30
30
|
export declare const EXPECTED_AGENTS: string[];
|
|
31
31
|
export declare const EXPECTED_HOOKS: string[];
|
|
32
|
+
export declare function checkSelfPinDeclaredCheck(baseDir: string): CheckResult;
|
|
32
33
|
/**
|
|
33
34
|
* 0.30.0 Class M — validate `.claude/settings.json` against the zod
|
|
34
35
|
* schema in `src/config/settings-schema.ts`.
|
package/dist/cli/doctor.js
CHANGED
|
@@ -19,6 +19,7 @@ import { DELEGATION_SIGNAL_TOOL_NAME } from '../audit/delegation-event.js';
|
|
|
19
19
|
import { computeHash } from '../audit/fs.js';
|
|
20
20
|
import { PREPARE_COMMIT_MSG_BODY_MARKER, PREPARE_COMMIT_MSG_MARKER, } from './install/prepare-commit-msg.js';
|
|
21
21
|
import { validateSettings } from '../config/settings-schema.js';
|
|
22
|
+
import { checkSelfPinDeclaredSync, REA_PACKAGE_NAME, stripUtf8Bom } from './install/self-pin.js';
|
|
22
23
|
import { POLICY_FILE, REA_DIR, REGISTRY_FILE, getPkgVersion, log, reaPath } from './utils.js';
|
|
23
24
|
function checkFileExists(label, filePath, fatal) {
|
|
24
25
|
const exists = fs.existsSync(filePath);
|
|
@@ -255,6 +256,240 @@ function checkHooksInstalled(baseDir) {
|
|
|
255
256
|
}
|
|
256
257
|
return { label: 'hooks installed + executable', status: 'fail', detail: issues.join('; ') };
|
|
257
258
|
}
|
|
259
|
+
/**
|
|
260
|
+
* 0.49.0 — fail when `.claude/hooks/` is present but no `@bookedsolid/rea`
|
|
261
|
+
* pin is declared in the consumer's `package.json`. This is the "brick
|
|
262
|
+
* state" detector — a fresh clone of a consumer repo whose hook shims
|
|
263
|
+
* exist but whose dependency declaration is missing is exactly the
|
|
264
|
+
* scenario the bash-gate bootstrap allowlist (paired Fix B) recovers
|
|
265
|
+
* from. Doctor surfaces it loudly so the operator runs `rea upgrade`
|
|
266
|
+
* (which re-runs the self-pin step) before assuming the shims have
|
|
267
|
+
* drifted.
|
|
268
|
+
*
|
|
269
|
+
* Statuses:
|
|
270
|
+
* - `pass` — hooks installed AND pin declared (in dependencies or
|
|
271
|
+
* devDependencies).
|
|
272
|
+
* - `pass` — no `.claude/hooks/` directory (check is N/A; the
|
|
273
|
+
* brick scenario does not exist).
|
|
274
|
+
* - `pass` — `pkg.name === '@bookedsolid/rea'` (dogfood — never
|
|
275
|
+
* self-pins).
|
|
276
|
+
* - `warn` — hooks installed but NO `package.json` upward. The
|
|
277
|
+
* bootstrap allowlist requires a pkg.json precondition,
|
|
278
|
+
* so without one the gates cannot self-recover anyway.
|
|
279
|
+
* We warn rather than fail to avoid spamming non-Node
|
|
280
|
+
* consumers who landed `.claude/hooks/` through a
|
|
281
|
+
* separate vendoring flow.
|
|
282
|
+
* - `fail` — hooks installed, package.json found, no rea pin.
|
|
283
|
+
* This is the brick state.
|
|
284
|
+
* - `fail` — package.json exists but is malformed/non-object.
|
|
285
|
+
*/
|
|
286
|
+
/**
|
|
287
|
+
* R18-P1 (codex round 18) / R19-P2 (codex round 19): resolve the
|
|
288
|
+
* `@bookedsolid/rea` version that the consumer's HOOK SCRIPTS will
|
|
289
|
+
* actually invoke at runtime.
|
|
290
|
+
*
|
|
291
|
+
* Two layouts the shim chain accepts (mirrors `resolveCliDistPath`
|
|
292
|
+
* above):
|
|
293
|
+
*
|
|
294
|
+
* 1. `<baseDir>/node_modules/@bookedsolid/rea/package.json` — the
|
|
295
|
+
* consumer install (`pnpm i @bookedsolid/rea`). The version is
|
|
296
|
+
* that file's `version` field.
|
|
297
|
+
*
|
|
298
|
+
* 2. `<baseDir>/dist/cli/index.js` present AND `<baseDir>/package.json`
|
|
299
|
+
* has `name === '@bookedsolid/rea'` — the rea-repo dogfood
|
|
300
|
+
* after `pnpm build`. The dist is a build output of the same
|
|
301
|
+
* package.json that declares the rea CLI, so its `version`
|
|
302
|
+
* field is the CLI version the dogfood hooks will resolve.
|
|
303
|
+
*
|
|
304
|
+
* Pre-R18 doctor passed `getPkgVersion()` (the version of whatever
|
|
305
|
+
* `rea` binary launched `rea doctor`) into the pin-compat check.
|
|
306
|
+
* That caused false failures whenever an operator ran a newer
|
|
307
|
+
* GLOBAL CLI (e.g. `rea@0.50.0`) against a repo whose `package.json`
|
|
308
|
+
* intentionally pinned an older but compatible version. The repo's
|
|
309
|
+
* hooks resolve the LOCAL CLI; the global binary is irrelevant.
|
|
310
|
+
*
|
|
311
|
+
* R19-P2 narrows the fix: when neither layout resolves, return
|
|
312
|
+
* `null` — and the caller SKIPS the compat check entirely (no
|
|
313
|
+
* fallback to `getPkgVersion()`). Fresh clones (pre-`pnpm install`)
|
|
314
|
+
* and broken dist builds fall into this branch and now pass instead
|
|
315
|
+
* of false-failing.
|
|
316
|
+
*
|
|
317
|
+
* Returns `null` on any read/parse error or when no recognized
|
|
318
|
+
* layout matches. Best-effort, single read per call. Never throws.
|
|
319
|
+
*/
|
|
320
|
+
function resolveLocalCliVersion(baseDir) {
|
|
321
|
+
// Layout 1: consumer install via node_modules.
|
|
322
|
+
const nmPkgPath = path.join(baseDir, 'node_modules', '@bookedsolid', 'rea', 'package.json');
|
|
323
|
+
const nmVersion = readPackageVersion(nmPkgPath);
|
|
324
|
+
if (nmVersion !== null)
|
|
325
|
+
return nmVersion;
|
|
326
|
+
// Layout 2: rea-repo dogfood. The CLI is `<baseDir>/dist/cli/
|
|
327
|
+
// index.js`; the source of truth for its version is the SAME
|
|
328
|
+
// `<baseDir>/package.json` that declares it. Guard with name-
|
|
329
|
+
// match so we never mis-identify a consumer's package.json
|
|
330
|
+
// (which lacks the dist build) as a rea install.
|
|
331
|
+
const distCliPath = path.join(baseDir, 'dist', 'cli', 'index.js');
|
|
332
|
+
if (!fs.existsSync(distCliPath))
|
|
333
|
+
return null;
|
|
334
|
+
const dogfoodPkgPath = path.join(baseDir, 'package.json');
|
|
335
|
+
let raw;
|
|
336
|
+
try {
|
|
337
|
+
raw = fs.readFileSync(dogfoodPkgPath, 'utf8');
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
raw = stripUtf8Bom(raw);
|
|
343
|
+
let parsed;
|
|
344
|
+
try {
|
|
345
|
+
parsed = JSON.parse(raw);
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
const pkg = parsed;
|
|
354
|
+
if (pkg['name'] !== REA_PACKAGE_NAME)
|
|
355
|
+
return null;
|
|
356
|
+
const version = pkg['version'];
|
|
357
|
+
return typeof version === 'string' && version.length > 0 ? version : null;
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Helper for `resolveLocalCliVersion` — read a package.json's
|
|
361
|
+
* `version` field with the same BOM tolerance + defensive parse
|
|
362
|
+
* posture as the rest of the self-pin surface. Returns `null` on
|
|
363
|
+
* any failure.
|
|
364
|
+
*/
|
|
365
|
+
function readPackageVersion(pkgPath) {
|
|
366
|
+
let raw;
|
|
367
|
+
try {
|
|
368
|
+
raw = fs.readFileSync(pkgPath, 'utf8');
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
raw = stripUtf8Bom(raw);
|
|
374
|
+
let parsed;
|
|
375
|
+
try {
|
|
376
|
+
parsed = JSON.parse(raw);
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
const version = parsed['version'];
|
|
385
|
+
return typeof version === 'string' && version.length > 0 ? version : null;
|
|
386
|
+
}
|
|
387
|
+
export function checkSelfPinDeclaredCheck(baseDir) {
|
|
388
|
+
const label = `${REA_PACKAGE_NAME} declared in package.json`;
|
|
389
|
+
try {
|
|
390
|
+
// R11-P3 (codex round 11): pass the running CLI version so the
|
|
391
|
+
// check verifies the declared range admits it (not just that
|
|
392
|
+
// it's declared). Pre-R11 the check was presence-only — a stale
|
|
393
|
+
// pin like `"0.48.0"` reported pass even though the running
|
|
394
|
+
// CLI was 0.49.x. Doctor's job is to catch skew BEFORE it
|
|
395
|
+
// bricks the consumer.
|
|
396
|
+
//
|
|
397
|
+
// R18-P1 (codex round 18): prefer the LOCAL CLI's version
|
|
398
|
+
// (`<baseDir>/node_modules/@bookedsolid/rea`) over the global
|
|
399
|
+
// invoker's. The consumer's HOOKS resolve the local install at
|
|
400
|
+
// runtime, so that's the version the pin must admit.
|
|
401
|
+
//
|
|
402
|
+
// R19-P2 (codex round 19): when the local CLI is absent (no
|
|
403
|
+
// node_modules layout AND no dogfood dist build), SKIP the
|
|
404
|
+
// compat check entirely. Falling back to the invoker version
|
|
405
|
+
// (the R18-P1 implementation) still produced false-fails on
|
|
406
|
+
// fresh clones — an operator's newer global `rea@0.50.0`
|
|
407
|
+
// running doctor against a repo pinned `^0.49.0` before
|
|
408
|
+
// `pnpm install` saw fail-incompatible despite no real
|
|
409
|
+
// problem. With local CLI absent we cannot determine what
|
|
410
|
+
// version the hooks will run, so we report pass and let
|
|
411
|
+
// pnpm's own resolution-time errors surface any actual pin
|
|
412
|
+
// skew during install. The existing `fail-no-pin` /
|
|
413
|
+
// `fail-malformed` arms continue to catch the brick states.
|
|
414
|
+
const resolvedCliVersion = resolveLocalCliVersion(baseDir);
|
|
415
|
+
const result = checkSelfPinDeclaredSync(baseDir, resolvedCliVersion ?? undefined);
|
|
416
|
+
switch (result.kind) {
|
|
417
|
+
case 'pass':
|
|
418
|
+
return {
|
|
419
|
+
label,
|
|
420
|
+
status: 'pass',
|
|
421
|
+
detail: `declared in ${result.declaredIn} as ${result.declaredRange}`,
|
|
422
|
+
};
|
|
423
|
+
case 'pass-no-hooks':
|
|
424
|
+
return {
|
|
425
|
+
label,
|
|
426
|
+
status: 'pass',
|
|
427
|
+
detail: 'no .claude/hooks/ — check is N/A',
|
|
428
|
+
};
|
|
429
|
+
case 'pass-dogfood':
|
|
430
|
+
return {
|
|
431
|
+
label,
|
|
432
|
+
status: 'pass',
|
|
433
|
+
detail: 'dogfood install (pkg.name === @bookedsolid/rea)',
|
|
434
|
+
};
|
|
435
|
+
case 'pass-no-pkg':
|
|
436
|
+
return {
|
|
437
|
+
label,
|
|
438
|
+
status: 'warn',
|
|
439
|
+
detail: 'hook shims installed but no package.json found upward — bash gates will refuse on fresh clones (the bootstrap allowlist requires a package.json precondition)',
|
|
440
|
+
};
|
|
441
|
+
case 'fail':
|
|
442
|
+
return {
|
|
443
|
+
label,
|
|
444
|
+
status: 'fail',
|
|
445
|
+
detail: `hook shims at ${path.relative(baseDir, result.hooksDir)} but ${REA_PACKAGE_NAME} is not declared in ${path.relative(baseDir, result.packageJsonPath)}. ` +
|
|
446
|
+
`Fresh clones will brick (bash gates refuse without a CLI). Run \`rea upgrade\` to self-heal.`,
|
|
447
|
+
};
|
|
448
|
+
case 'fail-malformed':
|
|
449
|
+
return {
|
|
450
|
+
label,
|
|
451
|
+
status: 'fail',
|
|
452
|
+
detail: `${path.relative(baseDir, result.packageJsonPath)} is missing or not a valid JSON object`,
|
|
453
|
+
};
|
|
454
|
+
// R10-P2 (codex round 10): symlinked package.json. Surface
|
|
455
|
+
// the write-path's refusal verbatim so the operator sees the
|
|
456
|
+
// same diagnostic at doctor time as they would at upgrade
|
|
457
|
+
// time (avoiding drift between the two surfaces).
|
|
458
|
+
case 'fail-symlink':
|
|
459
|
+
return {
|
|
460
|
+
label,
|
|
461
|
+
status: 'fail',
|
|
462
|
+
detail: result.reason,
|
|
463
|
+
};
|
|
464
|
+
// R11-P3 (codex round 11): declared range doesn't admit the
|
|
465
|
+
// running CLI. Surface the helper's full reason — it includes
|
|
466
|
+
// the recovery command and the explainer.
|
|
467
|
+
case 'fail-incompatible':
|
|
468
|
+
return {
|
|
469
|
+
label,
|
|
470
|
+
status: 'fail',
|
|
471
|
+
detail: result.reason,
|
|
472
|
+
};
|
|
473
|
+
// R11-P3: declared as workspace:* / file:.. / git URL / dist-
|
|
474
|
+
// tag. Can't statically determine whether the resolved version
|
|
475
|
+
// admits the running CLI; surface as fail so the operator
|
|
476
|
+
// audits the resolution path.
|
|
477
|
+
case 'fail-non-semver':
|
|
478
|
+
return {
|
|
479
|
+
label,
|
|
480
|
+
status: 'fail',
|
|
481
|
+
detail: result.reason,
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
catch (e) {
|
|
486
|
+
return {
|
|
487
|
+
label,
|
|
488
|
+
status: 'fail',
|
|
489
|
+
detail: e instanceof Error ? e.message : String(e),
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
}
|
|
258
493
|
/**
|
|
259
494
|
* 0.30.0 Class M — validate `.claude/settings.json` against the zod
|
|
260
495
|
* schema in `src/config/settings-schema.ts`.
|
|
@@ -2239,6 +2474,12 @@ export function collectChecks(baseDir, codexProbeState, prePushState, options =
|
|
|
2239
2474
|
checkRegistryParses(baseDir, registryPath),
|
|
2240
2475
|
checkAgentsPresent(baseDir),
|
|
2241
2476
|
checkHooksInstalled(baseDir),
|
|
2477
|
+
// 0.49.0 brick-state detector. Hook shims installed without a
|
|
2478
|
+
// self-pin in package.json is the exact scenario the bash-gate
|
|
2479
|
+
// bootstrap allowlist (paired fix) recovers from. Doctor surfaces
|
|
2480
|
+
// it as a hard FAIL so the operator runs `rea upgrade` (which
|
|
2481
|
+
// re-runs self-pin) before assuming the gates are broken.
|
|
2482
|
+
checkSelfPinDeclaredCheck(baseDir),
|
|
2242
2483
|
checkSettingsJson(baseDir),
|
|
2243
2484
|
// 0.30.0 Class M — strict zod schema check of the full
|
|
2244
2485
|
// .claude/settings.json shape. Complements checkSettingsJson
|
package/dist/cli/init.d.ts
CHANGED
|
@@ -55,6 +55,18 @@ export interface ResolvedConfig {
|
|
|
55
55
|
email?: string;
|
|
56
56
|
skipMerge?: boolean;
|
|
57
57
|
};
|
|
58
|
+
/**
|
|
59
|
+
* R12-P1 (codex round 12 / 0.49.0): bootstrap_allowlist.enabled.
|
|
60
|
+
* Preserved across re-init so an operator who opted out via
|
|
61
|
+
* `bootstrap_allowlist: { enabled: false }` doesn't get silently
|
|
62
|
+
* re-enabled by the next `rea init`. Seeded from the layered
|
|
63
|
+
* profile on first install (only `bst-internal` currently pins
|
|
64
|
+
* `enabled: true` explicitly; every other profile inherits the
|
|
65
|
+
* zod schema default which is also `true`). When `undefined`, the
|
|
66
|
+
* writer emits no block — consumers fall through to the schema
|
|
67
|
+
* default at policy load.
|
|
68
|
+
*/
|
|
69
|
+
bootstrapAllowlistEnabled?: boolean;
|
|
58
70
|
fromReagent: boolean;
|
|
59
71
|
reagentPolicyPath: string | null;
|
|
60
72
|
reagentNotices: string[];
|
package/dist/cli/init.js
CHANGED
|
@@ -7,6 +7,7 @@ import { AutonomyLevel } from '../policy/types.js';
|
|
|
7
7
|
import { HARD_DEFAULTS, loadProfile, mergeProfiles } from '../policy/profiles.js';
|
|
8
8
|
import { copyArtifacts } from './install/copy.js';
|
|
9
9
|
import { ensureReaGitignore } from './install/gitignore.js';
|
|
10
|
+
import { checkUpgradeBlockingPin, selfPinRea } from './install/self-pin.js';
|
|
10
11
|
import { canonicalSettingsSubsetHash, defaultDesiredHooks, mergeSettings, readSettings, writeSettingsAtomic, } from './install/settings-merge.js';
|
|
11
12
|
import { EXPECTED_HOOKS } from './doctor.js';
|
|
12
13
|
import { installCommitMsgHook } from './install/commit-msg.js';
|
|
@@ -303,6 +304,9 @@ async function runWizard(options, targetDir, reagentPolicyPath, layeredBase, exi
|
|
|
303
304
|
// `enabled: false`). Conditional spread so undefined → key omitted
|
|
304
305
|
// (the field is exact-optional).
|
|
305
306
|
...attributionConfigSpread(layeredBase, existingPolicy),
|
|
307
|
+
// R12-P1 (codex round 12): preserve bootstrap_allowlist.enabled
|
|
308
|
+
// so an operator opt-out survives `rea init` re-runs.
|
|
309
|
+
...bootstrapAllowlistConfigSpread(layeredBase, existingPolicy),
|
|
306
310
|
fromReagent,
|
|
307
311
|
reagentPolicyPath,
|
|
308
312
|
reagentNotices: [],
|
|
@@ -348,6 +352,43 @@ function attributionConfigSpread(layered, existing) {
|
|
|
348
352
|
},
|
|
349
353
|
};
|
|
350
354
|
}
|
|
355
|
+
/**
|
|
356
|
+
* R12-P1 (codex round 12 / 0.49.0): same shape-spread helper for
|
|
357
|
+
* `bootstrap_allowlist.enabled`. Precedence:
|
|
358
|
+
*
|
|
359
|
+
* 1. Existing on-disk policy `bootstrap_allowlist.enabled` (highest).
|
|
360
|
+
* 2. Layered profile's `bootstrap_allowlist.enabled` (e.g.
|
|
361
|
+
* `bst-internal` pins `true` explicitly).
|
|
362
|
+
* 3. Omitted — the writer skips emission and consumers fall through
|
|
363
|
+
* to the zod schema default at policy-load time.
|
|
364
|
+
*
|
|
365
|
+
* The omit-vs-emit distinction matters: external profiles
|
|
366
|
+
* (open-source, client-engagement, etc.) leave the block unset, and
|
|
367
|
+
* we want a clean policy.yaml that does NOT mention the field unless
|
|
368
|
+
* the operator or the profile explicitly pinned it. That preserves
|
|
369
|
+
* the existing emit-only-when-set posture for the other preserved
|
|
370
|
+
* keys (local_review, commit_hygiene).
|
|
371
|
+
*
|
|
372
|
+
* Pre-R12 the field was dropped entirely on re-init — an operator
|
|
373
|
+
* who opted out via `bootstrap_allowlist: { enabled: false }` got
|
|
374
|
+
* silently flipped back to `true` (schema default). This helper
|
|
375
|
+
* closes that drop class.
|
|
376
|
+
*/
|
|
377
|
+
function bootstrapAllowlistConfigSpread(layered, existing) {
|
|
378
|
+
// Existing on-disk policy wins — preserves explicit opt-out.
|
|
379
|
+
if (existing?.bootstrapAllowlistEnabled !== undefined) {
|
|
380
|
+
return { bootstrapAllowlistEnabled: existing.bootstrapAllowlistEnabled };
|
|
381
|
+
}
|
|
382
|
+
// Profile-layer value next — `bst-internal` pins `true` explicitly
|
|
383
|
+
// so the on-disk policy shows the pinned posture rather than
|
|
384
|
+
// relying on the schema default.
|
|
385
|
+
const fromProfile = layered.bootstrap_allowlist?.enabled;
|
|
386
|
+
if (fromProfile !== undefined) {
|
|
387
|
+
return { bootstrapAllowlistEnabled: fromProfile };
|
|
388
|
+
}
|
|
389
|
+
// Neither set — omit from the emitted policy.yaml.
|
|
390
|
+
return {};
|
|
391
|
+
}
|
|
351
392
|
/**
|
|
352
393
|
* G6 — Codex install-assist probe.
|
|
353
394
|
*
|
|
@@ -563,6 +604,20 @@ function readExistingPolicyForPreservation(targetDir) {
|
|
|
563
604
|
out.attributionCoAuthor = preserved;
|
|
564
605
|
}
|
|
565
606
|
}
|
|
607
|
+
// R12-P1 (codex round 12 / 0.49.0): preserve bootstrap_allowlist
|
|
608
|
+
// across re-init. Critical for the documented opt-out — an
|
|
609
|
+
// operator who set `bootstrap_allowlist: { enabled: false }` MUST
|
|
610
|
+
// NOT have it silently flipped back to `true` by the next init.
|
|
611
|
+
// Inline (`bootstrap_allowlist: { enabled: false }`) and block
|
|
612
|
+
// (`bootstrap_allowlist:\n enabled: false`) forms fold to the
|
|
613
|
+
// same parsed object via yaml.parse.
|
|
614
|
+
const bootstrapAllowlist = policy['bootstrap_allowlist'];
|
|
615
|
+
if (bootstrapAllowlist !== null && typeof bootstrapAllowlist === 'object') {
|
|
616
|
+
const ba = bootstrapAllowlist;
|
|
617
|
+
if (typeof ba['enabled'] === 'boolean') {
|
|
618
|
+
out.bootstrapAllowlistEnabled = ba['enabled'];
|
|
619
|
+
}
|
|
620
|
+
}
|
|
566
621
|
return out;
|
|
567
622
|
}
|
|
568
623
|
function readExistingInstalledAt(policyPath) {
|
|
@@ -720,6 +775,16 @@ function writePolicyYaml(targetDir, config, layered) {
|
|
|
720
775
|
lines.push(` refuse_at_commits: ${config.commitHygieneRefuseAtCommits}`);
|
|
721
776
|
}
|
|
722
777
|
}
|
|
778
|
+
// R12-P1 (codex round 12 / 0.49.0): emit bootstrap_allowlist when
|
|
779
|
+
// the layered profile or the existing on-disk policy declared it.
|
|
780
|
+
// When unset, omit the block — consumers fall through to the zod
|
|
781
|
+
// schema default (`enabled: true`). The block form (vs flow form)
|
|
782
|
+
// mirrors what `bst-internal.yaml` emits so dogfood byte-fidelity
|
|
783
|
+
// is preserved.
|
|
784
|
+
if (config.bootstrapAllowlistEnabled !== undefined) {
|
|
785
|
+
lines.push(`bootstrap_allowlist:`);
|
|
786
|
+
lines.push(` enabled: ${config.bootstrapAllowlistEnabled ? 'true' : 'false'}`);
|
|
787
|
+
}
|
|
723
788
|
lines.push(``);
|
|
724
789
|
fs.writeFileSync(policyPath, lines.join('\n'), 'utf8');
|
|
725
790
|
return policyPath;
|
|
@@ -1425,6 +1490,9 @@ export async function runInit(options) {
|
|
|
1425
1490
|
// seeded from the layered profile. Same precedence as the
|
|
1426
1491
|
// wizard path above. Conditional spread for exact-optional.
|
|
1427
1492
|
...attributionConfigSpread(layeredBase, existingPolicy),
|
|
1493
|
+
// R12-P1 (codex round 12): preserve bootstrap_allowlist.enabled
|
|
1494
|
+
// so an operator opt-out survives `rea init` re-runs.
|
|
1495
|
+
...bootstrapAllowlistConfigSpread(layeredBase, existingPolicy),
|
|
1428
1496
|
fromReagent,
|
|
1429
1497
|
reagentPolicyPath,
|
|
1430
1498
|
reagentNotices,
|
|
@@ -1459,6 +1527,40 @@ export async function runInit(options) {
|
|
|
1459
1527
|
cancel('Init cancelled — no files written.');
|
|
1460
1528
|
}
|
|
1461
1529
|
}
|
|
1530
|
+
// R11-P1 (codex round 11): blocking-pin pre-flight. Same security
|
|
1531
|
+
// guarantee as `runUpgrade`'s pre-flight (R9-P1) but for the init
|
|
1532
|
+
// surface. If `package.json` already pins `@bookedsolid/rea` to a
|
|
1533
|
+
// version that does NOT admit the installed CLI version
|
|
1534
|
+
// (workspace:*, file:.., git URLs, dist-tags, exact older pins,
|
|
1535
|
+
// cross-major caret), writing 0.49 hooks + policy artifacts on
|
|
1536
|
+
// top creates a hook/CLI skew: the bash gates resolve the older
|
|
1537
|
+
// CLI from node_modules and that CLI's strict policy loader
|
|
1538
|
+
// rejects the new `bootstrap_allowlist:` top-level key.
|
|
1539
|
+
//
|
|
1540
|
+
// Pre-R11 the pre-flight was upgrade-only. Operators running `rea
|
|
1541
|
+
// init` to reinstall (a common pattern on consumer repos) hit
|
|
1542
|
+
// the same trap. We run the same check here, BEFORE the first
|
|
1543
|
+
// `.rea/` mkdir — so a refused init leaves the consumer's
|
|
1544
|
+
// existing state untouched.
|
|
1545
|
+
//
|
|
1546
|
+
// Fresh-clone repos (no existing pin) return `kind: 'ok'` from
|
|
1547
|
+
// the check, so this branch is a no-op for the canonical init
|
|
1548
|
+
// path.
|
|
1549
|
+
{
|
|
1550
|
+
const initPinCheck = await checkUpgradeBlockingPin({
|
|
1551
|
+
cwd: targetDir,
|
|
1552
|
+
cliVersion: getPkgVersion(),
|
|
1553
|
+
mode: 'init',
|
|
1554
|
+
});
|
|
1555
|
+
if (initPinCheck.kind === 'block' || initPinCheck.kind === 'block-symlink') {
|
|
1556
|
+
// R10-P2: block-symlink is the symlinked-pkg.json variant.
|
|
1557
|
+
// Both kinds share the `reason` field; throw with the
|
|
1558
|
+
// operator-actionable explainer. The throw lands in
|
|
1559
|
+
// `main().catch(...)` (src/cli/index.ts) which surfaces the
|
|
1560
|
+
// multi-line message via the standard `err` path.
|
|
1561
|
+
throw new Error(initPinCheck.reason);
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1462
1564
|
if (!fs.existsSync(reaDir))
|
|
1463
1565
|
fs.mkdirSync(reaDir, { recursive: true });
|
|
1464
1566
|
// 0.43.0 UX polish: wrap the file-write phase in a clack spinner so
|
|
@@ -1477,6 +1579,7 @@ export async function runInit(options) {
|
|
|
1477
1579
|
let prePushResult;
|
|
1478
1580
|
let mdResult;
|
|
1479
1581
|
let gitignoreResult;
|
|
1582
|
+
let selfPinResult;
|
|
1480
1583
|
let manifestPath;
|
|
1481
1584
|
let fragmentInput;
|
|
1482
1585
|
try {
|
|
@@ -1514,6 +1617,32 @@ export async function runInit(options) {
|
|
|
1514
1617
|
// `rea serve` / `rea cache` / `/freeze` can write under `.rea/`. Idempotent
|
|
1515
1618
|
// append (and `rea upgrade` backfills older installs that never got this).
|
|
1516
1619
|
gitignoreResult = await ensureReaGitignore(targetDir);
|
|
1620
|
+
// 0.49.0 — self-pin `@bookedsolid/rea` as `^<cli-version>` in the
|
|
1621
|
+
// consumer's package.json (devDependencies). Without this, the hook
|
|
1622
|
+
// shims that init JUST wrote depend on a CLI that the next `pnpm
|
|
1623
|
+
// install` does not actually install. The bash-gate bootstrap
|
|
1624
|
+
// allowlist (Fix B) recovers the brick state when the dep IS
|
|
1625
|
+
// declared but the CLI is not yet built; without the dep declared,
|
|
1626
|
+
// the allowlist refuses (no precondition forge route — must be a
|
|
1627
|
+
// legitimate top-level declaration). See `src/cli/install/self-pin.ts`
|
|
1628
|
+
// for the full contract.
|
|
1629
|
+
//
|
|
1630
|
+
// R13-P1 (codex round 13): `mode: 'upgrade'` — managed-caret pins
|
|
1631
|
+
// that don't admit the installed CLI MUST bump in place during
|
|
1632
|
+
// `rea init` too. Pre-R13 init used the default `mode: 'init'`
|
|
1633
|
+
// (warn-and-skip), but the R11-P1 preflight already filters out
|
|
1634
|
+
// the non-managed-caret cases (workspace, file:, git, dist-tag,
|
|
1635
|
+
// exact) — so anything reaching this line is either a fresh
|
|
1636
|
+
// write OR a managed-caret bump. `mode: 'upgrade'` is the right
|
|
1637
|
+
// semantics for both. Without this fix, `rea init` on a repo
|
|
1638
|
+
// with `^0.49.0` + CLI 0.50.0 wrote new hooks/policy but left
|
|
1639
|
+
// the pin behind — recreating the hook/CLI skew the preflight
|
|
1640
|
+
// was supposed to prevent.
|
|
1641
|
+
selfPinResult = await selfPinRea({
|
|
1642
|
+
cwd: targetDir,
|
|
1643
|
+
cliVersion: getPkgVersion(),
|
|
1644
|
+
mode: 'upgrade',
|
|
1645
|
+
});
|
|
1517
1646
|
// G12 — record the install manifest. SHAs are of the files actually on disk
|
|
1518
1647
|
// after the copy pass, so drift detection compares against real state (not
|
|
1519
1648
|
// canonical, which may differ if the consumer's copy was aborted mid-run).
|
|
@@ -1577,6 +1706,38 @@ export async function runInit(options) {
|
|
|
1577
1706
|
}
|
|
1578
1707
|
for (const w of gitignoreResult.warnings)
|
|
1579
1708
|
warn(w);
|
|
1709
|
+
// 0.49.0 self-pin reporting. One line per outcome; warn-and-skip is
|
|
1710
|
+
// surfaced loudly so the operator notices a mismatched pin.
|
|
1711
|
+
//
|
|
1712
|
+
// R18-P2 (codex round 18): R13-P1 switched `rea init` to
|
|
1713
|
+
// `mode: 'upgrade'` so re-running init on a repo with a managed-
|
|
1714
|
+
// caret pin from an older CLI auto-bumps to the new CLI's caret.
|
|
1715
|
+
// The reporting ladder lacked a `'bumped'` arm — the file was
|
|
1716
|
+
// mutated but the success summary printed no line about it,
|
|
1717
|
+
// making the install output incomplete. Mirrors the `'bumped'`
|
|
1718
|
+
// arm in `rea upgrade` (see src/cli/upgrade.ts) so the operator
|
|
1719
|
+
// sees the pin delta explicitly in both surfaces.
|
|
1720
|
+
if (selfPinResult.action === 'wrote' && selfPinResult.packageJsonPath !== null) {
|
|
1721
|
+
console.log(` ~ ${path.relative(targetDir, selfPinResult.packageJsonPath)} (self-pin: @bookedsolid/rea@${selfPinResult.pinnedRange})`);
|
|
1722
|
+
}
|
|
1723
|
+
else if (selfPinResult.action === 'bumped' && selfPinResult.packageJsonPath !== null) {
|
|
1724
|
+
console.log(` ✓ ${path.relative(targetDir, selfPinResult.packageJsonPath)} (self-pin: bumped @bookedsolid/rea from ${selfPinResult.existingRange ?? '?'} to ${selfPinResult.pinnedRange})`);
|
|
1725
|
+
}
|
|
1726
|
+
else if (selfPinResult.action === 'skipped-same' && selfPinResult.packageJsonPath !== null) {
|
|
1727
|
+
console.log(` · ${path.relative(targetDir, selfPinResult.packageJsonPath)} (self-pin: already declared)`);
|
|
1728
|
+
}
|
|
1729
|
+
else if (selfPinResult.action === 'skipped-different') {
|
|
1730
|
+
warn(selfPinResult.message);
|
|
1731
|
+
}
|
|
1732
|
+
else if (selfPinResult.action === 'skipped-dogfood') {
|
|
1733
|
+
// Silent — dogfood install, expected.
|
|
1734
|
+
}
|
|
1735
|
+
else if (selfPinResult.action === 'skipped-no-package-json') {
|
|
1736
|
+
warn('self-pin skipped — no package.json found upward from target; bash gates will refuse on a fresh clone unless you add `@bookedsolid/rea` to a package.json');
|
|
1737
|
+
}
|
|
1738
|
+
else if (selfPinResult.action === 'skipped-malformed-package-json') {
|
|
1739
|
+
warn(selfPinResult.message);
|
|
1740
|
+
}
|
|
1580
1741
|
console.log(` + ${path.relative(targetDir, manifestPath)}`);
|
|
1581
1742
|
if (mergeResult.warnings.length > 0) {
|
|
1582
1743
|
console.log('');
|