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

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.
@@ -36,9 +36,11 @@ import {
36
36
  import { createSyntheticBaselineFromFiles } from "../../lib/rehearsal/baseline_builder.mjs";
37
37
  import {
38
38
  applyBaselinePreparation,
39
+ inspectBaselineInputFiles,
39
40
  planBaselinePreparation,
40
41
  summarizeBaselinePreparation,
41
42
  } from "../../lib/rehearsal/baseline_preparation.mjs";
43
+ import { discoverBaselineInputFiles } from "../../lib/rehearsal/input_discovery.mjs";
42
44
  import {
43
45
  applyRehearsalSetup,
44
46
  inspectRehearsalNodeRuntime,
@@ -49,8 +51,11 @@ import { formatCount } from "../../lib/rehearsal/human_output.mjs";
49
51
  import {
50
52
  applyReviewedPolicy,
51
53
  completePolicyDraft,
54
+ createSafeTablePreset,
52
55
  readReviewablePolicyDraft,
56
+ suggestPolicyExceptionColumns,
53
57
  } from "../../lib/rehearsal/policy_review.mjs";
58
+ import { collectRehearsalSupportReport } from "../../lib/rehearsal/support_report.mjs";
54
59
 
55
60
  const packageRoot = fileURLToPath(new URL("../../..", import.meta.url));
56
61
  const projectRoot = process.cwd();
@@ -75,6 +80,7 @@ const parseArguments = (arguments_) => {
75
80
  recordsPath: undefined,
76
81
  ledgerPath: undefined,
77
82
  assetsPath: undefined,
83
+ target: undefined,
78
84
  };
79
85
  const positionals = [];
80
86
  for (const argument of arguments_) {
@@ -95,6 +101,8 @@ const parseArguments = (arguments_) => {
95
101
  flags.ledgerPath = argument.slice("--ledger=".length);
96
102
  } else if (argument.startsWith("--assets=")) {
97
103
  flags.assetsPath = argument.slice("--assets=".length);
104
+ } else if (argument.startsWith("--target=")) {
105
+ flags.target = argument.slice("--target=".length);
98
106
  } else if (argument.startsWith("--")) {
99
107
  throw new Error(`Unknown Rehearsal option: ${argument}.`);
100
108
  } else positionals.push(argument);
@@ -269,10 +277,16 @@ const formatDuration = (durationMs) =>
269
277
  ? `${Math.round(durationMs)}ms`
270
278
  : `${(durationMs / 1_000).toFixed(1)}s`;
271
279
 
272
- const promptForChoice = async ({ message, options, flags = {} }) => {
280
+ const promptForChoice = async ({
281
+ message,
282
+ options,
283
+ flags = {},
284
+ optionsAlreadyShown = false,
285
+ }) => {
273
286
  if (useStyledPrompts(flags)) {
274
287
  const selected = await prompts.select({
275
288
  message,
289
+ maxItems: options.length,
276
290
  options: options.map((option) => ({
277
291
  value: option,
278
292
  label: option.label,
@@ -283,6 +297,18 @@ const promptForChoice = async ({ message, options, flags = {} }) => {
283
297
  ? options.find((option) => option.command === "exit")
284
298
  : selected;
285
299
  }
300
+ if (!optionsAlreadyShown) {
301
+ console.log(
302
+ [
303
+ "",
304
+ ...options.map(
305
+ (option, index) =>
306
+ ` ${index === 0 ? "›" : " "} ${index + 1}. ${option.label}${option.hint ? ` — ${option.hint}` : ""}`,
307
+ ),
308
+ "",
309
+ ].join("\n"),
310
+ );
311
+ }
286
312
  const prompt = createInterface({
287
313
  input: process.stdin,
288
314
  output: process.stdout,
@@ -341,7 +367,7 @@ const promptForPath = async ({
341
367
  placeholder: defaultValue ?? (optional ? "Leave blank for none" : ""),
342
368
  defaultValue,
343
369
  validate: (value) => {
344
- const resolved = String(value ?? defaultValue ?? "").trim();
370
+ const resolved = String(value || defaultValue || "").trim();
345
371
  if (!optional && !resolved) return "Enter a value.";
346
372
  return resolved && validate ? validate(resolved) : undefined;
347
373
  },
@@ -375,16 +401,107 @@ const promptForPath = async ({
375
401
  }
376
402
  };
377
403
 
404
+ const promptForDiscoveredPath = async ({
405
+ message,
406
+ candidates,
407
+ optional = false,
408
+ flags,
409
+ }) => {
410
+ if (candidates.length === 0) {
411
+ return promptForPath({ message, optional, flags });
412
+ }
413
+ const noneValue = "\0none";
414
+ const manualValue = "\0manual";
415
+ const selected = await promptForSelection({
416
+ message,
417
+ flags,
418
+ options: [
419
+ ...(optional
420
+ ? [
421
+ {
422
+ label: "No storage assets",
423
+ hint: "Continue without an asset manifest",
424
+ value: noneValue,
425
+ },
426
+ ]
427
+ : []),
428
+ ...candidates.map((path) => ({ label: path, value: path })),
429
+ { label: "Enter another path", value: manualValue },
430
+ ],
431
+ });
432
+ if (!selected || selected === noneValue) return undefined;
433
+ if (selected !== manualValue) return selected;
434
+ return promptForPath({ message, optional, flags });
435
+ };
436
+
378
437
  const promptForSelection = async ({ message, options, flags }) => {
379
438
  const selected = await promptForChoice({ message, options, flags });
380
439
  return selected?.value;
381
440
  };
382
441
 
383
- const firstExistingPath = async (candidates) => {
384
- for (const candidate of candidates) {
385
- if (await pathExists(join(projectRoot, candidate))) return candidate;
442
+ const promptForMultipleChoice = async ({
443
+ message,
444
+ options,
445
+ initialValues = [],
446
+ flags,
447
+ }) => {
448
+ if (useStyledPrompts(flags)) {
449
+ const selected = await prompts.multiselect({
450
+ message,
451
+ options: options.map((option) => ({
452
+ value: option.value,
453
+ label: option.label,
454
+ hint: option.hint,
455
+ })),
456
+ initialValues,
457
+ required: false,
458
+ });
459
+ return prompts.isCancel(selected) ? undefined : selected;
460
+ }
461
+ const initial = new Set(initialValues);
462
+ console.log("");
463
+ console.log(message);
464
+ for (const [index, option] of options.entries()) {
465
+ console.log(
466
+ ` ${index + 1}. ${option.label}${initial.has(option.value) ? " (suggested)" : ""}`,
467
+ );
468
+ }
469
+ const defaultNumbers = options
470
+ .map((option, index) => (initial.has(option.value) ? index + 1 : undefined))
471
+ .filter(Boolean)
472
+ .join(",");
473
+ const prompt = createInterface({
474
+ input: process.stdin,
475
+ output: process.stdout,
476
+ });
477
+ try {
478
+ while (true) {
479
+ const suffix = defaultNumbers ? ` [${defaultNumbers}]` : " [none]";
480
+ const answer = (
481
+ await prompt.question(
482
+ `Choose comma-separated numbers, all, or none${suffix}: `,
483
+ )
484
+ )
485
+ .trim()
486
+ .toLowerCase();
487
+ if (!answer) return [...initialValues];
488
+ if (answer === "none") return [];
489
+ if (answer === "all") return options.map((option) => option.value);
490
+ const indexes = answer.split(",").map((value) => Number(value.trim()));
491
+ if (
492
+ indexes.length > 0 &&
493
+ indexes.every(
494
+ (index) =>
495
+ Number.isInteger(index) && index >= 1 && index <= options.length,
496
+ )
497
+ ) {
498
+ return [...new Set(indexes)].map((index) => options[index - 1].value);
499
+ }
500
+ console.log(`Choose numbers from 1 to ${options.length}, all, or none.`);
501
+ }
502
+ } finally {
503
+ prompt.close();
386
504
  }
387
- return undefined;
388
505
  };
389
506
 
390
507
  const inspectGuidedProject = async (planOptions) => {
@@ -405,6 +522,7 @@ const inspectGuidedProject = async (planOptions) => {
405
522
  detected,
406
523
  node,
407
524
  config: "missing",
525
+ target: detected.hasSupabaseConfig ? "supabase" : "postgresql",
408
526
  baseline: false,
409
527
  runtime: false,
410
528
  };
@@ -417,6 +535,7 @@ const inspectGuidedProject = async (planOptions) => {
417
535
  detected,
418
536
  node,
419
537
  config: "invalid",
538
+ target: undefined,
420
539
  configPath,
421
540
  configError: redactDiagnosticValue(String(error?.message ?? error)),
422
541
  baseline: false,
@@ -447,6 +566,7 @@ const inspectGuidedProject = async (planOptions) => {
447
566
  detected,
448
567
  node,
449
568
  config: "valid",
569
+ target: loaded.config.runtime.target,
450
570
  configPath,
451
571
  baseline: Boolean(migrationSummary),
452
572
  policy,
@@ -467,116 +587,180 @@ const reviewPolicyInteractively = async ({ state, flags }) => {
467
587
  (total, table) => total + (table.columns?.length ?? 0),
468
588
  0,
469
589
  );
470
- let reviewedColumns = 0;
590
+ const tableSummaries = [];
591
+ let individuallyReviewed = 0;
471
592
  if (useStyledPrompts(flags)) {
472
593
  prompts.note(
473
594
  [
474
- `${formatCount(totalColumns, "column")} require explicit review.`,
595
+ `${formatCount(totalColumns, "column")} across ${formatCount(draft.tables.length, "table")} require explicit review.`,
475
596
  "No row values are displayed or changed by this step.",
476
- "The safest replacement-oriented choices appear first.",
597
+ "Safe defaults replace values, mark generated NEVER and identity NO, and set no foreign key.",
598
+ "Likely structural columns start selected for individual review.",
477
599
  ].join("\n"),
478
600
  "Review the script",
479
601
  );
480
602
  }
603
+ const reviewColumn = async ({ table, column }) => {
604
+ individuallyReviewed += 1;
605
+ const label = `${table}.${column}`;
606
+ const action = await promptForSelection({
607
+ message: `${label}: how should this value be handled?`,
608
+ flags,
609
+ options: [
610
+ {
611
+ label: "Replace with synthetic data",
612
+ hint: "Safest default",
613
+ value: "REPLACE WITH SYNTHETIC",
614
+ },
615
+ {
616
+ label: "Pseudonymize consistently",
617
+ hint: "Stable links without original values",
618
+ value: "PSEUDONYMIZE",
619
+ },
620
+ {
621
+ label: "Derive a safe value",
622
+ value: "DERIVE",
623
+ },
624
+ { label: "Exclude this field", value: "EXCLUDE" },
625
+ {
626
+ label: "Keep exactly",
627
+ hint: "Only for values explicitly safe to retain",
628
+ value: "KEEP EXACTLY",
629
+ },
630
+ ],
631
+ });
632
+ if (!action) throw new Error("Policy review was cancelled.");
633
+ const generated = await promptForSelection({
634
+ message: `${label}: is this column always database-generated?`,
635
+ flags,
636
+ options: [
637
+ { label: "No", value: "NEVER" },
638
+ { label: "Yes", value: "ALWAYS" },
639
+ ],
640
+ });
641
+ if (!generated) throw new Error("Policy review was cancelled.");
642
+ const identity = await promptForSelection({
643
+ message: `${label}: is this an identity column?`,
644
+ flags,
645
+ options: [
646
+ { label: "No", value: "NO" },
647
+ { label: "Yes", value: "YES" },
648
+ ],
649
+ });
650
+ if (!identity) throw new Error("Policy review was cancelled.");
651
+ const hasForeignKey = await promptForConfirmation(
652
+ `${label}: does this column reference another table?`,
653
+ flags,
654
+ { cancelValue: null },
655
+ );
656
+ if (hasForeignKey === null) {
657
+ throw new Error("Policy review was cancelled.");
658
+ }
659
+ let foreignKey = null;
660
+ if (hasForeignKey) {
661
+ const schema = await promptForPath({
662
+ message: `${label}: referenced schema`,
663
+ defaultValue: "public",
664
+ flags,
665
+ validate: (value) =>
666
+ /^[a-z][a-z0-9_]{0,62}$/u.test(value)
667
+ ? undefined
668
+ : "Use a lowercase PostgreSQL identifier.",
669
+ });
670
+ const referencedTable = await promptForPath({
671
+ message: `${label}: referenced table`,
672
+ flags,
673
+ validate: (value) =>
674
+ /^[a-z][a-z0-9_]{0,62}$/u.test(value)
675
+ ? undefined
676
+ : "Use a lowercase PostgreSQL identifier.",
677
+ });
678
+ const referencedColumn = await promptForPath({
679
+ message: `${label}: referenced column`,
680
+ flags,
681
+ validate: (value) =>
682
+ /^[a-z][a-z0-9_]{0,62}$/u.test(value)
683
+ ? undefined
684
+ : "Use a lowercase PostgreSQL identifier.",
685
+ });
686
+ if (!schema || !referencedTable || !referencedColumn) {
687
+ throw new Error("Policy review was cancelled.");
688
+ }
689
+ foreignKey = {
690
+ schema,
691
+ table: referencedTable,
692
+ column: referencedColumn,
693
+ };
694
+ }
695
+ return { action, generated, identity, foreignKey };
696
+ };
481
697
  const policy = await completePolicyDraft({
482
698
  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?`,
699
+ reviewColumn,
700
+ reviewTable: async ({ table, columns }) => {
701
+ if (columns.length === 1) {
702
+ tableSummaries.push({ table, defaulted: 0, individual: 1 });
703
+ return {};
704
+ }
705
+ const mode = await promptForSelection({
706
+ message: `${table}: how would you like to review ${formatCount(columns.length, "column")}?`,
488
707
  flags,
489
708
  options: [
490
709
  {
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",
710
+ label: "Use safe defaults, then review exceptions",
711
+ hint: "Best for larger tables",
712
+ value: "preset",
499
713
  },
500
714
  {
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",
715
+ label: "Review every column individually",
716
+ value: "individual",
509
717
  },
510
718
  ],
511
719
  });
512
- if (!action) throw new Error("Policy review was cancelled.");
513
- const generated = await promptForSelection({
514
- message: `${label}: is this column always database-generated?`,
720
+ if (!mode) throw new Error("Policy review was cancelled.");
721
+ if (mode === "individual") {
722
+ tableSummaries.push({
723
+ table,
724
+ defaulted: 0,
725
+ individual: columns.length,
726
+ });
727
+ return {};
728
+ }
729
+ const suggested = suggestPolicyExceptionColumns(columns);
730
+ const exceptions = await promptForMultipleChoice({
731
+ message: `${table}: select columns that need individual review`,
515
732
  flags,
516
- options: [
517
- { label: "No", value: "NEVER" },
518
- { label: "Yes", value: "ALWAYS" },
519
- ],
733
+ options: columns.map((column) => ({ label: column, value: column })),
734
+ initialValues: suggested,
520
735
  });
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
- ],
736
+ if (!exceptions) throw new Error("Policy review was cancelled.");
737
+ const tableDecisions = createSafeTablePreset(columns);
738
+ for (const column of exceptions) delete tableDecisions[column];
739
+ tableSummaries.push({
740
+ table,
741
+ defaulted: columns.length - exceptions.length,
742
+ individual: exceptions.length,
529
743
  });
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
- };
744
+ if (useStyledPrompts(flags)) {
745
+ prompts.log.info(
746
+ `${table}: ${formatCount(columns.length - exceptions.length, "safe default")}; ${formatCount(exceptions.length, "exception")}.`,
747
+ );
574
748
  }
575
- return { action, generated, identity, foreignKey };
749
+ return tableDecisions;
576
750
  },
577
751
  });
752
+ const defaultedColumns = totalColumns - individuallyReviewed;
753
+ const summaryLines = tableSummaries.map(
754
+ (summary) =>
755
+ `${summary.table}: ${formatCount(summary.defaulted, "safe default")}; ${formatCount(summary.individual, "individual review")}`,
756
+ );
757
+ if (useStyledPrompts(flags)) {
758
+ prompts.note(summaryLines.join("\n"), "Review summary");
759
+ } else {
760
+ console.log(["", "Review summary", ...summaryLines, ""].join("\n"));
761
+ }
578
762
  const accepted = await promptForConfirmation(
579
- `Save ${formatCount(totalColumns, "reviewed column")} and activate this policy?`,
763
+ `Save ${formatCount(totalColumns, "classified column")} and activate this policy?`,
580
764
  flags,
581
765
  );
582
766
  if (!accepted) return false;
@@ -585,14 +769,11 @@ const reviewPolicyInteractively = async ({ state, flags }) => {
585
769
  originalSource: source,
586
770
  policy,
587
771
  });
772
+ const result = `${formatCount(totalColumns, "column")} classified (${formatCount(defaultedColumns, "safe default")}; ${formatCount(individuallyReviewed, "individual review")}).`;
588
773
  if (useStyledPrompts(flags)) {
589
- prompts.log.success(
590
- `${formatCount(totalColumns, "column")} reviewed; the policy is ready for baseline creation.`,
591
- );
774
+ prompts.log.success(`${result} The policy is ready for baseline creation.`);
592
775
  } else {
593
- console.log(
594
- `Reviewed policy saved: ${formatCount(totalColumns, "column")} classified.`,
595
- );
776
+ console.log(`Reviewed policy saved: ${result}`);
596
777
  }
597
778
  return true;
598
779
  };
@@ -632,7 +813,7 @@ const runGuidedHome = async ({ flags, planOptions }) => {
632
813
  } else if (state.policy === "needs-review") {
633
814
  options.push({
634
815
  label: "Review the script",
635
- hint: "Classify every column interactively",
816
+ hint: "Apply table presets and review exceptions",
636
817
  command: "policy-review",
637
818
  });
638
819
  } else {
@@ -660,14 +841,27 @@ const runGuidedHome = async ({ flags, planOptions }) => {
660
841
  });
661
842
  }
662
843
  options.push(
844
+ {
845
+ label: "Get help",
846
+ hint: "Create a safe, copy-ready support report",
847
+ command: "support",
848
+ },
663
849
  { label: "Show all commands", command: "help" },
664
850
  { label: "Exit", command: "exit" },
665
851
  );
666
852
 
667
853
  const statusLines = [
668
854
  `${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`,
855
+ `${marker(
856
+ state.target === "supabase"
857
+ ? state.detected.hasSupabaseConfig
858
+ : state.detected.hasPostgresqlMigrations,
859
+ )} ${state.target === "supabase" ? "Supabase" : "PostgreSQL"} project detected`,
860
+ `${marker(
861
+ state.target === "supabase"
862
+ ? state.detected.hasMigrations
863
+ : state.detected.hasPostgresqlMigrations,
864
+ )} Migration history detected`,
671
865
  `${marker(state.config === "valid")} Rehearsal configuration${state.config === "invalid" ? " needs attention" : ""}`,
672
866
  ...(state.config === "valid"
673
867
  ? [
@@ -719,6 +913,7 @@ const runGuidedHome = async ({ flags, planOptions }) => {
719
913
  message: "What would you like to do?",
720
914
  options,
721
915
  flags,
916
+ optionsAlreadyShown: !useStyledPrompts(flags),
722
917
  });
723
918
  if (selected.command === "exit") {
724
919
  return null;
@@ -793,63 +988,129 @@ const runGuidedHome = async ({ flags, planOptions }) => {
793
988
  "",
794
989
  terminalStyle(flags, "1", "CREATE A SAFE BASELINE"),
795
990
  "Rehearsal only reads the local files you name here.",
796
- "It does not extract data or contact a hosted Supabase project.",
991
+ "It does not extract data or contact a hosted database.",
797
992
  "",
798
993
  ].join("\n"),
799
994
  );
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({
995
+ const discovered = await discoverBaselineInputFiles({ projectRoot });
996
+ const detectedLines = [
997
+ discovered.records.length
998
+ ? `Records: ${discovered.records.join(", ")}`
999
+ : "Records: none detected",
1000
+ discovered.ledgers.length
1001
+ ? `Migration ledgers: ${discovered.ledgers.join(", ")}`
1002
+ : "Migration ledgers: none detected",
1003
+ ...(state.target === "supabase"
1004
+ ? [
1005
+ discovered.assetManifests.length
1006
+ ? `Storage manifests: ${discovered.assetManifests.join(", ")}`
1007
+ : "Storage manifests: none detected",
1008
+ ]
1009
+ : []),
1010
+ "Only paths and file structure were inspected; row values stay hidden.",
1011
+ ];
1012
+ if (useStyledPrompts(flags)) {
1013
+ prompts.note(detectedLines.join("\n"), "Detected safe local inputs");
1014
+ } else {
1015
+ console.log(
1016
+ ["Detected safe local inputs", ...detectedLines, ""].join("\n"),
1017
+ );
1018
+ }
1019
+ flags.recordsPath = await promptForDiscoveredPath({
808
1020
  message: "Sanitized NDJSON records",
809
- defaultValue: recordsDefault,
1021
+ candidates: discovered.records,
810
1022
  flags,
811
1023
  });
812
- flags.ledgerPath = await promptForPath({
1024
+ flags.ledgerPath = await promptForDiscoveredPath({
813
1025
  message: "Migration ledger",
814
- defaultValue: ledgerDefault,
1026
+ candidates: discovered.ledgers,
815
1027
  flags,
816
1028
  });
817
- if (selected.command === "baseline-prepare") {
818
- const preparationPlan = await planBaselinePreparation({
1029
+ try {
1030
+ if (selected.command === "baseline-prepare") {
1031
+ const preparationPlan = await planBaselinePreparation({
1032
+ ...planOptions,
1033
+ recordsPath: flags.recordsPath,
1034
+ ledgerPath: flags.ledgerPath,
1035
+ });
1036
+ const preview = {
1037
+ ...summarizeBaselinePreparation(preparationPlan, {
1038
+ mode: "preview",
1039
+ }),
1040
+ nextAction:
1041
+ "Review this schema-only summary. No file is written unless you approve below.",
1042
+ };
1043
+ console.log(`\n${renderBaselinePreparation(preview, flags)}\n`);
1044
+ const accepted = await promptForConfirmation(
1045
+ `Create the REVIEW REQUIRED draft at ${preview.destination}?`,
1046
+ flags,
1047
+ );
1048
+ if (!accepted) {
1049
+ console.log("No changes made.");
1050
+ return "refresh";
1051
+ }
1052
+ flags.preparationPlan = preparationPlan;
1053
+ flags.write = true;
1054
+ return "baseline prepare";
1055
+ }
1056
+ flags.assetsPath =
1057
+ state.target === "supabase"
1058
+ ? await promptForDiscoveredPath({
1059
+ message: "Storage assets",
1060
+ candidates: discovered.assetManifests,
1061
+ optional: true,
1062
+ flags,
1063
+ })
1064
+ : undefined;
1065
+ const inspection = await inspectBaselineInputFiles({
819
1066
  ...planOptions,
820
1067
  recordsPath: flags.recordsPath,
821
1068
  ledgerPath: flags.ledgerPath,
1069
+ assetsPath: flags.assetsPath,
822
1070
  });
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`);
1071
+ console.log(`\n${renderBaselineInputInspection(inspection, flags)}\n`);
831
1072
  const accepted = await promptForConfirmation(
832
- `Create the REVIEW REQUIRED draft at ${preview.destination}?`,
1073
+ "Create and activate this immutable local baseline?",
833
1074
  flags,
834
1075
  );
835
1076
  if (!accepted) {
836
1077
  console.log("No changes made.");
837
1078
  return "refresh";
838
1079
  }
839
- flags.preparationPlan = preparationPlan;
840
- flags.write = true;
841
- return "baseline prepare";
1080
+ return "baseline create";
1081
+ } catch (error) {
1082
+ const message = redactDiagnosticValue(String(error?.message ?? error));
1083
+ if (useStyledPrompts(flags)) {
1084
+ prompts.cancel("These baseline inputs need attention.");
1085
+ prompts.note(message, "How to continue");
1086
+ } else {
1087
+ console.log(`\nBASELINE INPUTS NEED ATTENTION\n${message}\n`);
1088
+ }
1089
+ return "refresh";
842
1090
  }
843
- flags.assetsPath = await promptForPath({
844
- message: "Storage asset manifest (optional)",
845
- optional: true,
846
- flags,
847
- });
848
- return "baseline create";
849
1091
  }
850
1092
  if (selected.command !== "setup-write") return selected.command;
851
1093
 
852
- const setupPlan = await planRehearsalSetup({ projectRoot });
1094
+ const targetChoice = await promptForChoice({
1095
+ message: "Which database should Rehearsal use?",
1096
+ flags,
1097
+ options:
1098
+ state.target === "supabase"
1099
+ ? [
1100
+ { label: "Supabase", command: "supabase" },
1101
+ { label: "PostgreSQL", command: "postgresql" },
1102
+ ]
1103
+ : [
1104
+ { label: "PostgreSQL", command: "postgresql" },
1105
+ { label: "Supabase", command: "supabase" },
1106
+ ],
1107
+ });
1108
+ if (!targetChoice) return "refresh";
1109
+ flags.target = targetChoice.command;
1110
+ const setupPlan = await planRehearsalSetup({
1111
+ projectRoot,
1112
+ target: flags.target,
1113
+ });
853
1114
  const preview = {
854
1115
  ...summarizeRehearsalSetup(setupPlan, { mode: "preview" }),
855
1116
  nextAction:
@@ -1012,6 +1273,14 @@ const runManager = ({ action, flags }) => {
1012
1273
  const inferred = normalizeRehearsalError(
1013
1274
  new Error(completeOutput || `Rehearsal ${action} failed.`),
1014
1275
  );
1276
+ const category =
1277
+ action === "migrate" ||
1278
+ (action === "run" &&
1279
+ /migrateRuntime|candidate migration|migration up/iu.test(
1280
+ completeOutput,
1281
+ ))
1282
+ ? "migration_candidate_failure"
1283
+ : inferred.category;
1015
1284
  const conciseOutput = completeOutput
1016
1285
  .replaceAll(String.fromCodePoint(27), "")
1017
1286
  .split("\n")
@@ -1021,7 +1290,7 @@ const runManager = ({ action, flags }) => {
1021
1290
  .join("\n")
1022
1291
  .slice(-2_000);
1023
1292
  throw new RehearsalError({
1024
- category: inferred.category,
1293
+ category,
1025
1294
  code: `MANAGER_${action.toUpperCase()}_FAILED`,
1026
1295
  message: `The local Rehearsal ${action} operation did not complete.`,
1027
1296
  expected: "the isolated runtime operation to finish and verify",
@@ -1179,7 +1448,12 @@ const renderInit = (result) =>
1179
1448
  ].join("\n");
1180
1449
 
1181
1450
  const runSetup = async ({ flags, planOptions }) => {
1182
- const plan = flags.setupPlan ?? (await planRehearsalSetup({ projectRoot }));
1451
+ const plan =
1452
+ flags.setupPlan ??
1453
+ (await planRehearsalSetup({
1454
+ projectRoot,
1455
+ target: flags.target ?? "supabase",
1456
+ }));
1183
1457
  if (flags.write) await applyRehearsalSetup(plan);
1184
1458
  const result = summarizeRehearsalSetup(plan, {
1185
1459
  mode: flags.write ? "written" : "preview",
@@ -1251,9 +1525,12 @@ const renderSetup = (result) => {
1251
1525
  return [
1252
1526
  `REHEARSAL SETUP — ${result.mode.toUpperCase()}`,
1253
1527
  `Project: ${result.project}`,
1528
+ `Database: ${result.target === "postgresql" ? "PostgreSQL" : "Supabase"}`,
1254
1529
  `Runtime ID: ${result.projectId}`,
1255
1530
  `Application: ${result.applicationUrl}`,
1256
- `Ports: API ${result.ports.api}, database ${result.ports.database}, Studio ${result.ports.studio}`,
1531
+ result.target === "postgresql"
1532
+ ? `Port: database ${result.ports.database}`
1533
+ : `Ports: API ${result.ports.api}, database ${result.ports.database}, Studio ${result.ports.studio}`,
1257
1534
  "",
1258
1535
  "Files",
1259
1536
  ...result.files.map(
@@ -1314,15 +1591,66 @@ const renderBaselinePreparation = (result, flags = {}) =>
1314
1591
  result.nextAction,
1315
1592
  ].join("\n");
1316
1593
 
1594
+ const renderBaselineInputInspection = (result, flags = {}) =>
1595
+ [
1596
+ "BASELINE INPUTS — READY",
1597
+ `Records: ${formatCount(result.rowCount, "row")} across ${formatCount(result.tables.length, "table")} from ${result.recordsPath}`,
1598
+ `Migrations: ${formatCount(result.migrationCount, "migration")} through ${result.migrationCutoff} from ${result.ledgerPath}`,
1599
+ result.assetsPath
1600
+ ? `Storage: ${formatCount(result.assetCount, "asset")} from ${result.assetsPath}`
1601
+ : "Storage: no asset manifest selected",
1602
+ "",
1603
+ "Detected tables (values are never printed)",
1604
+ ...result.tables.map(
1605
+ (table) =>
1606
+ ` ${terminalStyle(flags, "36", "→")} ${table.name}: ${formatCount(table.rowCount, "row")}`,
1607
+ ),
1608
+ "",
1609
+ "All inputs are project-local regular files and passed structural validation.",
1610
+ ].join("\n");
1611
+
1612
+ const renderSupportReport = (report) => {
1613
+ const toolLine = (label, tool) =>
1614
+ `${label}: ${tool.status === "available" ? tool.version : tool.status.replaceAll("_", " ")}`;
1615
+ return [
1616
+ "REHEARSAL SUPPORT REPORT",
1617
+ "",
1618
+ `Rehearsal: ${report.rehearsalVersion}`,
1619
+ `System: ${report.system.platform} ${report.system.architecture} (${report.system.release})`,
1620
+ `Node.js: ${report.system.node}`,
1621
+ toolLine("npm", report.tools.npm),
1622
+ toolLine("Supabase CLI", report.tools.supabase),
1623
+ toolLine("Docker client", report.tools.dockerClient),
1624
+ toolLine("Docker server", report.tools.dockerServer),
1625
+ "",
1626
+ `Readiness: ${report.readiness.state}`,
1627
+ ...report.readiness.checks.map((check) =>
1628
+ check.status === "pass"
1629
+ ? ` ✓ ${check.label}`
1630
+ : ` ✗ ${check.label}${check.remediation ? ` — ${check.remediation}` : ""}`,
1631
+ ),
1632
+ ...(report.readiness.quarantinedHostedVariableCount
1633
+ ? [
1634
+ ` ! ${formatCount(report.readiness.quarantinedHostedVariableCount, "ambient hosted variable")} detected and quarantined`,
1635
+ ]
1636
+ : []),
1637
+ "",
1638
+ "Privacy: no row values, credentials, project paths, migration SQL, or baseline identifiers are included.",
1639
+ "Review this report before sharing it.",
1640
+ `Report a reproducible issue: ${report.supportUrl}`,
1641
+ ].join("\n");
1642
+ };
1643
+
1317
1644
  const usage = () => `Usage: rehearsal <command> [options]
1318
1645
 
1319
1646
  Commands:
1320
1647
  guide Open the interactive, state-aware home screen
1321
- setup [--write] Preview or create safe first-run scaffolding
1648
+ setup [--target=] [--write] Preview or create safe first-run scaffolding
1322
1649
  init [--write] Preview or explicitly write safe starter config
1323
1650
  baseline prepare --records= --ledger= [--write] Create a fail-closed policy draft
1324
1651
  baseline create --records= --ledger= [--assets=] Create a baseline from safe local inputs
1325
1652
  doctor Check whether Rehearsal is safe and ready
1653
+ support Print a safe, copy-ready support report
1326
1654
  explain Show the immutable execution plan
1327
1655
  run --dry-run Alias the exact explain plan without mutations
1328
1656
  run --confirm-candidates= Execute reset, migration, and verification locally
@@ -1337,7 +1665,7 @@ Commands:
1337
1665
  discard Remove only this project's disposable runtime
1338
1666
  verify Verify the current local Rehearsal runtime
1339
1667
 
1340
- Options: --json --verbose --debug --plain --config=<path>`;
1668
+ Options: --json --verbose --debug --plain --config=<path> --target=supabase|postgresql`;
1341
1669
 
1342
1670
  const executeCommand = async ({ command, flags, planOptions, guided }) => {
1343
1671
  if (command === "help" || flags.help) {
@@ -1365,6 +1693,12 @@ const executeCommand = async ({ command, flags, planOptions, guided }) => {
1365
1693
  return;
1366
1694
  }
1367
1695
  if (command === "baseline create") {
1696
+ await inspectBaselineInputFiles({
1697
+ ...planOptions,
1698
+ recordsPath: flags.recordsPath,
1699
+ ledgerPath: flags.ledgerPath,
1700
+ assetsPath: flags.assetsPath,
1701
+ });
1368
1702
  const data = await createSyntheticBaselineFromFiles({
1369
1703
  ...planOptions,
1370
1704
  recordsPath: flags.recordsPath,
@@ -1398,6 +1732,11 @@ const executeCommand = async ({ command, flags, planOptions, guided }) => {
1398
1732
  if (data.state !== "READY" && !guided) process.exitCode = 1;
1399
1733
  return;
1400
1734
  }
1735
+ if (command === "support") {
1736
+ const data = await collectRehearsalSupportReport(planOptions);
1737
+ emit({ command, data, flags, render: renderSupportReport });
1738
+ return;
1739
+ }
1401
1740
  if (command === "explain" || (command === "run" && flags.dryRun)) {
1402
1741
  const data = await buildRehearsalPlan(planOptions);
1403
1742
  emit({ command, data, flags, render: renderPlan });
@@ -1532,10 +1871,39 @@ const resetGuidedFlags = (flags) => {
1532
1871
  flags.recordsPath = undefined;
1533
1872
  flags.ledgerPath = undefined;
1534
1873
  flags.assetsPath = undefined;
1874
+ flags.target = undefined;
1535
1875
  flags.setupPlan = undefined;
1536
1876
  flags.preparationPlan = undefined;
1537
1877
  };
1538
1878
 
1879
+ const installGuidedExitShortcut = (flags) => {
1880
+ let exiting = false;
1881
+ const exit = () => {
1882
+ if (exiting) return;
1883
+ exiting = true;
1884
+ if (process.stdin.isTTY && process.stdin.isRaw) {
1885
+ process.stdin.setRawMode(false);
1886
+ }
1887
+ process.stdout.write("\u001B[?25h");
1888
+ if (useStyledPrompts(flags)) {
1889
+ prompts.outro("Rehearsal exited.");
1890
+ } else {
1891
+ console.log("\nRehearsal exited.");
1892
+ }
1893
+ process.exit(0);
1894
+ };
1895
+ const onInput = (chunk) => {
1896
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
1897
+ if (bytes.includes(0x1a)) exit();
1898
+ };
1899
+ process.stdin.prependListener("data", onInput);
1900
+ process.prependListener("SIGTSTP", exit);
1901
+ return () => {
1902
+ process.stdin.removeListener("data", onInput);
1903
+ process.removeListener("SIGTSTP", exit);
1904
+ };
1905
+ };
1906
+
1539
1907
  const main = async () => {
1540
1908
  const { flags, positionals } = parseArguments(process.argv.slice(2));
1541
1909
  const command = positionals.join(" ") || "help";
@@ -1560,20 +1928,25 @@ const main = async () => {
1560
1928
  );
1561
1929
  }
1562
1930
  flags.guided = true;
1931
+ const removeGuidedExitShortcut = installGuidedExitShortcut(flags);
1563
1932
  if (useStyledPrompts(flags)) {
1564
1933
  prompts.intro("REHEARSAL · Safe local migration testing");
1565
1934
  }
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
- });
1935
+ try {
1936
+ while (true) {
1937
+ resetGuidedFlags(flags);
1938
+ const selected = await runGuidedHome({ flags, planOptions });
1939
+ if (!selected) break;
1940
+ if (selected === "refresh") continue;
1941
+ await executeCommand({
1942
+ command: selected,
1943
+ flags,
1944
+ planOptions,
1945
+ guided: true,
1946
+ });
1947
+ }
1948
+ } finally {
1949
+ removeGuidedExitShortcut();
1577
1950
  }
1578
1951
  if (useStyledPrompts(flags)) {
1579
1952
  prompts.outro("See you at the next rehearsal.");