@rehearsal-db/core 0.1.0-beta.3 → 0.1.0-beta.4

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/CHANGELOG.md CHANGED
@@ -5,6 +5,18 @@ a Changelog, and versions will follow Semantic Versioning after the package exis
5
5
 
6
6
  ## Unreleased
7
7
 
8
+ ## [0.1.0-beta.4] - 2026-10-01
9
+
10
+ ### Added
11
+
12
+ - A persistent guided session with polished terminal prompts, interactive policy review,
13
+ concise runtime receipts, expandable technical details, and real-PTY regression tests.
14
+
15
+ ### Changed
16
+
17
+ - Guided menus now place the next recommended action first and automatically re-inspect
18
+ project state after every completed step while preserving plain and non-TTY modes.
19
+
8
20
  ## [0.1.0-beta.3] - 2026-10-01
9
21
 
10
22
  ### Added
@@ -106,7 +118,8 @@ a Changelog, and versions will follow Semantic Versioning after the package exis
106
118
  publication uses short-lived trusted OIDC, and every release tag must already exist on
107
119
  protected `main`.
108
120
 
109
- [Unreleased]: https://github.com/Ddupasquier/rehearsal-db/compare/v0.1.0-beta.3...HEAD
121
+ [Unreleased]: https://github.com/Ddupasquier/rehearsal-db/compare/v0.1.0-beta.4...HEAD
122
+ [0.1.0-beta.4]: https://github.com/Ddupasquier/rehearsal-db/compare/v0.1.0-beta.3...v0.1.0-beta.4
110
123
  [0.1.0-beta.3]: https://github.com/Ddupasquier/rehearsal-db/compare/v0.1.0-beta.2...v0.1.0-beta.3
111
124
  [0.1.0-beta.2]: https://github.com/Ddupasquier/rehearsal-db/compare/v0.1.0-beta.1...v0.1.0-beta.2
112
125
  [0.1.0-beta.1]: https://github.com/Ddupasquier/rehearsal-db/releases/tag/v0.1.0-beta.1
package/README.md CHANGED
@@ -108,8 +108,10 @@ npx rehearsal discard
108
108
  ```
109
109
 
110
110
  Running `npx rehearsal` in a terminal opens a state-aware guide that shows completed
111
- setup steps and recommends available actions. The explicit commands remain the stable
112
- interface for automation and CI.
111
+ setup steps and recommends available actions. The guide stays open after each action,
112
+ re-inspects the project, and advances to the next useful step. It includes an interactive
113
+ column-by-column policy reviewer, concise completion receipts, and optional technical
114
+ details. The explicit commands remain the stable interface for automation and CI.
113
115
 
114
116
  `setup` previews a conservative first-run scaffold: the Rehearsal configuration, a
115
117
  dedicated local-only Supabase configuration on an available port block, and protective
package/docs/commands.md CHANGED
@@ -40,6 +40,12 @@ bounded local Storage bytes; every manifest `file` must also remain inside the p
40
40
  The guided home screen can ask for these paths so they do not need to be supplied as
41
41
  flags.
42
42
 
43
+ The guide is a persistent session: after setup, policy review, baseline creation, or a
44
+ runtime action it re-reads project state and offers the next relevant action. Generated
45
+ policy drafts can be completed interactively without editing JSON. Each column still
46
+ requires explicit action, generated, identity, and foreign-key decisions; the guide does
47
+ not silently infer them.
48
+
43
49
  `baseline prepare` reads only table and column names from the NDJSON records; row values
44
50
  are never included in its result. Its generated policy deliberately marks every column
45
51
  decision `REVIEW REQUIRED` and cannot be activated until a human completes the metadata
@@ -50,6 +56,8 @@ Automation should use `--json` and inspect both exit status and the versioned en
50
56
  Exit-code meanings are documented in the root README. Scripts must not parse human text.
51
57
  When standard input or output is not an interactive terminal, bare `rehearsal` prints
52
58
  help instead of prompting. Use `--plain` to disable decorative terminal styling.
59
+ `NO_COLOR` also selects the plain numbered interface. Both interactive modes preserve
60
+ the same safety decisions and cancellation behavior.
53
61
 
54
62
  `setup` chooses an available local port block and generates a conservative Supabase
55
63
  configuration with hosted access and optional networked services disabled. It does not
@@ -47,7 +47,8 @@ npx rehearsal
47
47
 
48
48
  Choose **Set up Rehearsal** in the guide. It previews a versioned configuration, a
49
49
  dedicated local-only Supabase configuration using available ports, and protective
50
- `.gitignore` entries before asking permission to write.
50
+ `.gitignore` entries before asking permission to write. The guide remains open afterward
51
+ and recommends the next incomplete stage.
51
52
 
52
53
  The same flow is available noninteractively as an explicit preview and write:
53
54
 
@@ -104,7 +105,12 @@ npx rehearsal baseline prepare \
104
105
  --write
105
106
  ```
106
107
 
107
- Review every `REVIEW REQUIRED` field, replace it with correct metadata, and remove
108
+ After creating the draft, choose **Review the script** in the guide. Rehearsal walks each
109
+ column through sanitization action, generated status, identity status, and optional
110
+ foreign-key metadata. Every answer is explicit, the complete policy is validated before
111
+ writing, and the original draft is replaced only if it did not change during review.
112
+
113
+ For noninteractive workflows, review every `REVIEW REQUIRED` field directly and remove
108
114
  `"draft": true` only after that review. Rehearsal refuses to activate a draft.
109
115
 
110
116
  Historical migration `supabase/migrations/20260101000000_create_widgets.sql`:
package/docs/releasing.md CHANGED
@@ -37,7 +37,7 @@ publication must change and prove the workflow before narrowing that permission.
37
37
  onboarding. Correct and retest the first confusing, missing, or wrong instruction.
