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

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();
@@ -273,6 +278,7 @@ const promptForChoice = async ({ message, options, flags = {} }) => {
273
278
  if (useStyledPrompts(flags)) {
274
279
  const selected = await prompts.select({
275
280
  message,
281
+ maxItems: options.length,
276
282
  options: options.map((option) => ({
277
283
  value: option,
278
284
  label: option.label,
@@ -341,7 +347,7 @@ const promptForPath = async ({
341
347
  placeholder: defaultValue ?? (optional ? "Leave blank for none" : ""),
342
348
  defaultValue,
343
349
  validate: (value) => {
344
- const resolved = String(value ?? defaultValue ?? "").trim();
350
+ const resolved = String(value || defaultValue || "").trim();
345
351
  if (!optional && !resolved) return "Enter a value.";
346
352
  return resolved && validate ? validate(resolved) : undefined;
347
353
  },
@@ -375,16 +381,107 @@ const promptForPath = async ({
375
381
  }
376
382
  };
377
383
 
384
+ const promptForDiscoveredPath = async ({
385
+ message,
386
+ candidates,
387
+ optional = false,
388
+ flags,
389
+ }) => {
390
+ if (candidates.length === 0) {
391
+ return promptForPath({ message, optional, flags });
392
+ }
393
+ const noneValue = "\0none";
394
+ const manualValue = "\0manual";
395
+ const selected = await promptForSelection({
396
+ message,
397
+ flags,
398
+ options: [
399
+ ...(optional
400
+ ? [
401
+ {
402
+ label: "No storage assets",
403
+ hint: "Continue without an asset manifest",
404
+ value: noneValue,
405
+ },
406
+ ]
407
+ : []),
408
+ ...candidates.map((path) => ({ label: path, value: path })),
409
+ { label: "Enter another path", value: manualValue },
410
+ ],
411
+ });
412
+ if (!selected || selected === noneValue) return undefined;
413
+ if (selected !== manualValue) return selected;
414
+ return promptForPath({ message, optional, flags });
415
+ };
416
+
378
417
  const promptForSelection = async ({ message, options, flags }) => {
379
418
  const selected = await promptForChoice({ message, options, flags });
380
419
  return selected?.value;
381
420
  };
382
421
 
383
- const firstExistingPath = async (candidates) => {
384
- for (const candidate of candidates) {
385
- if (await pathExists(join(projectRoot, candidate))) return candidate;
422
+ const promptForMultipleChoice = async ({
423
+ message,
424
+ options,
425
+ initialValues = [],
426
+ flags,
427
+ }) => {
428
+ if (useStyledPrompts(flags)) {
429
+ const selected = await prompts.multiselect({
430
+ message,
431
+ options: options.map((option) => ({
432
+ value: option.value,
433
+ label: option.label,
434
+ hint: option.hint,
435
+ })),
436
+ initialValues,
437
+ required: false,
438
+ });
439
+ return prompts.isCancel(selected) ? undefined : selected;
440
+ }
441
+ const initial = new Set(initialValues);
442
+ console.log("");
443
+ console.log(message);
444
+ for (const [index, option] of options.entries()) {
445
+ console.log(
446
+ ` ${index + 1}. ${option.label}${initial.has(option.value) ? " (suggested)" : ""}`,
447
+ );
448
+ }
449
+ const defaultNumbers = options
450
+ .map((option, index) => (initial.has(option.value) ? index + 1 : undefined))
451
+ .filter(Boolean)
452
+ .join(",");
453
+ const prompt = createInterface({
454
+ input: process.stdin,
455
+ output: process.stdout,
456
+ });
457
+ try {
458
+ while (true) {
459
+ const suffix = defaultNumbers ? ` [${defaultNumbers}]` : " [none]";
460
+ const answer = (
461
+ await prompt.question(
462
+ `Choose comma-separated numbers, all, or none${suffix}: `,
463
+ )
464
+ )
465
+ .trim()
466
+ .toLowerCase();
467
+ if (!answer) return [...initialValues];
468
+ if (answer === "none") return [];
469
+ if (answer === "all") return options.map((option) => option.value);
470
+ const indexes = answer.split(",").map((value) => Number(value.trim()));
471
+ if (
472
+ indexes.length > 0 &&
473
+ indexes.every(
474
+ (index) =>
475
+ Number.isInteger(index) && index >= 1 && index <= options.length,
476
+ )
477
+ ) {
478
+ return [...new Set(indexes)].map((index) => options[index - 1].value);
479
+ }
480
+ console.log(`Choose numbers from 1 to ${options.length}, all, or none.`);
481
+ }
482
+ } finally {
483
+ prompt.close();
386
484
  }
387
- return undefined;
388
485
  };
389
486
 
390
487
  const inspectGuidedProject = async (planOptions) => {
@@ -467,116 +564,180 @@ const reviewPolicyInteractively = async ({ state, flags }) => {
467
564
  (total, table) => total + (table.columns?.length ?? 0),
468
565
  0,
469
566
  );
470
- let reviewedColumns = 0;
567
+ const tableSummaries = [];
568
+ let individuallyReviewed = 0;
471
569
  if (useStyledPrompts(flags)) {
472
570
  prompts.note(
473
571
  [
474
- `${formatCount(totalColumns, "column")} require explicit review.`,
572
+ `${formatCount(totalColumns, "column")} across ${formatCount(draft.tables.length, "table")} require explicit review.`,
475
573
  "No row values are displayed or changed by this step.",
476
- "The safest replacement-oriented choices appear first.",
574
+ "Safe defaults replace values, mark generated NEVER and identity NO, and set no foreign key.",
575
+ "Likely structural columns start selected for individual review.",
477
576
  ].join("\n"),
478
577
  "Review the script",
479
578
  );
480
579
  }
580
+ const reviewColumn = async ({ table, column }) => {
581
+ individuallyReviewed += 1;
582
+ const label = `${table}.${column}`;
583
+ const action = await promptForSelection({
584
+ message: `${label}: how should this value be handled?`,
585
+ flags,
586
+ options: [
587
+ {
588
+ label: "Replace with synthetic data",
589
+ hint: "Safest default",
590
+ value: "REPLACE WITH SYNTHETIC",
591
+ },
592
+ {
593
+ label: "Pseudonymize consistently",
594
+ hint: "Stable links without original values",
595
+ value: "PSEUDONYMIZE",
596
+ },
597
+ {
598
+ label: "Derive a safe value",
599
+ value: "DERIVE",
600
+ },
601
+ { label: "Exclude this field", value: "EXCLUDE" },
602
+ {
603
+ label: "Keep exactly",
604
+ hint: "Only for values explicitly safe to retain",
605
+ value: "KEEP EXACTLY",
606
+ },
607
+ ],
608
+ });
609
+ if (!action) throw new Error("Policy review was cancelled.");
610
+ const generated = await promptForSelection({
611
+ message: `${label}: is this column always database-generated?`,
612
+ flags,
613
+ options: [
614
+ { label: "No", value: "NEVER" },
615
+ { label: "Yes", value: "ALWAYS" },
616
+ ],
617
+ });
618
+ if (!generated) throw new Error("Policy review was cancelled.");
619
+ const identity = await promptForSelection({
620
+ message: `${label}: is this an identity column?`,
621
+ flags,
622
+ options: [
623
+ { label: "No", value: "NO" },
624
+ { label: "Yes", value: "YES" },
625
+ ],
626
+ });
627
+ if (!identity) throw new Error("Policy review was cancelled.");
628
+ const hasForeignKey = await promptForConfirmation(
629
+ `${label}: does this column reference another table?`,
630
+ flags,
631
+ { cancelValue: null },
632
+ );
633
+ if (hasForeignKey === null) {
634
+ throw new Error("Policy review was cancelled.");
635
+ }
636
+ let foreignKey = null;
637
+ if (hasForeignKey) {
638
+ const schema = await promptForPath({
639
+ message: `${label}: referenced schema`,
640
+ defaultValue: "public",
641
+ flags,
642
+ validate: (value) =>
643
+ /^[a-z][a-z0-9_]{0,62}$/u.test(value)
644
+ ? undefined
645
+ : "Use a lowercase PostgreSQL identifier.",
646
+ });
647
+ const referencedTable = await promptForPath({
648
+ message: `${label}: referenced table`,
649
+ flags,
650
+ validate: (value) =>
651
+ /^[a-z][a-z0-9_]{0,62}$/u.test(value)
652
+ ? undefined
653
+ : "Use a lowercase PostgreSQL identifier.",
654
+ });
655
+ const referencedColumn = await promptForPath({
656
+ message: `${label}: referenced column`,
657
+ flags,
658
+ validate: (value) =>
659
+ /^[a-z][a-z0-9_]{0,62}$/u.test(value)
660
+ ? undefined
661
+ : "Use a lowercase PostgreSQL identifier.",
662
+ });
663
+ if (!schema || !referencedTable || !referencedColumn) {
664
+ throw new Error("Policy review was cancelled.");
665
+ }
666
+ foreignKey = {
667
+ schema,
668
+ table: referencedTable,
669
+ column: referencedColumn,
670
+ };
671
+ }
672
+ return { action, generated, identity, foreignKey };
673
+ };
481
674
  const policy = await completePolicyDraft({
482
675
  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?`,
676
+ reviewColumn,
677
+ reviewTable: async ({ table, columns }) => {
678
+ if (columns.length === 1) {
679
+ tableSummaries.push({ table, defaulted: 0, individual: 1 });
680
+ return {};
681
+ }
682
+ const mode = await promptForSelection({
683
+ message: `${table}: how would you like to review ${formatCount(columns.length, "column")}?`,
488
684
  flags,
489
685
  options: [
490
686
  {
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",
687
+ label: "Use safe defaults, then review exceptions",
688
+ hint: "Best for larger tables",
689
+ value: "preset",
503
690
  },
504
- { label: "Exclude this field", value: "EXCLUDE" },
505
691
  {
506
- label: "Keep exactly",
507
- hint: "Only for values explicitly safe to retain",
508
- value: "KEEP EXACTLY",
692
+ label: "Review every column individually",
693
+ value: "individual",
509
694
  },
510
695
  ],
511
696
  });
512
- if (!action) throw new Error("Policy review was cancelled.");
513
- const generated = await promptForSelection({
514
- message: `${label}: is this column always database-generated?`,
697
+ if (!mode) throw new Error("Policy review was cancelled.");
698
+ if (mode === "individual") {
699
+ tableSummaries.push({
700
+ table,
701
+ defaulted: 0,
702
+ individual: columns.length,
703
+ });
704
+ return {};
705
+ }
706
+ const suggested = suggestPolicyExceptionColumns(columns);
707
+ const exceptions = await promptForMultipleChoice({
708
+ message: `${table}: select columns that need individual review`,
515
709
  flags,
516
- options: [
517
- { label: "No", value: "NEVER" },
518
- { label: "Yes", value: "ALWAYS" },
519
- ],
710
+ options: columns.map((column) => ({ label: column, value: column })),
711
+ initialValues: suggested,
520
712
  });
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
- ],
713
+ if (!exceptions) throw new Error("Policy review was cancelled.");
714
+ const tableDecisions = createSafeTablePreset(columns);
715
+ for (const column of exceptions) delete tableDecisions[column];
716
+ tableSummaries.push({
717
+ table,
718
+ defaulted: columns.length - exceptions.length,
719
+ individual: exceptions.length,
529
720
  });
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
- };
721
+ if (useStyledPrompts(flags)) {
722
+ prompts.log.info(
723
+ `${table}: ${formatCount(columns.length - exceptions.length, "safe default")}; ${formatCount(exceptions.length, "exception")}.`,
724
+ );
574
725
  }
575
- return { action, generated, identity, foreignKey };
726
+ return tableDecisions;
576
727
  },
577
728
  });
729
+ const defaultedColumns = totalColumns - individuallyReviewed;
730
+ const summaryLines = tableSummaries.map(
731
+ (summary) =>
732
+ `${summary.table}: ${formatCount(summary.defaulted, "safe default")}; ${formatCount(summary.individual, "individual review")}`,
733
+ );
734
+ if (useStyledPrompts(flags)) {
735
+ prompts.note(summaryLines.join("\n"), "Review summary");
736
+ } else {
737
+ console.log(["", "Review summary", ...summaryLines, ""].join("\n"));
738
+ }
578
739
  const accepted = await promptForConfirmation(
579
- `Save ${formatCount(totalColumns, "reviewed column")} and activate this policy?`,
740
+ `Save ${formatCount(totalColumns, "classified column")} and activate this policy?`,
580
741
  flags,
581
742
  );
582
743
  if (!accepted) return false;
@@ -585,14 +746,11 @@ const reviewPolicyInteractively = async ({ state, flags }) => {
585
746
  originalSource: source,
586
747
  policy,
587
748
  });
749
+ const result = `${formatCount(totalColumns, "column")} classified (${formatCount(defaultedColumns, "safe default")}; ${formatCount(individuallyReviewed, "individual review")}).`;
588
750
  if (useStyledPrompts(flags)) {
589
- prompts.log.success(
590
- `${formatCount(totalColumns, "column")} reviewed; the policy is ready for baseline creation.`,
591
- );
751
+ prompts.log.success(`${result} The policy is ready for baseline creation.`);
592
752
  } else {
593
- console.log(
594
- `Reviewed policy saved: ${formatCount(totalColumns, "column")} classified.`,
595
- );
753
+ console.log(`Reviewed policy saved: ${result}`);
596
754
  }
597
755
  return true;
598
756
  };
@@ -632,7 +790,7 @@ const runGuidedHome = async ({ flags, planOptions }) => {
632
790
  } else if (state.policy === "needs-review") {
633
791
  options.push({
634
792
  label: "Review the script",
635
- hint: "Classify every column interactively",
793
+ hint: "Apply table presets and review exceptions",
636
794
  command: "policy-review",
637
795
  });
638
796
  } else {
@@ -660,6 +818,11 @@ const runGuidedHome = async ({ flags, planOptions }) => {
660
818
  });
661
819
  }
662
820
  options.push(
821
+ {
822
+ label: "Get help",
823
+ hint: "Create a safe, copy-ready support report",
824
+ command: "support",
825
+ },
663
826
  { label: "Show all commands", command: "help" },
664
827
  { label: "Exit", command: "exit" },
665
828
  );
@@ -797,55 +960,95 @@ const runGuidedHome = async ({ flags, planOptions }) => {
797
960
  "",
798
961
  ].join("\n"),
799
962
  );
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({
963
+ const discovered = await discoverBaselineInputFiles({ projectRoot });
964
+ const detectedLines = [
965
+ discovered.records.length
966
+ ? `Records: ${discovered.records.join(", ")}`
967
+ : "Records: none detected",
968
+ discovered.ledgers.length
969
+ ? `Migration ledgers: ${discovered.ledgers.join(", ")}`
970
+ : "Migration ledgers: none detected",
971
+ discovered.assetManifests.length
972
+ ? `Storage manifests: ${discovered.assetManifests.join(", ")}`
973
+ : "Storage manifests: none detected",
974
+ "Only paths and file structure were inspected; row values stay hidden.",
975
+ ];
976
+ if (useStyledPrompts(flags)) {
977
+ prompts.note(detectedLines.join("\n"), "Detected safe local inputs");
978
+ } else {
979
+ console.log(
980
+ ["Detected safe local inputs", ...detectedLines, ""].join("\n"),
981
+ );
982
+ }
983
+ flags.recordsPath = await promptForDiscoveredPath({
808
984
  message: "Sanitized NDJSON records",
809
- defaultValue: recordsDefault,
985
+ candidates: discovered.records,
810
986
  flags,
811
987
  });
812
- flags.ledgerPath = await promptForPath({
988
+ flags.ledgerPath = await promptForDiscoveredPath({
813
989
  message: "Migration ledger",
814
- defaultValue: ledgerDefault,
990
+ candidates: discovered.ledgers,
815
991
  flags,
816
992
  });
817
- if (selected.command === "baseline-prepare") {
818
- const preparationPlan = await planBaselinePreparation({
993
+ try {
994
+ if (selected.command === "baseline-prepare") {
995
+ const preparationPlan = await planBaselinePreparation({
996
+ ...planOptions,
997
+ recordsPath: flags.recordsPath,
998
+ ledgerPath: flags.ledgerPath,
999
+ });
1000
+ const preview = {
1001
+ ...summarizeBaselinePreparation(preparationPlan, {
1002
+ mode: "preview",
1003
+ }),
1004
+ nextAction:
1005
+ "Review this schema-only summary. No file is written unless you approve below.",
1006
+ };
1007
+ console.log(`\n${renderBaselinePreparation(preview, flags)}\n`);
1008
+ const accepted = await promptForConfirmation(
1009
+ `Create the REVIEW REQUIRED draft at ${preview.destination}?`,
1010
+ flags,
1011
+ );
1012
+ if (!accepted) {
1013
+ console.log("No changes made.");
1014
+ return "refresh";
1015
+ }
1016
+ flags.preparationPlan = preparationPlan;
1017
+ flags.write = true;
1018
+ return "baseline prepare";
1019
+ }
1020
+ flags.assetsPath = await promptForDiscoveredPath({
1021
+ message: "Storage assets",
1022
+ candidates: discovered.assetManifests,
1023
+ optional: true,
1024
+ flags,
1025
+ });
1026
+ const inspection = await inspectBaselineInputFiles({
819
1027
  ...planOptions,
820
1028
  recordsPath: flags.recordsPath,
821
1029
  ledgerPath: flags.ledgerPath,
1030
+ assetsPath: flags.assetsPath,
822
1031
  });
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`);
1032
+ console.log(`\n${renderBaselineInputInspection(inspection, flags)}\n`);
831
1033
  const accepted = await promptForConfirmation(
832
- `Create the REVIEW REQUIRED draft at ${preview.destination}?`,
1034
+ "Create and activate this immutable local baseline?",
833
1035
  flags,
834
1036
  );
835
1037
  if (!accepted) {
836
1038
  console.log("No changes made.");
837
1039
  return "refresh";
838
1040
  }
839
- flags.preparationPlan = preparationPlan;
840
- flags.write = true;
841
- return "baseline prepare";
1041
+ return "baseline create";
1042
+ } catch (error) {
1043
+ const message = redactDiagnosticValue(String(error?.message ?? error));
1044
+ if (useStyledPrompts(flags)) {
1045
+ prompts.cancel("These baseline inputs need attention.");
1046
+ prompts.note(message, "How to continue");
1047
+ } else {
1048
+ console.log(`\nBASELINE INPUTS NEED ATTENTION\n${message}\n`);
1049
+ }
1050
+ return "refresh";
842
1051
  }
843
- flags.assetsPath = await promptForPath({
844
- message: "Storage asset manifest (optional)",
845
- optional: true,
846
- flags,
847
- });
848
- return "baseline create";
849
1052
  }
850
1053
  if (selected.command !== "setup-write") return selected.command;
851
1054
 
@@ -1314,6 +1517,56 @@ const renderBaselinePreparation = (result, flags = {}) =>
1314
1517
  result.nextAction,
1315
1518
  ].join("\n");
1316
1519
 
1520
+ const renderBaselineInputInspection = (result, flags = {}) =>
1521
+ [
1522
+ "BASELINE INPUTS — READY",
1523
+ `Records: ${formatCount(result.rowCount, "row")} across ${formatCount(result.tables.length, "table")} from ${result.recordsPath}`,
1524
+ `Migrations: ${formatCount(result.migrationCount, "migration")} through ${result.migrationCutoff} from ${result.ledgerPath}`,
1525
+ result.assetsPath
1526
+ ? `Storage: ${formatCount(result.assetCount, "asset")} from ${result.assetsPath}`
1527
+ : "Storage: no asset manifest selected",
1528
+ "",
1529
+ "Detected tables (values are never printed)",
1530
+ ...result.tables.map(
1531
+ (table) =>
1532
+ ` ${terminalStyle(flags, "36", "→")} ${table.name}: ${formatCount(table.rowCount, "row")}`,
1533
+ ),
1534
+ "",
1535
+ "All inputs are project-local regular files and passed structural validation.",
1536
+ ].join("\n");
1537
+
1538
+ const renderSupportReport = (report) => {
1539
+ const toolLine = (label, tool) =>
1540
+ `${label}: ${tool.status === "available" ? tool.version : tool.status.replaceAll("_", " ")}`;
1541
+ return [
1542
+ "REHEARSAL SUPPORT REPORT",
1543
+ "",
1544
+ `Rehearsal: ${report.rehearsalVersion}`,
1545
+ `System: ${report.system.platform} ${report.system.architecture} (${report.system.release})`,
1546
+ `Node.js: ${report.system.node}`,
1547
+ toolLine("npm", report.tools.npm),
1548
+ toolLine("Supabase CLI", report.tools.supabase),
1549
+ toolLine("Docker client", report.tools.dockerClient),
1550
+ toolLine("Docker server", report.tools.dockerServer),
1551
+ "",
1552
+ `Readiness: ${report.readiness.state}`,
1553
+ ...report.readiness.checks.map((check) =>
1554
+ check.status === "pass"
1555
+ ? ` ✓ ${check.label}`
1556
+ : ` ✗ ${check.label}${check.remediation ? ` — ${check.remediation}` : ""}`,
1557
+ ),
1558
+ ...(report.readiness.quarantinedHostedVariableCount
1559
+ ? [
1560
+ ` ! ${formatCount(report.readiness.quarantinedHostedVariableCount, "ambient hosted variable")} detected and quarantined`,
1561
+ ]
1562
+ : []),
1563
+ "",
1564
+ "Privacy: no row values, credentials, project paths, migration SQL, or baseline identifiers are included.",
1565
+ "Review this report before sharing it.",
1566
+ `Report a reproducible issue: ${report.supportUrl}`,
1567
+ ].join("\n");
1568
+ };
1569
+
1317
1570
  const usage = () => `Usage: rehearsal <command> [options]
1318
1571
 
1319
1572
  Commands:
@@ -1323,6 +1576,7 @@ Commands:
1323
1576
  baseline prepare --records= --ledger= [--write] Create a fail-closed policy draft
1324
1577
  baseline create --records= --ledger= [--assets=] Create a baseline from safe local inputs
1325
1578
  doctor Check whether Rehearsal is safe and ready
1579
+ support Print a safe, copy-ready support report
1326
1580
  explain Show the immutable execution plan
1327
1581
  run --dry-run Alias the exact explain plan without mutations
1328
1582
  run --confirm-candidates= Execute reset, migration, and verification locally
@@ -1365,6 +1619,12 @@ const executeCommand = async ({ command, flags, planOptions, guided }) => {
1365
1619
  return;
1366
1620
  }
1367
1621
  if (command === "baseline create") {
1622
+ await inspectBaselineInputFiles({
1623
+ ...planOptions,
1624
+ recordsPath: flags.recordsPath,
1625
+ ledgerPath: flags.ledgerPath,
1626
+ assetsPath: flags.assetsPath,
1627
+ });
1368
1628
  const data = await createSyntheticBaselineFromFiles({
1369
1629
  ...planOptions,
1370
1630
  recordsPath: flags.recordsPath,
@@ -1398,6 +1658,11 @@ const executeCommand = async ({ command, flags, planOptions, guided }) => {
1398
1658
  if (data.state !== "READY" && !guided) process.exitCode = 1;
1399
1659
  return;
1400
1660
  }
1661
+ if (command === "support") {
1662
+ const data = await collectRehearsalSupportReport(planOptions);
1663
+ emit({ command, data, flags, render: renderSupportReport });
1664
+ return;
1665
+ }
1401
1666
  if (command === "explain" || (command === "run" && flags.dryRun)) {
1402
1667
  const data = await buildRehearsalPlan(planOptions);
1403
1668
  emit({ command, data, flags, render: renderPlan });