@rehearsal-db/core 0.1.0-beta.7 → 0.1.0-beta.9

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.
Files changed (64) hide show
  1. package/CHANGELOG.md +77 -1
  2. package/COMPATIBILITY.md +15 -3
  3. package/README.md +31 -13
  4. package/docs/README.md +29 -0
  5. package/docs/adapters.md +15 -6
  6. package/docs/architecture.md +58 -0
  7. package/docs/baselines.md +22 -1
  8. package/docs/commands.md +81 -14
  9. package/docs/configuration.md +144 -11
  10. package/docs/getting-started.md +12 -2
  11. package/docs/glossary.md +4 -0
  12. package/docs/production-source.md +176 -33
  13. package/docs/roadmap.md +30 -28
  14. package/docs/runtime-policies.md +191 -0
  15. package/docs/sanitization.md +24 -8
  16. package/docs/security-model.md +29 -8
  17. package/docs/standalone-workflow.md +107 -0
  18. package/docs/troubleshooting.md +8 -0
  19. package/package.json +25 -22
  20. package/scripts/runtime/manage_database.mjs +18 -0
  21. package/src/README.md +17 -0
  22. package/src/application/session.mjs +313 -0
  23. package/{scripts/lib/rehearsal/baseline_artifact.mjs → src/baseline/artifact.mjs} +110 -14
  24. package/{scripts/lib/rehearsal/baseline_builder.mjs → src/baseline/builder.mjs} +8 -4
  25. package/{scripts/lib/rehearsal/baseline_preparation.mjs → src/baseline/preparation.mjs} +11 -4
  26. package/src/baseline/privacy_engine.mjs +413 -0
  27. package/{scripts/lib/rehearsal → src/baseline}/sanitization_policy.mjs +27 -7
  28. package/{scripts/lib/rehearsal → src/baseline}/schema_snapshot.mjs +1 -1
  29. package/src/cli/arguments.mjs +120 -0
  30. package/src/cli/guided.mjs +807 -0
  31. package/src/cli/rehearsal.mjs +933 -0
  32. package/src/cli/renderers.mjs +584 -0
  33. package/src/cli/runtime_commands.mjs +553 -0
  34. package/src/cli/source_commands.mjs +326 -0
  35. package/src/cli/terminal.mjs +275 -0
  36. package/src/identity/claim.mjs +975 -0
  37. package/src/identity/storage.mjs +165 -0
  38. package/{scripts/lib/rehearsal → src/project}/configuration.d.mts +34 -0
  39. package/{scripts/lib/rehearsal → src/project}/configuration.mjs +353 -3
  40. package/{scripts/lib/rehearsal → src/project}/setup.mjs +1 -1
  41. package/{scripts/lib/rehearsal → src/project}/support_report.mjs +5 -2
  42. package/{scripts/lib/rehearsal → src/runtime}/cleanup.mjs +3 -3
  43. package/{scripts/lib/rehearsal → src/runtime}/plan.mjs +5 -5
  44. package/src/runtime/policy.mjs +438 -0
  45. package/{scripts/lib/rehearsal/runtime_restore.mjs → src/runtime/restore.mjs} +40 -25
  46. package/src/runtime/topology.mjs +177 -0
  47. package/{scripts/lib/rehearsal → src/shared}/diagnostics.mjs +1 -1
  48. package/src/shared/operation_guard.mjs +162 -0
  49. package/{scripts/lib/rehearsal → src/shared}/process_environment.mjs +5 -1
  50. package/src/source/access.mjs +578 -0
  51. package/src/source/asset_transfer.mjs +177 -0
  52. package/src/source/baseline.mjs +446 -0
  53. package/src/source/postgresql_access.mjs +480 -0
  54. package/{scripts/lib/runtime/postgresql_runtime.mjs → src/targets/postgresql.mjs} +49 -13
  55. package/{scripts/lib/runtime/supabase_runtime.mjs → src/targets/supabase.mjs} +54 -16
  56. package/{scripts/lib/environment/local_supabase.mjs → src/targets/supabase_environment.mjs} +52 -20
  57. package/{scripts/lib/runtime/runtime_target.mjs → src/targets/target.mjs} +27 -2
  58. package/scripts/operations/database/manage_rehearsal_database.mjs +0 -11
  59. package/scripts/operations/rehearsal/rehearsal_cli.mjs +0 -2166
  60. /package/{scripts/lib/rehearsal → src/baseline}/input_discovery.mjs +0 -0
  61. /package/{scripts/lib/rehearsal → src/baseline}/policy_review.mjs +0 -0
  62. /package/{scripts/lib/rehearsal → src/runtime}/migration_history.mjs +0 -0
  63. /package/{scripts/lib/rehearsal → src/runtime}/service_environment.mjs +0 -0
  64. /package/{scripts/lib/rehearsal → src/shared}/human_output.mjs +0 -0
