@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.
- package/CHANGELOG.md +55 -1
- package/COMPATIBILITY.md +5 -0
- package/README.md +105 -368
- package/SECURITY.md +2 -1
- package/SUPPORT.md +5 -0
- package/docs/adapters.md +5 -0
- package/docs/baselines.md +81 -22
- package/docs/commands.md +76 -64
- package/docs/configuration.md +47 -4
- package/docs/getting-started.md +87 -218
- package/docs/glossary.md +7 -8
- package/docs/sanitization.md +8 -5
- package/docs/security-model.md +5 -1
- package/docs/troubleshooting.md +46 -1
- package/docs/tutorial.md +70 -59
- package/package.json +5 -3
- package/scripts/lib/rehearsal/baseline_preparation.mjs +138 -23
- package/scripts/lib/rehearsal/configuration.d.mts +18 -3
- package/scripts/lib/rehearsal/configuration.mjs +227 -56
- package/scripts/lib/rehearsal/diagnostics.mjs +4 -2
- package/scripts/lib/rehearsal/input_discovery.mjs +155 -0
- package/scripts/lib/rehearsal/plan.mjs +85 -10
- package/scripts/lib/rehearsal/policy_review.mjs +45 -5
- package/scripts/lib/rehearsal/runtime_restore.mjs +6 -2
- package/scripts/lib/rehearsal/setup.mjs +60 -28
- package/scripts/lib/rehearsal/support_report.mjs +102 -0
- package/scripts/lib/runtime/postgresql_runtime.mjs +772 -0
- package/scripts/lib/runtime/runtime_target.mjs +41 -0
- package/scripts/lib/runtime/supabase_runtime.mjs +790 -0
- package/scripts/operations/database/manage_rehearsal_database.mjs +6 -785
- package/scripts/operations/rehearsal/rehearsal_cli.mjs +522 -149
|
@@ -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 ({
|
|
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
|
|
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
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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: "
|
|
492
|
-
hint: "
|
|
493
|
-
value: "
|
|
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: "
|
|
502
|
-
value: "
|
|
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 (!
|
|
513
|
-
|
|
514
|
-
|
|
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
|
-
|
|
518
|
-
{ label: "Yes", value: "ALWAYS" },
|
|
519
|
-
],
|
|
733
|
+
options: columns.map((column) => ({ label: column, value: column })),
|
|
734
|
+
initialValues: suggested,
|
|
520
735
|
});
|
|
521
|
-
if (!
|
|
522
|
-
const
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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 (
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
|
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, "
|
|
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: "
|
|
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(
|
|
670
|
-
|
|
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
|
|
991
|
+
"It does not extract data or contact a hosted database.",
|
|
797
992
|
"",
|
|
798
993
|
].join("\n"),
|
|
799
994
|
);
|
|
800
|
-
const
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
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
|
-
|
|
1021
|
+
candidates: discovered.records,
|
|
810
1022
|
flags,
|
|
811
1023
|
});
|
|
812
|
-
flags.ledgerPath = await
|
|
1024
|
+
flags.ledgerPath = await promptForDiscoveredPath({
|
|
813
1025
|
message: "Migration ledger",
|
|
814
|
-
|
|
1026
|
+
candidates: discovered.ledgers,
|
|
815
1027
|
flags,
|
|
816
1028
|
});
|
|
817
|
-
|
|
818
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
840
|
-
|
|
841
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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]
|
|
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
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
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.");
|