@rehearsal-db/core 0.1.0-beta.2 → 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.
@@ -6,10 +6,12 @@
6
6
  */
7
7
 
8
8
  import { spawnSync } from "node:child_process";
9
- import { writeFile } from "node:fs/promises";
9
+ import { access, readFile, writeFile } from "node:fs/promises";
10
10
  import { join, relative } from "node:path";
11
11
  import { performance } from "node:perf_hooks";
12
+ import { createInterface } from "node:readline/promises";
12
13
  import { fileURLToPath } from "node:url";
14
+ import * as prompts from "@clack/prompts";
13
15
  import {
14
16
  RehearsalError,
15
17
  createRehearsalResult,
@@ -23,6 +25,7 @@ import {
23
25
  findRehearsalConfigPath,
24
26
  loadRehearsalConfig,
25
27
  renderDetectedConfig,
28
+ validateRuntimeSanitizationPolicy,
26
29
  } from "../../lib/rehearsal/configuration.mjs";
27
30
  import {
28
31
  buildRehearsalPlan,
@@ -31,6 +34,23 @@ import {
31
34
  runRehearsalDoctor,
32
35
  } from "../../lib/rehearsal/plan.mjs";
33
36
  import { createSyntheticBaselineFromFiles } from "../../lib/rehearsal/baseline_builder.mjs";
37
+ import {
38
+ applyBaselinePreparation,
39
+ planBaselinePreparation,
40
+ summarizeBaselinePreparation,
41
+ } from "../../lib/rehearsal/baseline_preparation.mjs";
42
+ import {
43
+ applyRehearsalSetup,
44
+ inspectRehearsalNodeRuntime,
45
+ planRehearsalSetup,
46
+ summarizeRehearsalSetup,
47
+ } from "../../lib/rehearsal/setup.mjs";
48
+ import { formatCount } from "../../lib/rehearsal/human_output.mjs";
49
+ import {
50
+ applyReviewedPolicy,
51
+ completePolicyDraft,
52
+ readReviewablePolicyDraft,
53
+ } from "../../lib/rehearsal/policy_review.mjs";
34
54
 
35
55
  const packageRoot = fileURLToPath(new URL("../../..", import.meta.url));
36
56
  const projectRoot = process.cwd();
@@ -40,6 +60,7 @@ const managerPath = join(
40
60
  packageRoot,
41
61
  "scripts/operations/database/manage_rehearsal_database.mjs",
42
62
  );
63
+ let lastGuidedDetails;
43
64
 
44
65
  const parseArguments = (arguments_) => {
45
66
  const flags = {
@@ -48,6 +69,7 @@ const parseArguments = (arguments_) => {
48
69
  verbosity: "normal",
49
70
  dryRun: false,
50
71
  write: false,
72
+ plain: false,
51
73
  configPath: undefined,
52
74
  confirmation: undefined,
53
75
  recordsPath: undefined,
@@ -62,6 +84,7 @@ const parseArguments = (arguments_) => {
62
84
  else if (argument === "--debug") flags.verbosity = "debug";
63
85
  else if (argument === "--dry-run") flags.dryRun = true;
64
86
  else if (argument === "--write") flags.write = true;
87
+ else if (argument === "--plain") flags.plain = true;
65
88
  else if (argument.startsWith("--config=")) {
66
89
  flags.configPath = argument.slice("--config=".length);
67
90
  } else if (argument.startsWith("--confirm-candidates=")) {
@@ -109,7 +132,7 @@ const renderPlan = (plan, verbosity) => {
109
132
  "",
110
133
  "Baseline",
111
134
  ` ✓ ${plan.baseline.generationId}`,
112
- ` ✓ ${plan.baseline.tableCount} tables; ${plan.baseline.rowCount} rows`,
135
+ ` ✓ ${formatCount(plan.baseline.tableCount, "table")}; ${formatCount(plan.baseline.rowCount, "row")}`,
113
136
  ` ✓ ${plan.baseline.verification}`,
114
137
  "",
115
138
  "Migrations",
@@ -121,6 +144,10 @@ const renderPlan = (plan, verbosity) => {
121
144
  ...verbose,
122
145
  "",
123
146
  plan.guarantee,
147
+ "",
148
+ plan.migrations.candidateCount
149
+ ? "Next: run rehearsal run to review and confirm this exact candidate set."
150
+ : "Next: run rehearsal run to verify the baseline and application proof.",
124
151
  ].join("\n");
125
152
  };
126
153
 
@@ -138,12 +165,20 @@ const renderDoctor = (doctor, verbosity) => {
138
165
  )) {
139
166
  if (check.remediation) lines.push(` Try: ${check.remediation}`);
140
167
  }
141
- if (doctor.ambientHostedVariables.presentButQuarantined.length) {
168
+ if (doctor.ambientHostedVariables?.presentButQuarantined.length) {
142
169
  lines.push(
143
170
  `! ${doctor.ambientHostedVariables.presentButQuarantined.length} ambient hosted credential variables detected and quarantined.`,
144
171
  );
145
172
  }
146
- return [...lines, "", doctor.state].join("\n");
173
+ return [
174
+ ...lines,
175
+ "",
176
+ doctor.state,
177
+ "",
178
+ doctor.state === "READY"
179
+ ? "Next: run rehearsal explain to review the migration plan."
180
+ : "Next: fix the items above, then run rehearsal doctor again.",
181
+ ].join("\n");
147
182
  };
148
183
 
149
184
  const renderBaseline = (baseline) =>
@@ -201,8 +236,705 @@ const renderCandidates = (summary, verbosity) =>
201
236
  : ` → ${migration.filename} (${migration.sha256})`,
202
237
  )
203
238
  : [" ✓ No candidate migrations"]),
239
+ "",
240
+ summary.candidateCount
241
+ ? "Next: run rehearsal run for a guided confirmation, or pass the full digest in automation."
242
+ : "Next: run rehearsal run to verify the baseline and application proof.",
204
243
  ].join("\n");
205
244
 
245
+ const pathExists = (path) =>
246
+ access(path)
247
+ .then(() => true)
248
+ .catch((error) => {
249
+ if (error?.code === "ENOENT") return false;
250
+ throw error;
251
+ });
252
+
253
+ const useColor = (flags) =>
254
+ !flags.plain &&
255
+ process.stdout.isTTY &&
256
+ process.env.NO_COLOR === undefined &&
257
+ process.env.TERM !== "dumb";
258
+
259
+ const terminalStyle = (flags, code, value) =>
260
+ useColor(flags) ? `\u001B[${code}m${value}\u001B[0m` : value;
261
+
262
+ const isHumanTerminal = (flags) =>
263
+ !flags.json && process.stdin.isTTY && process.stdout.isTTY;
264
+
265
+ const useStyledPrompts = (flags) => isHumanTerminal(flags) && useColor(flags);
266
+
267
+ const formatDuration = (durationMs) =>
268
+ durationMs < 1_000
269
+ ? `${Math.round(durationMs)}ms`
270
+ : `${(durationMs / 1_000).toFixed(1)}s`;
271
+
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
+ }
286
+ const prompt = createInterface({
287
+ input: process.stdin,
288
+ output: process.stdout,
289
+ });
290
+ try {
291
+ while (true) {
292
+ const answer = (await prompt.question(`${message} [1]: `)).trim();
293
+ const selected = answer === "" ? 1 : Number(answer);
294
+ if (
295
+ Number.isInteger(selected) &&
296
+ selected >= 1 &&
297
+ selected <= options.length
298
+ ) {
299
+ return options[selected - 1];
300
+ }
301
+ console.log(`Choose a number from 1 to ${options.length}.`);
302
+ }
303
+ } finally {
304
+ prompt.close();
305
+ }
306
+ };
307
+
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
+ }
317
+ const prompt = createInterface({
318
+ input: process.stdin,
319
+ output: process.stdout,
320
+ });
321
+ try {
322
+ const answer = (await prompt.question(`${message} (y/N) `))
323
+ .trim()
324
+ .toLowerCase();
325
+ return answer === "y" || answer === "yes";
326
+ } finally {
327
+ prompt.close();
328
+ }
329
+ };
330
+
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
+ }
352
+ const prompt = createInterface({
353
+ input: process.stdin,
354
+ output: process.stdout,
355
+ });
356
+ try {
357
+ while (true) {
358
+ const suffix = defaultValue
359
+ ? ` [${defaultValue}]`
360
+ : optional
361
+ ? ""
362
+ : " (required)";
363
+ const answer = (await prompt.question(`${message}${suffix}: `)).trim();
364
+ const value = answer || defaultValue || "";
365
+ const invalid = value && validate ? validate(value) : undefined;
366
+ if (invalid) {
367
+ console.log(invalid);
368
+ continue;
369
+ }
370
+ if (value || optional) return value || undefined;
371
+ console.log("Enter a project-relative path.");
372
+ }
373
+ } finally {
374
+ prompt.close();
375
+ }
376
+ };
377
+
378
+ const promptForSelection = async ({ message, options, flags }) => {
379
+ const selected = await promptForChoice({ message, options, flags });
380
+ return selected?.value;
381
+ };
382
+
383
+ const firstExistingPath = async (candidates) => {
384
+ for (const candidate of candidates) {
385
+ if (await pathExists(join(projectRoot, candidate))) return candidate;
386
+ }
387
+ return undefined;
388
+ };
389
+
390
+ const inspectGuidedProject = async (planOptions) => {
391
+ const detected = await inspectDetectedProject({ projectRoot });
392
+ const node = inspectRehearsalNodeRuntime();
393
+ let configPath;
394
+ try {
395
+ configPath = await findRehearsalConfigPath(planOptions);
396
+ } catch (error) {
397
+ if (
398
+ !String(error?.message ?? error).startsWith("No Rehearsal configuration")
399
+ ) {
400
+ throw error;
401
+ }
402
+ }
403
+ if (!configPath) {
404
+ return {
405
+ detected,
406
+ node,
407
+ config: "missing",
408
+ baseline: false,
409
+ runtime: false,
410
+ };
411
+ }
412
+ let loaded;
413
+ try {
414
+ loaded = await loadRehearsalConfig(planOptions);
415
+ } catch (error) {
416
+ return {
417
+ detected,
418
+ node,
419
+ config: "invalid",
420
+ configPath,
421
+ configError: redactDiagnosticValue(String(error?.message ?? error)),
422
+ baseline: false,
423
+ runtime: false,
424
+ };
425
+ }
426
+ let migrationSummary;
427
+ try {
428
+ migrationSummary = candidateSummary(
429
+ await inspectRehearsalMigrations(planOptions),
430
+ );
431
+ } catch {
432
+ // Doctor owns the detailed, safely redacted explanation for an invalid or
433
+ // absent baseline. The home screen only needs enough state to guide users.
434
+ }
435
+ let policy = "missing";
436
+ if (await pathExists(loaded.paths.sanitizationPolicy)) {
437
+ try {
438
+ validateRuntimeSanitizationPolicy(
439
+ JSON.parse(await readFile(loaded.paths.sanitizationPolicy, "utf8")),
440
+ );
441
+ policy = "reviewed";
442
+ } catch {
443
+ policy = "needs-review";
444
+ }
445
+ }
446
+ return {
447
+ detected,
448
+ node,
449
+ config: "valid",
450
+ configPath,
451
+ baseline: Boolean(migrationSummary),
452
+ policy,
453
+ policyPath: relative(projectRoot, loaded.paths.sanitizationPolicy),
454
+ policyAbsolutePath: loaded.paths.sanitizationPolicy,
455
+ migrationSummary,
456
+ runtime: await pathExists(
457
+ join(loaded.paths.runtimeWorkdir, "baseline.json"),
458
+ ),
459
+ };
460
+ };
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
+
600
+ const runGuidedHome = async ({ flags, planOptions }) => {
601
+ const state = await inspectGuidedProject(planOptions);
602
+ const marker = (complete) =>
603
+ complete ? terminalStyle(flags, "32", "✓") : terminalStyle(flags, "2", "○");
604
+ const options = [];
605
+
606
+ if (!state.node.supported) {
607
+ options.push({
608
+ label: "Show Node.js 24 setup instructions",
609
+ hint: "Required before Rehearsal can change this project",
610
+ command: "node-help",
611
+ });
612
+ } else if (state.config === "missing") {
613
+ options.push({
614
+ label: "Set the stage",
615
+ hint: "Create safe local configuration",
616
+ command: "setup-write",
617
+ });
618
+ } else if (state.baseline) {
619
+ options.push(
620
+ { label: "Run a rehearsal", command: "run" },
621
+ { label: "Review the migration plan", command: "explain" },
622
+ { label: "Show candidate migrations", command: "candidates" },
623
+ { label: "Check readiness", command: "doctor" },
624
+ );
625
+ } else if (state.node.supported && state.config === "valid") {
626
+ if (state.policy === "missing") {
627
+ options.push({
628
+ label: "Prepare the script",
629
+ hint: "Create a reviewable policy draft",
630
+ command: "baseline-prepare",
631
+ });
632
+ } else if (state.policy === "needs-review") {
633
+ options.push({
634
+ label: "Review the script",
635
+ hint: "Classify every column interactively",
636
+ command: "policy-review",
637
+ });
638
+ } else {
639
+ options.push({
640
+ label: "Create the baseline",
641
+ hint: "Activate reviewed local inputs",
642
+ command: "baseline-guide",
643
+ });
644
+ }
645
+ options.push({ label: "Check readiness", command: "doctor" });
646
+ } else {
647
+ options.push({ label: "Check readiness", command: "doctor" });
648
+ }
649
+ if (state.node.supported && state.runtime) {
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
+ });
661
+ }
662
+ options.push(
663
+ { label: "Show all commands", command: "help" },
664
+ { label: "Exit", command: "exit" },
665
+ );
666
+
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
+ }
717
+
718
+ const selected = await promptForChoice({
719
+ message: "What would you like to do?",
720
+ options,
721
+ flags,
722
+ });
723
+ if (selected.command === "exit") {
724
+ return null;
725
+ }
726
+ if (selected.command === "node-help") {
727
+ console.log(
728
+ [
729
+ "",
730
+ terminalStyle(flags, "1", "NODE.JS 24 REQUIRED"),
731
+ `This shell is using Node.js ${state.node.version}. Rehearsal has not changed your project.`,
732
+ "",
733
+ "With nvm:",
734
+ " nvm install 24",
735
+ " nvm use 24",
736
+ "",
737
+ "Then reinstall Rehearsal in your scratch project and run npx rehearsal again.",
738
+ ].join("\n"),
739
+ );
740
+ return "refresh";
741
+ }
742
+ if (selected.command === "policy-review") {
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;
789
+ }
790
+ if (["baseline-guide", "baseline-prepare"].includes(selected.command)) {
791
+ console.log(
792
+ [
793
+ "",
794
+ terminalStyle(flags, "1", "CREATE A SAFE BASELINE"),
795
+ "Rehearsal only reads the local files you name here.",
796
+ "It does not extract data or contact a hosted Supabase project.",
797
+ "",
798
+ ].join("\n"),
799
+ );
800
+ const recordsDefault = await firstExistingPath([
801
+ "rehearsal/sanitized-data.ndjson",
802
+ "rehearsal/synthetic-data.ndjson",
803
+ ]);
804
+ const ledgerDefault = await firstExistingPath([
805
+ "rehearsal/migration-ledger.json",
806
+ ]);
807
+ flags.recordsPath = await promptForPath({
808
+ message: "Sanitized NDJSON records",
809
+ defaultValue: recordsDefault,
810
+ flags,
811
+ });
812
+ flags.ledgerPath = await promptForPath({
813
+ message: "Migration ledger",
814
+ defaultValue: ledgerDefault,
815
+ flags,
816
+ });
817
+ if (selected.command === "baseline-prepare") {
818
+ const preparationPlan = await planBaselinePreparation({
819
+ ...planOptions,
820
+ recordsPath: flags.recordsPath,
821
+ ledgerPath: flags.ledgerPath,
822
+ });
823
+ const preview = {
824
+ ...summarizeBaselinePreparation(preparationPlan, {
825
+ mode: "preview",
826
+ }),
827
+ nextAction:
828
+ "Review this schema-only summary. No file is written unless you approve below.",
829
+ };
830
+ console.log(`\n${renderBaselinePreparation(preview, flags)}\n`);
831
+ const accepted = await promptForConfirmation(
832
+ `Create the REVIEW REQUIRED draft at ${preview.destination}?`,
833
+ flags,
834
+ );
835
+ if (!accepted) {
836
+ console.log("No changes made.");
837
+ return "refresh";
838
+ }
839
+ flags.preparationPlan = preparationPlan;
840
+ flags.write = true;
841
+ return "baseline prepare";
842
+ }
843
+ flags.assetsPath = await promptForPath({
844
+ message: "Storage asset manifest (optional)",
845
+ optional: true,
846
+ flags,
847
+ });
848
+ return "baseline create";
849
+ }
850
+ if (selected.command !== "setup-write") return selected.command;
851
+
852
+ const setupPlan = await planRehearsalSetup({ projectRoot });
853
+ const preview = {
854
+ ...summarizeRehearsalSetup(setupPlan, { mode: "preview" }),
855
+ nextAction:
856
+ "Review this plan. No files are written unless you approve below.",
857
+ };
858
+ console.log(`\n${renderSetup(preview)}\n`);
859
+ const accepted = await promptForConfirmation(
860
+ "Create these project-local files?",
861
+ flags,
862
+ );
863
+ if (!accepted) {
864
+ console.log("No changes made.");
865
+ return "refresh";
866
+ }
867
+ flags.setupPlan = setupPlan;
868
+ flags.write = true;
869
+ return "setup";
870
+ };
871
+
872
+ const prepareCandidateConfirmation = async ({
873
+ command,
874
+ flags,
875
+ planOptions,
876
+ }) => {
877
+ if (!["run", "migrate"].includes(command) || flags.dryRun) {
878
+ return true;
879
+ }
880
+ const summary = candidateSummary(
881
+ await inspectRehearsalMigrations(planOptions),
882
+ );
883
+ if (summary.candidateCount === 0) return true;
884
+ if (flags.confirmation === summary.candidateSha256) return true;
885
+ const interactive =
886
+ !flags.json && process.stdin.isTTY && process.stdout.isTTY;
887
+ if (!interactive || flags.confirmation) {
888
+ throw new RehearsalError({
889
+ category: "migration_candidate_failure",
890
+ code: "CANDIDATE_CONFIRMATION_REQUIRED",
891
+ message: flags.confirmation
892
+ ? "The candidate migration confirmation does not match the current candidate set."
893
+ : "Candidate migration confirmation is required in noninteractive use.",
894
+ expected: summary.candidateSha256,
895
+ actual: flags.confirmation ?? "missing",
896
+ context: {
897
+ command,
898
+ candidateCount: summary.candidateCount,
899
+ candidates: summary.candidates.map((migration) => migration.filename),
900
+ },
901
+ refused:
902
+ "Rehearsal did not reset, start, or modify the disposable runtime.",
903
+ suggestions: [
904
+ "Run rehearsal candidates, review every file, then pass the current digest with --confirm-candidates=.",
905
+ ],
906
+ });
907
+ }
908
+ console.log(
909
+ [
910
+ "",
911
+ terminalStyle(
912
+ flags,
913
+ "1",
914
+ `${summary.candidateCount} candidate migration${summary.candidateCount === 1 ? "" : "s"}`,
915
+ ),
916
+ "",
917
+ ...summary.candidates.map((migration) => ` → ${migration.filename}`),
918
+ "",
919
+ `Candidate set: ${summary.candidateSha256.slice(0, 12)}`,
920
+ "",
921
+ "Rehearsal will apply only this exact set to the disposable local runtime.",
922
+ ].join("\n"),
923
+ );
924
+ const accepted = await promptForConfirmation(
925
+ `Apply exactly ${summary.candidateCount === 1 ? "this migration" : "these migrations"}?`,
926
+ flags,
927
+ );
928
+ if (!accepted) {
929
+ console.log("No changes made.");
930
+ return false;
931
+ }
932
+ // The manager independently recomputes this digest. A file changed after this
933
+ // review is therefore rejected before candidate SQL is applied.
934
+ flags.confirmation = summary.candidateSha256;
935
+ return true;
936
+ };
937
+
206
938
  const emit = ({ command, data, flags, render, status = "success" }) => {
207
939
  const result = createRehearsalResult({
208
940
  command,
@@ -238,6 +970,25 @@ const runManager = ({ action, flags }) => {
238
970
  process.env[key] === undefined ? [] : [[key, process.env[key]]],
239
971
  ),
240
972
  );
973
+ const runtimeStartedMs = performance.now();
974
+ const actionDescription = {
975
+ run: "Restoring the baseline and applying reviewed migrations",
976
+ start: "Starting the disposable local runtime",
977
+ migrate: "Applying reviewed migrations to the current runtime",
978
+ reset: "Restoring the immutable baseline",
979
+ status: "Inspecting the disposable local runtime",
980
+ stop: "Stopping the disposable local runtime",
981
+ discard: "Removing the disposable local runtime",
982
+ verify: "Verifying the current local runtime",
983
+ }[action];
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)) {
988
+ console.log(
989
+ `\n${terminalStyle(flags, "1;36", action === "run" ? "REHEARSING" : "REHEARSAL")}\n${terminalStyle(flags, "36", "→")} ${actionDescription}. This can take a moment.`,
990
+ );
991
+ }
241
992
  const result = spawnSync(process.execPath, args, {
242
993
  cwd: projectRoot,
243
994
  encoding: "utf8",
@@ -245,8 +996,12 @@ const runManager = ({ action, flags }) => {
245
996
  stdio: ["inherit", "pipe", "pipe"],
246
997
  maxBuffer: 16 * 1024 * 1024,
247
998
  });
248
- if (result.error) throw result.error;
999
+ if (result.error) {
1000
+ if (spinner) spinner.stop(`${actionDescription} did not complete.`);
1001
+ throw result.error;
1002
+ }
249
1003
  if (result.status !== 0) {
1004
+ if (spinner) spinner.stop(`${actionDescription} did not complete.`);
250
1005
  const completeOutput = [
251
1006
  String(result.stdout ?? ""),
252
1007
  String(result.stderr ?? ""),
@@ -280,8 +1035,19 @@ const runManager = ({ action, flags }) => {
280
1035
  cause: new Error(completeOutput || `exit ${result.status}`),
281
1036
  });
282
1037
  }
1038
+ const durationMs = performance.now() - runtimeStartedMs;
1039
+ if (spinner) {
1040
+ spinner.stop(
1041
+ `${actionDescription} completed in ${formatDuration(durationMs)}.`,
1042
+ );
1043
+ } else if (isHumanTerminal(flags)) {
1044
+ console.log(
1045
+ `${terminalStyle(flags, "32", "✓")} Local runtime step completed in ${formatDuration(durationMs)}.`,
1046
+ );
1047
+ }
283
1048
  return {
284
1049
  action,
1050
+ durationMs: Math.round(durationMs * 100) / 100,
285
1051
  output: redactDiagnosticValue(String(result.stdout ?? "").trim()),
286
1052
  };
287
1053
  };