@@ -1,2166 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Purpose: Run the versioned, project-configured Rehearsal developer CLI for
4
- * initialization, readiness, planning, inspection, and verified local execution.
5
- * Run: `npm run rehearsal -- doctor` or `npm run rehearsal -- explain`.
6
- */
7
-
8
- import { spawnSync } from "node:child_process";
9
- import { access, readFile, writeFile } from "node:fs/promises";
10
- import { join, relative } from "node:path";
11
- import { performance } from "node:perf_hooks";
12
- import { createInterface } from "node:readline/promises";
13
- import { fileURLToPath } from "node:url";
14
- import * as prompts from "@clack/prompts";
15
- import {
16
- RehearsalError,
17
- createRehearsalResult,
18
- normalizeRehearsalError,
19
- redactDiagnosticValue,
20
- renderHumanError,
21
- serializeRehearsalError,
22
- } from "../../lib/rehearsal/diagnostics.mjs";
23
- import {
24
- inspectDetectedProject,
25
- findRehearsalConfigPath,
26
- loadRehearsalConfig,
27
- renderDetectedConfig,
28
- validateRuntimeSanitizationPolicy,
29
- } from "../../lib/rehearsal/configuration.mjs";
30
- import {
31
- buildRehearsalPlan,
32
- inspectRehearsalBaseline,
33
- inspectRehearsalMigrations,
34
- runRehearsalDoctor,
35
- } from "../../lib/rehearsal/plan.mjs";
36
- import { createSyntheticBaselineFromFiles } from "../../lib/rehearsal/baseline_builder.mjs";
37
- import {
38
- applyBaselinePreparation,
39
- inspectBaselineInputFiles,
40
- planBaselinePreparation,
41
- summarizeBaselinePreparation,
42
- } from "../../lib/rehearsal/baseline_preparation.mjs";
43
- import { discoverBaselineInputFiles } from "../../lib/rehearsal/input_discovery.mjs";
44
- import {
45
- applyRehearsalSetup,
46
- inspectRehearsalNodeRuntime,
47
- planRehearsalSetup,
48
- summarizeRehearsalSetup,
49
- } from "../../lib/rehearsal/setup.mjs";
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";
59
- import {
60
- applyRehearsalCleanup,
61
- planRehearsalCleanup,
62
- } from "../../lib/rehearsal/cleanup.mjs";
63
-
64
- const packageRoot = fileURLToPath(new URL("../../..", import.meta.url));
65
- const projectRoot = process.cwd();
66
- const commandStartedAt = new Date();
67
- const commandStartedMs = performance.now();
68
- const managerPath = join(
69
- packageRoot,
70
- "scripts/operations/database/manage_rehearsal_database.mjs",
71
- );
72
- let lastGuidedDetails;
73
-
74
- const parseArguments = (arguments_) => {
75
- const flags = {
76
- json: false,
77
- help: false,
78
- verbosity: "normal",
79
- dryRun: false,
80
- write: false,
81
- plain: false,
82
- configPath: undefined,
83
- confirmation: undefined,
84
- recordsPath: undefined,
85
- ledgerPath: undefined,
86
- assetsPath: undefined,
87
- target: undefined,
88
- includeRuntime: false,
89
- includeImages: false,
90
- cleanupConfirmation: undefined,
91
- };
92
- const positionals = [];
93
- for (const argument of arguments_) {
94
- if (argument === "--json") flags.json = true;
95
- else if (argument === "--help" || argument === "-h") flags.help = true;
96
- else if (argument === "--verbose") flags.verbosity = "verbose";
97
- else if (argument === "--debug") flags.verbosity = "debug";
98
- else if (argument === "--dry-run") flags.dryRun = true;
99
- else if (argument === "--write") flags.write = true;
100
- else if (argument === "--plain") flags.plain = true;
101
- else if (argument.startsWith("--config=")) {
102
- flags.configPath = argument.slice("--config=".length);
103
- } else if (argument.startsWith("--confirm-candidates=")) {
104
- flags.confirmation = argument.slice("--confirm-candidates=".length);
105
- } else if (argument.startsWith("--confirm-cleanup=")) {
106
- flags.cleanupConfirmation = argument.slice("--confirm-cleanup=".length);
107
- } else if (argument.startsWith("--records=")) {
108
- flags.recordsPath = argument.slice("--records=".length);
109
- } else if (argument.startsWith("--ledger=")) {
110
- flags.ledgerPath = argument.slice("--ledger=".length);
111
- } else if (argument.startsWith("--assets=")) {
112
- flags.assetsPath = argument.slice("--assets=".length);
113
- } else if (argument.startsWith("--target=")) {
114
- flags.target = argument.slice("--target=".length);
115
- } else if (argument === "--include-runtime") {
116
- flags.includeRuntime = true;
117
- } else if (argument === "--include-images") {
118
- flags.includeImages = true;
119
- } else if (argument.startsWith("--")) {
120
- throw new Error(`Unknown Rehearsal option: ${argument}.`);
121
- } else positionals.push(argument);
122
- }
123
- return { flags, positionals };
124
- };
125
-
126
- const renderPlan = (plan, verbosity) => {
127
- const candidates = plan.migrations.candidates.length
128
- ? plan.migrations.candidates
129
- .map((entry) => ` → ${entry.filename} (${entry.sha256.slice(0, 12)})`)
130
- .join("\n")
131
- : " ✓ No candidate migrations";
132
- const verbose =
133
- verbosity === "normal"
134
- ? []
135
- : [
136
- "",
137
- "Safety barriers",
138
- ...plan.environment.barriers.map((barrier) => ` ✓ ${barrier}`),
139
- ` ✓ Candidate digest: ${plan.migrations.candidateSha256}`,
140
- ];
141
- return [
142
- "REHEARSAL PLAN",
143
- "",
144
- "Environment",
145
- ` ✓ ${plan.environment.kind}`,
146
- ` ✓ Hosted access ${plan.environment.hostedAccess}`,
147
- ` ✓ Hosted target configuration ${plan.environment.outboundNetwork}`,
148
- ...(plan.environment.authenticationProviders.length
149
- ? [
150
- ` ✓ External identity providers ${plan.environment.authenticationProviders.join(", ")}`,
151
- ]
152
- : []),
153
- "",
154
- "Baseline",
155
- ` ✓ ${plan.baseline.generationId}`,
156
- ` ✓ ${formatCount(plan.baseline.tableCount, "table")}; ${formatCount(plan.baseline.rowCount, "row")}`,
157
- ` ✓ ${plan.baseline.verification}`,
158
- "",
159
- "Migrations",
160
- ` ✓ ${plan.migrations.representedCount} represented by baseline`,
161
- candidates,
162
- "",
163
- "Execution plan",
164
- ...plan.execution.map((step, index) => ` ${index + 1}. ${step}`),
165
- ...verbose,
166
- "",
167
- plan.guarantee,
168
- "",
169
- plan.migrations.candidateCount
170
- ? "Next: run rehearsal run to review and confirm this exact candidate set."
171
- : "Next: run rehearsal run to verify the baseline and application proof.",
172
- ].join("\n");
173
- };
174
-
175
- const renderDoctor = (doctor, verbosity) => {
176
- const lines = doctor.checks.map((check) => {
177
- const marker = check.status === "pass" ? "✓" : "✗";
178
- const detail =
179
- verbosity === "normal" && check.status === "pass"
180
- ? ""
181
- : ` — ${check.detail}`;
182
- return `${marker} ${check.label}${detail}`;
183
- });
184
- for (const check of doctor.checks.filter(
185
- (entry) => entry.status === "fail",
186
- )) {
187
- if (check.remediation) lines.push(` Try: ${check.remediation}`);
188
- }
189
- if (doctor.ambientHostedVariables?.presentButQuarantined.length) {
190
- lines.push(
191
- `! ${doctor.ambientHostedVariables.presentButQuarantined.length} ambient hosted credential variables detected and quarantined.`,
192
- );
193
- }
194
- return [
195
- ...lines,
196
- "",
197
- doctor.state,
198
- "",
199
- doctor.state === "READY"
200
- ? "Next: run rehearsal explain to review the migration plan."
201
- : "Next: fix the items above, then run rehearsal doctor again.",
202
- ].join("\n");
203
- };
204
-
205
- const renderBaseline = (baseline) =>
206
- [
207
- "REHEARSAL BASELINE",
208
- `Rehearsal: ${baseline.rehearsalVersion}`,
209
- `Generation: ${baseline.generationId}`,
210
- `Created: ${baseline.createdAt ?? "unknown"}`,
211
- `Format: ${baseline.formatVersion}`,
212
- `Tables: ${baseline.tableCount}`,
213
- `Rows: ${baseline.rowCount}`,
214
- `Migrations: ${baseline.migrationCount} through ${baseline.migrationCutoff}`,
215
- `Data SHA-256: ${baseline.dataSha256}`,
216
- `Sanitization policy SHA-256: ${baseline.sanitizationPolicySha256}`,
217
- `Verification: ${baseline.verification}`,
218
- "Source rows and sensitive values are never printed by inspect.",
219
- ].join("\n");
220
-
221
- const renderMigrations = (inspection, verbosity) => {
222
- const entries = inspection.migrations.map((migration) => {
223
- const digest = verbosity === "normal" ? "" : ` (${migration.sha256})`;
224
- return `${migration.status.padEnd(28)} ${migration.filename}${digest}`;
225
- });
226
- return [
227
- "REHEARSAL MIGRATIONS",
228
- `Baseline: ${inspection.baselineGenerationId} through ${inspection.baselineCutoff}`,
229
- `Candidate digest: ${inspection.candidateSha256}`,
230
- "",
231
- ...entries,
232
- ].join("\n");
233
- };
234
-
235
- const candidateSummary = (inspection) => {
236
- const candidates = inspection.migrations.filter(
237
- (migration) => migration.status === "candidate",
238
- );
239
- return {
240
- baselineGenerationId: inspection.baselineGenerationId,
241
- candidateSha256: inspection.candidateSha256,
242
- candidateCount: candidates.length,
243
- candidates,
244
- };
245
- };
246
-
247
- const renderCandidates = (summary, verbosity) =>
248
- [
249
- "REHEARSAL CANDIDATES",
250
- `Baseline: ${summary.baselineGenerationId}`,
251
- `Candidate digest: ${summary.candidateSha256}`,
252
- `Pending: ${summary.candidateCount}`,
253
- ...(summary.candidates.length
254
- ? summary.candidates.map((migration) =>
255
- verbosity === "normal"
256
- ? ` → ${migration.filename}`
257
- : ` → ${migration.filename} (${migration.sha256})`,
258
- )
259
- : [" ✓ No candidate migrations"]),
260
- "",
261
- summary.candidateCount
262
- ? "Next: run rehearsal run for a guided confirmation, or pass the full digest in automation."
263
- : "Next: run rehearsal run to verify the baseline and application proof.",
264
- ].join("\n");
265
-
266
- const pathExists = (path) =>
267
- access(path)
268
- .then(() => true)
269
- .catch((error) => {
270
- if (error?.code === "ENOENT") return false;
271
- throw error;
272
- });
273
-
274
- const useColor = (flags) =>
275
- !flags.plain &&
276
- process.stdout.isTTY &&
277
- process.env.NO_COLOR === undefined &&
278
- process.env.TERM !== "dumb";
279
-
280
- const terminalStyle = (flags, code, value) =>
281
- useColor(flags) ? `\u001B[${code}m${value}\u001B[0m` : value;
282
-
283
- const isHumanTerminal = (flags) =>
284
- !flags.json && process.stdin.isTTY && process.stdout.isTTY;
285
-
286
- const useStyledPrompts = (flags) => isHumanTerminal(flags) && useColor(flags);
287
-
288
- const formatDuration = (durationMs) =>
289
- durationMs < 1_000
290
- ? `${Math.round(durationMs)}ms`
291
- : `${(durationMs / 1_000).toFixed(1)}s`;
292
-
293
- const promptForChoice = async ({
294
- message,
295
- options,
296
- flags = {},
297
- optionsAlreadyShown = false,
298
- }) => {
299
- if (useStyledPrompts(flags)) {
300
- const selected = await prompts.select({
301
- message,
302
- maxItems: options.length,
303
- options: options.map((option) => ({
304
- value: option,
305
- label: option.label,
306
- hint: option.hint,
307
- })),
308
- });
309
- return prompts.isCancel(selected)
310
- ? options.find((option) => option.command === "exit")
311
- : selected;
312
- }
313
- if (!optionsAlreadyShown) {
314
- console.log(
315
- [
316
- "",
317
- ...options.map(
318
- (option, index) =>
319
- ` ${index === 0 ? "›" : " "} ${index + 1}. ${option.label}${option.hint ? ` — ${option.hint}` : ""}`,
320
- ),
321
- "",
322
- ].join("\n"),
323
- );
324
- }
325
- const prompt = createInterface({
326
- input: process.stdin,
327
- output: process.stdout,
328
- });
329
- try {
330
- while (true) {
331
- const answer = (await prompt.question(`${message} [1]: `)).trim();
332
- const selected = answer === "" ? 1 : Number(answer);
333
- if (
334
- Number.isInteger(selected) &&
335
- selected >= 1 &&
336
- selected <= options.length
337
- ) {
338
- return options[selected - 1];
339
- }
340
- console.log(`Choose a number from 1 to ${options.length}.`);
341
- }
342
- } finally {
343
- prompt.close();
344
- }
345
- };
346
-
347
- const promptForConfirmation = async (
348
- message,
349
- flags = {},
350
- { cancelValue = false } = {},
351
- ) => {
352
- if (useStyledPrompts(flags)) {
353
- const accepted = await prompts.confirm({ message, initialValue: false });
354
- return prompts.isCancel(accepted) ? cancelValue : accepted;
355
- }
356
- const prompt = createInterface({
357
- input: process.stdin,
358
- output: process.stdout,
359
- });
360
- try {
361
- const answer = (await prompt.question(`${message} (y/N) `))
362
- .trim()
363
- .toLowerCase();
364
- return answer === "y" || answer === "yes";
365
- } finally {
366
- prompt.close();
367
- }
368
- };
369
-
370
- const promptForPath = async ({
371
- message,
372
- defaultValue,
373
- optional = false,
374
- flags = {},
375
- validate,
376
- }) => {
377
- if (useStyledPrompts(flags)) {
378
- const answer = await prompts.text({
379
- message,
380
- placeholder: defaultValue ?? (optional ? "Leave blank for none" : ""),
381
- defaultValue,
382
- validate: (value) => {
383
- const resolved = String(value || defaultValue || "").trim();
384
- if (!optional && !resolved) return "Enter a value.";
385
- return resolved && validate ? validate(resolved) : undefined;
386
- },
387
- });
388
- if (prompts.isCancel(answer)) return undefined;
389
- return String(answer || defaultValue || "").trim() || undefined;
390
- }
391
- const prompt = createInterface({
392
- input: process.stdin,
393
- output: process.stdout,
394
- });
395
- try {
396
- while (true) {
397
- const suffix = defaultValue
398
- ? ` [${defaultValue}]`
399
- : optional
400
- ? ""
401
- : " (required)";
402
- const answer = (await prompt.question(`${message}${suffix}: `)).trim();
403
- const value = answer || defaultValue || "";
404
- const invalid = value && validate ? validate(value) : undefined;
405
- if (invalid) {
406
- console.log(invalid);
407
- continue;
408
- }
409
- if (value || optional) return value || undefined;
410
- console.log("Enter a project-relative path.");
411
- }
412
- } finally {
413
- prompt.close();
414
- }
415
- };
416
-
417
- const promptForDiscoveredPath = async ({
418
- message,
419
- candidates,
420
- optional = false,
421
- flags,
422
- }) => {
423
- if (candidates.length === 0) {
424
- return promptForPath({ message, optional, flags });
425
- }
426
- const noneValue = "\0none";
427
- const manualValue = "\0manual";
428
- const selected = await promptForSelection({
429
- message,
430
- flags,
431
- options: [
432
- ...(optional
433
- ? [
434
- {
435
- label: "No storage assets",
436
- hint: "Continue without an asset manifest",
437
- value: noneValue,
438
- },
439
- ]
440
- : []),
441
- ...candidates.map((path) => ({ label: path, value: path })),
442
- { label: "Enter another path", value: manualValue },
443
- ],
444
- });
445
- if (!selected || selected === noneValue) return undefined;
446
- if (selected !== manualValue) return selected;
447
- return promptForPath({ message, optional, flags });
448
- };
449
-
450
- const promptForSelection = async ({ message, options, flags }) => {
451
- const selected = await promptForChoice({ message, options, flags });
452
- return selected?.value;
453
- };
454
-
455
- const promptForMultipleChoice = async ({
456
- message,
457
- options,
458
- initialValues = [],
459
- flags,
460
- }) => {
461
- if (useStyledPrompts(flags)) {
462
- const selected = await prompts.multiselect({
463
- message,
464
- options: options.map((option) => ({
465
- value: option.value,
466
- label: option.label,
467
- hint: option.hint,
468
- })),
469
- initialValues,
470
- required: false,
471
- });
472
- return prompts.isCancel(selected) ? undefined : selected;
473
- }
474
- const initial = new Set(initialValues);
475
- console.log("");
476
- console.log(message);
477
- for (const [index, option] of options.entries()) {
478
- console.log(
479
- ` ${index + 1}. ${option.label}${initial.has(option.value) ? " (suggested)" : ""}`,
480
- );
481
- }
482
- const defaultNumbers = options
483
- .map((option, index) => (initial.has(option.value) ? index + 1 : undefined))
484
- .filter(Boolean)
485
- .join(",");
486
- const prompt = createInterface({
487
- input: process.stdin,
488
- output: process.stdout,
489
- });
490
- try {
491
- while (true) {
492
- const suffix = defaultNumbers ? ` [${defaultNumbers}]` : " [none]";
493
- const answer = (
494
- await prompt.question(
495
- `Choose comma-separated numbers, all, or none${suffix}: `,
496
- )
497
- )
498
- .trim()
499
- .toLowerCase();
500
- if (!answer) return [...initialValues];
501
- if (answer === "none") return [];
502
- if (answer === "all") return options.map((option) => option.value);
503
- const indexes = answer.split(",").map((value) => Number(value.trim()));
504
- if (
505
- indexes.length > 0 &&
506
- indexes.every(
507
- (index) =>
508
- Number.isInteger(index) && index >= 1 && index <= options.length,
509
- )
510
- ) {
511
- return [...new Set(indexes)].map((index) => options[index - 1].value);
512
- }
513
- console.log(`Choose numbers from 1 to ${options.length}, all, or none.`);
514
- }
515
- } finally {
516
- prompt.close();
517
- }
518
- };
519
-
520
- const inspectGuidedProject = async (planOptions) => {
521
- const detected = await inspectDetectedProject({ projectRoot });
522
- const node = inspectRehearsalNodeRuntime();
523
- let configPath;
524
- try {
525
- configPath = await findRehearsalConfigPath(planOptions);
526
- } catch (error) {
527
- if (
528
- !String(error?.message ?? error).startsWith("No Rehearsal configuration")
529
- ) {
530
- throw error;
531
- }
532
- }
533
- if (!configPath) {
534
- return {
535
- detected,
536
- node,
537
- config: "missing",
538
- target: detected.hasSupabaseConfig ? "supabase" : "postgresql",
539
- baseline: false,
540
- runtime: false,
541
- };
542
- }
543
- let loaded;
544
- try {
545
- loaded = await loadRehearsalConfig(planOptions);
546
- } catch (error) {
547
- return {
548
- detected,
549
- node,
550
- config: "invalid",
551
- target: undefined,
552
- configPath,
553
- configError: redactDiagnosticValue(String(error?.message ?? error)),
554
- baseline: false,
555
- runtime: false,
556
- };
557
- }
558
- let migrationSummary;
559
- try {
560
- migrationSummary = candidateSummary(
561
- await inspectRehearsalMigrations(planOptions),
562
- );
563
- } catch {
564
- // Doctor owns the detailed, safely redacted explanation for an invalid or
565
- // absent baseline. The home screen only needs enough state to guide users.
566
- }
567
- let policy = "missing";
568
- if (await pathExists(loaded.paths.sanitizationPolicy)) {
569
- try {
570
- validateRuntimeSanitizationPolicy(
571
- JSON.parse(await readFile(loaded.paths.sanitizationPolicy, "utf8")),
572
- );
573
- policy = "reviewed";
574
- } catch {
575
- policy = "needs-review";
576
- }
577
- }
578
- return {
579
- detected,
580
- node,
581
- config: "valid",
582
- target: loaded.config.runtime.target,
583
- configPath,
584
- baseline: Boolean(migrationSummary),
585
- policy,
586
- policyPath: relative(projectRoot, loaded.paths.sanitizationPolicy),
587
- policyAbsolutePath: loaded.paths.sanitizationPolicy,
588
- migrationSummary,
589
- runtime: await pathExists(
590
- join(loaded.paths.runtimeWorkdir, "baseline.json"),
591
- ),
592
- };
593
- };
594
-
595
- const reviewPolicyInteractively = async ({ state, flags }) => {
596
- const { source, draft } = await readReviewablePolicyDraft(
597
- state.policyAbsolutePath,
598
- );
599
- const totalColumns = draft.tables.reduce(
600
- (total, table) => total + (table.columns?.length ?? 0),
601
- 0,
602
- );
603
- const tableSummaries = [];
604
- let individuallyReviewed = 0;
605
- if (useStyledPrompts(flags)) {
606
- prompts.note(
607
- [
608
- `${formatCount(totalColumns, "column")} across ${formatCount(draft.tables.length, "table")} require explicit review.`,
609
- "No row values are displayed or changed by this step.",
610
- "Safe defaults replace values, mark generated NEVER and identity NO, and set no foreign key.",
611
- "Likely structural columns start selected for individual review.",
612
- ].join("\n"),
613
- "Review the script",
614
- );
615
- }
616
- const reviewColumn = async ({ table, column }) => {
617
- individuallyReviewed += 1;
618
- const label = `${table}.${column}`;
619
- const action = await promptForSelection({
620
- message: `${label}: how should this value be handled?`,
621
- flags,
622
- options: [
623
- {
624
- label: "Replace with synthetic data",
625
- hint: "Safest default",
626
- value: "REPLACE WITH SYNTHETIC",
627
- },
628
- {
629
- label: "Pseudonymize consistently",
630
- hint: "Stable links without original values",
631
- value: "PSEUDONYMIZE",
632
- },
633
- {
634
- label: "Derive a safe value",
635
- value: "DERIVE",
636
- },
637
- { label: "Exclude this field", value: "EXCLUDE" },
638
- {
639
- label: "Keep exactly",
640
- hint: "Only for values explicitly safe to retain",
641
- value: "KEEP EXACTLY",
642
- },
643
- ],
644
- });
645
- if (!action) throw new Error("Policy review was cancelled.");
646
- const generated = await promptForSelection({
647
- message: `${label}: is this column always database-generated?`,
648
- flags,
649
- options: [
650
- { label: "No", value: "NEVER" },
651
- { label: "Yes", value: "ALWAYS" },
652
- ],
653
- });
654
- if (!generated) throw new Error("Policy review was cancelled.");
655
- const identity = await promptForSelection({
656
- message: `${label}: is this an identity column?`,
657
- flags,
658
- options: [
659
- { label: "No", value: "NO" },
660
- { label: "Yes", value: "YES" },
661
- ],
662
- });
663
- if (!identity) throw new Error("Policy review was cancelled.");
664
- const hasForeignKey = await promptForConfirmation(
665
- `${label}: does this column reference another table?`,
666
- flags,
667
- { cancelValue: null },
668
- );
669
- if (hasForeignKey === null) {
670
- throw new Error("Policy review was cancelled.");
671
- }
672
- let foreignKey = null;
673
- if (hasForeignKey) {
674
- const schema = await promptForPath({
675
- message: `${label}: referenced schema`,
676
- defaultValue: "public",
677
- flags,
678
- validate: (value) =>
679
- /^[a-z][a-z0-9_]{0,62}$/u.test(value)
680
- ? undefined
681
- : "Use a lowercase PostgreSQL identifier.",
682
- });
683
- const referencedTable = await promptForPath({
684
- message: `${label}: referenced table`,
685
- flags,
686
- validate: (value) =>
687
- /^[a-z][a-z0-9_]{0,62}$/u.test(value)
688
- ? undefined
689
- : "Use a lowercase PostgreSQL identifier.",
690
- });
691
- const referencedColumn = await promptForPath({
692
- message: `${label}: referenced column`,
693
- flags,
694
- validate: (value) =>
695
- /^[a-z][a-z0-9_]{0,62}$/u.test(value)
696
- ? undefined
697
- : "Use a lowercase PostgreSQL identifier.",
698
- });
699
- if (!schema || !referencedTable || !referencedColumn) {
700
- throw new Error("Policy review was cancelled.");
701
- }
702
- foreignKey = {
703
- schema,
704
- table: referencedTable,
705
- column: referencedColumn,
706
- };
707
- }
708
- return { action, generated, identity, foreignKey };
709
- };
710
- const policy = await completePolicyDraft({
711
- draft,
712
- reviewColumn,
713
- reviewTable: async ({ table, columns }) => {
714
- if (columns.length === 1) {
715
- tableSummaries.push({ table, defaulted: 0, individual: 1 });
716
- return {};
717
- }
718
- const mode = await promptForSelection({
719
- message: `${table}: how would you like to review ${formatCount(columns.length, "column")}?`,
720
- flags,
721
- options: [
722
- {
723
- label: "Use safe defaults, then review exceptions",
724
- hint: "Best for larger tables",
725
- value: "preset",
726
- },
727
- {
728
- label: "Review every column individually",
729
- value: "individual",
730
- },
731
- ],
732
- });
733
- if (!mode) throw new Error("Policy review was cancelled.");
734
- if (mode === "individual") {
735
- tableSummaries.push({
736
- table,
737
- defaulted: 0,
738
- individual: columns.length,
739
- });
740
- return {};
741
- }
742
- const suggested = suggestPolicyExceptionColumns(columns);
743
- const exceptions = await promptForMultipleChoice({
744
- message: `${table}: select columns that need individual review`,
745
- flags,
746
- options: columns.map((column) => ({ label: column, value: column })),
747
- initialValues: suggested,
748
- });
749
- if (!exceptions) throw new Error("Policy review was cancelled.");
750
- const tableDecisions = createSafeTablePreset(columns);
751
- for (const column of exceptions) delete tableDecisions[column];
752
- tableSummaries.push({
753
- table,
754
- defaulted: columns.length - exceptions.length,
755
- individual: exceptions.length,
756
- });
757
- if (useStyledPrompts(flags)) {
758
- prompts.log.info(
759
- `${table}: ${formatCount(columns.length - exceptions.length, "safe default")}; ${formatCount(exceptions.length, "exception")}.`,
760
- );
761
- }
762
- return tableDecisions;
763
- },
764
- });
765
- const defaultedColumns = totalColumns - individuallyReviewed;
766
- const summaryLines = tableSummaries.map(
767
- (summary) =>
768
- `${summary.table}: ${formatCount(summary.defaulted, "safe default")}; ${formatCount(summary.individual, "individual review")}`,
769
- );
770
- if (useStyledPrompts(flags)) {
771
- prompts.note(summaryLines.join("\n"), "Review summary");
772
- } else {
773
- console.log(["", "Review summary", ...summaryLines, ""].join("\n"));
774
- }
775
- const accepted = await promptForConfirmation(
776
- `Save ${formatCount(totalColumns, "classified column")} and activate this policy?`,
777
- flags,
778
- );
779
- if (!accepted) return false;
780
- await applyReviewedPolicy({
781
- path: state.policyAbsolutePath,
782
- originalSource: source,
783
- policy,
784
- });
785
- const result = `${formatCount(totalColumns, "column")} classified (${formatCount(defaultedColumns, "safe default")}; ${formatCount(individuallyReviewed, "individual review")}).`;
786
- if (useStyledPrompts(flags)) {
787
- prompts.log.success(`${result} The policy is ready for baseline creation.`);
788
- } else {
789
- console.log(`Reviewed policy saved: ${result}`);
790
- }
791
- return true;
792
- };
793
-
794
- const runGuidedHome = async ({ flags, planOptions }) => {
795
- const state = await inspectGuidedProject(planOptions);
796
- const marker = (complete) =>
797
- complete ? terminalStyle(flags, "32", "✓") : terminalStyle(flags, "2", "○");
798
- const options = [];
799
-
800
- if (!state.node.supported) {
801
- options.push({
802
- label: "Show Node.js 24 setup instructions",
803
- hint: "Required before Rehearsal can change this project",
804
- command: "node-help",
805
- });
806
- } else if (state.config === "missing") {
807
- options.push({
808
- label: "Set the stage",
809
- hint: "Create safe local configuration",
810
- command: "setup-write",
811
- });
812
- } else if (state.baseline) {
813
- options.push(
814
- { label: "Run a rehearsal", command: "run" },
815
- { label: "Review the migration plan", command: "explain" },
816
- { label: "Show candidate migrations", command: "candidates" },
817
- { label: "Check readiness", command: "doctor" },
818
- );
819
- } else if (state.node.supported && state.config === "valid") {
820
- if (state.policy === "missing") {
821
- options.push({
822
- label: "Prepare the script",
823
- hint: "Create a reviewable policy draft",
824
- command: "baseline-prepare",
825
- });
826
- } else if (state.policy === "needs-review") {
827
- options.push({
828
- label: "Review the script",
829
- hint: "Apply table presets and review exceptions",
830
- command: "policy-review",
831
- });
832
- } else {
833
- options.push({
834
- label: "Create the baseline",
835
- hint: "Activate reviewed local inputs",
836
- command: "baseline-guide",
837
- });
838
- }
839
- options.push({ label: "Check readiness", command: "doctor" });
840
- } else {
841
- options.push({ label: "Check readiness", command: "doctor" });
842
- }
843
- if (state.node.supported && state.runtime) {
844
- options.push({
845
- label: "Manage the local runtime",
846
- hint: "Status, verification, and cleanup",
847
- command: "manage-runtime",
848
- });
849
- }
850
- if (state.node.supported && state.config === "valid") {
851
- options.push({
852
- label: "Clean up disk space",
853
- hint: "Preview old baselines, the runtime, and unused images",
854
- command: "cleanup-guide",
855
- });
856
- }
857
- if (lastGuidedDetails) {
858
- options.push({
859
- label: "Show details from the last action",
860
- command: "last-details",
861
- });
862
- }
863
- options.push(
864
- {
865
- label: "Get help",
866
- hint: "Create a safe, copy-ready support report",
867
- command: "support",
868
- },
869
- { label: "Show all commands", command: "help" },
870
- { label: "Exit", command: "exit" },
871
- );
872
-
873
- const statusLines = [
874
- `${marker(state.node.supported)} Node.js ${state.node.version}${state.node.supported ? "" : " (Node.js 24 required)"}`,
875
- `${marker(
876
- state.target === "supabase"
877
- ? state.detected.hasSupabaseConfig
878
- : state.detected.hasPostgresqlMigrations,
879
- )} ${state.target === "supabase" ? "Supabase" : "PostgreSQL"} project detected`,
880
- `${marker(
881
- state.target === "supabase"
882
- ? state.detected.hasMigrations
883
- : state.detected.hasPostgresqlMigrations,
884
- )} Migration history detected`,
885
- `${marker(state.config === "valid")} Rehearsal configuration${state.config === "invalid" ? " needs attention" : ""}`,
886
- ...(state.config === "valid"
887
- ? [
888
- `${marker(state.policy === "reviewed")} ${state.policy === "needs-review" ? "Baseline policy needs review" : state.policy === "reviewed" ? "Baseline policy reviewed" : "Baseline policy needed"}`,
889
- ]
890
- : []),
891
- `${marker(state.baseline)} Verified baseline`,
892
- ...(state.runtime ? [`${marker(true)} Disposable runtime created`] : []),
893
- ...(state.configError
894
- ? ["", terminalStyle(flags, "33", `! ${state.configError}`)]
895
- : []),
896
- ];
897
- const stage =
898
- state.config !== "valid"
899
- ? 1
900
- : state.policy !== "reviewed"
901
- ? 2
902
- : !state.baseline
903
- ? 3
904
- : 4;
905
- if (useStyledPrompts(flags)) {
906
- prompts.note(
907
- statusLines.join("\n"),
908
- `${state.detected.projectName} · Stage ${stage} of 4`,
909
- );
910
- } else {
911
- console.log(
912
- [
913
- "",
914
- terminalStyle(flags, "1;36", "REHEARSAL"),
915
- "Safe local migration testing",
916
- "",
917
- terminalStyle(flags, "1", state.detected.projectName),
918
- `Stage ${stage} of 4`,
919
- ...statusLines,
920
- "",
921
- "What would you like to do?",
922
- "",
923
- ...options.map(
924
- (option, index) =>
925
- ` ${index === 0 ? terminalStyle(flags, "36", "›") : " "} ${index + 1}. ${option.label}`,
926
- ),
927
- "",
928
- ].join("\n"),
929
- );
930
- }
931
-
932
- const selected = await promptForChoice({
933
- message: "What would you like to do?",
934
- options,
935
- flags,
936
- optionsAlreadyShown: !useStyledPrompts(flags),
937
- });
938
- if (selected.command === "exit") {
939
- return null;
940
- }
941
- if (selected.command === "node-help") {
942
- console.log(
943
- [
944
- "",
945
- terminalStyle(flags, "1", "NODE.JS 24 REQUIRED"),
946
- `This shell is using Node.js ${state.node.version}. Rehearsal has not changed your project.`,
947
- "",
948
- "With nvm:",
949
- " nvm install 24",
950
- " nvm use 24",
951
- "",
952
- "Then reinstall Rehearsal in your scratch project and run npx rehearsal again.",
953
- ].join("\n"),
954
- );
955
- return "refresh";
956
- }
957
- if (selected.command === "policy-review") {
958
- try {
959
- await reviewPolicyInteractively({ state, flags });
960
- } catch (error) {
961
- if (String(error?.message ?? error) !== "Policy review was cancelled.") {
962
- throw error;
963
- }
964
- if (useStyledPrompts(flags)) {
965
- prompts.cancel("Policy review cancelled; the draft was not changed.");
966
- } else {
967
- console.log("Policy review cancelled; the draft was not changed.");
968
- }
969
- }
970
- return "refresh";
971
- }
972
- if (selected.command === "last-details") {
973
- if (useStyledPrompts(flags)) {
974
- prompts.note(lastGuidedDetails, "Technical details");
975
- } else {
976
- console.log(`\nTECHNICAL DETAILS\n${lastGuidedDetails}`);
977
- }
978
- return "refresh";
979
- }
980
- if (selected.command === "manage-runtime") {
981
- const runtimeAction = await promptForChoice({
982
- message: "Manage the disposable local runtime",
983
- flags,
984
- options: [
985
- { label: "Show status", command: "status" },
986
- { label: "Verify runtime", command: "verify" },
987
- { label: "Reset to the immutable baseline", command: "reset" },
988
- { label: "Stop runtime", command: "stop" },
989
- { label: "Discard runtime", command: "discard" },
990
- { label: "Back", command: "exit" },
991
- ],
992
- });
993
- if (!runtimeAction || runtimeAction.command === "exit") return "refresh";
994
- if (["reset", "discard"].includes(runtimeAction.command)) {
995
- const accepted = await promptForConfirmation(
996
- runtimeAction.command === "reset"
997
- ? "Reset the disposable runtime and remove its current changes?"
998
- : "Discard the disposable runtime? The immutable baseline will remain.",
999
- flags,
1000
- );
1001
- if (!accepted) return "refresh";
1002
- }
1003
- return runtimeAction.command;
1004
- }
1005
- if (selected.command === "cleanup-guide") {
1006
- const cleanupScope = await promptForChoice({
1007
- message: "What may Rehearsal include in the cleanup preview?",
1008
- flags,
1009
- options: [
1010
- {
1011
- label: "Old baselines only",
1012
- hint: "Keep the configured number of recent generations",
1013
- command: "baselines",
1014
- },
1015
- {
1016
- label: "Old baselines and this project's runtime",
1017
- hint: "Also remove the disposable database and its volumes",
1018
- command: "runtime",
1019
- },
1020
- {
1021
- label: "Old baselines and older unused Supabase images",
1022
- hint: "Images may be shared and can be downloaded again",
1023
- command: "images",
1024
- },
1025
- {
1026
- label: "Everything above",
1027
- command: "all",
1028
- },
1029
- { label: "Back", command: "exit" },
1030
- ],
1031
- });
1032
- if (!cleanupScope || cleanupScope.command === "exit") return "refresh";
1033
- flags.includeRuntime = ["runtime", "all"].includes(cleanupScope.command);
1034
- flags.includeImages = ["images", "all"].includes(cleanupScope.command);
1035
- flags.cleanupPlan = await planRehearsalCleanup({
1036
- ...planOptions,
1037
- includeRuntime: flags.includeRuntime,
1038
- includeImages: flags.includeImages,
1039
- });
1040
- console.log(
1041
- `\n${renderCleanup({ mode: "preview", plan: flags.cleanupPlan })}`,
1042
- );
1043
- const selectedCount =
1044
- flags.cleanupPlan.baselines.removed.length +
1045
- (flags.cleanupPlan.runtime.included && flags.cleanupPlan.runtime.detected
1046
- ? 1
1047
- : 0) +
1048
- flags.cleanupPlan.images.candidates.length;
1049
- if (selectedCount === 0) return "refresh";
1050
- const accepted = await promptForConfirmation(
1051
- "Remove exactly the resources shown above?",
1052
- flags,
1053
- );
1054
- if (!accepted) return "refresh";
1055
- flags.write = true;
1056
- flags.cleanupConfirmation = flags.cleanupPlan.digest;
1057
- return "cleanup";
1058
- }
1059
- if (["baseline-guide", "baseline-prepare"].includes(selected.command)) {
1060
- console.log(
1061
- [
1062
- "",
1063
- terminalStyle(flags, "1", "CREATE A SAFE BASELINE"),
1064
- "Rehearsal only reads the local files you name here.",
1065
- "It does not extract data or contact a hosted database.",
1066
- "",
1067
- ].join("\n"),
1068
- );
1069
- const discovered = await discoverBaselineInputFiles({ projectRoot });
1070
- const detectedLines = [
1071
- discovered.records.length
1072
- ? `Records: ${discovered.records.join(", ")}`
1073
- : "Records: none detected",
1074
- discovered.ledgers.length
1075
- ? `Migration ledgers: ${discovered.ledgers.join(", ")}`
1076
- : "Migration ledgers: none detected",
1077
- ...(state.target === "supabase"
1078
- ? [
1079
- discovered.assetManifests.length
1080
- ? `Storage manifests: ${discovered.assetManifests.join(", ")}`
1081
- : "Storage manifests: none detected",
1082
- ]
1083
- : []),
1084
- "Only paths and file structure were inspected; row values stay hidden.",
1085
- ];
1086
- if (useStyledPrompts(flags)) {
1087
- prompts.note(detectedLines.join("\n"), "Detected safe local inputs");
1088
- } else {
1089
- console.log(
1090
- ["Detected safe local inputs", ...detectedLines, ""].join("\n"),
1091
- );
1092
- }
1093
- flags.recordsPath = await promptForDiscoveredPath({
1094
- message: "Sanitized NDJSON records",
1095
- candidates: discovered.records,
1096
- flags,
1097
- });
1098
- flags.ledgerPath = await promptForDiscoveredPath({
1099
- message: "Migration ledger",
1100
- candidates: discovered.ledgers,
1101
- flags,
1102
- });
1103
- try {
1104
- if (selected.command === "baseline-prepare") {
1105
- const preparationPlan = await planBaselinePreparation({
1106
- ...planOptions,
1107
- recordsPath: flags.recordsPath,
1108
- ledgerPath: flags.ledgerPath,
1109
- });
1110
- const preview = {
1111
- ...summarizeBaselinePreparation(preparationPlan, {
1112
- mode: "preview",
1113
- }),
1114
- nextAction:
1115
- "Review this schema-only summary. No file is written unless you approve below.",
1116
- };
1117
- console.log(`\n${renderBaselinePreparation(preview, flags)}\n`);
1118
- const accepted = await promptForConfirmation(
1119
- `Create the REVIEW REQUIRED draft at ${preview.destination}?`,
1120
- flags,
1121
- );
1122
- if (!accepted) {
1123
- console.log("No changes made.");
1124
- return "refresh";
1125
- }
1126
- flags.preparationPlan = preparationPlan;
1127
- flags.write = true;
1128
- return "baseline prepare";
1129
- }
1130
- flags.assetsPath =
1131
- state.target === "supabase"
1132
- ? await promptForDiscoveredPath({
1133
- message: "Storage assets",
1134
- candidates: discovered.assetManifests,
1135
- optional: true,
1136
- flags,
1137
- })
1138
- : undefined;
1139
- const inspection = await inspectBaselineInputFiles({
1140
- ...planOptions,
1141
- recordsPath: flags.recordsPath,
1142
- ledgerPath: flags.ledgerPath,
1143
- assetsPath: flags.assetsPath,
1144
- });
1145
- console.log(`\n${renderBaselineInputInspection(inspection, flags)}\n`);
1146
- const accepted = await promptForConfirmation(
1147
- "Create and activate this immutable local baseline?",
1148
- flags,
1149
- );
1150
- if (!accepted) {
1151
- console.log("No changes made.");
1152
- return "refresh";
1153
- }
1154
- return "baseline create";
1155
- } catch (error) {
1156
- const message = redactDiagnosticValue(String(error?.message ?? error));
1157
- if (useStyledPrompts(flags)) {
1158
- prompts.cancel("These baseline inputs need attention.");
1159
- prompts.note(message, "How to continue");
1160
- } else {
1161
- console.log(`\nBASELINE INPUTS NEED ATTENTION\n${message}\n`);
1162
- }
1163
- return "refresh";
1164
- }
1165
- }
1166
- if (selected.command !== "setup-write") return selected.command;
1167
-
1168
- const targetChoice = await promptForChoice({
1169
- message: "Which database should Rehearsal use?",
1170
- flags,
1171
- options:
1172
- state.target === "supabase"
1173
- ? [
1174
- { label: "Supabase", command: "supabase" },
1175
- { label: "PostgreSQL", command: "postgresql" },
1176
- ]
1177
- : [
1178
- { label: "PostgreSQL", command: "postgresql" },
1179
- { label: "Supabase", command: "supabase" },
1180
- ],
1181
- });
1182
- if (!targetChoice) return "refresh";
1183
- flags.target = targetChoice.command;
1184
- const setupPlan = await planRehearsalSetup({
1185
- projectRoot,
1186
- target: flags.target,
1187
- });
1188
- const preview = {
1189
- ...summarizeRehearsalSetup(setupPlan, { mode: "preview" }),
1190
- nextAction:
1191
- "Review this plan. No files are written unless you approve below.",
1192
- };
1193
- console.log(`\n${renderSetup(preview)}\n`);
1194
- const accepted = await promptForConfirmation(
1195
- "Create these project-local files?",
1196
- flags,
1197
- );
1198
- if (!accepted) {
1199
- console.log("No changes made.");
1200
- return "refresh";
1201
- }
1202
- flags.setupPlan = setupPlan;
1203
- flags.write = true;
1204
- return "setup";
1205
- };
1206
-
1207
- const prepareCandidateConfirmation = async ({
1208
- command,
1209
- flags,
1210
- planOptions,
1211
- }) => {
1212
- if (!["run", "migrate"].includes(command) || flags.dryRun) {
1213
- return true;
1214
- }
1215
- const summary = candidateSummary(
1216
- await inspectRehearsalMigrations(planOptions),
1217
- );
1218
- if (summary.candidateCount === 0) return true;
1219
- if (flags.confirmation === summary.candidateSha256) return true;
1220
- const interactive =
1221
- !flags.json && process.stdin.isTTY && process.stdout.isTTY;
1222
- if (!interactive || flags.confirmation) {
1223
- throw new RehearsalError({
1224
- category: "migration_candidate_failure",
1225
- code: "CANDIDATE_CONFIRMATION_REQUIRED",
1226
- message: flags.confirmation
1227
- ? "The candidate migration confirmation does not match the current candidate set."
1228
- : "Candidate migration confirmation is required in noninteractive use.",
1229
- expected: summary.candidateSha256,
1230
- actual: flags.confirmation ?? "missing",
1231
- context: {
1232
- command,
1233
- candidateCount: summary.candidateCount,
1234
- candidates: summary.candidates.map((migration) => migration.filename),
1235
- },
1236
- refused:
1237
- "Rehearsal did not reset, start, or modify the disposable runtime.",
1238
- suggestions: [
1239
- "Run rehearsal candidates, review every file, then pass the current digest with --confirm-candidates=.",
1240
- ],
1241
- });
1242
- }
1243
- console.log(
1244
- [
1245
- "",
1246
- terminalStyle(
1247
- flags,
1248
- "1",
1249
- `${summary.candidateCount} candidate migration${summary.candidateCount === 1 ? "" : "s"}`,
1250
- ),
1251
- "",
1252
- ...summary.candidates.map((migration) => ` → ${migration.filename}`),
1253
- "",
1254
- `Candidate set: ${summary.candidateSha256.slice(0, 12)}`,
1255
- "",
1256
- "Rehearsal will apply only this exact set to the disposable local runtime.",
1257
- ].join("\n"),
1258
- );
1259
- const accepted = await promptForConfirmation(
1260
- `Apply exactly ${summary.candidateCount === 1 ? "this migration" : "these migrations"}?`,
1261
- flags,
1262
- );
1263
- if (!accepted) {
1264
- console.log("No changes made.");
1265
- return false;
1266
- }
1267
- // The manager independently recomputes this digest. A file changed after this
1268
- // review is therefore rejected before candidate SQL is applied.
1269
- flags.confirmation = summary.candidateSha256;
1270
- return true;
1271
- };
1272
-
1273
- const emit = ({ command, data, flags, render, status = "success" }) => {
1274
- const result = createRehearsalResult({
1275
- command,
1276
- status,
1277
- data,
1278
- startedAt: commandStartedAt,
1279
- durationMs: Math.round((performance.now() - commandStartedMs) * 100) / 100,
1280
- });
1281
- if (flags.json) console.log(JSON.stringify(result, null, 2));
1282
- else console.log(render(data, flags.verbosity));
1283
- };
1284
-
1285
- const runManager = ({ action, flags }) => {
1286
- const args = [managerPath, action];
1287
- if (flags.confirmation) {
1288
- args.push(`--confirm-candidates=${flags.confirmation}`);
1289
- }
1290
- const environment = Object.fromEntries(
1291
- [
1292
- "CI",
1293
- "COLORTERM",
1294
- "FORCE_COLOR",
1295
- "HOME",
1296
- "LANG",
1297
- "LC_ALL",
1298
- "NO_COLOR",
1299
- "PATH",
1300
- "SHELL",
1301
- "TERM",
1302
- "TMPDIR",
1303
- "USER",
1304
- ].flatMap((key) =>
1305
- process.env[key] === undefined ? [] : [[key, process.env[key]]],
1306
- ),
1307
- );
1308
- const runtimeStartedMs = performance.now();
1309
- const actionDescription = {
1310
- run: "Restoring the baseline and applying reviewed migrations",
1311
- start: "Starting the disposable local runtime",
1312
- migrate: "Applying reviewed migrations to the current runtime",
1313
- reset: "Restoring the immutable baseline",
1314
- status: "Inspecting the disposable local runtime",
1315
- stop: "Stopping the disposable local runtime",
1316
- discard: "Removing the disposable local runtime",
1317
- verify: "Verifying the current local runtime",
1318
- }[action];
1319
- const spinner = useStyledPrompts(flags) ? prompts.spinner() : null;
1320
- if (spinner) {
1321
- spinner.start(`${actionDescription}. This can take a moment.`);
1322
- } else if (isHumanTerminal(flags)) {
1323
- console.log(
1324
- `\n${terminalStyle(flags, "1;36", action === "run" ? "REHEARSING" : "REHEARSAL")}\n${terminalStyle(flags, "36", "→")} ${actionDescription}. This can take a moment.`,
1325
- );
1326
- }
1327
- const result = spawnSync(process.execPath, args, {
1328
- cwd: projectRoot,
1329
- encoding: "utf8",
1330
- env: environment,
1331
- stdio: ["inherit", "pipe", "pipe"],
1332
- maxBuffer: 16 * 1024 * 1024,
1333
- });
1334
- if (result.error) {
1335
- if (spinner) spinner.stop(`${actionDescription} did not complete.`);
1336
- throw result.error;
1337
- }
1338
- if (result.status !== 0) {
1339
- if (spinner) spinner.stop(`${actionDescription} did not complete.`);
1340
- const completeOutput = [
1341
- String(result.stdout ?? ""),
1342
- String(result.stderr ?? ""),
1343
- ]
1344
- .filter(Boolean)
1345
- .join("\n")
1346
- .trim();
1347
- const inferred = normalizeRehearsalError(
1348
- new Error(completeOutput || `Rehearsal ${action} failed.`),
1349
- );
1350
- const category =
1351
- action === "migrate" ||
1352
- (action === "run" &&
1353
- /migrateRuntime|candidate migration|migration up/iu.test(
1354
- completeOutput,
1355
- ))
1356
- ? "migration_candidate_failure"
1357
- : inferred.category;
1358
- const conciseOutput = completeOutput
1359
- .replaceAll(String.fromCodePoint(27), "")
1360
- .split("\n")
1361
- .map((line) => line.trim())
1362
- .filter(Boolean)
1363
- .slice(-8)
1364
- .join("\n")
1365
- .slice(-2_000);
1366
- throw new RehearsalError({
1367
- category,
1368
- code: `MANAGER_${action.toUpperCase()}_FAILED`,
1369
- message: `The local Rehearsal ${action} operation did not complete.`,
1370
- expected: "the isolated runtime operation to finish and verify",
1371
- actual: conciseOutput || `exit ${result.status}`,
1372
- context: { action },
1373
- refused: "The runtime was not reported as trusted.",
1374
- suggestions: [
1375
- "Review the concise failure above, then rerun rehearsal doctor before retrying.",
1376
- "Use --debug only when the safe diagnostic detail is needed.",
1377
- ],
1378
- cause: new Error(completeOutput || `exit ${result.status}`),
1379
- });
1380
- }
1381
- const durationMs = performance.now() - runtimeStartedMs;
1382
- if (spinner) {
1383
- spinner.stop(
1384
- `${actionDescription} completed in ${formatDuration(durationMs)}.`,
1385
- );
1386
- } else if (isHumanTerminal(flags)) {
1387
- console.log(
1388
- `${terminalStyle(flags, "32", "✓")} Local runtime step completed in ${formatDuration(durationMs)}.`,
1389
- );
1390
- }
1391
- return {
1392
- action,
1393
- durationMs: Math.round(durationMs * 100) / 100,
1394
- output: redactDiagnosticValue(String(result.stdout ?? "").trim()),
1395
- };
1396
- };
1397
-
1398
- const parseCommand = (source) => {
1399
- const values = [];
1400
- let current = "";
1401
- let quote = null;
1402
- for (const character of source) {
1403
- if (quote) {
1404
- if (character === quote) quote = null;
1405
- else current += character;
1406
- } else if (character === '"' || character === "'") quote = character;
1407
- else if (/\s/u.test(character)) {
1408
- if (current) values.push(current);
1409
- current = "";
1410
- } else current += character;
1411
- }
1412
- if (quote)
1413
- throw new Error("Application proof command contains an unmatched quote.");
1414
- if (current) values.push(current);
1415
- if (values.length === 0)
1416
- throw new Error("Application proof command is empty.");
1417
- return values;
1418
- };
1419
-
1420
- const runApplicationProof = async (planOptions) => {
1421
- const { config, projectRoot: configuredRoot } =
1422
- await loadRehearsalConfig(planOptions);
1423
- const [command, ...args] = parseCommand(config.application.proofCommand);
1424
- const environment = Object.fromEntries(
1425
- [
1426
- "CI",
1427
- "COLORTERM",
1428
- "FORCE_COLOR",
1429
- "HOME",
1430
- "LANG",
1431
- "LC_ALL",
1432
- "NO_COLOR",
1433
- "PATH",
1434
- "SHELL",
1435
- "TERM",
1436
- "TMPDIR",
1437
- "USER",
1438
- ].flatMap((key) =>
1439
- process.env[key] === undefined ? [] : [[key, process.env[key]]],
1440
- ),
1441
- );
1442
- const result = spawnSync(command, args, {
1443
- cwd: configuredRoot,
1444
- encoding: "utf8",
1445
- env: environment,
1446
- stdio: ["ignore", "pipe", "pipe"],
1447
- maxBuffer: 16 * 1024 * 1024,
1448
- });
1449
- if (result.error || result.status !== 0) {
1450
- throw new RehearsalError({
1451
- category: "application_proof_failure",
1452
- code: "APPLICATION_PROOF_FAILED",
1453
- message: "The configured application proof did not pass.",
1454
- expected: `${config.application.proofCommand} exits successfully`,
1455
- actual: result.error?.message ?? result.stderr ?? `exit ${result.status}`,
1456
- context: { project: config.project.name },
1457
- refused:
1458
- "Rehearsal did not report the migrated runtime as fully verified.",
1459
- suggestions: [
1460
- "Run the proof command directly in the same project and correct the failure.",
1461
- ],
1462
- cause: result.error,
1463
- });
1464
- }
1465
- return {
1466
- command: config.application.proofCommand,
1467
- output: redactDiagnosticValue(String(result.stdout ?? "").trim()),
1468
- };
1469
- };
1470
-
1471
- const runInit = async ({ flags }) => {
1472
- const detected = await inspectDetectedProject({ projectRoot });
1473
- const source = renderDetectedConfig(detected);
1474
- const destination = join(projectRoot, "rehearsal.config.mjs");
1475
- let existingPath = null;
1476
- try {
1477
- existingPath = await findRehearsalConfigPath({ projectRoot });
1478
- } catch (error) {
1479
- if (
1480
- !String(error?.message ?? error).startsWith("No Rehearsal configuration")
1481
- ) {
1482
- throw error;
1483
- }
1484
- }
1485
- if (existingPath) {
1486
- if (flags.write) {
1487
- throw new Error(
1488
- `A Rehearsal configuration already exists at ${relative(projectRoot, existingPath)}; Rehearsal will not overwrite it.`,
1489
- );
1490
- }
1491
- return {
1492
- mode: "existing",
1493
- destination: relative(projectRoot, existingPath),
1494
- detected,
1495
- source: "",
1496
- nextAction:
1497
- "Review the existing configuration, then run rehearsal doctor.",
1498
- };
1499
- }
1500
- if (flags.write)
1501
- await writeFile(destination, source, { flag: "wx", mode: 0o600 });
1502
- return {
1503
- mode: flags.write ? "written" : "preview",
1504
- destination: relative(projectRoot, destination),
1505
- detected,
1506
- source,
1507
- nextAction: flags.write
1508
- ? "Review the generated safety settings and run rehearsal doctor."
1509
- : "Review this preview, then rerun rehearsal init --write to create it.",
1510
- };
1511
- };
1512
-
1513
- const renderInit = (result) =>
1514
- [
1515
- `REHEARSAL INIT — ${result.mode.toUpperCase()}`,
1516
- `Destination: ${result.destination}`,
1517
- `Detected package manager: ${result.detected.packageManager}`,
1518
- `Detected Supabase config: ${result.detected.hasSupabaseConfig ? "yes" : "no"}`,
1519
- `Detected migrations: ${result.detected.hasMigrations ? "yes" : "no"}`,
1520
- ...(result.source ? ["", result.source] : []),
1521
- result.nextAction,
1522
- ].join("\n");
1523
-
1524
- const runSetup = async ({ flags, planOptions }) => {
1525
- const plan =
1526
- flags.setupPlan ??
1527
- (await planRehearsalSetup({
1528
- projectRoot,
1529
- target: flags.target ?? "supabase",
1530
- }));
1531
- if (flags.write) await applyRehearsalSetup(plan);
1532
- const result = summarizeRehearsalSetup(plan, {
1533
- mode: flags.write ? "written" : "preview",
1534
- });
1535
- if (!flags.write) return result;
1536
- const readiness = await runRehearsalDoctor(planOptions);
1537
- const needsBaselinePolicy = readiness.checks.some(
1538
- (check) => check.id === "sanitization-policy" && check.status === "fail",
1539
- );
1540
- return {
1541
- ...result,
1542
- readiness,
1543
- nextAction:
1544
- readiness.state === "READY"
1545
- ? "Next: run rehearsal explain to review the migration plan."
1546
- : needsBaselinePolicy
1547
- ? flags.guided
1548
- ? "Next: choose Prepare the script to create a reviewable policy draft."
1549
- : "Next: run rehearsal again and choose Prepare a reviewable baseline policy draft."
1550
- : "Next: complete the remaining readiness items shown above.",
1551
- };
1552
- };
1553
-
1554
- const summarizeSetupReadinessChecks = (readiness) => {
1555
- const failures = readiness.checks.filter((check) => check.status === "fail");
1556
- const policyMissing = failures.some(
1557
- (check) =>
1558
- check.id === "sanitization-policy" &&
1559
- /ENOENT|no such file/iu.test(check.detail),
1560
- );
1561
- const baselineMissing = failures.some(
1562
- (check) =>
1563
- check.id === "baseline" && /ENOENT|no such file/iu.test(check.detail),
1564
- );
1565
- const hidden = new Set([
1566
- ...(policyMissing ? ["sanitization-policy"] : []),
1567
- ...(baselineMissing
1568
- ? ["baseline", "baseline-permissions", "migration-history"]
1569
- : []),
1570
- ]);
1571
- if (
1572
- policyMissing &&
1573
- failures.some(
1574
- (check) =>
1575
- check.id === "paths" && /sanitization-policy/iu.test(check.detail),
1576
- )
1577
- ) {
1578
- hidden.add("paths");
1579
- }
1580
- const passed = readiness.checks.filter(
1581
- (check) => check.status === "pass",
1582
- ).length;
1583
- return [
1584
- ...(passed ? [` ✓ ${passed} environment and safety checks passed`] : []),
1585
- ...failures
1586
- .filter((check) => !hidden.has(check.id))
1587
- .map((check) => ` ○ ${check.label}: ${check.detail}`),
1588
- ...(policyMissing
1589
- ? [" ○ Baseline policy: not created yet (next guided step)"]
1590
- : []),
1591
- ...(baselineMissing
1592
- ? [" ○ Verified baseline: not created yet (after policy review)"]
1593
- : []),
1594
- ];
1595
- };
1596
-
1597
- const renderSetup = (result) => {
1598
- const actionMarker = { create: "+", update: "~", unchanged: "=" };
1599
- return [
1600
- `REHEARSAL SETUP — ${result.mode.toUpperCase()}`,
1601
- `Project: ${result.project}`,
1602
- `Database: ${result.target === "postgresql" ? "PostgreSQL" : "Supabase"}`,
1603
- `Runtime ID: ${result.projectId}`,
1604
- `Application: ${result.applicationUrl}`,
1605
- result.target === "postgresql"
1606
- ? `Port: database ${result.ports.database}`
1607
- : `Ports: API ${result.ports.api}, database ${result.ports.database}, Studio ${result.ports.studio}`,
1608
- "",
1609
- "Files",
1610
- ...result.files.map(
1611
- (file) => ` ${actionMarker[file.action]} ${file.action} ${file.path}`,
1612
- ),
1613
- "",
1614
- "Safety boundaries",
1615
- ...result.safety.map((barrier) => ` ✓ ${barrier}`),
1616
- ...(result.readiness
1617
- ? [
1618
- "",
1619
- `Readiness — ${result.readiness.state}`,
1620
- ...summarizeSetupReadinessChecks(result.readiness),
1621
- ]
1622
- : []),
1623
- "",
1624
- result.nextAction,
1625
- ].join("\n");
1626
- };
1627
-
1628
- const runBaselinePreparation = async ({ flags, planOptions }) => {
1629
- const plan =
1630
- flags.preparationPlan ??
1631
- (await planBaselinePreparation({
1632
- ...planOptions,
1633
- recordsPath: flags.recordsPath,
1634
- ledgerPath: flags.ledgerPath,
1635
- }));
1636
- if (flags.write) await applyBaselinePreparation(plan);
1637
- const result = summarizeBaselinePreparation(plan, {
1638
- mode: flags.write ? "written" : "preview",
1639
- });
1640
- return flags.guided && flags.write
1641
- ? {
1642
- ...result,
1643
- nextAction:
1644
- "Next: choose Review the script to classify every column interactively.",
1645
- }
1646
- : result;
1647
- };
1648
-
1649
- const renderBaselinePreparation = (result, flags = {}) =>
1650
- [
1651
- `BASELINE POLICY — ${result.mode.toUpperCase()}`,
1652
- `Destination: ${result.destination}`,
1653
- `Records: ${formatCount(result.rowCount, "row")} from ${result.recordsPath}`,
1654
- `Migrations: ${formatCount(result.migrationCount, "migration")} through ${result.migrationCutoff}`,
1655
- "",
1656
- "Detected shape (values are never printed)",
1657
- ...result.tables.map(
1658
- (table) =>
1659
- ` ${terminalStyle(flags, "36", "→")} ${table.name}: ${formatCount(table.rowCount, "row")}; ${table.columns.join(", ")}`,
1660
- ),
1661
- "",
1662
- "Every column is marked REVIEW REQUIRED for action, generated status, identity, and foreign key metadata.",
1663
- "The draft cannot be activated until those decisions are completed.",
1664
- "",
1665
- result.nextAction,
1666
- ].join("\n");
1667
-
1668
- const renderBaselineInputInspection = (result, flags = {}) =>
1669
- [
1670
- "BASELINE INPUTS — READY",
1671
- `Records: ${formatCount(result.rowCount, "row")} across ${formatCount(result.tables.length, "table")} from ${result.recordsPath}`,
1672
- `Migrations: ${formatCount(result.migrationCount, "migration")} through ${result.migrationCutoff} from ${result.ledgerPath}`,
1673
- result.assetsPath
1674
- ? `Storage: ${formatCount(result.assetCount, "asset")} from ${result.assetsPath}`
1675
- : "Storage: no asset manifest selected",
1676
- "",
1677
- "Detected tables (values are never printed)",
1678
- ...result.tables.map(
1679
- (table) =>
1680
- ` ${terminalStyle(flags, "36", "→")} ${table.name}: ${formatCount(table.rowCount, "row")}`,
1681
- ),
1682
- "",
1683
- "All inputs are project-local regular files and passed structural validation.",
1684
- ].join("\n");
1685
-
1686
- const renderSupportReport = (report) => {
1687
- const toolLine = (label, tool) =>
1688
- `${label}: ${tool.status === "available" ? tool.version : tool.status.replaceAll("_", " ")}`;
1689
- return [
1690
- "REHEARSAL SUPPORT REPORT",
1691
- "",
1692
- `Rehearsal: ${report.rehearsalVersion}`,
1693
- `System: ${report.system.platform} ${report.system.architecture} (${report.system.release})`,
1694
- `Node.js: ${report.system.node}`,
1695
- toolLine("npm", report.tools.npm),
1696
- toolLine("Supabase CLI", report.tools.supabase),
1697
- toolLine("Docker client", report.tools.dockerClient),
1698
- toolLine("Docker server", report.tools.dockerServer),
1699
- "",
1700
- `Readiness: ${report.readiness.state}`,
1701
- ...report.readiness.checks.map((check) =>
1702
- check.status === "pass"
1703
- ? ` ✓ ${check.label}`
1704
- : ` ✗ ${check.label}${check.remediation ? ` — ${check.remediation}` : ""}`,
1705
- ),
1706
- ...(report.readiness.quarantinedHostedVariableCount
1707
- ? [
1708
- ` ! ${formatCount(report.readiness.quarantinedHostedVariableCount, "ambient hosted variable")} detected and quarantined`,
1709
- ]
1710
- : []),
1711
- "",
1712
- "Privacy: no row values, credentials, project paths, migration SQL, or baseline identifiers are included.",
1713
- "Review this report before sharing it.",
1714
- `Report a reproducible issue: ${report.supportUrl}`,
1715
- ].join("\n");
1716
- };
1717
-
1718
- const formatBytes = (bytes) => {
1719
- if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
1720
- const units = ["B", "KiB", "MiB", "GiB", "TiB"];
1721
- const unit = Math.min(
1722
- Math.floor(Math.log(bytes) / Math.log(1024)),
1723
- units.length - 1,
1724
- );
1725
- const value = bytes / 1024 ** unit;
1726
- return `${value >= 10 || unit === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unit]}`;
1727
- };
1728
-
1729
- const renderCleanup = (result) => {
1730
- const plan = result.plan;
1731
- const imageBytes = plan.images.candidates.reduce(
1732
- (total, image) => total + image.bytes,
1733
- 0,
1734
- );
1735
- const dockerImages = plan.docker.resources.find(
1736
- (resource) => resource.Type === "Images",
1737
- );
1738
- const selectedCount =
1739
- plan.baselines.removed.length +
1740
- (plan.runtime.included && plan.runtime.detected ? 1 : 0) +
1741
- plan.images.candidates.length;
1742
- return [
1743
- `REHEARSAL CLEANUP — ${result.mode.toUpperCase()}`,
1744
- `Project: ${plan.project}`,
1745
- ...(dockerImages
1746
- ? [
1747
- `Docker images: ${dockerImages.Size}; ${dockerImages.Reclaimable} reclaimable across Docker`,
1748
- ]
1749
- : ["Docker usage: unavailable"]),
1750
- ...(plan.docker.disk
1751
- ? [
1752
- `Container disk: ${formatBytes(plan.docker.disk.usedBytes)} used of ${formatBytes(plan.docker.disk.totalBytes)}; ${formatBytes(plan.docker.disk.availableBytes)} free (${plan.docker.disk.usedPercent}% used)`,
1753
- ]
1754
- : []),
1755
- "",
1756
- "Project-owned files",
1757
- plan.baselines.available
1758
- ? ` ${plan.baselines.removed.length ? "→" : "✓"} ${formatCount(plan.baselines.removed.length, "old baseline generation")} selected; keeping ${plan.baselines.retained.length}`
1759
- : " ○ No active baseline found",
1760
- "",
1761
- "Disposable runtime",
1762
- !plan.runtime.detected
1763
- ? " ✓ No runtime found"
1764
- : plan.runtime.included
1765
- ? " → Current project runtime selected"
1766
- : " ○ Current project runtime preserved (add --include-runtime to select it)",
1767
- "",
1768
- "Shared Docker images",
1769
- !plan.images.requested
1770
- ? " ○ Not inspected (add --include-images to inspect older unused Supabase images)"
1771
- : !plan.images.inspected
1772
- ? " ! Docker is unavailable; images could not be inspected"
1773
- : plan.images.candidates.length
1774
- ? ` → ${formatCount(plan.images.candidates.length, "older unused Supabase image")} selected; approximately ${formatBytes(imageBytes)}`
1775
- : " ✓ No older unused Supabase images found",
1776
- ...(plan.images.candidates.length
1777
- ? plan.images.candidates.flatMap((image) =>
1778
- image.tags.map((tag) => ` ${tag}`),
1779
- )
1780
- : []),
1781
- "",
1782
- ...(result.mode === "preview"
1783
- ? selectedCount
1784
- ? [
1785
- "Nothing has been removed.",
1786
- `Cleanup set: ${plan.digest.slice(0, 12)}`,
1787
- `Exact confirmation: --confirm-cleanup=${plan.digest}`,
1788
- "Review this list, then rerun the same command with --write and the exact confirmation above.",
1789
- ]
1790
- : [
1791
- "Nothing has been removed, and nothing is currently selected.",
1792
- "Use --include-runtime or --include-images only when you want those shared resources considered.",
1793
- ]
1794
- : [
1795
- `Removed ${formatCount(result.applied.baselines.removed.length, "old baseline generation")}.`,
1796
- result.applied.runtimeRemoved
1797
- ? "Removed this project's disposable runtime and volumes."
1798
- : "Preserved this project's disposable runtime.",
1799
- `Removed ${formatCount(result.applied.imagesRemoved.length, "unused image tag")}.`,
1800
- ]),
1801
- ].join("\n");
1802
- };
1803
-
1804
- const runCleanup = async ({ flags, planOptions }) => {
1805
- const plan =
1806
- flags.cleanupPlan ??
1807
- (await planRehearsalCleanup({
1808
- ...planOptions,
1809
- includeRuntime: flags.includeRuntime,
1810
- includeImages: flags.includeImages,
1811
- }));
1812
- if (!flags.write) return { mode: "preview", plan };
1813
- const applied = await applyRehearsalCleanup({
1814
- plan,
1815
- confirmation: flags.cleanupConfirmation,
1816
- ...planOptions,
1817
- removeRuntime: async () => {
1818
- runManager({ action: "discard", flags });
1819
- },
1820
- });
1821
- return { mode: "written", plan, applied };
1822
- };
1823
-
1824
- const usage = () => `Usage: rehearsal <command> [options]
1825
-
1826
- Commands:
1827
- guide Open the interactive, state-aware home screen
1828
- setup [--target=] [--write] Preview or create safe first-run scaffolding
1829
- init [--write] Preview or explicitly write safe starter config
1830
- baseline prepare --records= --ledger= [--write] Create a fail-closed policy draft
1831
- baseline create --records= --ledger= [--assets=] Create a baseline from safe local inputs
1832
- doctor Check whether Rehearsal is safe and ready
1833
- support Print a safe, copy-ready support report
1834
- explain Show the immutable execution plan
1835
- run --dry-run Alias the exact explain plan without mutations
1836
- run --confirm-candidates= Execute reset, migration, and verification locally
1837
- candidates Show the exact pending migration digest
1838
- inspect baseline Show verified baseline provenance
1839
- inspect migrations Classify represented, applied, and candidate migrations
1840
- start Start an existing verified local runtime
1841
- migrate --confirm-candidates= Apply the exact candidate suffix without resetting
1842
- reset Restore and verify the immutable local baseline
1843
- status Report the disposable local runtime state
1844
- stop Stop only this project's local runtime
1845
- discard Remove only this project's disposable runtime
1846
- cleanup [--include-runtime] [--include-images] [--write] [--confirm-cleanup=]
1847
- Preview or apply conservative disk cleanup
1848
- verify Verify the current local Rehearsal runtime
1849
-
1850
- Options: --json --verbose --debug --plain --config=<path> --target=supabase|postgresql`;
1851
-
1852
- const executeCommand = async ({ command, flags, planOptions, guided }) => {
1853
- if (command === "help" || flags.help) {
1854
- console.log(usage());
1855
- return;
1856
- }
1857
- if (command === "setup") {
1858
- const data = await runSetup({ flags, planOptions });
1859
- emit({ command, data, flags, render: renderSetup });
1860
- return;
1861
- }
1862
- if (command === "init") {
1863
- const data = await runInit({ flags });
1864
- emit({ command, data, flags, render: renderInit });
1865
- return;
1866
- }
1867
- if (command === "baseline prepare") {
1868
- const data = await runBaselinePreparation({ flags, planOptions });
1869
- emit({
1870
- command,
1871
- data,
1872
- flags,
1873
- render: (result) => renderBaselinePreparation(result, flags),
1874
- });
1875
- return;
1876
- }
1877
- if (command === "baseline create") {
1878
- await inspectBaselineInputFiles({
1879
- ...planOptions,
1880
- recordsPath: flags.recordsPath,
1881
- ledgerPath: flags.ledgerPath,
1882
- assetsPath: flags.assetsPath,
1883
- });
1884
- const data = await createSyntheticBaselineFromFiles({
1885
- ...planOptions,
1886
- recordsPath: flags.recordsPath,
1887
- ledgerPath: flags.ledgerPath,
1888
- assetsPath: flags.assetsPath,
1889
- });
1890
- emit({
1891
- command,
1892
- data,
1893
- flags,
1894
- render: (baseline) =>
1895
- [
1896
- `Activated synthetic baseline ${baseline.generationId}: ${formatCount(baseline.rowCount, "row")} across ${formatCount(baseline.tableCount, "table")}; ${formatCount(baseline.migrationCount, "migration")} through ${baseline.migrationCutoff}.`,
1897
- "",
1898
- flags.guided
1899
- ? "Next: choose Run a rehearsal when you are ready."
1900
- : "Next: run rehearsal doctor to check readiness.",
1901
- ].join("\n"),
1902
- });
1903
- return;
1904
- }
1905
- if (command === "doctor") {
1906
- const data = await runRehearsalDoctor(planOptions);
1907
- emit({
1908
- command,
1909
- data,
1910
- flags,
1911
- render: renderDoctor,
1912
- status: data.state === "READY" ? "success" : "not_ready",
1913
- });
1914
- if (data.state !== "READY" && !guided) process.exitCode = 1;
1915
- return;
1916
- }
1917
- if (command === "support") {
1918
- const data = await collectRehearsalSupportReport(planOptions);
1919
- emit({ command, data, flags, render: renderSupportReport });
1920
- return;
1921
- }
1922
- if (command === "cleanup") {
1923
- const data = await runCleanup({ flags, planOptions });
1924
- emit({ command, data, flags, render: renderCleanup });
1925
- return;
1926
- }
1927
- if (command === "explain" || (command === "run" && flags.dryRun)) {
1928
- const data = await buildRehearsalPlan(planOptions);
1929
- emit({ command, data, flags, render: renderPlan });
1930
- return;
1931
- }
1932
- if (command === "inspect baseline") {
1933
- const data = await inspectRehearsalBaseline(planOptions);
1934
- emit({ command, data, flags, render: renderBaseline });
1935
- return;
1936
- }
1937
- if (command === "inspect migrations") {
1938
- const data = await inspectRehearsalMigrations(planOptions);
1939
- emit({ command, data, flags, render: renderMigrations });
1940
- return;
1941
- }
1942
- if (command === "candidates") {
1943
- const data = candidateSummary(
1944
- await inspectRehearsalMigrations(planOptions),
1945
- );
1946
- emit({ command, data, flags, render: renderCandidates });
1947
- return;
1948
- }
1949
- if (
1950
- [
1951
- "run",
1952
- "start",
1953
- "migrate",
1954
- "reset",
1955
- "status",
1956
- "stop",
1957
- "discard",
1958
- "verify",
1959
- ].includes(command)
1960
- ) {
1961
- if (flags.dryRun && command !== "run") {
1962
- throw new Error("--dry-run is supported only by rehearsal run.");
1963
- }
1964
- if (
1965
- !(await prepareCandidateConfirmation({ command, flags, planOptions }))
1966
- ) {
1967
- return;
1968
- }
1969
- const runtime = runManager({ action: command, flags });
1970
- let applicationProof;
1971
- if (command === "run") {
1972
- const proofSpinner = useStyledPrompts(flags) ? prompts.spinner() : null;
1973
- if (proofSpinner) {
1974
- proofSpinner.start("Running the project-owned application proof.");
1975
- } else if (isHumanTerminal(flags)) {
1976
- console.log(
1977
- `${terminalStyle(flags, "36", "→")} Running the project-owned application proof.`,
1978
- );
1979
- }
1980
- try {
1981
- applicationProof = await runApplicationProof(planOptions);
1982
- } catch (error) {
1983
- if (proofSpinner) proofSpinner.stop("Application proof failed.");
1984
- throw error;
1985
- }
1986
- if (proofSpinner) {
1987
- proofSpinner.stop("Application proof passed.");
1988
- } else if (isHumanTerminal(flags)) {
1989
- console.log(
1990
- `${terminalStyle(flags, "32", "✓")} Application proof passed.`,
1991
- );
1992
- }
1993
- }
1994
- const data = command === "run" ? { runtime, applicationProof } : runtime;
1995
- if (guided) {
1996
- lastGuidedDetails = [runtime.output, applicationProof?.output]
1997
- .filter(Boolean)
1998
- .join("\n");
1999
- }
2000
- emit({
2001
- command,
2002
- data,
2003
- flags,
2004
- render: (value) => {
2005
- const runtimeResult = value.runtime ?? value;
2006
- const nextAction = {
2007
- run: "Next: exercise the local application, then run rehearsal verify.",
2008
- start:
2009
- "Next: exercise the local application or run rehearsal status.",
2010
- migrate: "Next: run rehearsal verify to prove the current runtime.",
2011
- reset: "Next: run rehearsal verify or continue testing locally.",
2012
- status: "Next: run rehearsal verify, reset, stop, or discard.",
2013
- stop: "Next: run rehearsal start when you want to resume.",
2014
- discard:
2015
- "The disposable runtime was removed; the immutable baseline remains.",
2016
- verify:
2017
- "Next: continue testing, reset to the baseline, or stop the runtime.",
2018
- }[runtimeResult.action];
2019
- if (guided) {
2020
- const title =
2021
- runtimeResult.action === "run"
2022
- ? "REHEARSAL PASSED"
2023
- : "ACTION COMPLETE";
2024
- return [
2025
- title,
2026
- `✓ ${runtimeResult.action} completed in ${formatDuration(runtimeResult.durationMs)}`,
2027
- value.applicationProof
2028
- ? `✓ Application proof passed: ${value.applicationProof.command}`
2029
- : null,
2030
- "",
2031
- "Technical output is available from Show details in the guide.",
2032
- ]
2033
- .filter(Boolean)
2034
- .join("\n");
2035
- }
2036
- return [
2037
- runtimeResult.output ||
2038
- `Rehearsal ${runtimeResult.action} completed.`,
2039
- value.applicationProof
2040
- ? `Application proof passed: ${value.applicationProof.command}`
2041
- : null,
2042
- nextAction ? "" : null,
2043
- nextAction,
2044
- ]
2045
- .filter((entry) => entry !== null)
2046
- .join("\n");
2047
- },
2048
- });
2049
- return;
2050
- }
2051
- throw new Error(`Unknown Rehearsal command: ${command}.\n\n${usage()}`);
2052
- };
2053
-
2054
- const resetGuidedFlags = (flags) => {
2055
- flags.dryRun = false;
2056
- flags.write = false;
2057
- flags.confirmation = undefined;
2058
- flags.recordsPath = undefined;
2059
- flags.ledgerPath = undefined;
2060
- flags.assetsPath = undefined;
2061
- flags.target = undefined;
2062
- flags.includeRuntime = false;
2063
- flags.includeImages = false;
2064
- flags.cleanupConfirmation = undefined;
2065
- flags.setupPlan = undefined;
2066
- flags.preparationPlan = undefined;
2067
- flags.cleanupPlan = undefined;
2068
- };
2069
-
2070
- const installGuidedExitShortcut = (flags) => {
2071
- let exiting = false;
2072
- const exit = () => {
2073
- if (exiting) return;
2074
- exiting = true;
2075
- if (process.stdin.isTTY && process.stdin.isRaw) {
2076
- process.stdin.setRawMode(false);
2077
- }
2078
- process.stdout.write("\u001B[?25h");
2079
- if (useStyledPrompts(flags)) {
2080
- prompts.outro("Rehearsal exited.");
2081
- } else {
2082
- console.log("\nRehearsal exited.");
2083
- }
2084
- process.exit(0);
2085
- };
2086
- const onInput = (chunk) => {
2087
- const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
2088
- if (bytes.includes(0x1a)) exit();
2089
- };
2090
- process.stdin.prependListener("data", onInput);
2091
- process.prependListener("SIGTSTP", exit);
2092
- return () => {
2093
- process.stdin.removeListener("data", onInput);
2094
- process.removeListener("SIGTSTP", exit);
2095
- };
2096
- };
2097
-
2098
- const main = async () => {
2099
- const { flags, positionals } = parseArguments(process.argv.slice(2));
2100
- const command = positionals.join(" ") || "help";
2101
- const planOptions = {
2102
- projectRoot,
2103
- configPath: flags.configPath,
2104
- };
2105
- const wantsAutomaticGuide =
2106
- positionals.length === 0 &&
2107
- !flags.help &&
2108
- !flags.json &&
2109
- process.stdin.isTTY &&
2110
- process.stdout.isTTY;
2111
- const guided = command === "guide" || wantsAutomaticGuide;
2112
- if (!guided) {
2113
- await executeCommand({ command, flags, planOptions, guided: false });
2114
- return;
2115
- }
2116
- if (!process.stdin.isTTY || !process.stdout.isTTY) {
2117
- throw new Error(
2118
- "The guided home screen requires an interactive terminal. Run rehearsal --help to list scriptable commands.",
2119
- );
2120
- }
2121
- flags.guided = true;
2122
- const removeGuidedExitShortcut = installGuidedExitShortcut(flags);
2123
- if (useStyledPrompts(flags)) {
2124
- prompts.intro("REHEARSAL · Safe local migration testing");
2125
- }
2126
- try {
2127
- while (true) {
2128
- resetGuidedFlags(flags);
2129
- const selected = await runGuidedHome({ flags, planOptions });
2130
- if (!selected) break;
2131
- if (selected === "refresh") continue;
2132
- await executeCommand({
2133
- command: selected,
2134
- flags,
2135
- planOptions,
2136
- guided: true,
2137
- });
2138
- }
2139
- } finally {
2140
- removeGuidedExitShortcut();
2141
- }
2142
- if (useStyledPrompts(flags)) {
2143
- prompts.outro("See you at the next rehearsal.");
2144
- } else {
2145
- console.log("See you at the next rehearsal.");
2146
- }
2147
- };
2148
-
2149
- try {
2150
- await main();
2151
- } catch (error) {
2152
- const failure = normalizeRehearsalError(error, {
2153
- expected: "a safe, versioned, local-only Rehearsal operation",
2154
- actual: String(error?.message ?? error),
2155
- refused: "No further Rehearsal action was performed.",
2156
- suggestions: ["Run rehearsal doctor for actionable readiness checks."],
2157
- });
2158
- const wantsJson = process.argv.includes("--json");
2159
- const debug = process.argv.includes("--debug");
2160
- if (wantsJson)
2161
- console.log(
2162
- JSON.stringify(serializeRehearsalError(failure, { debug }), null, 2),
2163
- );
2164
- else console.error(renderHumanError(failure, { debug }));
2165
- process.exitCode = failure.exitCode;
2166
- }