@rehearsal-db/core 0.1.0-beta.13 → 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.
@@ -39,11 +39,13 @@ Two policy versions exist:
39
39
  - Version 2 is required by `baseline refresh`. It adds bounded declarative recipes so
40
40
  Rehearsal can sanitize without project callbacks.
41
41
 
42
- Version 2 supports keyed UUID, email, text, and integer pseudonyms; explicit constant
43
- replacement; bounded and grouped date shifting; identity-aware path mapping;
44
- approved-owner conditionals; exhaustively classified JSON objects; and bounded JSON
45
- arrays. Unknown tables, columns, nested keys, formats, recipes, or missing required
46
- fields fail closed. It never evaluates JavaScript or SQL from the policy.
42
+ Version 2 supports keyed UUID, email, text, integer, fixed-width hexadecimal, and GTIN
43
+ pseudonyms; digests derived from named sanitized columns; explicit constant replacement;
44
+ bounded and grouped date shifting; identity-aware path mapping;
45
+ approved-owner conditionals; exhaustively classified JSON objects; bounded JSON arrays;
46
+ reviewed JSON type unions; and runtime-keyed JSON dictionaries. Unknown tables, columns,
47
+ nested keys, formats, recipes, variants, or missing required fields fail closed. It never
48
+ evaluates JavaScript, regular expressions, or SQL from the policy.
47
49
 
48
50
  ## Approved-owner values
49
51
 