@@ -412,10 +1178,149 @@ const renderInit = (result) =>
412
1178
  result.nextAction,
413
1179
  ].join("\n");
414
1180
 
1181
+ const runSetup = async ({ flags, planOptions }) => {
1182
+ const plan = flags.setupPlan ?? (await planRehearsalSetup({ projectRoot }));
1183
+ if (flags.write) await applyRehearsalSetup(plan);
1184
+ const result = summarizeRehearsalSetup(plan, {
1185
+ mode: flags.write ? "written" : "preview",
1186
+ });
1187
+ if (!flags.write) return result;
1188
+ const readiness = await runRehearsalDoctor(planOptions);
1189
+ const needsBaselinePolicy = readiness.checks.some(
1190
+ (check) => check.id === "sanitization-policy" && check.status === "fail",
1191
+ );
1192
+ return {
1193
+ ...result,
1194
+ readiness,
1195
+ nextAction:
1196
+ readiness.state === "READY"
1197
+ ? "Next: run rehearsal explain to review the migration plan."
1198
+ : needsBaselinePolicy
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."
1202
+ : "Next: complete the remaining readiness items shown above.",
1203
+ };
1204
+ };
1205
+
1206
+ const summarizeSetupReadinessChecks = (readiness) => {
1207
+ const failures = readiness.checks.filter((check) => check.status === "fail");
1208
+ const policyMissing = failures.some(
1209
+ (check) =>
1210
+ check.id === "sanitization-policy" &&
1211
+ /ENOENT|no such file/iu.test(check.detail),
1212
+ );
1213
+ const baselineMissing = failures.some(
1214
+ (check) =>
1215
+ check.id === "baseline" && /ENOENT|no such file/iu.test(check.detail),
1216
+ );
1217
+ const hidden = new Set([
1218
+ ...(policyMissing ? ["sanitization-policy"] : []),
1219
+ ...(baselineMissing
1220
+ ? ["baseline", "baseline-permissions", "migration-history"]
1221
+ : []),
1222
+ ]);
1223
+ if (
1224
+ policyMissing &&
1225
+ failures.some(
1226
+ (check) =>
1227
+ check.id === "paths" && /sanitization-policy/iu.test(check.detail),
1228
+ )
1229
+ ) {
1230
+ hidden.add("paths");
1231
+ }
1232
+ const passed = readiness.checks.filter(
1233
+ (check) => check.status === "pass",
1234
+ ).length;
1235
+ return [
1236
+ ...(passed ? [` ✓ ${passed} environment and safety checks passed`] : []),
1237
+ ...failures
1238
+ .filter((check) => !hidden.has(check.id))
1239
+ .map((check) => ` ○ ${check.label}: ${check.detail}`),
1240
+ ...(policyMissing
1241
+ ? [" ○ Baseline policy: not created yet (next guided step)"]
1242
+ : []),
1243
+ ...(baselineMissing
1244
+ ? [" ○ Verified baseline: not created yet (after policy review)"]
1245
+ : []),
1246
+ ];
1247
+ };
1248
+
1249
+ const renderSetup = (result) => {
1250
+ const actionMarker = { create: "+", update: "~", unchanged: "=" };
1251
+ return [
1252
+ `REHEARSAL SETUP — ${result.mode.toUpperCase()}`,
1253
+ `Project: ${result.project}`,
1254
+ `Runtime ID: ${result.projectId}`,
1255
+ `Application: ${result.applicationUrl}`,
1256
+ `Ports: API ${result.ports.api}, database ${result.ports.database}, Studio ${result.ports.studio}`,
1257
+ "",
1258
+ "Files",
1259
+ ...result.files.map(
1260
+ (file) => ` ${actionMarker[file.action]} ${file.action} ${file.path}`,
1261
+ ),
1262
+ "",
1263
+ "Safety boundaries",
1264
+ ...result.safety.map((barrier) => ` ✓ ${barrier}`),
1265
+ ...(result.readiness
1266
+ ? [
1267
+ "",
1268
+ `Readiness — ${result.readiness.state}`,
1269
+ ...summarizeSetupReadinessChecks(result.readiness),
1270
+ ]
1271
+ : []),
1272
+ "",
1273
+ result.nextAction,
1274
+ ].join("\n");
1275
+ };
1276
+
1277
+ const runBaselinePreparation = async ({ flags, planOptions }) => {
1278
+ const plan =
1279
+ flags.preparationPlan ??
1280
+ (await planBaselinePreparation({
1281
+ ...planOptions,
1282
+ recordsPath: flags.recordsPath,
1283
+ ledgerPath: flags.ledgerPath,
1284
+ }));
1285
+ if (flags.write) await applyBaselinePreparation(plan);
1286
+ const result = summarizeBaselinePreparation(plan, {
1287
+ mode: flags.write ? "written" : "preview",
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;
1296
+ };
1297
+
1298
+ const renderBaselinePreparation = (result, flags = {}) =>
1299
+ [
1300
+ `BASELINE POLICY — ${result.mode.toUpperCase()}`,
1301
+ `Destination: ${result.destination}`,
1302
+ `Records: ${formatCount(result.rowCount, "row")} from ${result.recordsPath}`,
1303
+ `Migrations: ${formatCount(result.migrationCount, "migration")} through ${result.migrationCutoff}`,
1304
+ "",
1305
+ "Detected shape (values are never printed)",
1306
+ ...result.tables.map(
1307
+ (table) =>
1308
+ ` ${terminalStyle(flags, "36", "→")} ${table.name}: ${formatCount(table.rowCount, "row")}; ${table.columns.join(", ")}`,
1309
+ ),
1310
+ "",
1311
+ "Every column is marked REVIEW REQUIRED for action, generated status, identity, and foreign key metadata.",
1312
+ "The draft cannot be activated until those decisions are completed.",
1313
+ "",
1314
+ result.nextAction,
1315
+ ].join("\n");
1316
+
415
1317
  const usage = () => `Usage: rehearsal <command> [options]
416
1318
 
417
1319
  Commands:
1320
+ guide Open the interactive, state-aware home screen
1321
+ setup [--write] Preview or create safe first-run scaffolding
418
1322
  init [--write] Preview or explicitly write safe starter config
1323
+ baseline prepare --records= --ledger= [--write] Create a fail-closed policy draft
419
1324
  baseline create --records= --ledger= [--assets=] Create a baseline from safe local inputs
420
1325
  doctor Check whether Rehearsal is safe and ready
421
1326
  explain Show the immutable execution plan
@@ -432,24 +1337,33 @@ Commands:
432
1337
  discard Remove only this project's disposable runtime
433
1338
  verify Verify the current local Rehearsal runtime
434
1339
 
435
- Options: --json --verbose --debug --config=<path>`;
1340
+ Options: --json --verbose --debug --plain --config=<path>`;
436
1341
 
437
- const main = async () => {
438
- const { flags, positionals } = parseArguments(process.argv.slice(2));
439
- const command = positionals.join(" ") || "help";
440
- const planOptions = {
441
- projectRoot,
442
- configPath: flags.configPath,
443
- };
1342
+ const executeCommand = async ({ command, flags, planOptions, guided }) => {
444
1343
  if (command === "help" || flags.help) {
445
1344
  console.log(usage());
446
1345
  return;
447
1346
  }
1347
+ if (command === "setup") {
1348
+ const data = await runSetup({ flags, planOptions });
1349
+ emit({ command, data, flags, render: renderSetup });
1350
+ return;
1351
+ }
448
1352
  if (command === "init") {
449
1353
  const data = await runInit({ flags });
450
1354
  emit({ command, data, flags, render: renderInit });
451
1355
  return;
452
1356
  }
1357
+ if (command === "baseline prepare") {
1358
+ const data = await runBaselinePreparation({ flags, planOptions });
1359
+ emit({
1360
+ command,
1361
+ data,
1362
+ flags,
1363
+ render: (result) => renderBaselinePreparation(result, flags),
1364
+ });
1365
+ return;
1366
+ }
453
1367
  if (command === "baseline create") {
454
1368
  const data = await createSyntheticBaselineFromFiles({
455
1369
  ...planOptions,
@@ -462,7 +1376,13 @@ const main = async () => {
462
1376
  data,
463
1377
  flags,
464
1378
  render: (baseline) =>
465
- `Activated synthetic baseline ${baseline.generationId}: ${baseline.rowCount} rows across ${baseline.tableCount} tables; ${baseline.migrationCount} migrations through ${baseline.migrationCutoff}.`,
1379
+ [
1380
+ `Activated synthetic baseline ${baseline.generationId}: ${formatCount(baseline.rowCount, "row")} across ${formatCount(baseline.tableCount, "table")}; ${formatCount(baseline.migrationCount, "migration")} through ${baseline.migrationCutoff}.`,
1381
+ "",
1382
+ flags.guided
1383
+ ? "Next: choose Run a rehearsal when you are ready."
1384
+ : "Next: run rehearsal doctor to check readiness.",
1385
+ ].join("\n"),
466
1386
  });
467
1387
  return;
468
1388
  }
@@ -475,7 +1395,7 @@ const main = async () => {
475
1395
  render: renderDoctor,
476
1396
  status: data.state === "READY" ? "success" : "not_ready",
477
1397
  });
478
- if (data.state !== "READY") process.exitCode = 1;
1398
+ if (data.state !== "READY" && !guided) process.exitCode = 1;
479
1399
  return;
480
1400
  }
481
1401
  if (command === "explain" || (command === "run" && flags.dryRun)) {
@@ -515,28 +1435,88 @@ const main = async () => {
515
1435
  if (flags.dryRun && command !== "run") {
516
1436
  throw new Error("--dry-run is supported only by rehearsal run.");
517
1437
  }
1438
+ if (
1439
+ !(await prepareCandidateConfirmation({ command, flags, planOptions }))
1440
+ ) {
1441
+ return;
1442
+ }
518
1443
  const runtime = runManager({ action: command, flags });
519
- const data =
520
- command === "run"
521
- ? {
522
- runtime,
523
- applicationProof: await runApplicationProof(planOptions),
524
- }
525
- : runtime;
1444
+ let applicationProof;
1445
+ if (command === "run") {
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)) {
1450
+ console.log(
1451
+ `${terminalStyle(flags, "36", "→")} Running the project-owned application proof.`,
1452
+ );
1453
+ }
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)) {
1463
+ console.log(
1464
+ `${terminalStyle(flags, "32", "✓")} Application proof passed.`,
1465
+ );
1466
+ }
1467
+ }
1468
+ const data = command === "run" ? { runtime, applicationProof } : runtime;
1469
+ if (guided) {
1470
+ lastGuidedDetails = [runtime.output, applicationProof?.output]
1471
+ .filter(Boolean)
1472
+ .join("\n");
1473
+ }
526
1474
  emit({
527
1475
  command,
528
1476
  data,
529
1477
  flags,
530
1478
  render: (value) => {
531
1479
  const runtimeResult = value.runtime ?? value;
1480
+ const nextAction = {
1481
+ run: "Next: exercise the local application, then run rehearsal verify.",
1482
+ start:
1483
+ "Next: exercise the local application or run rehearsal status.",
1484
+ migrate: "Next: run rehearsal verify to prove the current runtime.",
1485
+ reset: "Next: run rehearsal verify or continue testing locally.",
1486
+ status: "Next: run rehearsal verify, reset, stop, or discard.",
1487
+ stop: "Next: run rehearsal start when you want to resume.",
1488
+ discard:
1489
+ "The disposable runtime was removed; the immutable baseline remains.",
1490
+ verify:
1491
+ "Next: continue testing, reset to the baseline, or stop the runtime.",
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
+ }
532
1510
  return [
533
1511
  runtimeResult.output ||
534
1512
  `Rehearsal ${runtimeResult.action} completed.`,
535
1513
  value.applicationProof
536
1514
  ? `Application proof passed: ${value.applicationProof.command}`
537
1515
  : null,
1516
+ nextAction ? "" : null,
1517
+ nextAction,
538
1518
  ]
539
- .filter(Boolean)
1519
+ .filter((entry) => entry !== null)
540
1520
  .join("\n");
541
1521
  },
542
1522
  });
@@ -545,6 +1525,63 @@ const main = async () => {
545
1525
  throw new Error(`Unknown Rehearsal command: ${command}.\n\n${usage()}`);
546
1526
  };
547
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
+
548
1585
  try {
549
1586
  await main();
550
1587
  } catch (error) {