@rehearsal-db/core 0.1.0-beta.12 → 0.1.0-beta.14

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +116 -0
  2. package/COMPATIBILITY.md +6 -0
  3. package/README.md +5 -3
  4. package/docs/commands.md +15 -11
  5. package/docs/configuration.md +40 -5
  6. package/docs/getting-started.md +32 -3
  7. package/docs/production-source.md +175 -17
  8. package/docs/releasing.md +5 -3
  9. package/docs/roadmap.md +3 -2
  10. package/docs/sanitization.md +479 -12
  11. package/docs/security-model.md +8 -0
  12. package/docs/standalone-workflow.md +34 -13
  13. package/docs/troubleshooting.md +17 -6
  14. package/docs/tutorial.md +7 -1
  15. package/package.json +2 -2
  16. package/scripts/install/create_root_config.mjs +5 -22
  17. package/src/baseline/artifact.mjs +27 -4
  18. package/src/baseline/privacy_engine.mjs +865 -207
  19. package/src/baseline/privacy_structured_json.mjs +554 -0
  20. package/src/cli/arguments.mjs +3 -0
  21. package/src/cli/guided.mjs +8 -6
  22. package/src/cli/rehearsal.mjs +23 -3
  23. package/src/cli/renderers.mjs +29 -4
  24. package/src/cli/runtime_commands.mjs +92 -9
  25. package/src/cli/source_commands.mjs +37 -8
  26. package/src/identity/claim.mjs +23 -21
  27. package/src/project/install_scaffold.mjs +35 -1
  28. package/src/project/setup.mjs +14 -24
  29. package/src/runtime/plan.mjs +7 -3
  30. package/src/runtime/restore.mjs +13 -5
  31. package/src/shared/diagnostics.mjs +7 -0
  32. package/src/shared/node_runtime.mjs +31 -0
  33. package/src/source/access.mjs +271 -51
  34. package/src/source/asset_transfer.mjs +156 -74
  35. package/src/source/baseline.mjs +42 -6
  36. package/src/source/external_reader.mjs +368 -0
  37. package/src/source/postgresql_access.mjs +213 -55
  38. package/src/source/privacy_paths.mjs +38 -0