38
38
  5. Change `private` to `false` only in the reviewed release change.
39
39
  6. Record the exact tarball filename, SHA-1, SHA-256, allowlisted files, unpacked size,
40
- executable, and zero-runtime-dependency result.
40
+ executable, declared runtime dependency inventory, and dependency audit result.
41
41
  7. Obtain explicit publication authorization for that exact version and artifact.
42
42
  8. Merge the approved release commit through protected `main` and create the exact
43
43
  `v<package-version>` tag and GitHub release.
@@ -19,6 +19,12 @@ whether it is an identity column, and its foreign-key target or explicit absence
19
19
  draft uses `REVIEW REQUIRED` placeholders and cannot be activated until they are
20
20
  replaced and the `draft` marker is removed.
21
21
 
22
+ In an interactive terminal, the guided **Review the script** action completes these
23
+ decisions one column at a time. Replacement-oriented actions are shown first, retaining
24
+ a value is explicitly labeled as sensitive, and no choice is silently inferred. The
25
+ reviewer validates the completed policy and refuses to overwrite a draft changed during
26
+ the session.
27
+
22
28
  The package exports `validateSanitizationCoverage` and
23
29
  `applySanitizationAction` as generic primitives:
24
30
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rehearsal-db/core",
3
- "version": "0.1.0-beta.3",
3
+ "version": "0.1.0-beta.4",
4
4
  "private": false,
5
5
  "description": "Safely rehearse Supabase migrations against sanitized, production-shaped PostgreSQL data.",
6
6
  "repository": {
@@ -69,8 +69,11 @@
69
69
  "database"
70
70
  ],
71
71
  "license": "MIT",
72
- "dependencies": {},
72
+ "dependencies": {
73
+ "@clack/prompts": "^1.8.1"
74
+ },
73
75
  "devDependencies": {
76
+ "@lydell/node-pty": "^1.2.0-beta.15",
74
77
  "prettier": "^3.9.6",
75
78
  "vitest": "^4.1.10"
76
79
  }
@@ -7,7 +7,7 @@
7
7
  import { createHash } from "node:crypto";
8
8
 
9
9
  export const REHEARSAL_RESULT_VERSION = 1;
10
- export const REHEARSAL_VERSION = "0.1.0-beta.3";
10
+ export const REHEARSAL_VERSION = "0.1.0-beta.4";
11
11
 