@@ -100,16 +102,42 @@ node -e 'const {createHash}=require("node:crypto"); process.stdout.write(createH
100
102
  ```
101
103
 
102
104
  Every conditional table names its owner column. Missing bindings, hash mismatches,
103
- missing owner columns, null/unsupported owner values, and conditionals without an owner
105
+ missing owner columns, unsupported owner values, and conditionals without an owner
104
106
  binding are refused. The owner column must use `PSEUDONYMIZE`, preventing a conditional
105
- policy from retaining raw account IDs. `identity` describes PostgreSQL's generated SQL
106
- identity metadata, not whether a value identifies a person: ordinary UUID primary keys
107
- use `"NO"`, while a genuine `GENERATED ... AS IDENTITY` column uses `"YES"`. Rehearsal
108
- checks the restored PostgreSQL schema for a real backing sequence before attempting a
109
- sequence reset, so UUID identity values never receive numeric sequence operations. Other
110
- owners take the declared `otherwise` path; every other identity-bearing field still
111
- needs its own pseudonymization declaration. Supply the binding only to the local
112
- server-side Rehearsal process; do not put it in the policy, baseline, or tracked files.
107
+ policy from retaining raw account IDs.
108
+
109
+ When ownership may come through several relationships, replace `column` with an explicit
110
+ bounded any-of declaration:
111
+
112
+ ```json
113
+ {
114
+ "ownerBinding": {
115
+ "binding": "approved-owner",
116
+ "columns": ["target_user_id", "actor_user_id"],
117
+ "match": "any",
118
+ "nullBehavior": "otherwise"
119
+ }
120
+ }
121
+ ```
122
+
123
+ Every listed column must exist and be pseudonymized. One matching value selects the
124
+ `approved` branch. `nullBehavior: "otherwise"` explicitly treats a null value as a
125
+ non-match; it never grants owner access. Without that declaration, encountering null is
126
+ an error. Empty strings, non-finite numbers (`NaN`, positive infinity, or negative
127
+ infinity), objects, arrays, booleans, missing columns, duplicate declarations, and
128
+ unsupported matching modes remain errors. Rehearsal validates every declared owner
129
+ column before selecting either branch, even when another owner column matches. A
130
+ single-column binding may also declare `nullBehavior: "otherwise"` when that column is
131
+ reviewed as nullable.
132
+
133
+ `identity` describes PostgreSQL's generated SQL identity metadata, not whether a value
134
+ identifies a person: ordinary UUID primary keys use `"NO"`, while a genuine
135
+ `GENERATED ... AS IDENTITY` column uses `"YES"`. Rehearsal checks the restored PostgreSQL
136
+ schema for a real backing sequence before attempting a sequence reset, so UUID identity
137
+ values never receive numeric sequence operations. Other owners take the declared
138
+ `otherwise` path; every other identity-bearing field still needs its own pseudonymization
139
+ declaration. Supply the binding only to the local server-side Rehearsal process; do not
140
+ put it in the policy, baseline, or tracked files.
113
141
 
114
142
  ## Identity-aware Storage paths
115
143
 
@@ -173,6 +201,180 @@ offset. This preserves their order and duration:
173
201
  Apply the same recipe to `ends_at`. Rehearsal refuses a group that declares conflicting
174
202
  `days` values. Leave out `group` when a date should shift independently.
175
203
 
204
+ `date-shift` returns an ISO string by default, preserving the existing behavior. When
205
+ the reviewed application model stores a date as numeric epoch milliseconds, declare
206
+ that representation explicitly:
207
+
208
+ ```json
209
+ {
210
+ "action": "DERIVE",
211
+ "recipe": {
212
+ "kind": "date-shift",
213
+ "days": 30,
214
+ "group": "saved-recipe-window",
215
+ "representation": "epoch-milliseconds"
216
+ }
217
+ }
218
+ ```
219
+
220
+ This form accepts only finite safe integers that JavaScript can represent as dates and
221
+ returns a numeric epoch-millisecond value. It preserves `null`, refuses strings,
222
+ fractions, non-finite numbers, unsafe integers and values outside the supported date
223
+ range. ISO-string and epoch-millisecond declarations may share a group; they receive the
224
+ same stable offset while each retains its declared output representation. The only
225
+ supported representation names are `iso-string` and `epoch-milliseconds`.
226
+
227
+ ## Constraint-preserving identifiers and fingerprints
228
+
229
+ Use a keyed `hex` pseudonym when one source value must remain a lowercase 32- or
230
+ 64-character hexadecimal value:
231
+
232
+ ```json
233
+ {
234
+ "name": "source_fingerprint",
235
+ "action": "PSEUDONYMIZE",
236
+ "recipe": {
237
+ "format": "hex",
238
+ "namespace": "source-fingerprint",
239
+ "length": 64
240
+ },
241
+ "generated": "NEVER",
242
+ "identity": "NO",
243
+ "foreignKey": null
244
+ }
245
+ ```
246
+
247
+ Use a `digest` derivation when the fingerprint must describe other sanitized fields.
248
+ Inputs are column names, evaluated in the listed order after their own privacy recipes.
249
+ The existing raw fingerprint is ignored:
250
+
251
+ ```json
252
+ {
253
+ "name": "evidence_fingerprint",
254
+ "action": "DERIVE",
255
+ "recipe": {
256
+ "kind": "digest",
257
+ "format": "hex",
258
+ "namespace": "evidence-fingerprint",
259
+ "length": 32,
260
+ "inputs": ["sanitized_owner_id", "evidence_state"]
261
+ },
262
+ "generated": "NEVER",
263
+ "identity": "NO",
264
+ "foreignKey": null
265
+ }
266
+ ```
267
+
268
+ Digest inputs must exist, must not be excluded, and cannot form a dependency cycle.
269
+ Rehearsal supports only 32- and 64-character hexadecimal output. The namespace and local
270
+ privacy key separate otherwise identical declarations.
271
+
272
+ Use the `gtin` pseudonym format for a normalized GTIN whose length and check digit must
273
+ remain valid:
274
+
275
+ ```json
276
+ {
277
+ "name": "barcode",
278
+ "action": "PSEUDONYMIZE",
279
+ "recipe": { "format": "gtin", "namespace": "product-barcode", "length": 14 },
280
+ "generated": "NEVER",
281
+ "identity": "NO",
282
+ "foreignKey": null
283
+ }
284
+ ```
285
+
286
+ Supported GTIN lengths are 8, 12, 13, and 14. Non-null input must be a normalized string
287
+ of exactly that length with a valid check digit; malformed input stops preparation. Null
288
+ stays null. Rehearsal creates a deterministic keyed payload and recalculates the check
289
+ digit, so related fields using the same namespace, length, source value, and key remain
290
+ aligned.
291
+
292
+ When one reviewed field legitimately contains more than one GTIN length, replace
293
+ `"length": 14` with an explicit allowlist such as `"allowedLengths": [12, 14]`. The two
294
+ keys are mutually exclusive. Each source value must match one allowed length, and its
295
+ result retains that exact length. Empty, duplicated, or unsupported allowlist entries
296
+ are refused. This also works inside structured JSON declarations; a JSON union is not
297
+ needed merely to distinguish GTIN string lengths.
298
+
299
+ Use the `url` pseudonym format when a sanitized field must remain an HTTPS URL:
300
+
301
+ ```json
302
+ {
303
+ "name": "evidence_reference",
304
+ "action": "PSEUDONYMIZE",
305
+ "recipe": {
306
+ "format": "url",
307
+ "namespace": "evidence-url",
308
+ "origin": "https://127.0.0.1:58432",
309
+ "maxLength": 500
310
+ },
311
+ "generated": "NEVER",
312
+ "identity": "NO",
313
+ "foreignKey": null
314
+ }
315
+ ```
316
+
317
+ `origin` is required and must be an HTTPS loopback origin using `127.0.0.1`, `::1`, or
318
+ `localhost`. Credentials, paths, queries, fragments, non-HTTPS schemes, and non-local
319
+ hosts are refused. The value must be the canonical origin itself, optionally followed by
320
+ one `/`; dot segments and encoded dot segments are rejected before URL normalization.
321
+ `maxLength` is also required, cannot exceed 2,048 characters, and must fit the complete
322
+ generated URL.
323
+
324
+ Non-null input must be a valid HTTPS URL without surrounding or embedded whitespace.
325
+ Rehearsal hashes the entire source value with the local privacy key and namespace, then
326
+ emits only the reviewed origin and a 32-character pseudonymous path. It never copies the
327
+ source host, credentials, path, query, token, or fragment. Null remains null. Equal
328
+ inputs using the same namespace, key, and origin correspond across ordinary columns and
329
+ structured JSON; different inputs and rotated keys produce different paths.
330
+
331
+ The same declaration can be used for items inside bounded `json-array` recipes, which
332
+ covers PostgreSQL text-array projections represented by the source pipeline. Each array
333
+ and object still needs its ordinary item, depth, and byte bounds.
334
+
335
+ ### Retained text containing a reviewed identity
336
+
337
+ Use `binding-substitute` when permitted text may contain the exact value of a reviewed
338
+ UUID binding. This keeps the surrounding text but replaces every occurrence of that UUID
339
+ with the same keyed pseudonym used by a `uuid` declaration sharing its namespace:
340
+
341
+ ```json
342
+ {
343
+ "action": "DERIVE",
344
+ "recipe": {
345
+ "kind": "approved-owner",
346
+ "approved": {
347
+ "action": "DERIVE",
348
+ "recipe": {
349
+ "kind": "binding-substitute",
350
+ "binding": "approved-owner",
351
+ "format": "uuid",
352
+ "namespace": "account-id",
353
+ "maximumBytes": 256
354
+ }
355
+ },
356
+ "otherwise": {
357
+ "action": "PSEUDONYMIZE",
358
+ "recipe": {
359
+ "format": "text",
360
+ "namespace": "food-description",
361
+ "maxLength": 128
362
+ }
363
+ }
364
+ }
365
+ }
366
+ ```
367
+
368
+ The named binding must already exist in `policy.bindings`, and its environment value must
369
+ match the tracked SHA-256 review receipt. For this recipe the reviewed value must be a
370
+ UUID. `maximumBytes` is required and applies before and after replacement. Null remains
371
+ null; other input must be text. Ordinary text is unchanged, literal UUID occurrences are
372
+ all replaced, and the raw identity is never stored in policy.
373
+
374
+ This is intentionally not a general search-and-replace facility: there are no regular
375
+ expressions, arbitrary patterns, replacement strings, or callbacks. Pair it with an
376
+ `approved-owner` branch when only the reviewed owner's surrounding text may be retained.
377
+
176
378
  ## Structured JSON
177
379
 
178
380
  `json-object` fields are required by default. Set `"required": false` on an explicitly
@@ -211,6 +413,205 @@ the same declarations, and every nested identity uses an explicit pseudonym reci
211
413
  }
212
414
  ```
