@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
|
@@ -0,0 +1,853 @@
|
|
|
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
|
+
import fs from 'node:fs/promises';
|
|
54
|
+
import fsSync from 'node:fs';
|
|
55
|
+
import path from 'node:path';
|
|
56
|
+
import semver from 'semver';
|
|
57
|
+
/** Package name we self-pin. */
|
|
58
|
+
export const REA_PACKAGE_NAME = '@bookedsolid/rea';
|
|
59
|
+
/**
|
|
60
|
+
* Look for `package.json` in the explicit target directory ONLY. No upward
|
|
61
|
+
* walk.
|
|
62
|
+
*
|
|
63
|
+
* P2-4 (codex round 1 / locked design): the architect's "pin lands in
|
|
64
|
+
* workspace root package.json (refuse to mutate parent the operator did
|
|
65
|
+
* not explicitly target)" rule is implemented here as a hard refusal to
|
|
66
|
+
* walk past `start`. Earlier revisions walked upward up to 64 directories
|
|
67
|
+
* — that meant `rea init` invoked from a workspace subdirectory (e.g.
|
|
68
|
+
* `apps/web/`) with no `package.json` of its own would silently land on
|
|
69
|
+
* the monorepo root's manifest and mutate it. That violates the locked
|
|
70
|
+
* intent: the operator picked the cwd; if there's no `package.json`
|
|
71
|
+
* there, we refuse rather than guessing which parent to touch.
|
|
72
|
+
*
|
|
73
|
+
* Concretely:
|
|
74
|
+
* - `apps/web/` with no package.json → return `null`, caller maps to
|
|
75
|
+
* `skipped-no-package-json` (operator-facing message says
|
|
76
|
+
* "no package.json in the target directory").
|
|
77
|
+
* - `apps/web/` with its own package.json → pin there.
|
|
78
|
+
* - Monorepo root with package.json → pin there.
|
|
79
|
+
*
|
|
80
|
+
* The walk-up semantics also applied to `checkSelfPinDeclaredSync` (the
|
|
81
|
+
* doctor brick-state detector). Same tightening — doctor reports the
|
|
82
|
+
* absence rather than scanning the parent chain. Operators who want
|
|
83
|
+
* doctor to validate a parent's pin run `rea doctor` from that parent.
|
|
84
|
+
*/
|
|
85
|
+
function findPackageJson(start) {
|
|
86
|
+
const cur = path.resolve(start);
|
|
87
|
+
const candidate = path.join(cur, 'package.json');
|
|
88
|
+
if (fsSync.existsSync(candidate))
|
|
89
|
+
return candidate;
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Detect indent / EOL / trailing-newline so re-serialization preserves
|
|
94
|
+
* the operator's existing formatting verbatim. JSON.stringify with a
|
|
95
|
+
* numeric `space` argument always emits `\n` separators, so we
|
|
96
|
+
* post-process to apply CRLF when the source used it.
|
|
97
|
+
*/
|
|
98
|
+
function detectShape(raw) {
|
|
99
|
+
// EOL: take the first newline pair we encounter. Default to LF.
|
|
100
|
+
let eol = '\n';
|
|
101
|
+
const lf = raw.indexOf('\n');
|
|
102
|
+
if (lf > 0 && raw[lf - 1] === '\r')
|
|
103
|
+
eol = '\r\n';
|
|
104
|
+
// Indent: find the first line that begins with a space-or-tab and
|
|
105
|
+
// count the leading whitespace. Falls back to 2 (the package.json
|
|
106
|
+
// convention) when the file has no nested indentation visible.
|
|
107
|
+
let indent = 2;
|
|
108
|
+
const lines = raw.split(/\r?\n/);
|
|
109
|
+
for (const line of lines) {
|
|
110
|
+
const m = /^(\t+|[ ]+)\S/.exec(line);
|
|
111
|
+
if (!m)
|
|
112
|
+
continue;
|
|
113
|
+
const ws = m[1] ?? '';
|
|
114
|
+
if (ws.startsWith('\t')) {
|
|
115
|
+
indent = 1; // tab-indented; JSON.stringify will use `\t` when we pass it directly
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
indent = ws.length;
|
|
119
|
+
}
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
// Clamp to a sensible range. 0 indent (single-line JSON) is preserved
|
|
123
|
+
// by the caller — we don't re-serialize when no change is needed.
|
|
124
|
+
if (indent < 1 || indent > 8)
|
|
125
|
+
indent = 2;
|
|
126
|
+
const trailingNewline = raw.endsWith('\n');
|
|
127
|
+
return { indent, eol, trailingNewline };
|
|
128
|
+
}
|
|
129
|
+
function isPlainObject(v) {
|
|
130
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Strip a leading UTF-8 BOM (U+FEFF / EF BB BF) from a string, returning the
|
|
134
|
+
* rest. No-op when no BOM is present.
|
|
135
|
+
*
|
|
136
|
+
* Some Windows operators commit `package.json` with a leading BOM; `JSON.parse`
|
|
137
|
+
* rejects it (the spec says JSON.parse must error on a leading BOM). We
|
|
138
|
+
* silently strip it before parse — npm and pnpm both tolerate either form
|
|
139
|
+
* when writing back, so dropping the BOM on save is the simpler, more
|
|
140
|
+
* invariant choice. The alternative (detect-and-preserve) would need an
|
|
141
|
+
* extra field on `FileShape` plus a re-prepend in `serialize`, and the cost
|
|
142
|
+
* (one operator who deliberately wanted a BOM no longer has one) is much
|
|
143
|
+
* lower than the cost of an unrecoverable false-positive
|
|
144
|
+
* `skipped-malformed-package-json` on every BOM-bearing manifest.
|
|
145
|
+
*
|
|
146
|
+
* P3-1 (codex round 1): extracted into a shared helper so the same canonical
|
|
147
|
+
* BOM-strip applies to BOTH `readPackageJson` (the write path used by
|
|
148
|
+
* `selfPinRea`) AND `checkSelfPinDeclaredSync` (the doctor brick-state
|
|
149
|
+
* detector). Pre-extraction, only the write path stripped — doctor would
|
|
150
|
+
* report `fail-malformed` for a BOM-prefixed manifest that self-pin
|
|
151
|
+
* tolerated fine, which is the asymmetric-fix class we explicitly want
|
|
152
|
+
* to defend against.
|
|
153
|
+
*/
|
|
154
|
+
export function stripUtf8Bom(input) {
|
|
155
|
+
if (input.length > 0 && input.charCodeAt(0) === 0xfeff) {
|
|
156
|
+
return input.slice(1);
|
|
157
|
+
}
|
|
158
|
+
return input;
|
|
159
|
+
}
|
|
160
|
+
function checkPackageJsonShapeSync(pkgPath) {
|
|
161
|
+
let lst;
|
|
162
|
+
try {
|
|
163
|
+
lst = fsSync.lstatSync(pkgPath);
|
|
164
|
+
}
|
|
165
|
+
catch (e) {
|
|
166
|
+
// ENOENT is fine — caller handles missing pkg.json with its own
|
|
167
|
+
// logic. Any other error: treat as 'lstat-error' so the caller
|
|
168
|
+
// can fall through to existing read paths (which will surface
|
|
169
|
+
// the same error in a context-appropriate way).
|
|
170
|
+
const code = e.code;
|
|
171
|
+
if (code === 'ENOENT')
|
|
172
|
+
return 'ok';
|
|
173
|
+
return 'lstat-error';
|
|
174
|
+
}
|
|
175
|
+
if (lst.isSymbolicLink())
|
|
176
|
+
return 'symlink';
|
|
177
|
+
return 'ok';
|
|
178
|
+
}
|
|
179
|
+
async function checkPackageJsonShape(pkgPath) {
|
|
180
|
+
let lst;
|
|
181
|
+
try {
|
|
182
|
+
lst = await fs.lstat(pkgPath);
|
|
183
|
+
}
|
|
184
|
+
catch (e) {
|
|
185
|
+
const code = e.code;
|
|
186
|
+
if (code === 'ENOENT')
|
|
187
|
+
return 'ok';
|
|
188
|
+
return 'lstat-error';
|
|
189
|
+
}
|
|
190
|
+
if (lst.isSymbolicLink())
|
|
191
|
+
return 'symlink';
|
|
192
|
+
return 'ok';
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Build the operator-facing refusal message for a symlinked
|
|
196
|
+
* package.json. Pulled out as a helper so `selfPinRea` (throws),
|
|
197
|
+
* `checkUpgradeBlockingPin` (returns block), and
|
|
198
|
+
* `checkSelfPinDeclaredSync` (returns fail-symlink) all surface
|
|
199
|
+
* IDENTICAL wording. Drift between these three surfaces would
|
|
200
|
+
* confuse operators reading the same diagnostic at different layers.
|
|
201
|
+
*/
|
|
202
|
+
function buildSymlinkRefusalMessage(pkgPath) {
|
|
203
|
+
return (`rea self-pin refusing: ${pkgPath} is a symlink. ` +
|
|
204
|
+
`Writing through it would mutate a file outside the requested ` +
|
|
205
|
+
`project tree (the symlink's target). ` +
|
|
206
|
+
`To reconcile, either replace the symlink with a regular ` +
|
|
207
|
+
`package.json or run rea init/upgrade in the target directory ` +
|
|
208
|
+
`the symlink points to.`);
|
|
209
|
+
}
|
|
210
|
+
async function readPackageJson(pkgPath) {
|
|
211
|
+
let raw;
|
|
212
|
+
try {
|
|
213
|
+
raw = await fs.readFile(pkgPath, 'utf8');
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
// P2-3 (codex round 1): strip leading UTF-8 BOM. The original bytes
|
|
219
|
+
// tracked in `raw` go forward without the BOM so `detectShape` does
|
|
220
|
+
// not have to special-case its leading-whitespace scans either.
|
|
221
|
+
raw = stripUtf8Bom(raw);
|
|
222
|
+
let parsed;
|
|
223
|
+
try {
|
|
224
|
+
parsed = JSON.parse(raw);
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
if (!isPlainObject(parsed))
|
|
230
|
+
return null;
|
|
231
|
+
return { raw, parsed, shape: detectShape(raw) };
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Format a JS object back to a JSON string honoring the detected shape.
|
|
235
|
+
* Always uses spaces (JSON.stringify's `space` argument). Trailing
|
|
236
|
+
* newline is restored if the original carried one.
|
|
237
|
+
*
|
|
238
|
+
* Indent semantics: pass the indent count directly to JSON.stringify.
|
|
239
|
+
* When the source was tab-indented (`indent === 1` per detectShape's
|
|
240
|
+
* conversion above) we still emit spaces — JSON.stringify only supports
|
|
241
|
+
* a string or a positive integer for indent, and supporting tabs would
|
|
242
|
+
* require post-processing every indent run. The cost (one operator who
|
|
243
|
+
* tabs their package.json sees a one-time switch to spaces) is lower
|
|
244
|
+
* than the risk of indent-conversion bugs.
|
|
245
|
+
*/
|
|
246
|
+
/**
|
|
247
|
+
* Managed-caret pin shape (P1-1 / codex round 2).
|
|
248
|
+
*
|
|
249
|
+
* Matches what `selfPinRea` itself writes — a caret prefix followed by
|
|
250
|
+
* a 2- or 3-segment semver, with optional `-prerelease` tail. Anything
|
|
251
|
+
* NOT matching this shape is treated as operator-authored and
|
|
252
|
+
* `mode: 'upgrade'` will hands-off rather than bump.
|
|
253
|
+
*
|
|
254
|
+
* Examples that MATCH (rea-managed):
|
|
255
|
+
* `^0.49.0`, `^0.49`, `^1.0.0`, `^1.2.3-beta.1`
|
|
256
|
+
*
|
|
257
|
+
* Examples that DO NOT MATCH (operator-authored, hands-off):
|
|
258
|
+
* `workspace:^`, `workspace:*`, `file:../rea`, `git+https://...`,
|
|
259
|
+
* `next`, `latest`, `0.49.0` (exact pin), `~0.49.0` (tilde),
|
|
260
|
+
* `>=0.49.0 <0.50.0` (range), `0.49.x` (x-range), `^0.49.0 || ^0.48.0`.
|
|
261
|
+
*
|
|
262
|
+
* R4-P2 (codex round 4): the per-shape "is this a managed pin?" gate
|
|
263
|
+
* is still our own regex (semver doesn't have a "did rea write this?"
|
|
264
|
+
* concept — operator-authored carets like `^1.2.3` would also satisfy
|
|
265
|
+
* any semver-only shape gate, and we explicitly don't want to bump
|
|
266
|
+
* those without an audit-traceable rea-managed marker). The
|
|
267
|
+
* VERSION-SATISFIES decision uses `semver.satisfies` so prerelease
|
|
268
|
+
* upgrades are handled correctly: a non-prerelease range like
|
|
269
|
+
* `^0.49.0` does NOT satisfy `0.49.1-beta.0` (npm spec excludes
|
|
270
|
+
* prereleases from non-prerelease ranges); the pre-fix predicate
|
|
271
|
+
* compared major/minor only and treated this as already-covered,
|
|
272
|
+
* leaving the pin pointing at the older CLI when newer hooks shipped.
|
|
273
|
+
*/
|
|
274
|
+
const MANAGED_CARET_RE = /^\^(\d+)\.(\d+)(?:\.(\d+))?(?:-[A-Za-z0-9.+-]+)?$/;
|
|
275
|
+
/**
|
|
276
|
+
* Extract the major version number from a managed-caret range string,
|
|
277
|
+
* or null when the input is not a managed-caret shape (in which case
|
|
278
|
+
* the caller hands off — operator-authored).
|
|
279
|
+
*/
|
|
280
|
+
function managedCaretMajor(range) {
|
|
281
|
+
const m = MANAGED_CARET_RE.exec(range);
|
|
282
|
+
if (m === null || m[1] === undefined)
|
|
283
|
+
return null;
|
|
284
|
+
return Number(m[1]);
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Decide whether `rea upgrade` should auto-bump an existing pin to the
|
|
288
|
+
* newly-written caret. Returns `true` only when:
|
|
289
|
+
*
|
|
290
|
+
* 1. `existing` matches the managed-caret shape (we wrote it; it
|
|
291
|
+
* isn't a workspace/file/git/tag/exact pin).
|
|
292
|
+
* 2. `newRange` ALSO matches the managed-caret shape (sanity — the
|
|
293
|
+
* function should never be called with a non-caret target, but
|
|
294
|
+
* the predicate stays self-contained).
|
|
295
|
+
* 3. Both ranges share the same major version. Cross-major bumps
|
|
296
|
+
* are intentional operator decisions and we hands-off.
|
|
297
|
+
* 4. The existing caret does NOT already admit the version the new
|
|
298
|
+
* range would resolve to. We extract the floor version from
|
|
299
|
+
* `newRange` (strip the leading `^`) and ask
|
|
300
|
+
* `semver.satisfies(floor, existing)`:
|
|
301
|
+
*
|
|
302
|
+
* - `^0.49.0` + `^0.49.5` → 0.49.5 satisfies ^0.49.0 → NO bump
|
|
303
|
+
* - `^0.49.0` + `^0.50.0` → 0.50.0 does NOT satisfy → BUMP
|
|
304
|
+
* - `^0.49.0` + `^0.49.1-beta.0` → prerelease does NOT satisfy
|
|
305
|
+
* a non-prerelease range (npm spec) → BUMP (R4-P2)
|
|
306
|
+
* - `^1.0.0` + `^1.5.0` → 1.5.0 satisfies ^1.0.0 → NO bump
|
|
307
|
+
* - `^1.0.0` + `^1.1.0-beta.0` → prerelease does NOT satisfy
|
|
308
|
+
* → BUMP (R4-P2)
|
|
309
|
+
*
|
|
310
|
+
* The pre-fix predicate compared major/minor by hand, which
|
|
311
|
+
* mis-classified prerelease bumps as already-covered. Switching to
|
|
312
|
+
* `semver.satisfies` gives us npm-spec-correct semver behavior in
|
|
313
|
+
* one place.
|
|
314
|
+
*/
|
|
315
|
+
export function shouldBumpManagedCaret(existing, newRange) {
|
|
316
|
+
const existingMajor = managedCaretMajor(existing);
|
|
317
|
+
if (existingMajor === null)
|
|
318
|
+
return false;
|
|
319
|
+
const newMajor = managedCaretMajor(newRange);
|
|
320
|
+
if (newMajor === null)
|
|
321
|
+
return false;
|
|
322
|
+
if (existingMajor !== newMajor)
|
|
323
|
+
return false;
|
|
324
|
+
// Extract the version floor from the new range. `^X.Y.Z` floor is
|
|
325
|
+
// exactly `X.Y.Z` (with optional prerelease tail). Strip the `^`;
|
|
326
|
+
// semver.satisfies handles the rest. We do not use `semver.minVersion`
|
|
327
|
+
// here because for prerelease shapes (`^0.49.1-beta.0`) its result
|
|
328
|
+
// is `0.49.1-beta.0` — same as a manual strip — but for our managed
|
|
329
|
+
// shape the strip is unambiguous and avoids the cross-version
|
|
330
|
+
// semver quirks.
|
|
331
|
+
const newFloor = newRange.startsWith('^') ? newRange.slice(1) : newRange;
|
|
332
|
+
// Coerce-via-parse to validate the floor is a real semver; if not,
|
|
333
|
+
// we cannot reason about it and hands-off.
|
|
334
|
+
if (semver.valid(newFloor) === null)
|
|
335
|
+
return false;
|
|
336
|
+
// R9-P1 (codex round 9 / 0.49.0): hands-off on downgrades. The
|
|
337
|
+
// managed-caret bump is an UPGRADE primitive — when the existing
|
|
338
|
+
// caret pins a HIGHER floor than the new CLI version, the operator
|
|
339
|
+
// explicitly pinned newer-than-us (deliberate forward pin) and we
|
|
340
|
+
// must not silently rewrite their pin DOWN to an older floor. The
|
|
341
|
+
// R9-P1 abort gate in `runUpgrade` handles this case separately:
|
|
342
|
+
// it sees `shouldBumpManagedCaret = false` AND `existing range
|
|
343
|
+
// does not admit new CLI`, then blocks the upgrade with a clear
|
|
344
|
+
// operator-actionable message rather than silently downgrading.
|
|
345
|
+
const existingFloor = existing.startsWith('^') ? existing.slice(1) : existing;
|
|
346
|
+
if (semver.valid(existingFloor) !== null && semver.lt(newFloor, existingFloor)) {
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
// `includePrerelease: false` is semver's default and what we want:
|
|
350
|
+
// a non-prerelease range like `^0.49.0` does NOT include
|
|
351
|
+
// `0.49.1-beta.0`, so the !satisfies branch correctly returns true
|
|
352
|
+
// and we bump.
|
|
353
|
+
return !semver.satisfies(newFloor, existing);
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Determine whether the existing `@bookedsolid/rea` pin would block
|
|
357
|
+
* `rea init` / `rea upgrade` from writing 0.49 artifacts safely.
|
|
358
|
+
* See type doc.
|
|
359
|
+
*
|
|
360
|
+
* R11-P1 (codex round 11): the check applies to BOTH `rea init` and
|
|
361
|
+
* `rea upgrade`. The implementation is unchanged; only the
|
|
362
|
+
* operator-facing message prefix varies with `mode`.
|
|
363
|
+
*/
|
|
364
|
+
export async function checkUpgradeBlockingPin(options) {
|
|
365
|
+
const newCliVersion = options.cliVersion;
|
|
366
|
+
const newPinnedRange = `^${newCliVersion}`;
|
|
367
|
+
const cmdName = options.mode === 'init' ? 'rea init' : 'rea upgrade';
|
|
368
|
+
const pkgPath = findPackageJson(options.cwd);
|
|
369
|
+
if (pkgPath === null)
|
|
370
|
+
return { kind: 'no-pkg-json' };
|
|
371
|
+
// R10-P2 (codex round 10): symlink check BEFORE `readPackageJson`
|
|
372
|
+
// so the parser never touches the symlink target. Even on the
|
|
373
|
+
// read-only preview path, surfacing this as a block is the right
|
|
374
|
+
// signal to the operator — `selfPinRea`'s write path would throw
|
|
375
|
+
// moments later anyway, and the upgrade preflight needs to refuse
|
|
376
|
+
// before any artifact-writing step.
|
|
377
|
+
if (checkPackageJsonShapeSync(pkgPath) === 'symlink') {
|
|
378
|
+
return {
|
|
379
|
+
kind: 'block-symlink',
|
|
380
|
+
packageJsonPath: pkgPath,
|
|
381
|
+
newCliVersion,
|
|
382
|
+
newPinnedRange,
|
|
383
|
+
reason: buildSymlinkRefusalMessage(pkgPath),
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
const pkg = await readPackageJson(pkgPath);
|
|
387
|
+
if (pkg === null)
|
|
388
|
+
return { kind: 'malformed-pkg-json', packageJsonPath: pkgPath };
|
|
389
|
+
if (pkg.parsed['name'] === REA_PACKAGE_NAME) {
|
|
390
|
+
return { kind: 'dogfood', packageJsonPath: pkgPath };
|
|
391
|
+
}
|
|
392
|
+
const deps = isPlainObject(pkg.parsed['dependencies']) ? pkg.parsed['dependencies'] : null;
|
|
393
|
+
const devDeps = isPlainObject(pkg.parsed['devDependencies'])
|
|
394
|
+
? pkg.parsed['devDependencies']
|
|
395
|
+
: null;
|
|
396
|
+
const existingDep = deps !== null && typeof deps[REA_PACKAGE_NAME] === 'string'
|
|
397
|
+
? deps[REA_PACKAGE_NAME]
|
|
398
|
+
: undefined;
|
|
399
|
+
const existingDevDep = devDeps !== null && typeof devDeps[REA_PACKAGE_NAME] === 'string'
|
|
400
|
+
? devDeps[REA_PACKAGE_NAME]
|
|
401
|
+
: undefined;
|
|
402
|
+
// Authoritative pin (matches `selfPinRea`'s precedence).
|
|
403
|
+
const existing = existingDep ?? existingDevDep;
|
|
404
|
+
if (existing === undefined) {
|
|
405
|
+
return { kind: 'ok', packageJsonPath: pkgPath };
|
|
406
|
+
}
|
|
407
|
+
// Exact match — pin already at the new version. No skew possible.
|
|
408
|
+
if (existing === newPinnedRange) {
|
|
409
|
+
return { kind: 'ok', packageJsonPath: pkgPath, existingRange: existing };
|
|
410
|
+
}
|
|
411
|
+
// Managed-caret bumpable — `selfPinRea` will rewrite the pin in
|
|
412
|
+
// place; no skew once the upgrade completes.
|
|
413
|
+
if (shouldBumpManagedCaret(existing, newPinnedRange)) {
|
|
414
|
+
return { kind: 'ok', packageJsonPath: pkgPath, existingRange: existing };
|
|
415
|
+
}
|
|
416
|
+
// Does the existing pin admit the new CLI version?
|
|
417
|
+
// - Pin is a valid semver range AND semver.satisfies(newCliVersion, existing)
|
|
418
|
+
// → ok (e.g. `^0.49.0` admits `0.49.5`).
|
|
419
|
+
// - Pin is NOT a valid semver range (workspace:*, file:.., git URL,
|
|
420
|
+
// dist-tag like `next`) → semver.validRange returns null →
|
|
421
|
+
// we cannot statically determine admittance → block on uncertainty.
|
|
422
|
+
// The operator can re-run after editing the manifest.
|
|
423
|
+
let admits = false;
|
|
424
|
+
if (semver.validRange(existing) !== null) {
|
|
425
|
+
admits = semver.satisfies(newCliVersion, existing);
|
|
426
|
+
}
|
|
427
|
+
if (admits) {
|
|
428
|
+
return { kind: 'ok', packageJsonPath: pkgPath, existingRange: existing };
|
|
429
|
+
}
|
|
430
|
+
// Build the operator-facing reason. Customize the third bullet for
|
|
431
|
+
// workspace / file / git / tag shapes — those need a workspace-
|
|
432
|
+
// specific fix path.
|
|
433
|
+
const isWorkspacePin = existing.startsWith('workspace:');
|
|
434
|
+
const isFilePin = existing.startsWith('file:');
|
|
435
|
+
const isGitPin = existing.startsWith('git') ||
|
|
436
|
+
existing.startsWith('github:') ||
|
|
437
|
+
existing.startsWith('gitlab:') ||
|
|
438
|
+
existing.startsWith('bitbucket:') ||
|
|
439
|
+
/^https?:\/\//.test(existing);
|
|
440
|
+
const isLooksDistTag = semver.validRange(existing) === null && !isWorkspacePin && !isFilePin && !isGitPin;
|
|
441
|
+
const lines = [
|
|
442
|
+
`${cmdName} refusing: package.json pins ${REA_PACKAGE_NAME} to "${existing}"`,
|
|
443
|
+
`which does not admit the installed CLI version ${newCliVersion}.`,
|
|
444
|
+
'',
|
|
445
|
+
`Writing ${newCliVersion} hooks/policy artifacts now would create a hook/CLI skew:`,
|
|
446
|
+
`your shims would resolve the older CLI from node_modules, which cannot`,
|
|
447
|
+
`parse the new policy.yaml schema (strict).`,
|
|
448
|
+
'',
|
|
449
|
+
// R12-P2 (codex round 12): recommend the bare-spec form. The
|
|
450
|
+
// CLI-missing bootstrap allowlist (hooks/_lib/bootstrap-allowlist.sh)
|
|
451
|
+
// accepts ONLY `pnpm add -D @bookedsolid/rea` — bare. Version-
|
|
452
|
+
// pinned `@bookedsolid/rea@^X.Y.Z` forms are REFUSED at the bash
|
|
453
|
+
// gate (R6-P2 lock — security: prevents attacker version-pin
|
|
454
|
+
// downgrade in the CLI-missing state). If we recommended the
|
|
455
|
+
// version-pinned form here, agents running the diagnostic-
|
|
456
|
+
// suggested command would loop forever — the bash gate refuses
|
|
457
|
+
// the very recovery command we printed. The bare-spec form
|
|
458
|
+
// installs the latest version matching the consumer's existing
|
|
459
|
+
// constraint (or absolute latest if no constraint); a follow-up
|
|
460
|
+
// `${cmdName}` then runs the managed-caret bump (R2-P1-1) to
|
|
461
|
+
// set the canonical pin under audit.
|
|
462
|
+
'To reconcile, choose one:',
|
|
463
|
+
` 1. pnpm add -D ${REA_PACKAGE_NAME} (installs latest within range,`,
|
|
464
|
+
` then re-run: ${cmdName})`,
|
|
465
|
+
` 2. Edit package.json to pin a version that admits ${newCliVersion},`,
|
|
466
|
+
' then: pnpm install',
|
|
467
|
+
];
|
|
468
|
+
if (isWorkspacePin) {
|
|
469
|
+
lines.push(` 3. workspace:* points to a sibling package; ensure that package's`, ` version admits ${newCliVersion} before re-running ${cmdName}`);
|
|
470
|
+
}
|
|
471
|
+
else if (isFilePin) {
|
|
472
|
+
lines.push(` 3. file: pins to a local path; ensure the linked package's`, ` version admits ${newCliVersion} before re-running ${cmdName}`);
|
|
473
|
+
}
|
|
474
|
+
else if (isGitPin) {
|
|
475
|
+
lines.push(` 3. git URL pins to a remote ref; ensure the target ref's`, ` package version admits ${newCliVersion} before re-running ${cmdName}`);
|
|
476
|
+
}
|
|
477
|
+
else if (isLooksDistTag) {
|
|
478
|
+
lines.push(` 3. dist-tag "${existing}" resolves at install time; ensure the`, ` tag currently points at a version that admits ${newCliVersion}`);
|
|
479
|
+
}
|
|
480
|
+
else {
|
|
481
|
+
lines.push(` 3. If using workspace:* or file:.., ensure the workspace target`, ` resolves to >= ${newCliVersion} before re-running ${cmdName}`);
|
|
482
|
+
}
|
|
483
|
+
lines.push('', `Then re-run: ${cmdName}`);
|
|
484
|
+
return {
|
|
485
|
+
kind: 'block',
|
|
486
|
+
packageJsonPath: pkgPath,
|
|
487
|
+
existingRange: existing,
|
|
488
|
+
newCliVersion,
|
|
489
|
+
newPinnedRange,
|
|
490
|
+
reason: lines.join('\n'),
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
function serialize(obj, shape) {
|
|
494
|
+
let s = JSON.stringify(obj, null, shape.indent);
|
|
495
|
+
if (shape.eol === '\r\n') {
|
|
496
|
+
s = s.replace(/\n/g, '\r\n');
|
|
497
|
+
}
|
|
498
|
+
if (shape.trailingNewline) {
|
|
499
|
+
s += shape.eol;
|
|
500
|
+
}
|
|
501
|
+
return s;
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Idempotent self-pin step. See module header for the full contract.
|
|
505
|
+
*/
|
|
506
|
+
export async function selfPinRea(options) {
|
|
507
|
+
const pinnedRange = `^${options.cliVersion}`;
|
|
508
|
+
const pkgPath = findPackageJson(options.cwd);
|
|
509
|
+
if (pkgPath === null) {
|
|
510
|
+
return {
|
|
511
|
+
action: 'skipped-no-package-json',
|
|
512
|
+
packageJsonPath: null,
|
|
513
|
+
pinnedRange,
|
|
514
|
+
message: `self-pin skipped — no package.json in the target directory ${options.cwd}. ` +
|
|
515
|
+
`rea init refuses to walk up and mutate a parent the operator did not ` +
|
|
516
|
+
`explicitly target — re-invoke from the workspace root if that is the ` +
|
|
517
|
+
`intent.`,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
// R10-P2 (codex round 10): refuse symlinked package.json BEFORE
|
|
521
|
+
// any read or write. Writing through a symlink mutates the target
|
|
522
|
+
// — typically outside the requested project tree — which violates
|
|
523
|
+
// the R2-P4 "don't mutate a parent the operator did not target"
|
|
524
|
+
// contract.
|
|
525
|
+
//
|
|
526
|
+
// Live mode THROWS — security refusals must surface, not silently
|
|
527
|
+
// no-op. Dry-run mode RETURNS a `skipped-symlink-package-json`
|
|
528
|
+
// skip-shape so `rea upgrade --dry-run` can complete a full preview
|
|
529
|
+
// even when the symlink would block the live run. The upgrade
|
|
530
|
+
// pre-flight (`checkUpgradeBlockingPin`) already surfaced a
|
|
531
|
+
// `block-symlink` to the operator before reaching this code path,
|
|
532
|
+
// so dry-run consumers see the diagnostic ONCE at the pre-flight
|
|
533
|
+
// and the downstream `selfPinRea({ dryRun: true })` call simply
|
|
534
|
+
// reports "skipped — symlinked" without re-throwing.
|
|
535
|
+
if ((await checkPackageJsonShape(pkgPath)) === 'symlink') {
|
|
536
|
+
const message = buildSymlinkRefusalMessage(pkgPath);
|
|
537
|
+
if (options.dryRun === true) {
|
|
538
|
+
return {
|
|
539
|
+
action: 'skipped-symlink-package-json',
|
|
540
|
+
packageJsonPath: pkgPath,
|
|
541
|
+
pinnedRange,
|
|
542
|
+
message,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
throw new Error(message);
|
|
546
|
+
}
|
|
547
|
+
const pkg = await readPackageJson(pkgPath);
|
|
548
|
+
if (pkg === null) {
|
|
549
|
+
return {
|
|
550
|
+
action: 'skipped-malformed-package-json',
|
|
551
|
+
packageJsonPath: pkgPath,
|
|
552
|
+
pinnedRange,
|
|
553
|
+
message: `self-pin skipped — ${pkgPath} is missing or not a valid JSON object`,
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
// Dogfood short-circuit: pkg.name === '@bookedsolid/rea'.
|
|
557
|
+
if (pkg.parsed['name'] === REA_PACKAGE_NAME) {
|
|
558
|
+
return {
|
|
559
|
+
action: 'skipped-dogfood',
|
|
560
|
+
packageJsonPath: pkgPath,
|
|
561
|
+
pinnedRange,
|
|
562
|
+
message: `self-pin skipped — this IS @bookedsolid/rea (dogfood)`,
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
// Conflict check: where does `@bookedsolid/rea` currently live?
|
|
566
|
+
// We look in dependencies + devDependencies — those are the surfaces
|
|
567
|
+
// the bootstrap allowlist (Fix B) accepts. We do NOT look in
|
|
568
|
+
// peerDependencies / optionalDependencies / pnpm.overrides — those
|
|
569
|
+
// are NOT bootstrap declarations and inserting our pin there would
|
|
570
|
+
// silently shift the contract.
|
|
571
|
+
const deps = isPlainObject(pkg.parsed['dependencies']) ? pkg.parsed['dependencies'] : null;
|
|
572
|
+
const devDeps = isPlainObject(pkg.parsed['devDependencies'])
|
|
573
|
+
? pkg.parsed['devDependencies']
|
|
574
|
+
: null;
|
|
575
|
+
const existingDep = deps !== null && typeof deps[REA_PACKAGE_NAME] === 'string'
|
|
576
|
+
? deps[REA_PACKAGE_NAME]
|
|
577
|
+
: undefined;
|
|
578
|
+
const existingDevDep = devDeps !== null && typeof devDeps[REA_PACKAGE_NAME] === 'string'
|
|
579
|
+
? devDeps[REA_PACKAGE_NAME]
|
|
580
|
+
: undefined;
|
|
581
|
+
// P1-1 (codex round 2): upgrade-mode bump predicate. Resolved here
|
|
582
|
+
// once so the dependencies + devDependencies branches share the
|
|
583
|
+
// same shape gate. `mode: 'upgrade'` opts the caller in to auto-
|
|
584
|
+
// bumping a managed-caret pin when the existing range does not
|
|
585
|
+
// admit the new CLI minor (pre-1.0 caret = tilde, so `^0.49.0`
|
|
586
|
+
// does NOT admit `0.50.0` and a 0.49 → 0.50 upgrade would otherwise
|
|
587
|
+
// ship newer hooks against the older CLI — exactly the brick state
|
|
588
|
+
// this whole feature exists to prevent). `mode: 'init'` (default)
|
|
589
|
+
// never bumps: respects whatever pin the operator already chose.
|
|
590
|
+
const mode = options.mode ?? 'init';
|
|
591
|
+
const allowBump = mode === 'upgrade';
|
|
592
|
+
// R3-P2: dry-run prefixes "would " on the mutation messages so the
|
|
593
|
+
// operator-visible string is unambiguous. The `action` discriminant
|
|
594
|
+
// itself is identical between dry-run and live run — callers that
|
|
595
|
+
// pattern-match on `action` see the same value either way; only the
|
|
596
|
+
// message and the absence of an on-disk write differ.
|
|
597
|
+
const dryRun = options.dryRun ?? false;
|
|
598
|
+
const wroteVerb = dryRun ? 'would add' : 'added';
|
|
599
|
+
const bumpedVerb = dryRun ? 'would bump' : 'bumped';
|
|
600
|
+
// A dep present in `dependencies` AND `devDependencies` is unusual
|
|
601
|
+
// but legal. We treat the `dependencies` value as authoritative for
|
|
602
|
+
// conflict detection (npm install resolves it that way) and refuse
|
|
603
|
+
// to mutate the more constrained surface. Operator owns it.
|
|
604
|
+
if (existingDep !== undefined) {
|
|
605
|
+
if (existingDep === pinnedRange) {
|
|
606
|
+
return {
|
|
607
|
+
action: 'skipped-same',
|
|
608
|
+
packageJsonPath: pkgPath,
|
|
609
|
+
pinnedRange,
|
|
610
|
+
existingRange: existingDep,
|
|
611
|
+
message: `self-pin: ${REA_PACKAGE_NAME} already pinned in dependencies as ${existingDep}`,
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
if (allowBump && shouldBumpManagedCaret(existingDep, pinnedRange)) {
|
|
615
|
+
await writePin(pkgPath, pkg, 'dependencies', pinnedRange, deps, devDeps, dryRun);
|
|
616
|
+
return {
|
|
617
|
+
action: 'bumped',
|
|
618
|
+
packageJsonPath: pkgPath,
|
|
619
|
+
pinnedRange,
|
|
620
|
+
existingRange: existingDep,
|
|
621
|
+
message: `self-pin: ${bumpedVerb} ${REA_PACKAGE_NAME} pin in dependencies from ` +
|
|
622
|
+
`${existingDep} to ${pinnedRange} (managed-caret upgrade)`,
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
return {
|
|
626
|
+
action: 'skipped-different',
|
|
627
|
+
packageJsonPath: pkgPath,
|
|
628
|
+
pinnedRange,
|
|
629
|
+
existingRange: existingDep,
|
|
630
|
+
message: `self-pin: ${REA_PACKAGE_NAME} already pinned in dependencies as ${existingDep} ` +
|
|
631
|
+
`(different from ${pinnedRange}) — leaving operator's pin intact`,
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
if (existingDevDep !== undefined) {
|
|
635
|
+
if (existingDevDep === pinnedRange) {
|
|
636
|
+
return {
|
|
637
|
+
action: 'skipped-same',
|
|
638
|
+
packageJsonPath: pkgPath,
|
|
639
|
+
pinnedRange,
|
|
640
|
+
existingRange: existingDevDep,
|
|
641
|
+
message: `self-pin: ${REA_PACKAGE_NAME} already pinned in devDependencies as ${existingDevDep}`,
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
if (allowBump && shouldBumpManagedCaret(existingDevDep, pinnedRange)) {
|
|
645
|
+
await writePin(pkgPath, pkg, 'devDependencies', pinnedRange, deps, devDeps, dryRun);
|
|
646
|
+
return {
|
|
647
|
+
action: 'bumped',
|
|
648
|
+
packageJsonPath: pkgPath,
|
|
649
|
+
pinnedRange,
|
|
650
|
+
existingRange: existingDevDep,
|
|
651
|
+
message: `self-pin: ${bumpedVerb} ${REA_PACKAGE_NAME} pin in devDependencies from ` +
|
|
652
|
+
`${existingDevDep} to ${pinnedRange} (managed-caret upgrade)`,
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
return {
|
|
656
|
+
action: 'skipped-different',
|
|
657
|
+
packageJsonPath: pkgPath,
|
|
658
|
+
pinnedRange,
|
|
659
|
+
existingRange: existingDevDep,
|
|
660
|
+
message: `self-pin: ${REA_PACKAGE_NAME} already pinned in devDependencies as ${existingDevDep} ` +
|
|
661
|
+
`(different from ${pinnedRange}) — leaving operator's pin intact`,
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
// No existing pin — write a new one into devDependencies.
|
|
665
|
+
const result = await writePin(pkgPath, pkg, 'devDependencies', pinnedRange, deps, devDeps, dryRun);
|
|
666
|
+
if (result.newRaw === pkg.raw) {
|
|
667
|
+
return {
|
|
668
|
+
action: 'skipped-same',
|
|
669
|
+
packageJsonPath: pkgPath,
|
|
670
|
+
pinnedRange,
|
|
671
|
+
existingRange: pinnedRange,
|
|
672
|
+
message: `self-pin: ${REA_PACKAGE_NAME} already pinned at ${pinnedRange} (byte-identical)`,
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
return {
|
|
676
|
+
action: 'wrote',
|
|
677
|
+
packageJsonPath: pkgPath,
|
|
678
|
+
pinnedRange,
|
|
679
|
+
message: `self-pin: ${wroteVerb} ${REA_PACKAGE_NAME}@${pinnedRange} to devDependencies in ${pkgPath}`,
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Write the rea pin into either `dependencies` or `devDependencies`
|
|
684
|
+
* (caller picks). Sorts the target dep block alphabetically (matches
|
|
685
|
+
* the pre-refactor behavior for devDependencies and is the common
|
|
686
|
+
* tooling convention for both). Preserves top-level key order in the
|
|
687
|
+
* package.json object so the on-disk diff is minimal — JSON.stringify
|
|
688
|
+
* honors insertion order in V8.
|
|
689
|
+
*
|
|
690
|
+
* Returns `{ newRaw }` so the caller can compare against
|
|
691
|
+
* `pkg.raw` for the byte-fidelity guard.
|
|
692
|
+
*
|
|
693
|
+
* P1-1 (codex round 2): extracted as a shared helper so the new-write
|
|
694
|
+
* path and the upgrade-mode bump path use the same serializer. Pre-
|
|
695
|
+
* extraction the bump path would have needed duplicated logic.
|
|
696
|
+
*/
|
|
697
|
+
async function writePin(pkgPath, pkg, target, pinnedRange, deps, devDeps, dryRun) {
|
|
698
|
+
const baseBlock = target === 'dependencies' ? deps : devDeps;
|
|
699
|
+
const newBlock = { ...(baseBlock ?? {}), [REA_PACKAGE_NAME]: pinnedRange };
|
|
700
|
+
const sortedKeys = Object.keys(newBlock).sort();
|
|
701
|
+
const sortedBlock = {};
|
|
702
|
+
for (const k of sortedKeys)
|
|
703
|
+
sortedBlock[k] = newBlock[k];
|
|
704
|
+
const out = { ...pkg.parsed, [target]: sortedBlock };
|
|
705
|
+
const newRaw = serialize(out, pkg.shape);
|
|
706
|
+
// R3-P2: skip the write when previewing. The caller still gets the
|
|
707
|
+
// `newRaw` value back so the byte-fidelity guard ("did this even
|
|
708
|
+
// change?") works identically in both modes.
|
|
709
|
+
if (!dryRun && newRaw !== pkg.raw) {
|
|
710
|
+
await fs.writeFile(pkgPath, newRaw, 'utf8');
|
|
711
|
+
}
|
|
712
|
+
return { newRaw };
|
|
713
|
+
}
|
|
714
|
+
export async function checkSelfPinDeclared(baseDir, cliVersion) {
|
|
715
|
+
return checkSelfPinDeclaredSync(baseDir, cliVersion);
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* Synchronous variant of {@link checkSelfPinDeclared}. `rea doctor`
|
|
719
|
+
* runs all checks sync; the read+parse cost here is microseconds so
|
|
720
|
+
* the sync form is acceptable.
|
|
721
|
+
*
|
|
722
|
+
* R11-P3 (codex round 11): when `cliVersion` is provided, this check
|
|
723
|
+
* also verifies that the declared range admits the running CLI
|
|
724
|
+
* version (semver.satisfies). Without `cliVersion` the check
|
|
725
|
+
* reverts to presence-only behavior (backwards-compat for callers
|
|
726
|
+
* that don't yet pass the version). The doctor wrapper
|
|
727
|
+
* (`checkSelfPinDeclaredCheck`) passes `getPkgVersion()` so the
|
|
728
|
+
* skew detection always runs in the doctor surface.
|
|
729
|
+
*/
|
|
730
|
+
export function checkSelfPinDeclaredSync(baseDir, cliVersion) {
|
|
731
|
+
const hooksDir = path.join(baseDir, '.claude', 'hooks');
|
|
732
|
+
if (!fsSync.existsSync(hooksDir)) {
|
|
733
|
+
return { kind: 'pass-no-hooks' };
|
|
734
|
+
}
|
|
735
|
+
const pkgPath = findPackageJson(baseDir);
|
|
736
|
+
if (pkgPath === null) {
|
|
737
|
+
return { kind: 'pass-no-pkg', hooksDir };
|
|
738
|
+
}
|
|
739
|
+
// R10-P2 (codex round 10): mirror the write-path's symlink refusal
|
|
740
|
+
// here so doctor surfaces the same diagnostic. Operators get
|
|
741
|
+
// told about the misconfiguration BEFORE running `rea upgrade`,
|
|
742
|
+
// which would throw at the symlink check otherwise.
|
|
743
|
+
if (checkPackageJsonShapeSync(pkgPath) === 'symlink') {
|
|
744
|
+
return {
|
|
745
|
+
kind: 'fail-symlink',
|
|
746
|
+
packageJsonPath: pkgPath,
|
|
747
|
+
reason: buildSymlinkRefusalMessage(pkgPath),
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
let raw;
|
|
751
|
+
try {
|
|
752
|
+
raw = fsSync.readFileSync(pkgPath, 'utf8');
|
|
753
|
+
}
|
|
754
|
+
catch {
|
|
755
|
+
return { kind: 'fail-malformed', packageJsonPath: pkgPath };
|
|
756
|
+
}
|
|
757
|
+
// P3-1 (codex round 1): tolerate a leading UTF-8 BOM the same way
|
|
758
|
+
// the write path does — otherwise doctor reports `fail-malformed`
|
|
759
|
+
// on Windows-authored manifests that selfPinRea handles fine.
|
|
760
|
+
raw = stripUtf8Bom(raw);
|
|
761
|
+
let parsed;
|
|
762
|
+
try {
|
|
763
|
+
parsed = JSON.parse(raw);
|
|
764
|
+
}
|
|
765
|
+
catch {
|
|
766
|
+
return { kind: 'fail-malformed', packageJsonPath: pkgPath };
|
|
767
|
+
}
|
|
768
|
+
if (!isPlainObject(parsed)) {
|
|
769
|
+
return { kind: 'fail-malformed', packageJsonPath: pkgPath };
|
|
770
|
+
}
|
|
771
|
+
const pkg = { raw, parsed, shape: detectShape(raw) };
|
|
772
|
+
if (pkg.parsed['name'] === REA_PACKAGE_NAME) {
|
|
773
|
+
return { kind: 'pass-dogfood', packageJsonPath: pkgPath };
|
|
774
|
+
}
|
|
775
|
+
const deps = isPlainObject(pkg.parsed['dependencies']) ? pkg.parsed['dependencies'] : null;
|
|
776
|
+
const devDeps = isPlainObject(pkg.parsed['devDependencies'])
|
|
777
|
+
? pkg.parsed['devDependencies']
|
|
778
|
+
: null;
|
|
779
|
+
// Resolve the authoritative pin + its location (deps wins over
|
|
780
|
+
// devDeps, matching `selfPinRea`'s precedence).
|
|
781
|
+
let declaredRange;
|
|
782
|
+
let declaredIn;
|
|
783
|
+
if (deps !== null && typeof deps[REA_PACKAGE_NAME] === 'string') {
|
|
784
|
+
declaredRange = deps[REA_PACKAGE_NAME];
|
|
785
|
+
declaredIn = 'dependencies';
|
|
786
|
+
}
|
|
787
|
+
else if (devDeps !== null && typeof devDeps[REA_PACKAGE_NAME] === 'string') {
|
|
788
|
+
declaredRange = devDeps[REA_PACKAGE_NAME];
|
|
789
|
+
declaredIn = 'devDependencies';
|
|
790
|
+
}
|
|
791
|
+
if (declaredRange === undefined || declaredIn === undefined) {
|
|
792
|
+
return { kind: 'fail', packageJsonPath: pkgPath, hooksDir };
|
|
793
|
+
}
|
|
794
|
+
// R11-P3 (codex round 11): pin-compatibility check. When the
|
|
795
|
+
// caller passed a `cliVersion`, run semver.satisfies against the
|
|
796
|
+
// declared range. Non-semver shapes (workspace:*, file:.., git,
|
|
797
|
+
// dist-tag) cannot be resolved statically — surface a dedicated
|
|
798
|
+
// `fail-non-semver` so the operator audits the resolution path
|
|
799
|
+
// rather than seeing a misleading `pass`.
|
|
800
|
+
if (cliVersion !== undefined) {
|
|
801
|
+
const validRange = semver.validRange(declaredRange);
|
|
802
|
+
if (validRange === null) {
|
|
803
|
+
return {
|
|
804
|
+
kind: 'fail-non-semver',
|
|
805
|
+
packageJsonPath: pkgPath,
|
|
806
|
+
declaredRange,
|
|
807
|
+
declaredIn,
|
|
808
|
+
reason: `Self-pin declared as a non-semver shape: ${REA_PACKAGE_NAME} pinned to ` +
|
|
809
|
+
`"${declaredRange}" in ${declaredIn}.\n\n` +
|
|
810
|
+
`rea doctor cannot statically determine whether the resolved version admits the\n` +
|
|
811
|
+
`installed CLI version ${cliVersion}. Workspace, file:, git URL, and dist-tag\n` +
|
|
812
|
+
`pins resolve at install time — if the resolved version does not admit ${cliVersion},\n` +
|
|
813
|
+
`your hook scripts will resolve the older CLI from node_modules and may fail to\n` +
|
|
814
|
+
`parse the current policy.yaml schema.\n\n` +
|
|
815
|
+
// R12-P2 (codex round 12): bare-spec form only. The
|
|
816
|
+
// CLI-missing bash gate refuses version-pinned adds.
|
|
817
|
+
`To reconcile, either:\n` +
|
|
818
|
+
` 1. Replace the pin with pnpm add -D ${REA_PACKAGE_NAME}\n` +
|
|
819
|
+
` (installs latest within range, then re-run: rea upgrade)\n` +
|
|
820
|
+
` 2. Verify the resolved version admits ${cliVersion} (check node_modules/${REA_PACKAGE_NAME}/package.json)`,
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
// semver.satisfies with includePrerelease so a 0.49.0-beta.0
|
|
824
|
+
// running CLI passes against `^0.49.0` (otherwise a prerelease
|
|
825
|
+
// would fail-incompatible against its own non-prerelease range).
|
|
826
|
+
const admits = semver.satisfies(cliVersion, validRange, { includePrerelease: true });
|
|
827
|
+
if (!admits) {
|
|
828
|
+
return {
|
|
829
|
+
kind: 'fail-incompatible',
|
|
830
|
+
packageJsonPath: pkgPath,
|
|
831
|
+
declaredRange,
|
|
832
|
+
declaredIn,
|
|
833
|
+
currentCliVersion: cliVersion,
|
|
834
|
+
reason: `Self-pin declared but incompatible: package.json pins ${REA_PACKAGE_NAME} to ` +
|
|
835
|
+
`"${declaredRange}" in ${declaredIn} which does not admit the installed CLI ` +
|
|
836
|
+
`version ${cliVersion}.\n\n` +
|
|
837
|
+
`Your hook scripts will resolve the older CLI from node_modules and may fail to\n` +
|
|
838
|
+
`parse the current policy.yaml schema (strict).\n\n` +
|
|
839
|
+
// R12-P2 (codex round 12): bare-spec form only — see the
|
|
840
|
+
// R9-P1 reason builder above for the full rationale.
|
|
841
|
+
`To reconcile:\n` +
|
|
842
|
+
` pnpm add -D ${REA_PACKAGE_NAME} (installs latest within range,\n` +
|
|
843
|
+
` then re-run: rea upgrade)`,
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
return {
|
|
848
|
+
kind: 'pass',
|
|
849
|
+
packageJsonPath: pkgPath,
|
|
850
|
+
declaredRange,
|
|
851
|
+
declaredIn,
|
|
852
|
+
};
|
|
853
|
+
}
|