@@ -0,0 +1,554 @@
1
+ /** Validate and execute the bounded structured-JSON privacy recipes. */
2
+
3
+ const JSON_UNION_VARIANTS = new Set([
4
+ "string",
5
+ "number",
6
+ "boolean",
7
+ "array",
8
+ "object",
9
+ ]);
10
+ const DICTIONARY_KEY_FORMATS = new Set([
11
+ "integer",
12
+ "positive-integer",
13
+ "uuid",
14
+ "identifier",
15
+ "text",
16
+ ]);
17
+ const IDENTIFIER = /^[a-z][a-z0-9_]{0,62}$/u;
18
+ const UUID =
19
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
20
+ const DEFAULT_MAXIMUM_BYTES = 65_536;
21
+ const DEFAULT_MAXIMUM_DEPTH = 8;
22
+
23
+ export const STRUCTURED_JSON_KINDS = new Set([
24
+ "json-object",
25
+ "json-array",
26
+ "json-union",
27
+ "json-dictionary",
28
+ ]);
29
+
30
+ const isObject = (value) =>
31
+ value !== null &&
32
+ typeof value === "object" &&
33
+ !Array.isArray(value) &&
34
+ Object.getPrototypeOf(value) === Object.prototype;
35
+
36
+ const knownKeys = (value, allowed, label) => {
37
+ for (const key of Object.keys(value)) {
38
+ if (!allowed.includes(key)) throw new Error(`${label}.${key} is unknown.`);
39
+ }
40
+ };
41
+
42
+ const positiveInteger = (value, label, maximum) => {
43
+ if (!Number.isSafeInteger(value) || value < 1 || value > maximum) {
44
+ throw new Error(`${label} must be an integer from 1 through ${maximum}.`);
45
+ }
46
+ return value;
47
+ };
48
+
49
+ const safeInteger = (value, label) => {
50
+ if (!Number.isSafeInteger(value)) {
51
+ throw new Error(`${label} must be a safe integer.`);
52
+ }
53
+ return value;
54
+ };
55
+
56
+ const boundsFor = (recipe, label, maximumPolicyDepth) => ({
57
+ maximumBytes: positiveInteger(
58
+ recipe.maximumBytes ?? DEFAULT_MAXIMUM_BYTES,
59
+ `${label}.recipe.maximumBytes`,
60
+ 16_777_216,
61
+ ),
62
+ maximumDepth: positiveInteger(
63
+ recipe.maximumDepth ?? DEFAULT_MAXIMUM_DEPTH,
64
+ `${label}.recipe.maximumDepth`,
65
+ maximumPolicyDepth,
66
+ ),
67
+ });
68
+
69
+ const classifiesUnknownJsonValue = (declaration) => {
70
+ if (["REPLACE", "EXCLUDE", "PSEUDONYMIZE"].includes(declaration.action)) {
71
+ return true;
72
+ }
73
+ if (declaration.action !== "DERIVE") return false;
74
+ if (declaration.recipe.kind === "approved-owner") {
75
+ return (
76
+ classifiesUnknownJsonValue(declaration.recipe.approved) &&
77
+ classifiesUnknownJsonValue(declaration.recipe.otherwise)
78
+ );
79
+ }
80
+ if (declaration.recipe.kind === "json-union") return true;
81
+ if (declaration.recipe.kind === "json-array") {
82
+ return classifiesUnknownJsonValue(declaration.recipe.items);
83
+ }
84
+ if (declaration.recipe.kind === "json-object") {
85
+ return Object.values(declaration.recipe.fields).every(
86
+ classifiesUnknownJsonValue,
87
+ );
88
+ }
89
+ if (declaration.recipe.kind === "json-dictionary") {
90
+ return classifiesUnknownJsonValue(declaration.recipe.values);
91
+ }
92
+ return [
93
+ "date-shift",
94
+ "enum",
95
+ "validated-string",
96
+ "path-map",
97
+ "binding-substitute",
98
+ ].includes(declaration.recipe.kind);
99
+ };
100
+
101
+ const classifiesContainer = (declaration, expectedType) => {
102
+ if (["REPLACE", "EXCLUDE", "PSEUDONYMIZE"].includes(declaration.action)) {
103
+ return true;
104
+ }
105
+ if (declaration.action !== "DERIVE") return false;
106
+ if (declaration.recipe.kind === "approved-owner") {
107
+ return (
108
+ classifiesContainer(declaration.recipe.approved, expectedType) &&
109
+ classifiesContainer(declaration.recipe.otherwise, expectedType)
110
+ );
111
+ }
112
+ if (declaration.recipe.kind === "json-union") return true;
113
+ return expectedType === "array"
114
+ ? declaration.recipe.kind === "json-array" &&
115
+ classifiesUnknownJsonValue(declaration.recipe.items)
116
+ : declaration.recipe.kind === "json-object"
117
+ ? Object.values(declaration.recipe.fields).every(
118
+ classifiesUnknownJsonValue,
119
+ )
120
+ : declaration.recipe.kind === "json-dictionary" &&
121
+ classifiesUnknownJsonValue(declaration.recipe.values);
122
+ };
123
+
124
+ export const validateStructuredJsonRecipe = ({
125
+ recipe,
126
+ label,
127
+ depth,
128
+ maximumPolicyDepth,
129
+ validateDeclaration,
130
+ validatePseudonymRecipe,
131
+ }) => {
132
+ const bounds = boundsFor(recipe, label, maximumPolicyDepth);
133
+ if (recipe.allowNull !== undefined && typeof recipe.allowNull !== "boolean") {
134
+ throw new Error(`${label}.recipe.allowNull must be true or false.`);
135
+ }
136
+ if (recipe.kind === "json-array") {
137
+ knownKeys(
138
+ recipe,
139
+ [
140
+ "kind",
141
+ "items",
142
+ "allowNull",
143
+ "maximumBytes",
144
+ "maximumDepth",
145
+ "maximumItems",
146
+ ],
147
+ `${label}.recipe`,
148
+ );
149
+ return Object.freeze({
150
+ kind: "json-array",
151
+ allowNull: recipe.allowNull === true,
152
+ ...bounds,
153
+ maximumItems: positiveInteger(
154
+ recipe.maximumItems,
155
+ `${label}.recipe.maximumItems`,
156
+ 10_000,
157
+ ),
158
+ items: validateDeclaration(recipe.items, `${label}.recipe.items`, {
159
+ depth,
160
+ }),
161
+ });
162
+ }
163
+ if (recipe.kind === "json-union") {
164
+ knownKeys(
165
+ recipe,
166
+ [
167
+ "kind",
168
+ "variants",
169
+ "allowNull",
170
+ "maximumBytes",
171
+ "maximumDepth",
172
+ "maximumItems",
173
+ ],
174
+ `${label}.recipe`,
175
+ );
176
+ if (
177
+ !isObject(recipe.variants) ||
178
+ Object.keys(recipe.variants).length === 0
179
+ ) {
180
+ throw new Error(
181
+ `${label}.recipe.variants must classify JSON value types.`,
182
+ );
183
+ }
184
+ const variants = Object.fromEntries(
185
+ Object.entries(recipe.variants).map(([type, declaration]) => {
186
+ if (!JSON_UNION_VARIANTS.has(type)) {
187
+ throw new Error(`${label}.recipe.variants.${type} is unsupported.`);
188
+ }
189
+ const normalized = validateDeclaration(
190
+ declaration,
191
+ `${label}.recipe.variants.${type}`,
192
+ { depth },
193
+ );
194
+ if (
195
+ ["array", "object"].includes(type) &&
196
+ !classifiesContainer(normalized, type)
197
+ ) {
198
+ throw new Error(
199
+ `${label}.recipe.variants.${type} must classify container contents instead of retaining the whole value.`,
200
+ );
201
+ }
202
+ return [type, normalized];
203
+ }),
204
+ );
205
+ return Object.freeze({
206
+ kind: "json-union",
207
+ allowNull: recipe.allowNull === true,
208
+ ...bounds,
209
+ maximumItems: positiveInteger(
210
+ recipe.maximumItems,
211
+ `${label}.recipe.maximumItems`,
212
+ 10_000,
213
+ ),
214
+ variants: Object.freeze(variants),
215
+ });
216
+ }
217
+ if (recipe.kind === "json-dictionary") {
218
+ knownKeys(
219
+ recipe,
220
+ [
221
+ "kind",
222
+ "keys",
223
+ "values",
224
+ "allowNull",
225
+ "maximumBytes",
226
+ "maximumDepth",
227
+ "maximumItems",
228
+ ],
229
+ `${label}.recipe`,
230
+ );
231
+ if (!isObject(recipe.keys)) {
232
+ throw new Error(`${label}.recipe.keys must be an object.`);
233
+ }
234
+ knownKeys(
235
+ recipe.keys,
236
+ ["format", "action", "minimum", "maximum", "maximumLength", "recipe"],
237
+ `${label}.recipe.keys`,
238
+ );
239
+ if (!DICTIONARY_KEY_FORMATS.has(recipe.keys.format)) {
240
+ throw new Error(`${label}.recipe.keys.format is unsupported.`);
241
+ }
242
+ if (!["KEEP", "PSEUDONYMIZE"].includes(recipe.keys.action)) {
243
+ throw new Error(
244
+ `${label}.recipe.keys.action must be KEEP or PSEUDONYMIZE.`,
245
+ );
246
+ }
247
+ const maximumLength =
248
+ recipe.keys.format === "text"
249
+ ? positiveInteger(
250
+ recipe.keys.maximumLength,
251
+ `${label}.recipe.keys.maximumLength`,
252
+ 1_024,
253
+ )
254
+ : undefined;
255
+ const integerRange =
256
+ recipe.keys.format === "integer"
257
+ ? {
258
+ minimum: safeInteger(
259
+ recipe.keys.minimum,
260
+ `${label}.recipe.keys.minimum`,
261
+ ),
262
+ maximum: safeInteger(
263
+ recipe.keys.maximum,
264
+ `${label}.recipe.keys.maximum`,
265
+ ),
266
+ }
267
+ : null;
268
+ if (integerRange !== null && integerRange.minimum > integerRange.maximum) {
269
+ throw new Error(`${label}.recipe.keys.minimum must not exceed maximum.`);
270
+ }
271
+ if (
272
+ recipe.keys.format !== "integer" &&
273
+ (recipe.keys.minimum !== undefined || recipe.keys.maximum !== undefined)
274
+ ) {
275
+ throw new Error(
276
+ `${label}.recipe.keys.minimum and maximum are supported only for integer keys.`,
277
+ );
278
+ }
279
+ if (
280
+ recipe.keys.format !== "text" &&
281
+ recipe.keys.maximumLength !== undefined
282
+ ) {
283
+ throw new Error(
284
+ `${label}.recipe.keys.maximumLength is supported only for text keys.`,
285
+ );
286
+ }
287
+ const keyRecipe =
288
+ recipe.keys.action === "PSEUDONYMIZE"
289
+ ? validatePseudonymRecipe(
290
+ recipe.keys.recipe,
291
+ `${label}.recipe.keys`,
292
+ depth + 1,
293
+ )
294
+ : null;
295
+ if (recipe.keys.action === "KEEP" && recipe.keys.recipe !== undefined) {
296
+ throw new Error(
297
+ `${label}.recipe.keys.recipe is not allowed when keys are kept.`,
298
+ );
299
+ }
300
+ const values = validateDeclaration(
301
+ recipe.values,
302
+ `${label}.recipe.values`,
303
+ { depth },
304
+ );
305
+ if (!classifiesUnknownJsonValue(values)) {
306
+ throw new Error(
307
+ `${label}.recipe.values must classify value types instead of retaining unknown values.`,
308
+ );
309
+ }
310
+ return Object.freeze({
311
+ kind: "json-dictionary",
312
+ allowNull: recipe.allowNull === true,
313
+ ...bounds,
314
+ maximumItems: positiveInteger(
315
+ recipe.maximumItems,
316
+ `${label}.recipe.maximumItems`,
317
+ 10_000,
318
+ ),
319
+ keys: Object.freeze({
320
+ format: recipe.keys.format,
321
+ action: recipe.keys.action,
322
+ ...(integerRange === null ? {} : integerRange),
323
+ ...(maximumLength === undefined ? {} : { maximumLength }),
324
+ ...(keyRecipe === null ? {} : { recipe: keyRecipe }),
325
+ }),
326
+ values,
327
+ });
328
+ }
329
+ if (!isObject(recipe.fields) || Object.keys(recipe.fields).length === 0) {
330
+ throw new Error(`${label}.recipe.fields must classify JSON object keys.`);
331
+ }
332
+ knownKeys(
333
+ recipe,
334
+ ["kind", "fields", "allowNull", "maximumBytes", "maximumDepth"],
335
+ `${label}.recipe`,
336
+ );
337
+ const fields = Object.fromEntries(
338
+ Object.entries(recipe.fields).map(([key, declaration]) => {
339
+ if (!key) {
340
+ throw new Error(`${label}.recipe.fields contains an invalid key.`);
341
+ }
342
+ return [
343
+ key,
344
+ validateDeclaration(declaration, `${label}.recipe.fields.${key}`, {
345
+ allowRequired: true,
346
+ depth,
347
+ }),
348
+ ];
349
+ }),
350
+ );
351
+ return Object.freeze({
352
+ kind: "json-object",
353
+ allowNull: recipe.allowNull === true,
354
+ ...bounds,
355
+ fields: Object.freeze(fields),
356
+ });
357
+ };
358
+
359
+ export const structuredJsonDeclarations = (recipe) => {
360
+ if (recipe?.kind === "json-object") return Object.values(recipe.fields);
361
+ if (recipe?.kind === "json-array") return [recipe.items];
362
+ if (recipe?.kind === "json-union") return Object.values(recipe.variants);
363
+ if (recipe?.kind === "json-dictionary") return [recipe.values];
364
+ return [];
365
+ };
366
+
367
+ const assertJsonBounds = (value, recipe, path) => {
368
+ let bytes = 0;
369
+ const jsonStringBytes = (string) => {
370
+ let size = 2;
371
+ for (let index = 0; index < string.length; index += 1) {
372
+ const code = string.charCodeAt(index);
373
+ if (code === 34 || code === 92) size += 2;
374
+ else if (code < 32) size += [8, 9, 10, 12, 13].includes(code) ? 2 : 6;
375
+ else if (code >= 0xd800 && code <= 0xdbff) {
376
+ const next = string.charCodeAt(index + 1);
377
+ if (next >= 0xdc00 && next <= 0xdfff) {
378
+ size += 4;
379
+ index += 1;
380
+ } else size += 6;
381
+ } else if (code >= 0xdc00 && code <= 0xdfff) size += 6;
382
+ else if (code <= 0x7f) size += 1;
383
+ else if (code <= 0x7ff) size += 2;
384
+ else size += 3;
385
+ }
386
+ return size;
387
+ };
388
+ const visit = (current, depth) => {
389
+ if (depth > recipe.maximumDepth) {
390
+ throw new Error(`${path} exceeds maximumDepth ${recipe.maximumDepth}.`);
391
+ }
392
+ if (typeof current === "string") bytes += jsonStringBytes(current);
393
+ else if (current === null) bytes += 4;
394
+ else if (typeof current === "number" && !Number.isFinite(current)) {
395
+ throw new Error(`${path} contains an unsupported JSON value.`);
396
+ } else if (["number", "boolean"].includes(typeof current)) {
397
+ bytes += String(current).length;
398
+ } else if (Array.isArray(current)) {
399
+ if (
400
+ recipe.kind === "json-array" &&
401
+ current.length > recipe.maximumItems
402
+ ) {
403
+ throw new Error(`${path} exceeds maximumItems ${recipe.maximumItems}.`);
404
+ }
405
+ bytes += 2 + Math.max(0, current.length - 1);
406
+ for (const item of current) visit(item, depth + 1);
407
+ } else if (isObject(current)) {
408
+ bytes += 2 + Math.max(0, Object.keys(current).length - 1);
409
+ for (const [key, nested] of Object.entries(current)) {
410
+ bytes += jsonStringBytes(key) + 1;
411
+ visit(nested, depth + 1);
412
+ }
413
+ } else throw new Error(`${path} contains an unsupported JSON value.`);
414
+ if (bytes > recipe.maximumBytes) {
415
+ throw new Error(`${path} exceeds maximumBytes ${recipe.maximumBytes}.`);
416
+ }
417
+ };
418
+ visit(value, 1);
419
+ };
420
+
421
+ const jsonValueType = (value) => {
422
+ if (Array.isArray(value)) return "array";
423
+ if (isObject(value)) return "object";
424
+ if (["string", "number", "boolean"].includes(typeof value)) {
425
+ return typeof value;
426
+ }
427
+ return null;
428
+ };
429
+
430
+ const validateDictionaryKey = (value, rule, path) => {
431
+ if (rule.format === "integer") {
432
+ if (!/^(?:0|-?[1-9][0-9]{0,15})$/u.test(value)) {
433
+ throw new Error(`${path} contains an invalid integer key.`);
434
+ }
435
+ const integer = Number(value);
436
+ if (
437
+ !Number.isSafeInteger(integer) ||
438
+ String(integer) !== value ||
439
+ integer < rule.minimum ||
440
+ integer > rule.maximum
441
+ ) {
442
+ throw new Error(
443
+ `${path} contains an integer key outside the reviewed range ${rule.minimum} through ${rule.maximum}.`,
444
+ );
445
+ }
446
+ return integer;
447
+ } else if (rule.format === "positive-integer") {
448
+ if (!/^[1-9][0-9]{0,18}$/u.test(value)) {
449
+ throw new Error(`${path} contains an invalid positive-integer key.`);
450
+ }
451
+ } else if (rule.format === "uuid") {
452
+ if (!UUID.test(value)) {
453
+ throw new Error(`${path} contains an invalid uuid key.`);
454
+ }
455
+ } else if (rule.format === "identifier") {
456
+ if (!IDENTIFIER.test(value)) {
457
+ throw new Error(`${path} contains an invalid identifier key.`);
458
+ }
459
+ } else if (
460
+ !value ||
461
+ value.length > rule.maximumLength ||
462
+ /[\u0000-\u001f\u007f]/u.test(value)
463
+ ) {
464
+ throw new Error(`${path} contains an invalid bounded text key.`);
465
+ }
466
+ return value;
467
+ };
468
+
469
+ export const executeStructuredJsonDeclaration = ({
470
+ declaration,
471
+ value,
472
+ key,
473
+ path,
474
+ execute,
475
+ pseudonymize,
476
+ }) => {
477
+ const recipe = declaration.recipe;
478
+ if (value === null && recipe.allowNull) return null;
479
+ assertJsonBounds(value, recipe, path);
480
+ if (recipe.kind === "json-union") {
481
+ const type = jsonValueType(value);
482
+ if (type === null) {
483
+ throw new Error(`${path} contains an unsupported JSON value.`);
484
+ }
485
+ if (
486
+ ["array", "object"].includes(type) &&
487
+ Object.keys(value).length > recipe.maximumItems
488
+ ) {
489
+ throw new Error(`${path} exceeds maximumItems ${recipe.maximumItems}.`);
490
+ }
491
+ const variant = recipe.variants[type];
492
+ if (!variant) {
493
+ throw new Error(`${path} has an unreviewed JSON ${type} variant.`);
494
+ }
495
+ return execute(variant, value, path);
496
+ }
497
+ if (recipe.kind === "json-array") {
498
+ if (!Array.isArray(value)) throw new Error(`${path} must be a JSON array.`);
499
+ return value.flatMap((item, index) => {
500
+ const transformed = execute(recipe.items, item, `${path}[${index}]`);
501
+ return transformed === undefined ? [] : [transformed];
502
+ });
503
+ }
504
+ if (recipe.kind === "json-dictionary") {
505
+ if (!isObject(value)) {
506
+ throw new Error(`${path} must be a JSON dictionary object.`);
507
+ }
508
+ const entries = Object.entries(value);
509
+ if (entries.length > recipe.maximumItems) {
510
+ throw new Error(`${path} exceeds maximumItems ${recipe.maximumItems}.`);
511
+ }
512
+ const transformed = [];
513
+ const transformedKeys = new Set();
514
+ for (const [sourceKey, nestedValue] of entries) {
515
+ const canonicalSourceKey = validateDictionaryKey(
516
+ sourceKey,
517
+ recipe.keys,
518
+ path,
519
+ );
520
+ const destinationKey =
521
+ recipe.keys.action === "KEEP"
522
+ ? sourceKey
523
+ : String(pseudonymize(key, recipe.keys.recipe, canonicalSourceKey));
524
+ if (transformedKeys.has(destinationKey)) {
525
+ throw new Error(`${path} produced a duplicate transformed JSON key.`);
526
+ }
527
+ transformedKeys.add(destinationKey);
528
+ const nested = execute(
529
+ recipe.values,
530
+ nestedValue,
531
+ `${path}.${sourceKey}`,
532
+ );
533
+ if (nested !== undefined) transformed.push([destinationKey, nested]);
534
+ }
535
+ return Object.fromEntries(transformed);
536
+ }
537
+ if (!isObject(value)) throw new Error(`${path} must be a JSON object.`);
538
+ const actual = Object.keys(value).sort();
539
+ const expected = Object.keys(recipe.fields);
540
+ if (actual.some((field) => !expected.includes(field))) {
541
+ throw new Error(`${path} contains an unclassified JSON key.`);
542
+ }
543
+ const missing = expected.filter(
544
+ (field) => recipe.fields[field].required && !Object.hasOwn(value, field),
545
+ );
546
+ if (missing.length) throw new Error(`${path} is missing required JSON keys.`);
547
+ return Object.fromEntries(
548
+ Object.entries(recipe.fields).flatMap(([field, nested]) => {
549
+ if (!Object.hasOwn(value, field)) return [];
550
+ const transformed = execute(nested, value[field], `${path}.${field}`);
551
+ return transformed === undefined ? [] : [[field, transformed]];
552
+ }),
553
+ );
554
+ };
@@ -4,6 +4,7 @@ export const parseArguments = (arguments_) => {
4
4
  const flags = {
5
5
  json: false,
6
6
  help: false,
7
+ version: false,
7
8
  verbosity: "normal",
8
9
  dryRun: false,
9
10
  write: false,
@@ -30,6 +31,8 @@ export const parseArguments = (arguments_) => {
30
31
  for (const argument of arguments_) {
31
32
  if (argument === "--json") flags.json = true;
32
33
  else if (argument === "--help" || argument === "-h") flags.help = true;
34
+ else if (argument === "--version" || argument === "-V")
35
+ flags.version = true;
33
36
  else if (argument === "--verbose") flags.verbosity = "verbose";
34
37
  else if (argument === "--debug") flags.verbosity = "debug";
35
38
  else if (argument === "--dry-run") flags.dryRun = true;
@@ -31,6 +31,7 @@ import {
31
31
  } from "../baseline/policy_review.mjs";
32
32
  import { formatCount } from "../shared/human_output.mjs";
33
33
  import { redactDiagnosticValue } from "../shared/diagnostics.mjs";
34
+ import { REHEARSAL_NODE_LABEL } from "../shared/node_runtime.mjs";
34
35
  import {
35
36
  renderBaselineInputInspection,
36
37
  renderBaselinePreparation,
@@ -336,7 +337,7 @@ const guidedOptions = (state, hasLastDetails) => {
336
337
  const options = [];
337
338
  if (!state.node.supported) {
338
339
  options.push({
339
- label: "Show Node.js 24 setup instructions",
340
+ label: "Show supported Node.js versions",
340
341
  hint: "Required before Rehearsal can change this project",
341
342
  command: "node-help",
342
343
  });
@@ -427,7 +428,7 @@ const showGuidedStatus = ({ state, options, flags }) => {
427
428
  const marker = (complete) =>
428
429
  complete ? terminalStyle(flags, "32", "✓") : terminalStyle(flags, "2", "○");
429
430
  const statusLines = [
430
- `${marker(state.node.supported)} Node.js ${state.node.version}${state.node.supported ? "" : " (Node.js 24 required)"}`,
431
+ `${marker(state.node.supported)} Node.js ${state.node.version}${state.node.supported ? "" : ` (${REHEARSAL_NODE_LABEL} required)`}`,
431
432
  `${marker(state.target === "supabase" ? state.detected.hasSupabaseConfig : state.detected.hasPostgresqlMigrations)} ${state.target === "supabase" ? "Supabase" : "PostgreSQL"} project detected`,
432
433
  `${marker(state.target === "supabase" ? state.detected.hasMigrations : state.detected.hasPostgresqlMigrations)} Migration history detected`,
433
434
  `${marker(state.config === "valid")} Rehearsal configuration${state.config === "invalid" ? " needs attention" : ""}`,
@@ -685,12 +686,13 @@ export const createGuidedHome =
685
686
  console.log(
686
687
  [
687
688
  "",
688
- terminalStyle(flags, "1", "NODE.JS 24 REQUIRED"),
689
+ terminalStyle(flags, "1", "SUPPORTED NODE.JS REQUIRED"),
689
690
  `This shell is using Node.js ${state.node.version}. Rehearsal has not changed your project.`,
691
+ `Supported releases: ${REHEARSAL_NODE_LABEL}.`,
690
692
  "",
691
- "With nvm:",
692
- " nvm install 24",
693
- " nvm use 24",
693
+ "Recommended with nvm:",
694
+ " nvm install --lts",
695
+ " nvm use --lts",
694
696
  "",
695
697
  "Then reinstall Rehearsal in your scratch project and run npx rehearsal again.",
696
698
  ].join("\n"),
@@ -15,6 +15,7 @@ import * as prompts from "@clack/prompts";
15
15
  import {
16
16
  createRehearsalResult,
17
17
  normalizeRehearsalError,
18
+ REHEARSAL_VERSION,
18
19
  renderHumanError,
19
20
  serializeRehearsalError,
20
21
  } from "../shared/diagnostics.mjs";
@@ -164,7 +165,8 @@ const runSetup = async ({ flags, planOptions }) => {
164
165
  return {
165
166
  ...result,
166
167
  readiness,
167
- nextAction:
168
+ nextAction: [
169
+ `Review ${result.configurationPath}.`,
168
170
  readiness.state === "READY"
169
171
  ? "Next: run rehearsal explain to review the migration plan."
170
172
  : needsBaselinePolicy
@@ -172,6 +174,7 @@ const runSetup = async ({ flags, planOptions }) => {
172
174
  ? "Next: choose Prepare the script to create a reviewable policy draft."
173
175
  : "Next: run rehearsal again and choose Prepare a reviewable baseline policy draft."
174
176
  : "Next: complete the remaining readiness items shown above.",
177
+ ].join("\n"),
175
178
  };
176
179
  };
177
180
 
@@ -311,6 +314,7 @@ const loadIdentityClaimPlan = async ({ flags, planOptions }) => {
311
314
  const usage = () => `Usage: rehearsal <command> [options]
312
315
 
313
316
  Commands:
317
+ version Print the installed Rehearsal version
314
318
  guide Open the interactive, state-aware home screen
315
319
  setup [--target=] [--write] Preview or create safe first-run scaffolding
316
320
  init [--write] Preview or explicitly write safe starter config
@@ -343,7 +347,8 @@ Commands:
343
347
  Preview or apply conservative disk cleanup
344
348
  verify Verify the current local Rehearsal runtime
345
349
 
346
- Options: --json --verbose --debug --plain --config=<path> --target=supabase|postgresql
350
+ Options: --help, -h --version, -V --json --verbose --debug --plain
351
+ --config=<path> --target=supabase|postgresql
347
352
  --confirm-source-access=<digest> --confirm-source-retirement=<digest>
348
353
  --confirm-refresh=<digest>`;
349
354
 
@@ -362,6 +367,18 @@ const runGuidedHome = createGuidedHome({
362
367
  });
363
368
 
364
369
  const executeCommand = async ({ command, flags, planOptions, guided }) => {
370
+ if (command === "version" || flags.version) {
371
+ if (flags.json) {
372
+ console.log(
373
+ JSON.stringify(
374
+ { name: "@rehearsal-db/core", version: REHEARSAL_VERSION },
375
+ null,
376
+ 2,
377
+ ),
378
+ );
379
+ } else console.log(REHEARSAL_VERSION);
380
+ return;
381
+ }
365
382
  if (command === "help" || flags.help) {
366
383
  console.log(usage());
367
384
  return;
@@ -454,7 +471,10 @@ const executeCommand = async ({ command, flags, planOptions, guided }) => {
454
471
  ? renderSourceAccessPlan(result.plan)
455
472
  : [
456
473
  "SOURCE ACCESS — READY",
457
- `Temporary reader expires: ${result.receipt.expiresAt}`,
474
+ `${result.receipt.accessMode === "external" ? "External" : "Temporary"} reader expires: ${result.receipt.expiresAt}`,
475
+ result.receipt.accessMode === "external"
476
+ ? "Rehearsal made no source-side changes."
477
+ : "Rehearsal created only the reviewed temporary source access.",
458
478
  `Local credential: ${result.credentialFile}`,
459
479
  "",
460
480
  "Next: run rehearsal baseline refresh, then retire source access.",