213
415
 
416
+ ### JSON values with several reviewed types
417
+
418
+ Use `json-union` when one JSON column legitimately stores more than one root type. Every
419
+ accepted type has its own declaration. Null is rejected unless `allowNull` is explicitly
420
+ true. Arrays and objects cannot use whole-value `KEEP`; their contents must be classified
421
+ through `json-array`, `json-object`, `json-dictionary`, another bounded union, or a safe
422
+ replacement or pseudonymization.
423
+
424
+ ```json
425
+ {
426
+ "action": "DERIVE",
427
+ "recipe": {
428
+ "kind": "json-union",
429
+ "allowNull": true,
430
+ "maximumItems": 25,
431
+ "maximumDepth": 5,
432
+ "maximumBytes": 8192,
433
+ "variants": {
434
+ "string": { "action": "KEEP" },
435
+ "number": { "action": "KEEP" },
436
+ "array": {
437
+ "action": "DERIVE",
438
+ "recipe": {
439
+ "kind": "json-array",
440
+ "maximumItems": 25,
441
+ "items": {
442
+ "action": "REPLACE",
443
+ "recipe": { "kind": "constant", "value": "Synthetic item" }
444
+ }
445
+ }
446
+ },
447
+ "object": {
448
+ "action": "DERIVE",
449
+ "recipe": {
450
+ "kind": "json-object",
451
+ "fields": {
452
+ "status": { "action": "KEEP" },
453
+ "note": {
454
+ "action": "REPLACE",
455
+ "required": false,
456
+ "recipe": { "kind": "constant", "value": "Synthetic note" }
457
+ }
458
+ }
459
+ }
460
+ }
461
+ }
462
+ }
463
+ }
464
+ ```
465
+
466
+ Supported variant names are `string`, `number`, `boolean`, `array`, and `object`.
467
+ Undeclared types, non-finite numbers, excessive bytes, depth or items, and unclassified
468
+ object fields fail closed.
469
+
470
+ ### Reviewed scalar domains
471
+
472
+ Use `enum` when a structural string, number, or boolean must remain exact, but only a
473
+ small reviewed set is valid. For example, an image mode can retain its application
474
+ meaning without allowing arbitrary text through:
475
+
476
+ ```json
477
+ {
478
+ "action": "DERIVE",
479
+ "recipe": {
480
+ "kind": "enum",
481
+ "values": ["contain", "cover", "custom"],
482
+ "allowNull": true
483
+ }
484
+ }
485
+ ```
486
+
487
+ The recipe accepts one through 128 distinct values. Each value must be a JSON string,
488
+ finite number, or boolean; strings are limited to 256 UTF-8 bytes. `null` is accepted
489
+ only when `allowNull` is true. Runtime values match by both type and exact value, so the
490
+ number `1` does not authorize the string `"1"`. Rejected values are never included in
491
+ the error message.
492
+
493
+ An enum can be used directly or inside reviewed objects, arrays, dictionary values, and
494
+ `approved-owner` branches. It validates structure; it is not a place to list private
495
+ data, credentials, tokens, user-entered prose, or identifiers.
496
+
497
+ ### Open structural codes
498
+
499
+ Use `validated-string` when an open set of structural codes must remain exact and an
500
+ enum would incorrectly freeze the domain. The built-in `portable-code` format accepts
501
+ case-preserving ASCII letters, digits, `_`, and `-`, starting with a letter or digit:
502
+
503
+ ```json
504
+ {
505
+ "action": "DERIVE",
506
+ "recipe": {
507
+ "kind": "validated-string",
508
+ "format": "portable-code",
509
+ "maximumBytes": 64,
510
+ "allowNull": true
511
+ }
512
+ }
513
+ ```
514
+
515
+ `maximumBytes` is required and may be 1 through 1,024. Dots, whitespace, slashes,
516
+ colons, control characters, non-ASCII text, wrong JSON types, and over-limit strings
517
+ are rejected without printing the input. This preserves codes such as `PROCNT`, `203`,
518
+ or `energy-kcal_100g`, while refusing JWT-shaped strings. Arbitrary regular expressions
519
+ are not supported.
520
+
521
+ Like enums, validated strings compose inside reviewed objects, arrays, dictionary
522
+ values, and `approved-owner` branches. Use them only for documented structural code
523
+ domains—not prose, URLs, paths, credentials, secrets, personal data, or identifiers
524
+ whose legitimate alphabet requires other characters.
525
+
526
+ ### JSON dictionaries with runtime keys
527
+
528
+ Use `json-dictionary` for a reviewed object whose keys come from runtime data. This is
529
+ different from `json-object`, where every field name is known in advance. A dictionary
530
+ requires a key format, an explicit key action, one value declaration, and item, depth and
531
+ byte bounds:
532
+
533
+ ```json
534
+ {
535
+ "action": "DERIVE",
536
+ "recipe": {
537
+ "kind": "json-dictionary",
538
+ "maximumItems": 100,
539
+ "maximumDepth": 3,
540
+ "maximumBytes": 8192,
541
+ "keys": { "format": "positive-integer", "action": "KEEP" },
542
+ "values": {
543
+ "action": "DERIVE",
544
+ "recipe": {
545
+ "kind": "json-union",
546
+ "maximumItems": 1,
547
+ "variants": { "number": { "action": "KEEP" } }
548
+ }
549
+ }
550
+ }
551
+ }
552
+ ```
553
+
554
+ Key formats are `integer`, `positive-integer`, `uuid`, `identifier`, and bounded `text`.
555
+ Use `integer` when runtime keys may be positive, negative, or zero. It requires inclusive
556
+ safe-integer `minimum` and `maximum` bounds, accepts only canonical decimal keys, and
557
+ preserves their exact spelling:
558
+
559
+ ```json
560
+ {
561
+ "keys": {
562
+ "format": "integer",
563
+ "action": "KEEP",
564
+ "minimum": -9007199254740991,
565
+ "maximum": 9007199254740991
566
+ }
567
+ }
568
+ ```
569
+
570
+ The reviewed range decides whether zero is allowed. Leading zeroes, `-0`, a leading `+`,
571
+ fractions, exponents, unsafe integers, and unrelated text are rejected. Use
572
+ `positive-integer` for canonical decimal keys starting at 1. Text keys must declare
573
+ `maximumLength`. Every key explicitly uses either `KEEP` or `PSEUDONYMIZE`.
574
+ Identity-bearing keys should use `PSEUDONYMIZE` with the ordinary keyed pseudonym recipe:
575
+
576
+ ```json
577
+ {
578
+ "keys": {
579
+ "format": "uuid",
580
+ "action": "PSEUDONYMIZE",
581
+ "recipe": { "format": "uuid", "namespace": "account-id" }
582
+ }
583
+ }
584
+ ```
585
+
586
+ When a dictionary is keyed by the same numeric identity stored in a column or nested
587
+ numeric field, use `format: "integer"` for both pseudonym recipes and reuse the exact
588
+ namespace:
589
+
590
+ ```json
591
+ {
592
+ "keys": {
593
+ "format": "integer",
594
+ "action": "PSEUDONYMIZE",
595
+ "minimum": -9007199254740991,
596
+ "maximum": 9007199254740991,
597
+ "recipe": { "format": "integer", "namespace": "external:food-id" }
598
+ }
599
+ }
600
+ ```
601
+
602
+ Rehearsal validates the object key as canonical decimal text, converts it to its safe
603
+ integer identity, and then applies the keyed mapping. Therefore the source number `203`
604
+ and reviewed dictionary key `"203"` receive the same pseudonym. Positive, negative, and
605
+ reviewed zero identities correspond; namespace changes and privacy-key rotation still
606
+ produce separate mappings. This normalization applies only to explicitly typed
607
+ `integer` dictionary keys. It does not weaken the key syntax or range checks.
608
+
609
+ Invalid keys, duplicate transformed keys, excessive entries, unclassified surrounding
610
+ object fields, and values rejected by the declared value recipe stop preparation. A
611
+ dictionary never infers that runtime keys are safe. Its value declaration also cannot
612
+ retain an unknown JSON shape wholesale; use a bounded union when a scalar value should be
613
+ kept exactly.
614
+
214
615
  The package still exports `validateSanitizationCoverage` and