12
12
  export const REHEARSAL_EXIT_CODES = Object.freeze({
13
13
  success: 0,
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Purpose: Complete and safely apply a generated REVIEW REQUIRED policy draft.
3
+ * The caller owns the interactive presentation; this module owns validation and
4
+ * concurrent-change refusal.
5
+ */
6
+
7
+ import { readFile, writeFile } from "node:fs/promises";
8
+ import { validateRuntimeSanitizationPolicy } from "./sanitization_policy.mjs";
9
+
10
+ export const readReviewablePolicyDraft = async (path) => {
11
+ const source = await readFile(path, "utf8");
12
+ const draft = JSON.parse(source);
13
+ if (draft?.draft !== true || !Array.isArray(draft.tables)) {
14
+ throw new Error(
15
+ "Interactive review requires a generated sanitization policy with draft: true.",
16
+ );
17
+ }
18
+ return { source, draft };
19
+ };
20
+
21
+ export const completePolicyDraft = async ({ draft, reviewColumn }) => {
22
+ if (draft?.draft !== true || !Array.isArray(draft.tables)) {
23
+ throw new Error("A generated REVIEW REQUIRED policy draft is required.");
24
+ }
25
+ const tables = [];
26
+ for (const table of draft.tables) {
27
+ const columns = [];
28
+ for (const column of table.columns ?? []) {
29
+ const reviewed = await reviewColumn({
30
+ table: table.name,
31
+ column: column.name,
32
+ });
33
+ columns.push({ name: column.name, ...reviewed });
34
+ }
35
+ tables.push({ ...table, columns });
36
+ }
37
+ const reviewed = { ...draft, tables };
38
+ delete reviewed.draft;
39
+ return validateRuntimeSanitizationPolicy(reviewed);
40
+ };
41
+
42
+ export const applyReviewedPolicy = async ({ path, originalSource, policy }) => {
43
+ validateRuntimeSanitizationPolicy(policy);
44
+ if ((await readFile(path, "utf8")) !== originalSource) {
45
+ throw new Error(
46
+ "The sanitization policy changed during review; no reviewed policy was written.",
47
+ );
48
+ }
49
+ await writeFile(path, `${JSON.stringify(policy, null, 2)}\n`, {
50
+ mode: 0o600,
51
+ });
52
+ };
@@ -11,6 +11,7 @@ import { join, relative } from "node:path";
11
11
  import { performance } from "node:perf_hooks";
12
12
  import { createInterface } from "node:readline/promises";
13
13
  import { fileURLToPath } from "node:url";
14
+ import * as prompts from "@clack/prompts";
14
15
  import {
15
16
  RehearsalError,
16
17
  createRehearsalResult,
@@ -45,6 +46,11 @@ import {
45
46
  summarizeRehearsalSetup,
46
47
  } from "../../lib/rehearsal/setup.mjs";
47
48
  import { formatCount } from "../../lib/rehearsal/human_output.mjs";
49
+ import {
50
+ applyReviewedPolicy,
51
+ completePolicyDraft,
52
+ readReviewablePolicyDraft,
53
+ } from "../../lib/rehearsal/policy_review.mjs";
48
54
 
49
55
  const packageRoot = fileURLToPath(new URL("../../..", import.meta.url));
50
56
  const projectRoot = process.cwd();
@@ -54,6 +60,7 @@ const managerPath = join(
54
60
  packageRoot,
55
61
  "scripts/operations/database/manage_rehearsal_database.mjs",
56
62
  );
63
+ let lastGuidedDetails;
57
64
 
58
65
  const parseArguments = (arguments_) => {
59
66
  const flags = {
@@ -255,12 +262,27 @@ const terminalStyle = (flags, code, value) =>
255
262
  const isHumanTerminal = (flags) =>
256
263
  !flags.json && process.stdin.isTTY && process.stdout.isTTY;
257
264
 
265
+ const useStyledPrompts = (flags) => isHumanTerminal(flags) && useColor(flags);
266
+
258
267
  const formatDuration = (durationMs) =>
259
268
  durationMs < 1_000
260
269
  ? `${Math.round(durationMs)}ms`
261
270
  : `${(durationMs / 1_000).toFixed(1)}s`;
262
271
 
263
- const promptForChoice = async ({ message, options }) => {
272
+ const promptForChoice = async ({ message, options, flags = {} }) => {
273
+ if (useStyledPrompts(flags)) {
274
+ const selected = await prompts.select({
275
+ message,
276
+ options: options.map((option) => ({
277
+ value: option,
278
+ label: option.label,
279
+ hint: option.hint,
280
+ })),
281
+ });
282
+ return prompts.isCancel(selected)
283
+ ? options.find((option) => option.command === "exit")
284
+ : selected;
285
+ }
264
286
  const prompt = createInterface({
265
287
  input: process.stdin,
266
288
  output: process.stdout,
@@ -283,7 +305,15 @@ const promptForChoice = async ({ message, options }) => {
283
305
  }
284
306
  };
285
307
 
286
- const promptForConfirmation = async (message) => {
308
+ const promptForConfirmation = async (
309
+ message,
310
+ flags = {},
311
+ { cancelValue = false } = {},
312
+ ) => {
313
+ if (useStyledPrompts(flags)) {
314
+ const accepted = await prompts.confirm({ message, initialValue: false });
315
+ return prompts.isCancel(accepted) ? cancelValue : accepted;
316
+ }
287
317
  const prompt = createInterface({
288
318
  input: process.stdin,
289
319
  output: process.stdout,
@@ -298,7 +328,27 @@ const promptForConfirmation = async (message) => {
298
328
  }
299
329
  };
300
330
 
301
- const promptForPath = async ({ message, defaultValue, optional = false }) => {
331
+ const promptForPath = async ({
332
+ message,
333
+ defaultValue,
334
+ optional = false,
335
+ flags = {},
336
+ validate,
337
+ }) => {
338
+ if (useStyledPrompts(flags)) {
339
+ const answer = await prompts.text({
340
+ message,
341
+ placeholder: defaultValue ?? (optional ? "Leave blank for none" : ""),
342
+ defaultValue,
343
+ validate: (value) => {
344
+ const resolved = String(value ?? defaultValue ?? "").trim();
345
+ if (!optional && !resolved) return "Enter a value.";
346
+ return resolved && validate ? validate(resolved) : undefined;
347
+ },
348
+ });
349
+ if (prompts.isCancel(answer)) return undefined;
350
+ return String(answer || defaultValue || "").trim() || undefined;
351
+ }
302
352
  const prompt = createInterface({
303
353
  input: process.stdin,
304
354
  output: process.stdout,
@@ -312,6 +362,11 @@ const promptForPath = async ({ message, defaultValue, optional = false }) => {
312
362
  : " (required)";
313
363
  const answer = (await prompt.question(`${message}${suffix}: `)).trim();
314
364
  const value = answer || defaultValue || "";
365
+ const invalid = value && validate ? validate(value) : undefined;
366
+ if (invalid) {
367
+ console.log(invalid);
368
+ continue;
369
+ }
315
370
  if (value || optional) return value || undefined;
316
371
  console.log("Enter a project-relative path.");
317
372
  }
@@ -320,6 +375,11 @@ const promptForPath = async ({ message, defaultValue, optional = false }) => {
320
375
  }
321
376
  };
322
377
 
378
+ const promptForSelection = async ({ message, options, flags }) => {
379
+ const selected = await promptForChoice({ message, options, flags });
380
+ return selected?.value;
381
+ };
382
+
323
383
  const firstExistingPath = async (candidates) => {
324
384
  for (const candidate of candidates) {
325
385
  if (await pathExists(join(projectRoot, candidate))) return candidate;
@@ -391,6 +451,7 @@ const inspectGuidedProject = async (planOptions) => {
391
451
  baseline: Boolean(migrationSummary),
392
452
  policy,
393
453
  policyPath: relative(projectRoot, loaded.paths.sanitizationPolicy),
454
+ policyAbsolutePath: loaded.paths.sanitizationPolicy,
394
455
  migrationSummary,
395
456
  runtime: await pathExists(
396
457
  join(loaded.paths.runtimeWorkdir, "baseline.json"),
@@ -398,6 +459,144 @@ const inspectGuidedProject = async (planOptions) => {
398
459
  };
399
460
  };
400
461
 
462
+ const reviewPolicyInteractively = async ({ state, flags }) => {
463
+ const { source, draft } = await readReviewablePolicyDraft(
464
+ state.policyAbsolutePath,
465
+ );
466
+ const totalColumns = draft.tables.reduce(
467
+ (total, table) => total + (table.columns?.length ?? 0),
468
+ 0,
469
+ );
470
+ let reviewedColumns = 0;
471
+ if (useStyledPrompts(flags)) {
472
+ prompts.note(
473
+ [
474
+ `${formatCount(totalColumns, "column")} require explicit review.`,
475
+ "No row values are displayed or changed by this step.",
476
+ "The safest replacement-oriented choices appear first.",
477
+ ].join("\n"),
478
+ "Review the script",
479
+ );
480
+ }
481
+ const policy = await completePolicyDraft({
482
+ draft,
483
+ reviewColumn: async ({ table, column }) => {
484
+ reviewedColumns += 1;
485
+ const label = `${table}.${column} (${reviewedColumns}/${totalColumns})`;
486
+ const action = await promptForSelection({
487
+ message: `${label}: how should this value be handled?`,
488
+ flags,
489
+ options: [
490
+ {
491
+ label: "Replace with synthetic data",
492
+ hint: "Safest default",
493
+ value: "REPLACE WITH SYNTHETIC",
494
+ },
495
+ {
496
+ label: "Pseudonymize consistently",
497
+ hint: "Stable links without original values",
498
+ value: "PSEUDONYMIZE",
499
+ },
500
+ {
501
+ label: "Derive a safe value",
502
+ value: "DERIVE",
503
+ },
504
+ { label: "Exclude this field", value: "EXCLUDE" },
505
+ {
506
+ label: "Keep exactly",
507
+ hint: "Only for values explicitly safe to retain",
508
+ value: "KEEP EXACTLY",
509
+ },
510
+ ],
511
+ });
512
+ if (!action) throw new Error("Policy review was cancelled.");
513
+ const generated = await promptForSelection({
514
+ message: `${label}: is this column always database-generated?`,
515
+ flags,
516
+ options: [
517
+ { label: "No", value: "NEVER" },
518
+ { label: "Yes", value: "ALWAYS" },
519
+ ],
520
+ });
521
+ if (!generated) throw new Error("Policy review was cancelled.");
522
+ const identity = await promptForSelection({
523
+ message: `${label}: is this an identity column?`,
524
+ flags,
525
+ options: [
526
+ { label: "No", value: "NO" },
527
+ { label: "Yes", value: "YES" },
528
+ ],
529
+ });
530
+ if (!identity) throw new Error("Policy review was cancelled.");
531
+ const hasForeignKey = await promptForConfirmation(
532
+ `${label}: does this column reference another table?`,
533
+ flags,
534
+ { cancelValue: null },
535
+ );
536
+ if (hasForeignKey === null) {
537
+ throw new Error("Policy review was cancelled.");
538
+ }
539
+ let foreignKey = null;
540
+ if (hasForeignKey) {
541
+ const schema = await promptForPath({
542
+ message: `${label}: referenced schema`,
543
+ defaultValue: "public",
544
+ flags,
545
+ validate: (value) =>
546
+ /^[a-z][a-z0-9_]{0,62}$/u.test(value)
547
+ ? undefined
548
+ : "Use a lowercase PostgreSQL identifier.",
549
+ });
550
+ const referencedTable = await promptForPath({
551
+ message: `${label}: referenced table`,
552
+ flags,
553
+ validate: (value) =>
554
+ /^[a-z][a-z0-9_]{0,62}$/u.test(value)
555
+ ? undefined
556
+ : "Use a lowercase PostgreSQL identifier.",
557
+ });
558
+ const referencedColumn = await promptForPath({
559
+ message: `${label}: referenced column`,
560
+ flags,
561
+ validate: (value) =>
562
+ /^[a-z][a-z0-9_]{0,62}$/u.test(value)
563
+ ? undefined
564
+ : "Use a lowercase PostgreSQL identifier.",
565
+ });
566
+ if (!schema || !referencedTable || !referencedColumn) {
567
+ throw new Error("Policy review was cancelled.");
568
+ }
569
+ foreignKey = {
570
+ schema,
571
+ table: referencedTable,
572
+ column: referencedColumn,
573
+ };
574
+ }
575
+ return { action, generated, identity, foreignKey };
576
+ },
577
+ });
578
+ const accepted = await promptForConfirmation(
579
+ `Save ${formatCount(totalColumns, "reviewed column")} and activate this policy?`,
580
+ flags,
581
+ );
582
+ if (!accepted) return false;
583
+ await applyReviewedPolicy({
584
+ path: state.policyAbsolutePath,
585
+ originalSource: source,
586
+ policy,
587
+ });
588
+ if (useStyledPrompts(flags)) {
589
+ prompts.log.success(
590
+ `${formatCount(totalColumns, "column")} reviewed; the policy is ready for baseline creation.`,
591
+ );
592
+ } else {
593
+ console.log(
594
+ `Reviewed policy saved: ${formatCount(totalColumns, "column")} classified.`,
595
+ );
596
+ }
597
+ return true;
598
+ };
599
+
401
600
  const runGuidedHome = async ({ flags, planOptions }) => {
402
601
  const state = await inspectGuidedProject(planOptions);
403
602
  const marker = (complete) =>
@@ -407,89 +606,121 @@ const runGuidedHome = async ({ flags, planOptions }) => {
407
606
  if (!state.node.supported) {
408
607
  options.push({
409
608
  label: "Show Node.js 24 setup instructions",
609
+ hint: "Required before Rehearsal can change this project",
410
610
  command: "node-help",
411
611
  });
412
612
  } else if (state.config === "missing") {
413
613
  options.push({
414
- label: "Set up Rehearsal",
614
+ label: "Set the stage",
615
+ hint: "Create safe local configuration",
415
616
  command: "setup-write",
416
617
  });
417
- } else {
418
- options.push({ label: "Check readiness", command: "doctor" });
419
- }
420
- if (state.node.supported && state.baseline) {
618
+ } else if (state.baseline) {
421
619
  options.push(
422
- { label: "Review the migration plan", command: "explain" },
423
620
  { label: "Run a rehearsal", command: "run" },
621
+ { label: "Review the migration plan", command: "explain" },
424
622
  { label: "Show candidate migrations", command: "candidates" },
623
+ { label: "Check readiness", command: "doctor" },
425
624
  );
426
625
  } else if (state.node.supported && state.config === "valid") {
427
626
  if (state.policy === "missing") {
428
627
  options.push({
429
- label: "Prepare a reviewable baseline policy draft",
628
+ label: "Prepare the script",
629
+ hint: "Create a reviewable policy draft",
430
630
  command: "baseline-prepare",
431
631
  });
432
632
  } else if (state.policy === "needs-review") {
433
633
  options.push({
434
- label: "Review the baseline policy draft",
634
+ label: "Review the script",
635
+ hint: "Classify every column interactively",
435
636
  command: "policy-review",
436
637
  });
437
638
  } else {
438
639
  options.push({
439
- label: "Create a baseline from safe local files",
640
+ label: "Create the baseline",
641
+ hint: "Activate reviewed local inputs",
440
642
  command: "baseline-guide",
441
643
  });
442
644
  }
645
+ options.push({ label: "Check readiness", command: "doctor" });
646
+ } else {
647
+ options.push({ label: "Check readiness", command: "doctor" });
443
648
  }
444
649
  if (state.node.supported && state.runtime) {
445
- options.push(
446
- { label: "Show local runtime status", command: "status" },
447
- { label: "Verify the local runtime", command: "verify" },
448
- { label: "Reset to the baseline", command: "reset" },
449
- { label: "Stop the local runtime", command: "stop" },
450
- { label: "Discard the local runtime", command: "discard" },
451
- );
650
+ options.push({
651
+ label: "Manage the local runtime",
652
+ hint: "Status, verification, and cleanup",
653
+ command: "manage-runtime",
654
+ });
655
+ }
656
+ if (lastGuidedDetails) {
657
+ options.push({
658
+ label: "Show details from the last action",
659
+ command: "last-details",
660
+ });
452
661
  }
453
662
  options.push(
454
663
  { label: "Show all commands", command: "help" },
455
664
  { label: "Exit", command: "exit" },
456
665
  );
457
666
 
458
- console.log(
459
- [
460
- "",
461
- terminalStyle(flags, "1;36", "REHEARSAL"),
462
- "Safe local migration testing",
463
- "",
464
- terminalStyle(flags, "1", state.detected.projectName),
465
- `${marker(state.node.supported)} Node.js ${state.node.version}${state.node.supported ? "" : " (Node.js 24 required)"}`,
466
- `${marker(state.detected.hasSupabaseConfig)} Supabase project detected`,
467
- `${marker(state.detected.hasMigrations)} Migration history detected`,
468
- `${marker(state.config === "valid")} Rehearsal configuration${state.config === "invalid" ? " needs attention" : ""}`,
469
- ...(state.config === "valid"
470
- ? [
471
- `${marker(state.policy === "reviewed")} ${state.policy === "needs-review" ? "Baseline policy needs review" : state.policy === "reviewed" ? "Baseline policy reviewed" : "Baseline policy needed"}`,
472
- ]
473
- : []),
474
- `${marker(state.baseline)} Verified baseline`,
475
- ...(state.runtime ? [`${marker(true)} Disposable runtime created`] : []),
476
- ...(state.configError
477
- ? ["", terminalStyle(flags, "33", `! ${state.configError}`)]
478
- : []),
479
- "",
480
- "What would you like to do?",
481
- "",
482
- ...options.map(
483
- (option, index) =>
484
- ` ${index === 0 ? terminalStyle(flags, "36", "›") : " "} ${index + 1}. ${option.label}`,
485
- ),
486
- "",
487
- ].join("\n"),
488
- );
667
+ const statusLines = [
668
+ `${marker(state.node.supported)} Node.js ${state.node.version}${state.node.supported ? "" : " (Node.js 24 required)"}`,
669
+ `${marker(state.detected.hasSupabaseConfig)} Supabase project detected`,
670
+ `${marker(state.detected.hasMigrations)} Migration history detected`,
671
+ `${marker(state.config === "valid")} Rehearsal configuration${state.config === "invalid" ? " needs attention" : ""}`,
672
+ ...(state.config === "valid"
673
+ ? [
674
+ `${marker(state.policy === "reviewed")} ${state.policy === "needs-review" ? "Baseline policy needs review" : state.policy === "reviewed" ? "Baseline policy reviewed" : "Baseline policy needed"}`,
675
+ ]
676
+ : []),
677
+ `${marker(state.baseline)} Verified baseline`,
678
+ ...(state.runtime ? [`${marker(true)} Disposable runtime created`] : []),
679
+ ...(state.configError
680
+ ? ["", terminalStyle(flags, "33", `! ${state.configError}`)]
681
+ : []),
682
+ ];
683
+ const stage =
684
+ state.config !== "valid"
685
+ ? 1
686
+ : state.policy !== "reviewed"
687
+ ? 2
688
+ : !state.baseline
689
+ ? 3
690
+ : 4;
691
+ if (useStyledPrompts(flags)) {
692
+ prompts.note(
693
+ statusLines.join("\n"),
694
+ `${state.detected.projectName} · Stage ${stage} of 4`,
695
+ );
696
+ } else {
697
+ console.log(
698
+ [
699
+ "",
700
+ terminalStyle(flags, "1;36", "REHEARSAL"),
701
+ "Safe local migration testing",
702
+ "",
703
+ terminalStyle(flags, "1", state.detected.projectName),
704
+ `Stage ${stage} of 4`,
705
+ ...statusLines,
706
+ "",
707
+ "What would you like to do?",
708
+ "",
709
+ ...options.map(
710
+ (option, index) =>
711
+ ` ${index === 0 ? terminalStyle(flags, "36", "›") : " "} ${index + 1}. ${option.label}`,
712
+ ),
713
+ "",
714
+ ].join("\n"),
715
+ );
716
+ }
489
717
 
490
- const selected = await promptForChoice({ message: "Choose", options });
718
+ const selected = await promptForChoice({
719
+ message: "What would you like to do?",
720
+ options,
721
+ flags,
722
+ });
491
723
  if (selected.command === "exit") {
492
- console.log("No changes made.");
493
724
  return null;
494
725
  }
495
726
  if (selected.command === "node-help") {
@@ -506,20 +737,55 @@ const runGuidedHome = async ({ flags, planOptions }) => {
506
737
  "Then reinstall Rehearsal in your scratch project and run npx rehearsal again.",
507
738
  ].join("\n"),
508
739
  );
509
- return null;
740
+ return "refresh";
510
741
  }
511
742
  if (selected.command === "policy-review") {
512
- console.log(
513
- [
514
- "",
515
- terminalStyle(flags, "1", "REVIEW THE BASELINE POLICY"),
516
- `Open ${state.policyPath} and complete every REVIEW REQUIRED decision.`,
517
- "For each column, classify its sanitization action, generated status, identity behavior, and foreign key.",
518
- 'Remove "draft": true only after every decision has been reviewed.',
519
- "Then run rehearsal again to continue.",
520
- ].join("\n"),
521
- );
522
- return null;
743
+ try {
744
+ await reviewPolicyInteractively({ state, flags });
745
+ } catch (error) {
746
+ if (String(error?.message ?? error) !== "Policy review was cancelled.") {
747
+ throw error;
748
+ }
749
+ if (useStyledPrompts(flags)) {
750
+ prompts.cancel("Policy review cancelled; the draft was not changed.");
751
+ } else {
752
+ console.log("Policy review cancelled; the draft was not changed.");
753
+ }
754
+ }
755
+ return "refresh";
756
+ }
757
+ if (selected.command === "last-details") {
758
+ if (useStyledPrompts(flags)) {
759
+ prompts.note(lastGuidedDetails, "Technical details");
760
+ } else {
761
+ console.log(`\nTECHNICAL DETAILS\n${lastGuidedDetails}`);
762
+ }
763
+ return "refresh";
764
+ }
765
+ if (selected.command === "manage-runtime") {
766
+ const runtimeAction = await promptForChoice({
767
+ message: "Manage the disposable local runtime",
768
+ flags,
769
+ options: [
770
+ { label: "Show status", command: "status" },
771
+ { label: "Verify runtime", command: "verify" },
772
+ { label: "Reset to the immutable baseline", command: "reset" },
773
+ { label: "Stop runtime", command: "stop" },
774
+ { label: "Discard runtime", command: "discard" },
775
+ { label: "Back", command: "exit" },
776
+ ],
777
+ });
778
+ if (!runtimeAction || runtimeAction.command === "exit") return "refresh";
779
+ if (["reset", "discard"].includes(runtimeAction.command)) {
780
+ const accepted = await promptForConfirmation(
781
+ runtimeAction.command === "reset"
782
+ ? "Reset the disposable runtime and remove its current changes?"
783
+ : "Discard the disposable runtime? The immutable baseline will remain.",
784
+ flags,
785
+ );
786
+ if (!accepted) return "refresh";
787
+ }
788
+ return runtimeAction.command;
523
789
  }
524
790
  if (["baseline-guide", "baseline-prepare"].includes(selected.command)) {
525
791
  console.log(
@@ -541,10 +807,12 @@ const runGuidedHome = async ({ flags, planOptions }) => {
541
807
  flags.recordsPath = await promptForPath({
542
808
  message: "Sanitized NDJSON records",
543
809
  defaultValue: recordsDefault,
810
+ flags,
544
811
  });
545
812
  flags.ledgerPath = await promptForPath({
546
813
  message: "Migration ledger",
547
814
  defaultValue: ledgerDefault,
815
+ flags,
548
816
  });
549
817
  if (selected.command === "baseline-prepare") {
550
818
  const preparationPlan = await planBaselinePreparation({
@@ -562,10 +830,11 @@ const runGuidedHome = async ({ flags, planOptions }) => {
562
830
  console.log(`\n${renderBaselinePreparation(preview, flags)}\n`);
563
831
  const accepted = await promptForConfirmation(
564
832
  `Create the REVIEW REQUIRED draft at ${preview.destination}?`,
833
+ flags,
565
834
  );
566
835
  if (!accepted) {
567
836
  console.log("No changes made.");
568
- return null;
837
+ return "refresh";
569
838
  }
570
839
  flags.preparationPlan = preparationPlan;
571
840
  flags.write = true;
@@ -574,6 +843,7 @@ const runGuidedHome = async ({ flags, planOptions }) => {
574
843
  flags.assetsPath = await promptForPath({
575
844
  message: "Storage asset manifest (optional)",
576
845
  optional: true,
846
+ flags,
577
847
  });
578
848
  return "baseline create";
579
849
  }
@@ -588,10 +858,11 @@ const runGuidedHome = async ({ flags, planOptions }) => {
588
858
  console.log(`\n${renderSetup(preview)}\n`);
589
859
  const accepted = await promptForConfirmation(
590
860
  "Create these project-local files?",
861
+ flags,
591
862
  );
592
863
  if (!accepted) {
593
864
  console.log("No changes made.");
594
- return null;
865
+ return "refresh";
595
866
  }
596
867
  flags.setupPlan = setupPlan;
597
868
  flags.write = true;
@@ -652,6 +923,7 @@ const prepareCandidateConfirmation = async ({
652
923
  );
653
924
  const accepted = await promptForConfirmation(
654
925
  `Apply exactly ${summary.candidateCount === 1 ? "this migration" : "these migrations"}?`,
926
+ flags,
655
927
  );
656
928
  if (!accepted) {
657
929
  console.log("No changes made.");
@@ -709,7 +981,10 @@ const runManager = ({ action, flags }) => {
709
981
  discard: "Removing the disposable local runtime",
710
982
  verify: "Verifying the current local runtime",
711
983
  }[action];
712
- if (isHumanTerminal(flags)) {
984
+ const spinner = useStyledPrompts(flags) ? prompts.spinner() : null;
985
+ if (spinner) {
986
+ spinner.start(`${actionDescription}. This can take a moment.`);
987
+ } else if (isHumanTerminal(flags)) {
713
988
  console.log(
714
989
  `\n${terminalStyle(flags, "1;36", action === "run" ? "REHEARSING" : "REHEARSAL")}\n${terminalStyle(flags, "36", "→")} ${actionDescription}. This can take a moment.`,
715
990
  );
@@ -721,8 +996,12 @@ const runManager = ({ action, flags }) => {
721
996
  stdio: ["inherit", "pipe", "pipe"],
722
997
  maxBuffer: 16 * 1024 * 1024,
723
998
  });
724
- if (result.error) throw result.error;
999
+ if (result.error) {
1000
+ if (spinner) spinner.stop(`${actionDescription} did not complete.`);
1001
+ throw result.error;
1002
+ }
725
1003
  if (result.status !== 0) {
1004
+ if (spinner) spinner.stop(`${actionDescription} did not complete.`);
726
1005
  const completeOutput = [
727
1006
  String(result.stdout ?? ""),
728
1007
  String(result.stderr ?? ""),
@@ -757,7 +1036,11 @@ const runManager = ({ action, flags }) => {
757
1036
  });
758
1037
  }
759
1038
  const durationMs = performance.now() - runtimeStartedMs;
760
- if (isHumanTerminal(flags)) {
1039
+ if (spinner) {
1040
+ spinner.stop(
1041
+ `${actionDescription} completed in ${formatDuration(durationMs)}.`,
1042
+ );
1043
+ } else if (isHumanTerminal(flags)) {
761
1044
  console.log(
762
1045
  `${terminalStyle(flags, "32", "✓")} Local runtime step completed in ${formatDuration(durationMs)}.`,
763
1046
  );
@@ -913,7 +1196,9 @@ const runSetup = async ({ flags, planOptions }) => {
913
1196
  readiness.state === "READY"
914
1197
  ? "Next: run rehearsal explain to review the migration plan."
915
1198
  : needsBaselinePolicy
916
- ? "Next: run rehearsal again and choose Prepare a reviewable baseline policy draft."
1199
+ ? flags.guided
1200
+ ? "Next: choose Prepare the script to create a reviewable policy draft."
1201
+ : "Next: run rehearsal again and choose Prepare a reviewable baseline policy draft."
917
1202
  : "Next: complete the remaining readiness items shown above.",
918
1203
  };
919
1204
  };
@@ -998,9 +1283,16 @@ const runBaselinePreparation = async ({ flags, planOptions }) => {
998
1283
  ledgerPath: flags.ledgerPath,
999
1284
  }));
1000
1285
  if (flags.write) await applyBaselinePreparation(plan);
1001
- return summarizeBaselinePreparation(plan, {
1286
+ const result = summarizeBaselinePreparation(plan, {
1002
1287
  mode: flags.write ? "written" : "preview",
1003
1288
  });
1289
+ return flags.guided && flags.write
1290
+ ? {
1291
+ ...result,
1292
+ nextAction:
1293
+ "Next: choose Review the script to classify every column interactively.",
1294
+ }
1295
+ : result;
1004
1296
  };
1005
1297
 
1006
1298
  const renderBaselinePreparation = (result, flags = {}) =>
@@ -1047,29 +1339,7 @@ Commands:
1047
1339
 
1048
1340
  Options: --json --verbose --debug --plain --config=<path>`;
1049
1341
 
1050
- const main = async () => {
1051
- const { flags, positionals } = parseArguments(process.argv.slice(2));
1052
- let command = positionals.join(" ") || "help";
1053
- const planOptions = {
1054
- projectRoot,
1055
- configPath: flags.configPath,
1056
- };
1057
- const wantsAutomaticGuide =
1058
- positionals.length === 0 &&
1059
- !flags.help &&
1060
- !flags.json &&
1061
- process.stdin.isTTY &&
1062
- process.stdout.isTTY;
1063
- if (command === "guide" || wantsAutomaticGuide) {
1064
- if (!process.stdin.isTTY || !process.stdout.isTTY) {
1065
- throw new Error(
1066
- "The guided home screen requires an interactive terminal. Run rehearsal --help to list scriptable commands.",
1067
- );
1068
- }
1069
- const selected = await runGuidedHome({ flags, planOptions });
1070
- if (!selected) return;
1071
- command = selected;
1072
- }
1342
+ const executeCommand = async ({ command, flags, planOptions, guided }) => {
1073
1343
  if (command === "help" || flags.help) {
1074
1344
  console.log(usage());
1075
1345
  return;
@@ -1109,7 +1379,9 @@ const main = async () => {
1109
1379
  [
1110
1380
  `Activated synthetic baseline ${baseline.generationId}: ${formatCount(baseline.rowCount, "row")} across ${formatCount(baseline.tableCount, "table")}; ${formatCount(baseline.migrationCount, "migration")} through ${baseline.migrationCutoff}.`,
1111
1381
  "",
1112
- "Next: run rehearsal doctor to check readiness.",
1382
+ flags.guided
1383
+ ? "Next: choose Run a rehearsal when you are ready."
1384
+ : "Next: run rehearsal doctor to check readiness.",
1113
1385
  ].join("\n"),
1114
1386
  });
1115
1387
  return;
@@ -1123,7 +1395,7 @@ const main = async () => {
1123
1395
  render: renderDoctor,
1124
1396
  status: data.state === "READY" ? "success" : "not_ready",
1125
1397
  });
1126
- if (data.state !== "READY") process.exitCode = 1;
1398
+ if (data.state !== "READY" && !guided) process.exitCode = 1;
1127
1399
  return;
1128
1400
  }
1129
1401
  if (command === "explain" || (command === "run" && flags.dryRun)) {
@@ -1171,19 +1443,34 @@ const main = async () => {
1171
1443
  const runtime = runManager({ action: command, flags });
1172
1444
  let applicationProof;
1173
1445
  if (command === "run") {
1174
- if (isHumanTerminal(flags)) {
1446
+ const proofSpinner = useStyledPrompts(flags) ? prompts.spinner() : null;
1447
+ if (proofSpinner) {
1448
+ proofSpinner.start("Running the project-owned application proof.");
1449
+ } else if (isHumanTerminal(flags)) {
1175
1450
  console.log(
1176
1451
  `${terminalStyle(flags, "36", "→")} Running the project-owned application proof.`,
1177
1452
  );
1178
1453
  }
1179
- applicationProof = await runApplicationProof(planOptions);
1180
- if (isHumanTerminal(flags)) {
1454
+ try {
1455
+ applicationProof = await runApplicationProof(planOptions);
1456
+ } catch (error) {
1457
+ if (proofSpinner) proofSpinner.stop("Application proof failed.");
1458
+ throw error;
1459
+ }
1460
+ if (proofSpinner) {
1461
+ proofSpinner.stop("Application proof passed.");
1462
+ } else if (isHumanTerminal(flags)) {
1181
1463
  console.log(
1182
1464
  `${terminalStyle(flags, "32", "✓")} Application proof passed.`,
1183
1465
  );
1184
1466
  }
1185
1467
  }
1186
1468
  const data = command === "run" ? { runtime, applicationProof } : runtime;
1469
+ if (guided) {
1470
+ lastGuidedDetails = [runtime.output, applicationProof?.output]
1471
+ .filter(Boolean)
1472
+ .join("\n");
1473
+ }
1187
1474
  emit({
1188
1475
  command,
1189
1476
  data,
@@ -1203,6 +1490,23 @@ const main = async () => {
1203
1490
  verify:
1204
1491
  "Next: continue testing, reset to the baseline, or stop the runtime.",
1205
1492
  }[runtimeResult.action];
1493
+ if (guided) {
1494
+ const title =
1495
+ runtimeResult.action === "run"
1496
+ ? "REHEARSAL PASSED"
1497
+ : "ACTION COMPLETE";
1498
+ return [
1499
+ title,
1500
+ `✓ ${runtimeResult.action} completed in ${formatDuration(runtimeResult.durationMs)}`,
1501
+ value.applicationProof
1502
+ ? `✓ Application proof passed: ${value.applicationProof.command}`
1503
+ : null,
1504
+ "",
1505
+ "Technical output is available from Show details in the guide.",
1506
+ ]
1507
+ .filter(Boolean)
1508
+ .join("\n");
1509
+ }
1206
1510
  return [
1207
1511
  runtimeResult.output ||
1208
1512
  `Rehearsal ${runtimeResult.action} completed.`,
@@ -1221,6 +1525,63 @@ const main = async () => {
1221
1525
  throw new Error(`Unknown Rehearsal command: ${command}.\n\n${usage()}`);
1222
1526
  };
1223
1527
 
1528
+ const resetGuidedFlags = (flags) => {
1529
+ flags.dryRun = false;
1530
+ flags.write = false;
1531
+ flags.confirmation = undefined;
1532
+ flags.recordsPath = undefined;
1533
+ flags.ledgerPath = undefined;
1534
+ flags.assetsPath = undefined;
1535
+ flags.setupPlan = undefined;
1536
+ flags.preparationPlan = undefined;
1537
+ };
1538
+
1539
+ const main = async () => {
1540
+ const { flags, positionals } = parseArguments(process.argv.slice(2));
1541
+ const command = positionals.join(" ") || "help";
1542
+ const planOptions = {
1543
+ projectRoot,
1544
+ configPath: flags.configPath,
1545
+ };
1546
+ const wantsAutomaticGuide =
1547
+ positionals.length === 0 &&
1548
+ !flags.help &&
1549
+ !flags.json &&
1550
+ process.stdin.isTTY &&
1551
+ process.stdout.isTTY;
1552
+ const guided = command === "guide" || wantsAutomaticGuide;
1553
+ if (!guided) {
1554
+ await executeCommand({ command, flags, planOptions, guided: false });
1555
+ return;
1556
+ }
1557
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
1558
+ throw new Error(
1559
+ "The guided home screen requires an interactive terminal. Run rehearsal --help to list scriptable commands.",
1560
+ );
1561
+ }
1562
+ flags.guided = true;
1563
+ if (useStyledPrompts(flags)) {
1564
+ prompts.intro("REHEARSAL · Safe local migration testing");
1565
+ }
1566
+ while (true) {
1567
+ resetGuidedFlags(flags);
1568
+ const selected = await runGuidedHome({ flags, planOptions });
1569
+ if (!selected) break;
1570
+ if (selected === "refresh") continue;
1571
+ await executeCommand({
1572
+ command: selected,
1573
+ flags,
1574
+ planOptions,
1575
+ guided: true,
1576
+ });
1577
+ }
1578
+ if (useStyledPrompts(flags)) {
1579
+ prompts.outro("See you at the next rehearsal.");
1580
+ } else {
1581
+ console.log("See you at the next rehearsal.");
1582
+ }
1583
+ };
1584
+
1224
1585
  try {
1225
1586
  await main();
1226
1587
  } catch (error) {