@rehearsal-db/core 0.1.0-beta.3 → 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.
- package/CHANGELOG.md +38 -1
- package/README.md +13 -2
- package/SUPPORT.md +5 -0
- package/docs/commands.md +21 -2
- package/docs/getting-started.md +16 -2
- package/docs/releasing.md +1 -1
- package/docs/sanitization.md +9 -0
- package/package.json +6 -3
- package/scripts/lib/rehearsal/baseline_preparation.mjs +138 -23
- package/scripts/lib/rehearsal/diagnostics.mjs +1 -1
- package/scripts/lib/rehearsal/input_discovery.mjs +155 -0
- package/scripts/lib/rehearsal/policy_review.mjs +92 -0
- package/scripts/lib/rehearsal/support_report.mjs +102 -0
- package/scripts/operations/rehearsal/rehearsal_cli.mjs +759 -133
|
@@ -11,6 +11,7 @@ import { join, relative } from "node:path";
|
|
|
11
11
|
import { performance } from "node:perf_hooks";
|
|
12
12
|
import { createInterface } from "node:readline/promises";
|
|
13
13
|
import { fileURLToPath } from "node:url";
|
|
14
|
+
import * as prompts from "@clack/prompts";
|
|
14
15
|
import {
|
|
15
16
|
RehearsalError,
|
|
16
17
|
createRehearsalResult,
|
|
@@ -35,9 +36,11 @@ import {
|
|
|
35
36
|
import { createSyntheticBaselineFromFiles } from "../../lib/rehearsal/baseline_builder.mjs";
|
|
36
37
|
import {
|
|
37
38
|
applyBaselinePreparation,
|
|
39
|
+
inspectBaselineInputFiles,
|
|
38
40
|
planBaselinePreparation,
|
|
39
41
|
summarizeBaselinePreparation,
|
|
40
42
|
} from "../../lib/rehearsal/baseline_preparation.mjs";
|
|
43
|
+
import { discoverBaselineInputFiles } from "../../lib/rehearsal/input_discovery.mjs";
|
|
41
44
|
import {
|
|
42
45
|
applyRehearsalSetup,
|
|
43
46
|
inspectRehearsalNodeRuntime,
|
|
@@ -45,6 +48,14 @@ import {
|
|
|
45
48
|
summarizeRehearsalSetup,
|
|
46
49
|
} from "../../lib/rehearsal/setup.mjs";
|
|
47
50
|
import { formatCount } from "../../lib/rehearsal/human_output.mjs";
|
|
51
|
+
import {
|
|
52
|
+
applyReviewedPolicy,
|
|
53
|
+
completePolicyDraft,
|
|
54
|
+
createSafeTablePreset,
|
|
55
|
+
readReviewablePolicyDraft,
|
|
56
|
+
suggestPolicyExceptionColumns,
|
|
57
|
+
} from "../../lib/rehearsal/policy_review.mjs";
|
|
58
|
+
import { collectRehearsalSupportReport } from "../../lib/rehearsal/support_report.mjs";
|
|
48
59
|
|
|
49
60
|
const packageRoot = fileURLToPath(new URL("../../..", import.meta.url));
|
|
50
61
|
const projectRoot = process.cwd();
|
|
@@ -54,6 +65,7 @@ const managerPath = join(
|
|
|
54
65
|
packageRoot,
|
|
55
66
|
"scripts/operations/database/manage_rehearsal_database.mjs",
|
|
56
67
|
);
|
|
68
|
+
let lastGuidedDetails;
|
|
57
69
|
|
|
58
70
|
const parseArguments = (arguments_) => {
|
|
59
71
|
const flags = {
|
|
@@ -255,12 +267,28 @@ const terminalStyle = (flags, code, value) =>
|
|
|
255
267
|
const isHumanTerminal = (flags) =>
|
|
256
268
|
!flags.json && process.stdin.isTTY && process.stdout.isTTY;
|
|
257
269
|
|
|
270
|
+
const useStyledPrompts = (flags) => isHumanTerminal(flags) && useColor(flags);
|
|
271
|
+
|
|
258
272
|
const formatDuration = (durationMs) =>
|
|
259
273
|
durationMs < 1_000
|
|
260
274
|
? `${Math.round(durationMs)}ms`
|
|
261
275
|
: `${(durationMs / 1_000).toFixed(1)}s`;
|
|
262
276
|
|
|
263
|
-
const promptForChoice = async ({ message, options }) => {
|
|
277
|
+
const promptForChoice = async ({ message, options, flags = {} }) => {
|
|
278
|
+
if (useStyledPrompts(flags)) {
|
|
279
|
+
const selected = await prompts.select({
|
|
280
|
+
message,
|
|
281
|
+
maxItems: options.length,
|
|
282
|
+
options: options.map((option) => ({
|
|
283
|
+
value: option,
|
|
284
|
+
label: option.label,
|
|
285
|
+
hint: option.hint,
|
|
286
|
+
})),
|
|
287
|
+
});
|
|
288
|
+
return prompts.isCancel(selected)
|
|
289
|
+
? options.find((option) => option.command === "exit")
|
|
290
|
+
: selected;
|
|
291
|
+
}
|
|
264
292
|
const prompt = createInterface({
|
|
265
293
|
input: process.stdin,
|
|
266
294
|
output: process.stdout,
|
|
@@ -283,7 +311,15 @@ const promptForChoice = async ({ message, options }) => {
|
|
|
283
311
|
}
|
|
284
312
|
};
|
|
285
313
|
|
|
286
|
-
const promptForConfirmation = async (
|
|
314
|
+
const promptForConfirmation = async (
|
|
315
|
+
message,
|
|
316
|
+
flags = {},
|
|
317
|
+
{ cancelValue = false } = {},
|
|
318
|
+
) => {
|
|
319
|
+
if (useStyledPrompts(flags)) {
|
|
320
|
+
const accepted = await prompts.confirm({ message, initialValue: false });
|
|
321
|
+
return prompts.isCancel(accepted) ? cancelValue : accepted;
|
|
322
|
+
}
|
|
287
323
|
const prompt = createInterface({
|
|
288
324
|
input: process.stdin,
|
|
289
325
|
output: process.stdout,
|
|
@@ -298,7 +334,27 @@ const promptForConfirmation = async (message) => {
|
|
|
298
334
|
}
|
|
299
335
|
};
|
|
300
336
|
|
|
301
|
-
const promptForPath = async ({
|
|
337
|
+
const promptForPath = async ({
|
|
338
|
+
message,
|
|
339
|
+
defaultValue,
|
|
340
|
+
optional = false,
|
|
341
|
+
flags = {},
|
|
342
|
+
validate,
|
|
343
|
+
}) => {
|
|
344
|
+
if (useStyledPrompts(flags)) {
|
|
345
|
+
const answer = await prompts.text({
|
|
346
|
+
message,
|
|
347
|
+
placeholder: defaultValue ?? (optional ? "Leave blank for none" : ""),
|
|
348
|
+
defaultValue,
|
|
349
|
+
validate: (value) => {
|
|
350
|
+
const resolved = String(value || defaultValue || "").trim();
|
|
351
|
+
if (!optional && !resolved) return "Enter a value.";
|
|
352
|
+
return resolved && validate ? validate(resolved) : undefined;
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
if (prompts.isCancel(answer)) return undefined;
|
|
356
|
+
return String(answer || defaultValue || "").trim() || undefined;
|
|
357
|
+
}
|
|
302
358
|
const prompt = createInterface({
|
|
303
359
|
input: process.stdin,
|
|
304
360
|
output: process.stdout,
|
|
@@ -312,6 +368,11 @@ const promptForPath = async ({ message, defaultValue, optional = false }) => {
|
|
|
312
368
|
: " (required)";
|
|
313
369
|
const answer = (await prompt.question(`${message}${suffix}: `)).trim();
|
|
314
370
|
const value = answer || defaultValue || "";
|
|
371
|
+
const invalid = value && validate ? validate(value) : undefined;
|
|
372
|
+
if (invalid) {
|
|
373
|
+
console.log(invalid);
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
315
376
|
if (value || optional) return value || undefined;
|
|
316
377
|
console.log("Enter a project-relative path.");
|
|
317
378
|
}
|
|
@@ -320,11 +381,107 @@ const promptForPath = async ({ message, defaultValue, optional = false }) => {
|
|
|
320
381
|
}
|
|
321
382
|
};
|
|
322
383
|
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
|
|
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
|
+
|
|
417
|
+
const promptForSelection = async ({ message, options, flags }) => {
|
|
418
|
+
const selected = await promptForChoice({ message, options, flags });
|
|
419
|
+
return selected?.value;
|
|
420
|
+
};
|
|
421
|
+
|
|
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();
|
|
326
484
|
}
|
|
327
|
-
return undefined;
|
|
328
485
|
};
|
|
329
486
|
|
|
330
487
|
const inspectGuidedProject = async (planOptions) => {
|
|
@@ -391,6 +548,7 @@ const inspectGuidedProject = async (planOptions) => {
|
|
|
391
548
|
baseline: Boolean(migrationSummary),
|
|
392
549
|
policy,
|
|
393
550
|
policyPath: relative(projectRoot, loaded.paths.sanitizationPolicy),
|
|
551
|
+
policyAbsolutePath: loaded.paths.sanitizationPolicy,
|
|
394
552
|
migrationSummary,
|
|
395
553
|
runtime: await pathExists(
|
|
396
554
|
join(loaded.paths.runtimeWorkdir, "baseline.json"),
|
|
@@ -398,6 +556,205 @@ const inspectGuidedProject = async (planOptions) => {
|
|
|
398
556
|
};
|
|
399
557
|
};
|
|
400
558
|
|
|
559
|
+
const reviewPolicyInteractively = async ({ state, flags }) => {
|
|
560
|
+
const { source, draft } = await readReviewablePolicyDraft(
|
|
561
|
+
state.policyAbsolutePath,
|
|
562
|
+
);
|
|
563
|
+
const totalColumns = draft.tables.reduce(
|
|
564
|
+
(total, table) => total + (table.columns?.length ?? 0),
|
|
565
|
+
0,
|
|
566
|
+
);
|
|
567
|
+
const tableSummaries = [];
|
|
568
|
+
let individuallyReviewed = 0;
|
|
569
|
+
if (useStyledPrompts(flags)) {
|
|
570
|
+
prompts.note(
|
|
571
|
+
[
|
|
572
|
+
`${formatCount(totalColumns, "column")} across ${formatCount(draft.tables.length, "table")} require explicit review.`,
|
|
573
|
+
"No row values are displayed or changed by this step.",
|
|
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.",
|
|
576
|
+
].join("\n"),
|
|
577
|
+
"Review the script",
|
|
578
|
+
);
|
|
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
|
+
};
|
|
674
|
+
const policy = await completePolicyDraft({
|
|
675
|
+
draft,
|
|
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")}?`,
|
|
684
|
+
flags,
|
|
685
|
+
options: [
|
|
686
|
+
{
|
|
687
|
+
label: "Use safe defaults, then review exceptions",
|
|
688
|
+
hint: "Best for larger tables",
|
|
689
|
+
value: "preset",
|
|
690
|
+
},
|
|
691
|
+
{
|
|
692
|
+
label: "Review every column individually",
|
|
693
|
+
value: "individual",
|
|
694
|
+
},
|
|
695
|
+
],
|
|
696
|
+
});
|
|
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`,
|
|
709
|
+
flags,
|
|
710
|
+
options: columns.map((column) => ({ label: column, value: column })),
|
|
711
|
+
initialValues: suggested,
|
|
712
|
+
});
|
|
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,
|
|
720
|
+
});
|
|
721
|
+
if (useStyledPrompts(flags)) {
|
|
722
|
+
prompts.log.info(
|
|
723
|
+
`${table}: ${formatCount(columns.length - exceptions.length, "safe default")}; ${formatCount(exceptions.length, "exception")}.`,
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
return tableDecisions;
|
|
727
|
+
},
|
|
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
|
+
}
|
|
739
|
+
const accepted = await promptForConfirmation(
|
|
740
|
+
`Save ${formatCount(totalColumns, "classified column")} and activate this policy?`,
|
|
741
|
+
flags,
|
|
742
|
+
);
|
|
743
|
+
if (!accepted) return false;
|
|
744
|
+
await applyReviewedPolicy({
|
|
745
|
+
path: state.policyAbsolutePath,
|
|
746
|
+
originalSource: source,
|
|
747
|
+
policy,
|
|
748
|
+
});
|
|
749
|
+
const result = `${formatCount(totalColumns, "column")} classified (${formatCount(defaultedColumns, "safe default")}; ${formatCount(individuallyReviewed, "individual review")}).`;
|
|
750
|
+
if (useStyledPrompts(flags)) {
|
|
751
|
+
prompts.log.success(`${result} The policy is ready for baseline creation.`);
|
|
752
|
+
} else {
|
|
753
|
+
console.log(`Reviewed policy saved: ${result}`);
|
|
754
|
+
}
|
|
755
|
+
return true;
|
|
756
|
+
};
|
|
757
|
+
|
|
401
758
|
const runGuidedHome = async ({ flags, planOptions }) => {
|
|
402
759
|
const state = await inspectGuidedProject(planOptions);
|
|
403
760
|
const marker = (complete) =>
|
|
@@ -407,89 +764,126 @@ const runGuidedHome = async ({ flags, planOptions }) => {
|
|
|
407
764
|
if (!state.node.supported) {
|
|
408
765
|
options.push({
|
|
409
766
|
label: "Show Node.js 24 setup instructions",
|
|
767
|
+
hint: "Required before Rehearsal can change this project",
|
|
410
768
|
command: "node-help",
|
|
411
769
|
});
|
|
412
770
|
} else if (state.config === "missing") {
|
|
413
771
|
options.push({
|
|
414
|
-
label: "Set
|
|
772
|
+
label: "Set the stage",
|
|
773
|
+
hint: "Create safe local configuration",
|
|
415
774
|
command: "setup-write",
|
|
416
775
|
});
|
|
417
|
-
} else {
|
|
418
|
-
options.push({ label: "Check readiness", command: "doctor" });
|
|
419
|
-
}
|
|
420
|
-
if (state.node.supported && state.baseline) {
|
|
776
|
+
} else if (state.baseline) {
|
|
421
777
|
options.push(
|
|
422
|
-
{ label: "Review the migration plan", command: "explain" },
|
|
423
778
|
{ label: "Run a rehearsal", command: "run" },
|
|
779
|
+
{ label: "Review the migration plan", command: "explain" },
|
|
424
780
|
{ label: "Show candidate migrations", command: "candidates" },
|
|
781
|
+
{ label: "Check readiness", command: "doctor" },
|
|
425
782
|
);
|
|
426
783
|
} else if (state.node.supported && state.config === "valid") {
|
|
427
784
|
if (state.policy === "missing") {
|
|
428
785
|
options.push({
|
|
429
|
-
label: "Prepare
|
|
786
|
+
label: "Prepare the script",
|
|
787
|
+
hint: "Create a reviewable policy draft",
|
|
430
788
|
command: "baseline-prepare",
|
|
431
789
|
});
|
|
432
790
|
} else if (state.policy === "needs-review") {
|
|
433
791
|
options.push({
|
|
434
|
-
label: "Review the
|
|
792
|
+
label: "Review the script",
|
|
793
|
+
hint: "Apply table presets and review exceptions",
|
|
435
794
|
command: "policy-review",
|
|
436
795
|
});
|
|
437
796
|
} else {
|
|
438
797
|
options.push({
|
|
439
|
-
label: "Create
|
|
798
|
+
label: "Create the baseline",
|
|
799
|
+
hint: "Activate reviewed local inputs",
|
|
440
800
|
command: "baseline-guide",
|
|
441
801
|
});
|
|
442
802
|
}
|
|
803
|
+
options.push({ label: "Check readiness", command: "doctor" });
|
|
804
|
+
} else {
|
|
805
|
+
options.push({ label: "Check readiness", command: "doctor" });
|
|
443
806
|
}
|
|
444
807
|
if (state.node.supported && state.runtime) {
|
|
445
|
-
options.push(
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
808
|
+
options.push({
|
|
809
|
+
label: "Manage the local runtime",
|
|
810
|
+
hint: "Status, verification, and cleanup",
|
|
811
|
+
command: "manage-runtime",
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
if (lastGuidedDetails) {
|
|
815
|
+
options.push({
|
|
816
|
+
label: "Show details from the last action",
|
|
817
|
+
command: "last-details",
|
|
818
|
+
});
|
|
452
819
|
}
|
|
453
820
|
options.push(
|
|
821
|
+
{
|
|
822
|
+
label: "Get help",
|
|
823
|
+
hint: "Create a safe, copy-ready support report",
|
|
824
|
+
command: "support",
|
|
825
|
+
},
|
|
454
826
|
{ label: "Show all commands", command: "help" },
|
|
455
827
|
{ label: "Exit", command: "exit" },
|
|
456
828
|
);
|
|
457
829
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
830
|
+
const statusLines = [
|
|
831
|
+
`${marker(state.node.supported)} Node.js ${state.node.version}${state.node.supported ? "" : " (Node.js 24 required)"}`,
|
|
832
|
+
`${marker(state.detected.hasSupabaseConfig)} Supabase project detected`,
|
|
833
|
+
`${marker(state.detected.hasMigrations)} Migration history detected`,
|
|
834
|
+
`${marker(state.config === "valid")} Rehearsal configuration${state.config === "invalid" ? " needs attention" : ""}`,
|
|
835
|
+
...(state.config === "valid"
|
|
836
|
+
? [
|
|
837
|
+
`${marker(state.policy === "reviewed")} ${state.policy === "needs-review" ? "Baseline policy needs review" : state.policy === "reviewed" ? "Baseline policy reviewed" : "Baseline policy needed"}`,
|
|
838
|
+
]
|
|
839
|
+
: []),
|
|
840
|
+
`${marker(state.baseline)} Verified baseline`,
|
|
841
|
+
...(state.runtime ? [`${marker(true)} Disposable runtime created`] : []),
|
|
842
|
+
...(state.configError
|
|
843
|
+
? ["", terminalStyle(flags, "33", `! ${state.configError}`)]
|
|
844
|
+
: []),
|
|
845
|
+
];
|
|
846
|
+
const stage =
|
|
847
|
+
state.config !== "valid"
|
|
848
|
+
? 1
|
|
849
|
+
: state.policy !== "reviewed"
|
|
850
|
+
? 2
|
|
851
|
+
: !state.baseline
|
|
852
|
+
? 3
|
|
853
|
+
: 4;
|
|
854
|
+
if (useStyledPrompts(flags)) {
|
|
855
|
+
prompts.note(
|
|
856
|
+
statusLines.join("\n"),
|
|
857
|
+
`${state.detected.projectName} · Stage ${stage} of 4`,
|
|
858
|
+
);
|
|
859
|
+
} else {
|
|
860
|
+
console.log(
|
|
861
|
+
[
|
|
862
|
+
"",
|
|
863
|
+
terminalStyle(flags, "1;36", "REHEARSAL"),
|
|
864
|
+
"Safe local migration testing",
|
|
865
|
+
"",
|
|
866
|
+
terminalStyle(flags, "1", state.detected.projectName),
|
|
867
|
+
`Stage ${stage} of 4`,
|
|
868
|
+
...statusLines,
|
|
869
|
+
"",
|
|
870
|
+
"What would you like to do?",
|
|
871
|
+
"",
|
|
872
|
+
...options.map(
|
|
873
|
+
(option, index) =>
|
|
874
|
+
` ${index === 0 ? terminalStyle(flags, "36", "›") : " "} ${index + 1}. ${option.label}`,
|
|
875
|
+
),
|
|
876
|
+
"",
|
|
877
|
+
].join("\n"),
|
|
878
|
+
);
|
|
879
|
+
}
|
|
489
880
|
|
|
490
|
-
const selected = await promptForChoice({
|
|
881
|
+
const selected = await promptForChoice({
|
|
882
|
+
message: "What would you like to do?",
|
|
883
|
+
options,
|
|
884
|
+
flags,
|
|
885
|
+
});
|
|
491
886
|
if (selected.command === "exit") {
|
|
492
|
-
console.log("No changes made.");
|
|
493
887
|
return null;
|
|
494
888
|
}
|
|
495
889
|
if (selected.command === "node-help") {
|
|
@@ -506,20 +900,55 @@ const runGuidedHome = async ({ flags, planOptions }) => {
|
|
|
506
900
|
"Then reinstall Rehearsal in your scratch project and run npx rehearsal again.",
|
|
507
901
|
].join("\n"),
|
|
508
902
|
);
|
|
509
|
-
return
|
|
903
|
+
return "refresh";
|
|
510
904
|
}
|
|
511
905
|
if (selected.command === "policy-review") {
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
"
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
906
|
+
try {
|
|
907
|
+
await reviewPolicyInteractively({ state, flags });
|
|
908
|
+
} catch (error) {
|
|
909
|
+
if (String(error?.message ?? error) !== "Policy review was cancelled.") {
|
|
910
|
+
throw error;
|
|
911
|
+
}
|
|
912
|
+
if (useStyledPrompts(flags)) {
|
|
913
|
+
prompts.cancel("Policy review cancelled; the draft was not changed.");
|
|
914
|
+
} else {
|
|
915
|
+
console.log("Policy review cancelled; the draft was not changed.");
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
return "refresh";
|
|
919
|
+
}
|
|
920
|
+
if (selected.command === "last-details") {
|
|
921
|
+
if (useStyledPrompts(flags)) {
|
|
922
|
+
prompts.note(lastGuidedDetails, "Technical details");
|
|
923
|
+
} else {
|
|
924
|
+
console.log(`\nTECHNICAL DETAILS\n${lastGuidedDetails}`);
|
|
925
|
+
}
|
|
926
|
+
return "refresh";
|
|
927
|
+
}
|
|
928
|
+
if (selected.command === "manage-runtime") {
|
|
929
|
+
const runtimeAction = await promptForChoice({
|
|
930
|
+
message: "Manage the disposable local runtime",
|
|
931
|
+
flags,
|
|
932
|
+
options: [
|
|
933
|
+
{ label: "Show status", command: "status" },
|
|
934
|
+
{ label: "Verify runtime", command: "verify" },
|
|
935
|
+
{ label: "Reset to the immutable baseline", command: "reset" },
|
|
936
|
+
{ label: "Stop runtime", command: "stop" },
|
|
937
|
+
{ label: "Discard runtime", command: "discard" },
|
|
938
|
+
{ label: "Back", command: "exit" },
|
|
939
|
+
],
|
|
940
|
+
});
|
|
941
|
+
if (!runtimeAction || runtimeAction.command === "exit") return "refresh";
|
|
942
|
+
if (["reset", "discard"].includes(runtimeAction.command)) {
|
|
943
|
+
const accepted = await promptForConfirmation(
|
|
944
|
+
runtimeAction.command === "reset"
|
|
945
|
+
? "Reset the disposable runtime and remove its current changes?"
|
|
946
|
+
: "Discard the disposable runtime? The immutable baseline will remain.",
|
|
947
|
+
flags,
|
|
948
|
+
);
|
|
949
|
+
if (!accepted) return "refresh";
|
|
950
|
+
}
|
|
951
|
+
return runtimeAction.command;
|
|
523
952
|
}
|
|
524
953
|
if (["baseline-guide", "baseline-prepare"].includes(selected.command)) {
|
|
525
954
|
console.log(
|
|
@@ -531,51 +960,95 @@ const runGuidedHome = async ({ flags, planOptions }) => {
|
|
|
531
960
|
"",
|
|
532
961
|
].join("\n"),
|
|
533
962
|
);
|
|
534
|
-
const
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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({
|
|
542
984
|
message: "Sanitized NDJSON records",
|
|
543
|
-
|
|
985
|
+
candidates: discovered.records,
|
|
986
|
+
flags,
|
|
544
987
|
});
|
|
545
|
-
flags.ledgerPath = await
|
|
988
|
+
flags.ledgerPath = await promptForDiscoveredPath({
|
|
546
989
|
message: "Migration ledger",
|
|
547
|
-
|
|
990
|
+
candidates: discovered.ledgers,
|
|
991
|
+
flags,
|
|
548
992
|
});
|
|
549
|
-
|
|
550
|
-
|
|
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({
|
|
551
1027
|
...planOptions,
|
|
552
1028
|
recordsPath: flags.recordsPath,
|
|
553
1029
|
ledgerPath: flags.ledgerPath,
|
|
1030
|
+
assetsPath: flags.assetsPath,
|
|
554
1031
|
});
|
|
555
|
-
|
|
556
|
-
...summarizeBaselinePreparation(preparationPlan, {
|
|
557
|
-
mode: "preview",
|
|
558
|
-
}),
|
|
559
|
-
nextAction:
|
|
560
|
-
"Review this schema-only summary. No file is written unless you approve below.",
|
|
561
|
-
};
|
|
562
|
-
console.log(`\n${renderBaselinePreparation(preview, flags)}\n`);
|
|
1032
|
+
console.log(`\n${renderBaselineInputInspection(inspection, flags)}\n`);
|
|
563
1033
|
const accepted = await promptForConfirmation(
|
|
564
|
-
|
|
1034
|
+
"Create and activate this immutable local baseline?",
|
|
1035
|
+
flags,
|
|
565
1036
|
);
|
|
566
1037
|
if (!accepted) {
|
|
567
1038
|
console.log("No changes made.");
|
|
568
|
-
return
|
|
1039
|
+
return "refresh";
|
|
1040
|
+
}
|
|
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`);
|
|
569
1049
|
}
|
|
570
|
-
|
|
571
|
-
flags.write = true;
|
|
572
|
-
return "baseline prepare";
|
|
1050
|
+
return "refresh";
|
|
573
1051
|
}
|
|
574
|
-
flags.assetsPath = await promptForPath({
|
|
575
|
-
message: "Storage asset manifest (optional)",
|
|
576
|
-
optional: true,
|
|
577
|
-
});
|
|
578
|
-
return "baseline create";
|
|
579
1052
|
}
|
|
580
1053
|
if (selected.command !== "setup-write") return selected.command;
|
|
581
1054
|
|
|
@@ -588,10 +1061,11 @@ const runGuidedHome = async ({ flags, planOptions }) => {
|
|
|
588
1061
|
console.log(`\n${renderSetup(preview)}\n`);
|
|
589
1062
|
const accepted = await promptForConfirmation(
|
|
590
1063
|
"Create these project-local files?",
|
|
1064
|
+
flags,
|
|
591
1065
|
);
|
|
592
1066
|
if (!accepted) {
|
|
593
1067
|
console.log("No changes made.");
|
|
594
|
-
return
|
|
1068
|
+
return "refresh";
|
|
595
1069
|
}
|
|
596
1070
|
flags.setupPlan = setupPlan;
|
|
597
1071
|
flags.write = true;
|
|
@@ -652,6 +1126,7 @@ const prepareCandidateConfirmation = async ({
|
|
|
652
1126
|
);
|
|
653
1127
|
const accepted = await promptForConfirmation(
|
|
654
1128
|
`Apply exactly ${summary.candidateCount === 1 ? "this migration" : "these migrations"}?`,
|
|
1129
|
+
flags,
|
|
655
1130
|
);
|
|
656
1131
|
if (!accepted) {
|
|
657
1132
|
console.log("No changes made.");
|
|
@@ -709,7 +1184,10 @@ const runManager = ({ action, flags }) => {
|
|
|
709
1184
|
discard: "Removing the disposable local runtime",
|
|
710
1185
|
verify: "Verifying the current local runtime",
|
|
711
1186
|
}[action];
|
|
712
|
-
|
|
1187
|
+
const spinner = useStyledPrompts(flags) ? prompts.spinner() : null;
|
|
1188
|
+
if (spinner) {
|
|
1189
|
+
spinner.start(`${actionDescription}. This can take a moment.`);
|
|
1190
|
+
} else if (isHumanTerminal(flags)) {
|
|
713
1191
|
console.log(
|
|
714
1192
|
`\n${terminalStyle(flags, "1;36", action === "run" ? "REHEARSING" : "REHEARSAL")}\n${terminalStyle(flags, "36", "→")} ${actionDescription}. This can take a moment.`,
|
|
715
1193
|
);
|
|
@@ -721,8 +1199,12 @@ const runManager = ({ action, flags }) => {
|
|
|
721
1199
|
stdio: ["inherit", "pipe", "pipe"],
|
|
722
1200
|
maxBuffer: 16 * 1024 * 1024,
|
|
723
1201
|
});
|
|
724
|
-
if (result.error)
|
|
1202
|
+
if (result.error) {
|
|
1203
|
+
if (spinner) spinner.stop(`${actionDescription} did not complete.`);
|
|
1204
|
+
throw result.error;
|
|
1205
|
+
}
|
|
725
1206
|
if (result.status !== 0) {
|
|
1207
|
+
if (spinner) spinner.stop(`${actionDescription} did not complete.`);
|
|
726
1208
|
const completeOutput = [
|
|
727
1209
|
String(result.stdout ?? ""),
|
|
728
1210
|
String(result.stderr ?? ""),
|
|
@@ -757,7 +1239,11 @@ const runManager = ({ action, flags }) => {
|
|
|
757
1239
|
});
|
|
758
1240
|
}
|
|
759
1241
|
const durationMs = performance.now() - runtimeStartedMs;
|
|
760
|
-
if (
|
|
1242
|
+
if (spinner) {
|
|
1243
|
+
spinner.stop(
|
|
1244
|
+
`${actionDescription} completed in ${formatDuration(durationMs)}.`,
|
|
1245
|
+
);
|
|
1246
|
+
} else if (isHumanTerminal(flags)) {
|
|
761
1247
|
console.log(
|
|
762
1248
|
`${terminalStyle(flags, "32", "✓")} Local runtime step completed in ${formatDuration(durationMs)}.`,
|
|
763
1249
|
);
|
|
@@ -913,7 +1399,9 @@ const runSetup = async ({ flags, planOptions }) => {
|
|
|
913
1399
|
readiness.state === "READY"
|
|
914
1400
|
? "Next: run rehearsal explain to review the migration plan."
|
|
915
1401
|
: needsBaselinePolicy
|
|
916
|
-
?
|
|
1402
|
+
? flags.guided
|
|
1403
|
+
? "Next: choose Prepare the script to create a reviewable policy draft."
|
|
1404
|
+
: "Next: run rehearsal again and choose Prepare a reviewable baseline policy draft."
|
|
917
1405
|
: "Next: complete the remaining readiness items shown above.",
|
|
918
1406
|
};
|
|
919
1407
|
};
|
|
@@ -998,9 +1486,16 @@ const runBaselinePreparation = async ({ flags, planOptions }) => {
|
|
|
998
1486
|
ledgerPath: flags.ledgerPath,
|
|
999
1487
|
}));
|
|
1000
1488
|
if (flags.write) await applyBaselinePreparation(plan);
|
|
1001
|
-
|
|
1489
|
+
const result = summarizeBaselinePreparation(plan, {
|
|
1002
1490
|
mode: flags.write ? "written" : "preview",
|
|
1003
1491
|
});
|
|
1492
|
+
return flags.guided && flags.write
|
|
1493
|
+
? {
|
|
1494
|
+
...result,
|
|
1495
|
+
nextAction:
|
|
1496
|
+
"Next: choose Review the script to classify every column interactively.",
|
|
1497
|
+
}
|
|
1498
|
+
: result;
|
|
1004
1499
|
};
|
|
1005
1500
|
|
|
1006
1501
|
const renderBaselinePreparation = (result, flags = {}) =>
|
|
@@ -1022,6 +1517,56 @@ const renderBaselinePreparation = (result, flags = {}) =>
|
|
|
1022
1517
|
result.nextAction,
|
|
1023
1518
|
].join("\n");
|
|
1024
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
|
+
|
|
1025
1570
|
const usage = () => `Usage: rehearsal <command> [options]
|
|
1026
1571
|
|
|
1027
1572
|
Commands:
|
|
@@ -1031,6 +1576,7 @@ Commands:
|
|
|
1031
1576
|
baseline prepare --records= --ledger= [--write] Create a fail-closed policy draft
|
|
1032
1577
|
baseline create --records= --ledger= [--assets=] Create a baseline from safe local inputs
|
|
1033
1578
|
doctor Check whether Rehearsal is safe and ready
|
|
1579
|
+
support Print a safe, copy-ready support report
|
|
1034
1580
|
explain Show the immutable execution plan
|
|
1035
1581
|
run --dry-run Alias the exact explain plan without mutations
|
|
1036
1582
|
run --confirm-candidates= Execute reset, migration, and verification locally
|
|
@@ -1047,29 +1593,7 @@ Commands:
|
|
|
1047
1593
|
|
|
1048
1594
|
Options: --json --verbose --debug --plain --config=<path>`;
|
|
1049
1595
|
|
|
1050
|
-
const
|
|
1051
|
-
const { flags, positionals } = parseArguments(process.argv.slice(2));
|
|
1052
|
-
let command = positionals.join(" ") || "help";
|
|
1053
|
-
const planOptions = {
|
|
1054
|
-
projectRoot,
|
|
1055
|
-
configPath: flags.configPath,
|
|
1056
|
-
};
|
|
1057
|
-
const wantsAutomaticGuide =
|
|
1058
|
-
positionals.length === 0 &&
|
|
1059
|
-
!flags.help &&
|
|
1060
|
-
!flags.json &&
|
|
1061
|
-
process.stdin.isTTY &&
|
|
1062
|
-
process.stdout.isTTY;
|
|
1063
|
-
if (command === "guide" || wantsAutomaticGuide) {
|
|
1064
|
-
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
1065
|
-
throw new Error(
|
|
1066
|
-
"The guided home screen requires an interactive terminal. Run rehearsal --help to list scriptable commands.",
|
|
1067
|
-
);
|
|
1068
|
-
}
|
|
1069
|
-
const selected = await runGuidedHome({ flags, planOptions });
|
|
1070
|
-
if (!selected) return;
|
|
1071
|
-
command = selected;
|
|
1072
|
-
}
|
|
1596
|
+
const executeCommand = async ({ command, flags, planOptions, guided }) => {
|
|
1073
1597
|
if (command === "help" || flags.help) {
|
|
1074
1598
|
console.log(usage());
|
|
1075
1599
|
return;
|
|
@@ -1095,6 +1619,12 @@ const main = async () => {
|
|
|
1095
1619
|
return;
|
|
1096
1620
|
}
|
|
1097
1621
|
if (command === "baseline create") {
|
|
1622
|
+
await inspectBaselineInputFiles({
|
|
1623
|
+
...planOptions,
|
|
1624
|
+
recordsPath: flags.recordsPath,
|
|
1625
|
+
ledgerPath: flags.ledgerPath,
|
|
1626
|
+
assetsPath: flags.assetsPath,
|
|
1627
|
+
});
|
|
1098
1628
|
const data = await createSyntheticBaselineFromFiles({
|
|
1099
1629
|
...planOptions,
|
|
1100
1630
|
recordsPath: flags.recordsPath,
|
|
@@ -1109,7 +1639,9 @@ const main = async () => {
|
|
|
1109
1639
|
[
|
|
1110
1640
|
`Activated synthetic baseline ${baseline.generationId}: ${formatCount(baseline.rowCount, "row")} across ${formatCount(baseline.tableCount, "table")}; ${formatCount(baseline.migrationCount, "migration")} through ${baseline.migrationCutoff}.`,
|
|
1111
1641
|
"",
|
|
1112
|
-
|
|
1642
|
+
flags.guided
|
|
1643
|
+
? "Next: choose Run a rehearsal when you are ready."
|
|
1644
|
+
: "Next: run rehearsal doctor to check readiness.",
|
|
1113
1645
|
].join("\n"),
|
|
1114
1646
|
});
|
|
1115
1647
|
return;
|
|
@@ -1123,7 +1655,12 @@ const main = async () => {
|
|
|
1123
1655
|
render: renderDoctor,
|
|
1124
1656
|
status: data.state === "READY" ? "success" : "not_ready",
|
|
1125
1657
|
});
|
|
1126
|
-
if (data.state !== "READY") process.exitCode = 1;
|
|
1658
|
+
if (data.state !== "READY" && !guided) process.exitCode = 1;
|
|
1659
|
+
return;
|
|
1660
|
+
}
|
|
1661
|
+
if (command === "support") {
|
|
1662
|
+
const data = await collectRehearsalSupportReport(planOptions);
|
|
1663
|
+
emit({ command, data, flags, render: renderSupportReport });
|
|
1127
1664
|
return;
|
|
1128
1665
|
}
|
|
1129
1666
|
if (command === "explain" || (command === "run" && flags.dryRun)) {
|
|
@@ -1171,19 +1708,34 @@ const main = async () => {
|
|
|
1171
1708
|
const runtime = runManager({ action: command, flags });
|
|
1172
1709
|
let applicationProof;
|
|
1173
1710
|
if (command === "run") {
|
|
1174
|
-
|
|
1711
|
+
const proofSpinner = useStyledPrompts(flags) ? prompts.spinner() : null;
|
|
1712
|
+
if (proofSpinner) {
|
|
1713
|
+
proofSpinner.start("Running the project-owned application proof.");
|
|
1714
|
+
} else if (isHumanTerminal(flags)) {
|
|
1175
1715
|
console.log(
|
|
1176
1716
|
`${terminalStyle(flags, "36", "→")} Running the project-owned application proof.`,
|
|
1177
1717
|
);
|
|
1178
1718
|
}
|
|
1179
|
-
|
|
1180
|
-
|
|
1719
|
+
try {
|
|
1720
|
+
applicationProof = await runApplicationProof(planOptions);
|
|
1721
|
+
} catch (error) {
|
|
1722
|
+
if (proofSpinner) proofSpinner.stop("Application proof failed.");
|
|
1723
|
+
throw error;
|
|
1724
|
+
}
|
|
1725
|
+
if (proofSpinner) {
|
|
1726
|
+
proofSpinner.stop("Application proof passed.");
|
|
1727
|
+
} else if (isHumanTerminal(flags)) {
|
|
1181
1728
|
console.log(
|
|
1182
1729
|
`${terminalStyle(flags, "32", "✓")} Application proof passed.`,
|
|
1183
1730
|
);
|
|
1184
1731
|
}
|
|
1185
1732
|
}
|
|
1186
1733
|
const data = command === "run" ? { runtime, applicationProof } : runtime;
|
|
1734
|
+
if (guided) {
|
|
1735
|
+
lastGuidedDetails = [runtime.output, applicationProof?.output]
|
|
1736
|
+
.filter(Boolean)
|
|
1737
|
+
.join("\n");
|
|
1738
|
+
}
|
|
1187
1739
|
emit({
|
|
1188
1740
|
command,
|
|
1189
1741
|
data,
|
|
@@ -1203,6 +1755,23 @@ const main = async () => {
|
|
|
1203
1755
|
verify:
|
|
1204
1756
|
"Next: continue testing, reset to the baseline, or stop the runtime.",
|
|
1205
1757
|
}[runtimeResult.action];
|
|
1758
|
+
if (guided) {
|
|
1759
|
+
const title =
|
|
1760
|
+
runtimeResult.action === "run"
|
|
1761
|
+
? "REHEARSAL PASSED"
|
|
1762
|
+
: "ACTION COMPLETE";
|
|
1763
|
+
return [
|
|
1764
|
+
title,
|
|
1765
|
+
`✓ ${runtimeResult.action} completed in ${formatDuration(runtimeResult.durationMs)}`,
|
|
1766
|
+
value.applicationProof
|
|
1767
|
+
? `✓ Application proof passed: ${value.applicationProof.command}`
|
|
1768
|
+
: null,
|
|
1769
|
+
"",
|
|
1770
|
+
"Technical output is available from Show details in the guide.",
|
|
1771
|
+
]
|
|
1772
|
+
.filter(Boolean)
|
|
1773
|
+
.join("\n");
|
|
1774
|
+
}
|
|
1206
1775
|
return [
|
|
1207
1776
|
runtimeResult.output ||
|
|
1208
1777
|
`Rehearsal ${runtimeResult.action} completed.`,
|
|
@@ -1221,6 +1790,63 @@ const main = async () => {
|
|
|
1221
1790
|
throw new Error(`Unknown Rehearsal command: ${command}.\n\n${usage()}`);
|
|
1222
1791
|
};
|
|
1223
1792
|
|
|
1793
|
+
const resetGuidedFlags = (flags) => {
|
|
1794
|
+
flags.dryRun = false;
|
|
1795
|
+
flags.write = false;
|
|
1796
|
+
flags.confirmation = undefined;
|
|
1797
|
+
flags.recordsPath = undefined;
|
|
1798
|
+
flags.ledgerPath = undefined;
|
|
1799
|
+
flags.assetsPath = undefined;
|
|
1800
|
+
flags.setupPlan = undefined;
|
|
1801
|
+
flags.preparationPlan = undefined;
|
|
1802
|
+
};
|
|
1803
|
+
|
|
1804
|
+
const main = async () => {
|
|
1805
|
+
const { flags, positionals } = parseArguments(process.argv.slice(2));
|
|
1806
|
+
const command = positionals.join(" ") || "help";
|
|
1807
|
+
const planOptions = {
|
|
1808
|
+
projectRoot,
|
|
1809
|
+
configPath: flags.configPath,
|
|
1810
|
+
};
|
|
1811
|
+
const wantsAutomaticGuide =
|
|
1812
|
+
positionals.length === 0 &&
|
|
1813
|
+
!flags.help &&
|
|
1814
|
+
!flags.json &&
|
|
1815
|
+
process.stdin.isTTY &&
|
|
1816
|
+
process.stdout.isTTY;
|
|
1817
|
+
const guided = command === "guide" || wantsAutomaticGuide;
|
|
1818
|
+
if (!guided) {
|
|
1819
|
+
await executeCommand({ command, flags, planOptions, guided: false });
|
|
1820
|
+
return;
|
|
1821
|
+
}
|
|
1822
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
1823
|
+
throw new Error(
|
|
1824
|
+
"The guided home screen requires an interactive terminal. Run rehearsal --help to list scriptable commands.",
|
|
1825
|
+
);
|
|
1826
|
+
}
|
|
1827
|
+
flags.guided = true;
|
|
1828
|
+
if (useStyledPrompts(flags)) {
|
|
1829
|
+
prompts.intro("REHEARSAL · Safe local migration testing");
|
|
1830
|
+
}
|
|
1831
|
+
while (true) {
|
|
1832
|
+
resetGuidedFlags(flags);
|
|
1833
|
+
const selected = await runGuidedHome({ flags, planOptions });
|
|
1834
|
+
if (!selected) break;
|
|
1835
|
+
if (selected === "refresh") continue;
|
|
1836
|
+
await executeCommand({
|
|
1837
|
+
command: selected,
|
|
1838
|
+
flags,
|
|
1839
|
+
planOptions,
|
|
1840
|
+
guided: true,
|
|
1841
|
+
});
|
|
1842
|
+
}
|
|
1843
|
+
if (useStyledPrompts(flags)) {
|
|
1844
|
+
prompts.outro("See you at the next rehearsal.");
|
|
1845
|
+
} else {
|
|
1846
|
+
console.log("See you at the next rehearsal.");
|
|
1847
|
+
}
|
|
1848
|
+
};
|
|
1849
|
+
|
|
1224
1850
|
try {
|
|
1225
1851
|
await main();
|
|
1226
1852
|
} catch (error) {
|