215
616
  `applySanitizationAction` for existing version 1 preparation tools:
216
617
 
@@ -52,8 +52,12 @@ The default managed workflow creates and verifies its own temporary PostgreSQL r
52
52
  External mode accepts a provider-managed reader only when its complete non-system
53
53
  readable-column surface exactly matches the reviewed export views and migration ledger.
54
54
  It also verifies canonical view-definition fingerprints, `security_barrier`, ownership,
55
- write denial, role isolation, security-definer denial, and a short database-enforced
56
- expiration. Rehearsal never claims ownership or revocation of provider-managed objects.
55
+ write denial, security-definer denial, and a short database-enforced expiration. An
56
+ external login can declare a bounded exact list of provider-owned permission groups;
57
+ each must be non-login, non-privileged, directly inherited, unable to be assumed or
58
+ delegated, and free of nested membership. Undeclared or unsafe role membership fails as
59
+ an actionable source-authorization refusal. Rehearsal never claims ownership or
60
+ revocation of provider-managed objects.
57
61
 
58
62
  ## Snapshot and privacy guarantees
59
63
 
@@ -78,10 +82,12 @@ and records a SHA-256 in the immutable baseline. Transfer refuses an object that
78
82
  exceeds its declared limit, or falls outside an approved bucket and prefix.
79
83
 
80
84
  Privacy policy version 2 is executable without project sanitizer code. It
81
- supports exact keep, exclusion, keyed pseudonyms, constants, and a small set of
82
- documented derivations. Policies classify every selected field and supported
83
- nested JSON key. A new or unknown field blocks refresh until the policy is
84
- reviewed again.
85
+ supports exact keep, exclusion, keyed and constraint-preserving pseudonyms (including
86
+ safe loopback HTTPS replacements), bounded reviewed-identity substitution inside
87
+ retained text,
88
+ constants, named sanitized-input digests, and a small set of documented
89
+ derivations. Policies classify every selected field and supported nested JSON
90
+ key. A new or unknown field blocks refresh until the policy is reviewed again.
85
91
 
86
92
  The pseudonym key is an owner-only local secret. It is not stored in the
87
93
  baseline or source control. Receipts bind the policy bytes, schema shape,
@@ -32,7 +32,11 @@ npx rehearsal
32
32
 
33
33
  Choose **Set the stage** and approve the preview. For pnpm and Yarn, use this guided path;
34
34
  automatic root generation is currently tested with npm. An existing supported config is
35
- kept in place, including `infrastructure/rehearsal/rehearsal.config.mjs`.
35
+ kept in place, including `infrastructure/rehearsal/rehearsal.config.mjs`. When npm shows
36
+ lifecycle output, the install message names the active path and explains when that
37
+ existing file prevented `rehearsal.config.mjs` from being generated. You can always run
38
+ `npx rehearsal init --json` and read `data.destination`. To relocate it safely, follow
39
+ [Move an existing config to the project root](configuration.md#move-an-existing-config-to-the-project-root).
36
40
 
37
41
  ## Setup says this Node.js version is unsupported
38
42
 
package/docs/tutorial.md CHANGED
@@ -106,3 +106,8 @@ npm run test:fixture
106
106
  ```
107
107
 
108
108
  The PostgreSQL equivalent is `npm run test:fixture:postgresql`.
109
+
110
+ The release workflow also runs `npm run test:fixture:onboarding` from the packed package.
111
+ That check creates fresh Supabase and PostgreSQL consumers, verifies beta configuration
112
+ preservation, and exercises safe refusal and recovery for partial setup, occupied ports,
113
+ malformed configuration, and missing credentials.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rehearsal-db/core",
3
- "version": "0.1.0-beta.13",
3
+ "version": "0.1.0-beta.14",
4
4
  "private": false,
5
5
  "description": "Safely rehearse PostgreSQL and Supabase migrations against sanitized, production-shaped data.",
6
6
  "repository": {
@@ -2,33 +2,16 @@
2
2
  /** Create safe project-root Rehearsal scaffolding during a local install. */
3
3
 
4
4
  import { fileURLToPath } from "node:url";
5
- import { createInstalledRehearsalScaffold } from "../../src/project/install_scaffold.mjs";
5
+ import {
6
+ createInstalledRehearsalScaffold,
7
+ renderInstalledRehearsalScaffold,
8
+ } from "../../src/project/install_scaffold.mjs";
6
9
 
7
10
  const packageRoot = fileURLToPath(new URL("../..", import.meta.url));
8
11
 
9
12
  try {
10
13
  const result = await createInstalledRehearsalScaffold({ packageRoot });
11
- if (result.status === "ready") {
12
- const changed = result.files.filter((file) => file.action !== "unchanged");
13
- console.log(
14
- [
15
- "Rehearsal is ready in this project.",
16
- ...(changed.length
17
- ? [
18
- ...changed.map(
19
- (file) =>
20
- ` ${file.action === "create" ? "+" : "~"} ${file.path}`,
21
- ),
22
- ]
23
- : [" Existing Rehearsal configuration preserved."]),
24
- "Next: review rehearsal.config.mjs, then run npx rehearsal.",
25
- ].join("\n"),
26
- );
27
- } else {
28
- console.log(
29
- `Rehearsal did not create project files: ${result.reason}.\nNext: run npx rehearsal from the application directory.`,
30
- );
31
- }
14
+ console.log(renderInstalledRehearsalScaffold(result));
32
15
  } catch (error) {
33
16
  console.warn(
34
17
  `Rehearsal could not safely create project files during installation: ${String(error?.message ?? error)}\nNext: run npx rehearsal from the application directory.`,