@rehearsal-db/core 0.1.0-beta.2 → 0.1.0-beta.3

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.
@@ -6,9 +6,10 @@
6
6
  */
7
7
 
8
8
  import { spawnSync } from "node:child_process";
9
- import { writeFile } from "node:fs/promises";
9
+ import { access, readFile, writeFile } from "node:fs/promises";
10
10
  import { join, relative } from "node:path";
11
11
  import { performance } from "node:perf_hooks";
12
+ import { createInterface } from "node:readline/promises";
12
13
  import { fileURLToPath } from "node:url";
13
14
  import {
14
15
  RehearsalError,
@@ -23,6 +24,7 @@ import {
23
24
  findRehearsalConfigPath,
24
25
  loadRehearsalConfig,
25
26
  renderDetectedConfig,
27
+ validateRuntimeSanitizationPolicy,
26
28
  } from "../../lib/rehearsal/configuration.mjs";
27
29
  import {
28
30
  buildRehearsalPlan,
@@ -31,6 +33,18 @@ import {
31
33
  runRehearsalDoctor,
32
34
  } from "../../lib/rehearsal/plan.mjs";
33
35
  import { createSyntheticBaselineFromFiles } from "../../lib/rehearsal/baseline_builder.mjs";
36
+ import {
37
+ applyBaselinePreparation,
38
+ planBaselinePreparation,
39
+ summarizeBaselinePreparation,
40
+ } from "../../lib/rehearsal/baseline_preparation.mjs";
41
+ import {
42
+ applyRehearsalSetup,
43
+ inspectRehearsalNodeRuntime,
44
+ planRehearsalSetup,
45
+ summarizeRehearsalSetup,
46
+ } from "../../lib/rehearsal/setup.mjs";
47
+ import { formatCount } from "../../lib/rehearsal/human_output.mjs";
34
48
 
35
49
  const packageRoot = fileURLToPath(new URL("../../..", import.meta.url));
36
50
  const projectRoot = process.cwd();
@@ -48,6 +62,7 @@ const parseArguments = (arguments_) => {
48
62
  verbosity: "normal",
49
63
  dryRun: false,
50
64
  write: false,
65
+ plain: false,
51
66
  configPath: undefined,
52
67
  confirmation: undefined,
53
68
  recordsPath: undefined,
@@ -62,6 +77,7 @@ const parseArguments = (arguments_) => {
62
77
  else if (argument === "--debug") flags.verbosity = "debug";
63
78
  else if (argument === "--dry-run") flags.dryRun = true;
64
79
  else if (argument === "--write") flags.write = true;
80
+ else if (argument === "--plain") flags.plain = true;
65
81
  else if (argument.startsWith("--config=")) {
66
82
  flags.configPath = argument.slice("--config=".length);
67
83
  } else if (argument.startsWith("--confirm-candidates=")) {
@@ -109,7 +125,7 @@ const renderPlan = (plan, verbosity) => {
109
125
  "",
110
126
  "Baseline",
111
127
  ` ✓ ${plan.baseline.generationId}`,
112
- ` ✓ ${plan.baseline.tableCount} tables; ${plan.baseline.rowCount} rows`,
128
+ ` ✓ ${formatCount(plan.baseline.tableCount, "table")}; ${formatCount(plan.baseline.rowCount, "row")}`,
113
129
  ` ✓ ${plan.baseline.verification}`,
114
130
  "",
115
131
  "Migrations",
@@ -121,6 +137,10 @@ const renderPlan = (plan, verbosity) => {
121
137
  ...verbose,
122
138
  "",
123
139
  plan.guarantee,
140
+ "",
141
+ plan.migrations.candidateCount
142
+ ? "Next: run rehearsal run to review and confirm this exact candidate set."
143
+ : "Next: run rehearsal run to verify the baseline and application proof.",
124
144
  ].join("\n");
125
145
  };
126
146
 
@@ -138,12 +158,20 @@ const renderDoctor = (doctor, verbosity) => {
138
158
  )) {
139
159
  if (check.remediation) lines.push(` Try: ${check.remediation}`);
140
160
  }
141
- if (doctor.ambientHostedVariables.presentButQuarantined.length) {
161
+ if (doctor.ambientHostedVariables?.presentButQuarantined.length) {
142
162
  lines.push(
143
163
  `! ${doctor.ambientHostedVariables.presentButQuarantined.length} ambient hosted credential variables detected and quarantined.`,
144
164
  );
145
165
  }
146
- return [...lines, "", doctor.state].join("\n");
166
+ return [
167
+ ...lines,
168
+ "",
169
+ doctor.state,
170
+ "",
171
+ doctor.state === "READY"
172
+ ? "Next: run rehearsal explain to review the migration plan."
173
+ : "Next: fix the items above, then run rehearsal doctor again.",
174
+ ].join("\n");
147
175
  };
148
176
 
149
177
  const renderBaseline = (baseline) =>
@@ -201,8 +229,440 @@ const renderCandidates = (summary, verbosity) =>
201
229
  : ` → ${migration.filename} (${migration.sha256})`,
202
230
  )
203
231
  : [" ✓ No candidate migrations"]),
232
+ "",
233
+ summary.candidateCount
234
+ ? "Next: run rehearsal run for a guided confirmation, or pass the full digest in automation."
235
+ : "Next: run rehearsal run to verify the baseline and application proof.",
204
236
  ].join("\n");
205
237
 
238
+ const pathExists = (path) =>
239
+ access(path)
240
+ .then(() => true)
241
+ .catch((error) => {
242
+ if (error?.code === "ENOENT") return false;
243
+ throw error;
244
+ });
245
+
246
+ const useColor = (flags) =>
247
+ !flags.plain &&
248
+ process.stdout.isTTY &&
249
+ process.env.NO_COLOR === undefined &&
250
+ process.env.TERM !== "dumb";
251
+
252
+ const terminalStyle = (flags, code, value) =>
253
+ useColor(flags) ? `\u001B[${code}m${value}\u001B[0m` : value;
254
+
255
+ const isHumanTerminal = (flags) =>
256
+ !flags.json && process.stdin.isTTY && process.stdout.isTTY;
257
+
258
+ const formatDuration = (durationMs) =>
259
+ durationMs < 1_000
260
+ ? `${Math.round(durationMs)}ms`
261
+ : `${(durationMs / 1_000).toFixed(1)}s`;
262
+
263
+ const promptForChoice = async ({ message, options }) => {
264
+ const prompt = createInterface({
265
+ input: process.stdin,
266
+ output: process.stdout,
267
+ });
268
+ try {
269
+ while (true) {
270
+ const answer = (await prompt.question(`${message} [1]: `)).trim();
271
+ const selected = answer === "" ? 1 : Number(answer);
272
+ if (
273
+ Number.isInteger(selected) &&
274
+ selected >= 1 &&
275
+ selected <= options.length
276
+ ) {
277
+ return options[selected - 1];
278
+ }
279
+ console.log(`Choose a number from 1 to ${options.length}.`);
280
+ }
281
+ } finally {
282
+ prompt.close();
283
+ }
284
+ };
285
+
286
+ const promptForConfirmation = async (message) => {
287
+ const prompt = createInterface({
288
+ input: process.stdin,
289
+ output: process.stdout,
290
+ });
291
+ try {
292
+ const answer = (await prompt.question(`${message} (y/N) `))
293
+ .trim()
294
+ .toLowerCase();
295
+ return answer === "y" || answer === "yes";
296
+ } finally {
297
+ prompt.close();
298
+ }
299
+ };
300
+
301
+ const promptForPath = async ({ message, defaultValue, optional = false }) => {
302
+ const prompt = createInterface({
303
+ input: process.stdin,
304
+ output: process.stdout,
305
+ });
306
+ try {
307
+ while (true) {
308
+ const suffix = defaultValue
309
+ ? ` [${defaultValue}]`
310
+ : optional
311
+ ? ""
312
+ : " (required)";
313
+ const answer = (await prompt.question(`${message}${suffix}: `)).trim();
314
+ const value = answer || defaultValue || "";
315
+ if (value || optional) return value || undefined;
316
+ console.log("Enter a project-relative path.");
317
+ }
318
+ } finally {
319
+ prompt.close();
320
+ }
321
+ };
322
+
323
+ const firstExistingPath = async (candidates) => {
324
+ for (const candidate of candidates) {
325
+ if (await pathExists(join(projectRoot, candidate))) return candidate;
326
+ }
327
+ return undefined;
328
+ };
329
+
330
+ const inspectGuidedProject = async (planOptions) => {
331
+ const detected = await inspectDetectedProject({ projectRoot });
332
+ const node = inspectRehearsalNodeRuntime();
333
+ let configPath;
334
+ try {
335
+ configPath = await findRehearsalConfigPath(planOptions);
336
+ } catch (error) {
337
+ if (
338
+ !String(error?.message ?? error).startsWith("No Rehearsal configuration")
339
+ ) {
340
+ throw error;
341
+ }
342
+ }
343
+ if (!configPath) {
344
+ return {
345
+ detected,
346
+ node,
347
+ config: "missing",
348
+ baseline: false,
349
+ runtime: false,
350
+ };
351
+ }
352
+ let loaded;
353
+ try {
354
+ loaded = await loadRehearsalConfig(planOptions);
355
+ } catch (error) {
356
+ return {
357
+ detected,
358
+ node,
359
+ config: "invalid",
360
+ configPath,
361
+ configError: redactDiagnosticValue(String(error?.message ?? error)),
362
+ baseline: false,
363
+ runtime: false,
364
+ };
365
+ }
366
+ let migrationSummary;
367
+ try {
368
+ migrationSummary = candidateSummary(
369
+ await inspectRehearsalMigrations(planOptions),
370
+ );
371
+ } catch {
372
+ // Doctor owns the detailed, safely redacted explanation for an invalid or
373
+ // absent baseline. The home screen only needs enough state to guide users.
374
+ }
375
+ let policy = "missing";
376
+ if (await pathExists(loaded.paths.sanitizationPolicy)) {
377
+ try {
378
+ validateRuntimeSanitizationPolicy(
379
+ JSON.parse(await readFile(loaded.paths.sanitizationPolicy, "utf8")),
380
+ );
381
+ policy = "reviewed";
382
+ } catch {
383
+ policy = "needs-review";
384
+ }
385
+ }
386
+ return {
387
+ detected,
388
+ node,
389
+ config: "valid",
390
+ configPath,
391
+ baseline: Boolean(migrationSummary),
392
+ policy,
393
+ policyPath: relative(projectRoot, loaded.paths.sanitizationPolicy),
394
+ migrationSummary,
395
+ runtime: await pathExists(
396
+ join(loaded.paths.runtimeWorkdir, "baseline.json"),
397
+ ),
398
+ };
399
+ };
400
+
401
+ const runGuidedHome = async ({ flags, planOptions }) => {
402
+ const state = await inspectGuidedProject(planOptions);
403
+ const marker = (complete) =>
404
+ complete ? terminalStyle(flags, "32", "✓") : terminalStyle(flags, "2", "○");
405
+ const options = [];
406
+
407
+ if (!state.node.supported) {
408
+ options.push({
409
+ label: "Show Node.js 24 setup instructions",
410
+ command: "node-help",
411
+ });
412
+ } else if (state.config === "missing") {
413
+ options.push({
414
+ label: "Set up Rehearsal",
415
+ command: "setup-write",
416
+ });
417
+ } else {
418
+ options.push({ label: "Check readiness", command: "doctor" });
419
+ }
420
+ if (state.node.supported && state.baseline) {
421
+ options.push(
422
+ { label: "Review the migration plan", command: "explain" },
423
+ { label: "Run a rehearsal", command: "run" },
424
+ { label: "Show candidate migrations", command: "candidates" },
425
+ );
426
+ } else if (state.node.supported && state.config === "valid") {
427
+ if (state.policy === "missing") {
428
+ options.push({
429
+ label: "Prepare a reviewable baseline policy draft",
430
+ command: "baseline-prepare",
431
+ });
432
+ } else if (state.policy === "needs-review") {
433
+ options.push({
434
+ label: "Review the baseline policy draft",
435
+ command: "policy-review",
436
+ });
437
+ } else {
438
+ options.push({
439
+ label: "Create a baseline from safe local files",
440
+ command: "baseline-guide",
441
+ });
442
+ }
443
+ }
444
+ if (state.node.supported && state.runtime) {
445
+ options.push(
446
+ { label: "Show local runtime status", command: "status" },
447
+ { label: "Verify the local runtime", command: "verify" },
448
+ { label: "Reset to the baseline", command: "reset" },
449
+ { label: "Stop the local runtime", command: "stop" },
450
+ { label: "Discard the local runtime", command: "discard" },
451
+ );
452
+ }
453
+ options.push(
454
+ { label: "Show all commands", command: "help" },
455
+ { label: "Exit", command: "exit" },
456
+ );
457
+
458
+ console.log(
459
+ [
460
+ "",
461
+ terminalStyle(flags, "1;36", "REHEARSAL"),
462
+ "Safe local migration testing",
463
+ "",
464
+ terminalStyle(flags, "1", state.detected.projectName),
465
+ `${marker(state.node.supported)} Node.js ${state.node.version}${state.node.supported ? "" : " (Node.js 24 required)"}`,
466
+ `${marker(state.detected.hasSupabaseConfig)} Supabase project detected`,
467
+ `${marker(state.detected.hasMigrations)} Migration history detected`,
468
+ `${marker(state.config === "valid")} Rehearsal configuration${state.config === "invalid" ? " needs attention" : ""}`,
469
+ ...(state.config === "valid"
470
+ ? [
471
+ `${marker(state.policy === "reviewed")} ${state.policy === "needs-review" ? "Baseline policy needs review" : state.policy === "reviewed" ? "Baseline policy reviewed" : "Baseline policy needed"}`,
472
+ ]
473
+ : []),
474
+ `${marker(state.baseline)} Verified baseline`,
475
+ ...(state.runtime ? [`${marker(true)} Disposable runtime created`] : []),
476
+ ...(state.configError
477
+ ? ["", terminalStyle(flags, "33", `! ${state.configError}`)]
478
+ : []),
479
+ "",
480
+ "What would you like to do?",
481
+ "",
482
+ ...options.map(
483
+ (option, index) =>
484
+ ` ${index === 0 ? terminalStyle(flags, "36", "›") : " "} ${index + 1}. ${option.label}`,
485
+ ),
486
+ "",
487
+ ].join("\n"),
488
+ );
489
+
490
+ const selected = await promptForChoice({ message: "Choose", options });
491
+ if (selected.command === "exit") {
492
+ console.log("No changes made.");
493
+ return null;
494
+ }
495
+ if (selected.command === "node-help") {
496
+ console.log(
497
+ [
498
+ "",
499
+ terminalStyle(flags, "1", "NODE.JS 24 REQUIRED"),
500
+ `This shell is using Node.js ${state.node.version}. Rehearsal has not changed your project.`,
501
+ "",
502
+ "With nvm:",
503
+ " nvm install 24",
504
+ " nvm use 24",
505
+ "",
506
+ "Then reinstall Rehearsal in your scratch project and run npx rehearsal again.",
507
+ ].join("\n"),
508
+ );
509
+ return null;
510
+ }
511
+ if (selected.command === "policy-review") {
512
+ console.log(
513
+ [
514
+ "",
515
+ terminalStyle(flags, "1", "REVIEW THE BASELINE POLICY"),
516
+ `Open ${state.policyPath} and complete every REVIEW REQUIRED decision.`,
517
+ "For each column, classify its sanitization action, generated status, identity behavior, and foreign key.",
518
+ 'Remove "draft": true only after every decision has been reviewed.',
519
+ "Then run rehearsal again to continue.",
520
+ ].join("\n"),
521
+ );
522
+ return null;
523
+ }
524
+ if (["baseline-guide", "baseline-prepare"].includes(selected.command)) {
525
+ console.log(
526
+ [
527
+ "",
528
+ terminalStyle(flags, "1", "CREATE A SAFE BASELINE"),
529
+ "Rehearsal only reads the local files you name here.",
530
+ "It does not extract data or contact a hosted Supabase project.",
531
+ "",
532
+ ].join("\n"),
533
+ );
534
+ const recordsDefault = await firstExistingPath([
535
+ "rehearsal/sanitized-data.ndjson",
536
+ "rehearsal/synthetic-data.ndjson",
537
+ ]);
538
+ const ledgerDefault = await firstExistingPath([
539
+ "rehearsal/migration-ledger.json",
540
+ ]);
541
+ flags.recordsPath = await promptForPath({
542
+ message: "Sanitized NDJSON records",
543
+ defaultValue: recordsDefault,
544
+ });
545
+ flags.ledgerPath = await promptForPath({
546
+ message: "Migration ledger",
547
+ defaultValue: ledgerDefault,
548
+ });
549
+ if (selected.command === "baseline-prepare") {
550
+ const preparationPlan = await planBaselinePreparation({
551
+ ...planOptions,
552
+ recordsPath: flags.recordsPath,
553
+ ledgerPath: flags.ledgerPath,
554
+ });
555
+ const preview = {
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`);
563
+ const accepted = await promptForConfirmation(
564
+ `Create the REVIEW REQUIRED draft at ${preview.destination}?`,
565
+ );
566
+ if (!accepted) {
567
+ console.log("No changes made.");
568
+ return null;
569
+ }
570
+ flags.preparationPlan = preparationPlan;
571
+ flags.write = true;
572
+ return "baseline prepare";
573
+ }
574
+ flags.assetsPath = await promptForPath({
575
+ message: "Storage asset manifest (optional)",
576
+ optional: true,
577
+ });
578
+ return "baseline create";
579
+ }
580
+ if (selected.command !== "setup-write") return selected.command;
581
+
582
+ const setupPlan = await planRehearsalSetup({ projectRoot });
583
+ const preview = {
584
+ ...summarizeRehearsalSetup(setupPlan, { mode: "preview" }),
585
+ nextAction:
586
+ "Review this plan. No files are written unless you approve below.",
587
+ };
588
+ console.log(`\n${renderSetup(preview)}\n`);
589
+ const accepted = await promptForConfirmation(
590
+ "Create these project-local files?",
591
+ );
592
+ if (!accepted) {
593
+ console.log("No changes made.");
594
+ return null;
595
+ }
596
+ flags.setupPlan = setupPlan;
597
+ flags.write = true;
598
+ return "setup";
599
+ };
600
+
601
+ const prepareCandidateConfirmation = async ({
602
+ command,
603
+ flags,
604
+ planOptions,
605
+ }) => {
606
+ if (!["run", "migrate"].includes(command) || flags.dryRun) {
607
+ return true;
608
+ }
609
+ const summary = candidateSummary(
610
+ await inspectRehearsalMigrations(planOptions),
611
+ );
612
+ if (summary.candidateCount === 0) return true;
613
+ if (flags.confirmation === summary.candidateSha256) return true;
614
+ const interactive =
615
+ !flags.json && process.stdin.isTTY && process.stdout.isTTY;
616
+ if (!interactive || flags.confirmation) {
617
+ throw new RehearsalError({
618
+ category: "migration_candidate_failure",
619
+ code: "CANDIDATE_CONFIRMATION_REQUIRED",
620
+ message: flags.confirmation
621
+ ? "The candidate migration confirmation does not match the current candidate set."
622
+ : "Candidate migration confirmation is required in noninteractive use.",
623
+ expected: summary.candidateSha256,
624
+ actual: flags.confirmation ?? "missing",
625
+ context: {
626
+ command,
627
+ candidateCount: summary.candidateCount,
628
+ candidates: summary.candidates.map((migration) => migration.filename),
629
+ },
630
+ refused:
631
+ "Rehearsal did not reset, start, or modify the disposable runtime.",
632
+ suggestions: [
633
+ "Run rehearsal candidates, review every file, then pass the current digest with --confirm-candidates=.",
634
+ ],
635
+ });
636
+ }
637
+ console.log(
638
+ [
639
+ "",
640
+ terminalStyle(
641
+ flags,
642
+ "1",
643
+ `${summary.candidateCount} candidate migration${summary.candidateCount === 1 ? "" : "s"}`,
644
+ ),
645
+ "",
646
+ ...summary.candidates.map((migration) => ` → ${migration.filename}`),
647
+ "",
648
+ `Candidate set: ${summary.candidateSha256.slice(0, 12)}`,
649
+ "",
650
+ "Rehearsal will apply only this exact set to the disposable local runtime.",
651
+ ].join("\n"),
652
+ );
653
+ const accepted = await promptForConfirmation(
654
+ `Apply exactly ${summary.candidateCount === 1 ? "this migration" : "these migrations"}?`,
655
+ );
656
+ if (!accepted) {
657
+ console.log("No changes made.");
658
+ return false;
659
+ }
660
+ // The manager independently recomputes this digest. A file changed after this
661
+ // review is therefore rejected before candidate SQL is applied.
662
+ flags.confirmation = summary.candidateSha256;
663
+ return true;
664
+ };
665
+
206
666
  const emit = ({ command, data, flags, render, status = "success" }) => {
207
667
  const result = createRehearsalResult({
208
668
  command,
@@ -238,6 +698,22 @@ const runManager = ({ action, flags }) => {
238
698
  process.env[key] === undefined ? [] : [[key, process.env[key]]],
239
699
  ),
240
700
  );
701
+ const runtimeStartedMs = performance.now();
702
+ const actionDescription = {
703
+ run: "Restoring the baseline and applying reviewed migrations",
704
+ start: "Starting the disposable local runtime",
705
+ migrate: "Applying reviewed migrations to the current runtime",
706
+ reset: "Restoring the immutable baseline",
707
+ status: "Inspecting the disposable local runtime",
708
+ stop: "Stopping the disposable local runtime",
709
+ discard: "Removing the disposable local runtime",
710
+ verify: "Verifying the current local runtime",
711
+ }[action];
712
+ if (isHumanTerminal(flags)) {
713
+ console.log(
714
+ `\n${terminalStyle(flags, "1;36", action === "run" ? "REHEARSING" : "REHEARSAL")}\n${terminalStyle(flags, "36", "→")} ${actionDescription}. This can take a moment.`,
715
+ );
716
+ }
241
717
  const result = spawnSync(process.execPath, args, {
242
718
  cwd: projectRoot,
243
719
  encoding: "utf8",
@@ -280,8 +756,15 @@ const runManager = ({ action, flags }) => {
280
756
  cause: new Error(completeOutput || `exit ${result.status}`),
281
757
  });
282
758
  }
759
+ const durationMs = performance.now() - runtimeStartedMs;
760
+ if (isHumanTerminal(flags)) {
761
+ console.log(
762
+ `${terminalStyle(flags, "32", "✓")} Local runtime step completed in ${formatDuration(durationMs)}.`,
763
+ );
764
+ }
283
765
  return {
284
766
  action,
767
+ durationMs: Math.round(durationMs * 100) / 100,
285
768
  output: redactDiagnosticValue(String(result.stdout ?? "").trim()),
286
769
  };
287
770
  };
@@ -412,10 +895,140 @@ const renderInit = (result) =>
412
895
  result.nextAction,
413
896
  ].join("\n");
414
897
 
898
+ const runSetup = async ({ flags, planOptions }) => {
899
+ const plan = flags.setupPlan ?? (await planRehearsalSetup({ projectRoot }));
900
+ if (flags.write) await applyRehearsalSetup(plan);
901
+ const result = summarizeRehearsalSetup(plan, {
902
+ mode: flags.write ? "written" : "preview",
903
+ });
904
+ if (!flags.write) return result;
905
+ const readiness = await runRehearsalDoctor(planOptions);
906
+ const needsBaselinePolicy = readiness.checks.some(
907
+ (check) => check.id === "sanitization-policy" && check.status === "fail",
908
+ );
909
+ return {
910
+ ...result,
911
+ readiness,
912
+ nextAction:
913
+ readiness.state === "READY"
914
+ ? "Next: run rehearsal explain to review the migration plan."
915
+ : needsBaselinePolicy
916
+ ? "Next: run rehearsal again and choose Prepare a reviewable baseline policy draft."
917
+ : "Next: complete the remaining readiness items shown above.",
918
+ };
919
+ };
920
+
921
+ const summarizeSetupReadinessChecks = (readiness) => {
922
+ const failures = readiness.checks.filter((check) => check.status === "fail");
923
+ const policyMissing = failures.some(
924
+ (check) =>
925
+ check.id === "sanitization-policy" &&
926
+ /ENOENT|no such file/iu.test(check.detail),
927
+ );
928
+ const baselineMissing = failures.some(
929
+ (check) =>
930
+ check.id === "baseline" && /ENOENT|no such file/iu.test(check.detail),
931
+ );
932
+ const hidden = new Set([
933
+ ...(policyMissing ? ["sanitization-policy"] : []),
934
+ ...(baselineMissing
935
+ ? ["baseline", "baseline-permissions", "migration-history"]
936
+ : []),
937
+ ]);
938
+ if (
939
+ policyMissing &&
940
+ failures.some(
941
+ (check) =>
942
+ check.id === "paths" && /sanitization-policy/iu.test(check.detail),
943
+ )
944
+ ) {
945
+ hidden.add("paths");
946
+ }
947
+ const passed = readiness.checks.filter(
948
+ (check) => check.status === "pass",
949
+ ).length;
950
+ return [
951
+ ...(passed ? [` ✓ ${passed} environment and safety checks passed`] : []),
952
+ ...failures
953
+ .filter((check) => !hidden.has(check.id))
954
+ .map((check) => ` ○ ${check.label}: ${check.detail}`),
955
+ ...(policyMissing
956
+ ? [" ○ Baseline policy: not created yet (next guided step)"]
957
+ : []),
958
+ ...(baselineMissing
959
+ ? [" ○ Verified baseline: not created yet (after policy review)"]
960
+ : []),
961
+ ];
962
+ };
963
+
964
+ const renderSetup = (result) => {
965
+ const actionMarker = { create: "+", update: "~", unchanged: "=" };
966
+ return [
967
+ `REHEARSAL SETUP — ${result.mode.toUpperCase()}`,
968
+ `Project: ${result.project}`,
969
+ `Runtime ID: ${result.projectId}`,
970
+ `Application: ${result.applicationUrl}`,
971
+ `Ports: API ${result.ports.api}, database ${result.ports.database}, Studio ${result.ports.studio}`,
972
+ "",
973
+ "Files",
974
+ ...result.files.map(
975
+ (file) => ` ${actionMarker[file.action]} ${file.action} ${file.path}`,
976
+ ),
977
+ "",
978
+ "Safety boundaries",
979
+ ...result.safety.map((barrier) => ` ✓ ${barrier}`),
980
+ ...(result.readiness
981
+ ? [
982
+ "",
983
+ `Readiness — ${result.readiness.state}`,
984
+ ...summarizeSetupReadinessChecks(result.readiness),
985
+ ]
986
+ : []),
987
+ "",
988
+ result.nextAction,
989
+ ].join("\n");
990
+ };
991
+
992
+ const runBaselinePreparation = async ({ flags, planOptions }) => {
993
+ const plan =
994
+ flags.preparationPlan ??
995
+ (await planBaselinePreparation({
996
+ ...planOptions,
997
+ recordsPath: flags.recordsPath,
998
+ ledgerPath: flags.ledgerPath,
999
+ }));
1000
+ if (flags.write) await applyBaselinePreparation(plan);
1001
+ return summarizeBaselinePreparation(plan, {
1002
+ mode: flags.write ? "written" : "preview",
1003
+ });
1004
+ };
1005
+
1006
+ const renderBaselinePreparation = (result, flags = {}) =>
1007
+ [
1008
+ `BASELINE POLICY — ${result.mode.toUpperCase()}`,
1009
+ `Destination: ${result.destination}`,
1010
+ `Records: ${formatCount(result.rowCount, "row")} from ${result.recordsPath}`,
1011
+ `Migrations: ${formatCount(result.migrationCount, "migration")} through ${result.migrationCutoff}`,
1012
+ "",
1013
+ "Detected shape (values are never printed)",
1014
+ ...result.tables.map(
1015
+ (table) =>
1016
+ ` ${terminalStyle(flags, "36", "→")} ${table.name}: ${formatCount(table.rowCount, "row")}; ${table.columns.join(", ")}`,
1017
+ ),
1018
+ "",
1019
+ "Every column is marked REVIEW REQUIRED for action, generated status, identity, and foreign key metadata.",
1020
+ "The draft cannot be activated until those decisions are completed.",
1021
+ "",
1022
+ result.nextAction,
1023
+ ].join("\n");
1024
+
415
1025
  const usage = () => `Usage: rehearsal <command> [options]
416
1026
 
417
1027
  Commands:
1028
+ guide Open the interactive, state-aware home screen
1029
+ setup [--write] Preview or create safe first-run scaffolding
418
1030
  init [--write] Preview or explicitly write safe starter config
1031
+ baseline prepare --records= --ledger= [--write] Create a fail-closed policy draft
419
1032
  baseline create --records= --ledger= [--assets=] Create a baseline from safe local inputs
420
1033
  doctor Check whether Rehearsal is safe and ready
421
1034
  explain Show the immutable execution plan
@@ -432,24 +1045,55 @@ Commands:
432
1045
  discard Remove only this project's disposable runtime
433
1046
  verify Verify the current local Rehearsal runtime
434
1047
 
435
- Options: --json --verbose --debug --config=<path>`;
1048
+ Options: --json --verbose --debug --plain --config=<path>`;
436
1049
 
437
1050
  const main = async () => {
438
1051
  const { flags, positionals } = parseArguments(process.argv.slice(2));
439
- const command = positionals.join(" ") || "help";
1052
+ let command = positionals.join(" ") || "help";
440
1053
  const planOptions = {
441
1054
  projectRoot,
442
1055
  configPath: flags.configPath,
443
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
+ }
444
1073
  if (command === "help" || flags.help) {
445
1074
  console.log(usage());
446
1075
  return;
447
1076
  }
1077
+ if (command === "setup") {
1078
+ const data = await runSetup({ flags, planOptions });
1079
+ emit({ command, data, flags, render: renderSetup });
1080
+ return;
1081
+ }
448
1082
  if (command === "init") {
449
1083
  const data = await runInit({ flags });
450
1084
  emit({ command, data, flags, render: renderInit });
451
1085
  return;
452
1086
  }
1087
+ if (command === "baseline prepare") {
1088
+ const data = await runBaselinePreparation({ flags, planOptions });
1089
+ emit({
1090
+ command,
1091
+ data,
1092
+ flags,
1093
+ render: (result) => renderBaselinePreparation(result, flags),
1094
+ });
1095
+ return;
1096
+ }
453
1097
  if (command === "baseline create") {
454
1098
  const data = await createSyntheticBaselineFromFiles({
455
1099
  ...planOptions,
@@ -462,7 +1106,11 @@ const main = async () => {
462
1106
  data,
463
1107
  flags,
464
1108
  render: (baseline) =>
465
- `Activated synthetic baseline ${baseline.generationId}: ${baseline.rowCount} rows across ${baseline.tableCount} tables; ${baseline.migrationCount} migrations through ${baseline.migrationCutoff}.`,
1109
+ [
1110
+ `Activated synthetic baseline ${baseline.generationId}: ${formatCount(baseline.rowCount, "row")} across ${formatCount(baseline.tableCount, "table")}; ${formatCount(baseline.migrationCount, "migration")} through ${baseline.migrationCutoff}.`,
1111
+ "",
1112
+ "Next: run rehearsal doctor to check readiness.",
1113
+ ].join("\n"),
466
1114
  });
467
1115
  return;
468
1116
  }
@@ -515,28 +1163,56 @@ const main = async () => {
515
1163
  if (flags.dryRun && command !== "run") {
516
1164
  throw new Error("--dry-run is supported only by rehearsal run.");
517
1165
  }
1166
+ if (
1167
+ !(await prepareCandidateConfirmation({ command, flags, planOptions }))
1168
+ ) {
1169
+ return;
1170
+ }
518
1171
  const runtime = runManager({ action: command, flags });
519
- const data =
520
- command === "run"
521
- ? {
522
- runtime,
523
- applicationProof: await runApplicationProof(planOptions),
524
- }
525
- : runtime;
1172
+ let applicationProof;
1173
+ if (command === "run") {
1174
+ if (isHumanTerminal(flags)) {
1175
+ console.log(
1176
+ `${terminalStyle(flags, "36", "→")} Running the project-owned application proof.`,
1177
+ );
1178
+ }
1179
+ applicationProof = await runApplicationProof(planOptions);
1180
+ if (isHumanTerminal(flags)) {
1181
+ console.log(
1182
+ `${terminalStyle(flags, "32", "✓")} Application proof passed.`,
1183
+ );
1184
+ }
1185
+ }
1186
+ const data = command === "run" ? { runtime, applicationProof } : runtime;
526
1187
  emit({
527
1188
  command,
528
1189
  data,
529
1190
  flags,
530
1191
  render: (value) => {
531
1192
  const runtimeResult = value.runtime ?? value;
1193
+ const nextAction = {
1194
+ run: "Next: exercise the local application, then run rehearsal verify.",
1195
+ start:
1196
+ "Next: exercise the local application or run rehearsal status.",
1197
+ migrate: "Next: run rehearsal verify to prove the current runtime.",
1198
+ reset: "Next: run rehearsal verify or continue testing locally.",
1199
+ status: "Next: run rehearsal verify, reset, stop, or discard.",
1200
+ stop: "Next: run rehearsal start when you want to resume.",
1201
+ discard:
1202
+ "The disposable runtime was removed; the immutable baseline remains.",
1203
+ verify:
1204
+ "Next: continue testing, reset to the baseline, or stop the runtime.",
1205
+ }[runtimeResult.action];
532
1206
  return [
533
1207
  runtimeResult.output ||
534
1208
  `Rehearsal ${runtimeResult.action} completed.`,
535
1209
  value.applicationProof
536
1210
  ? `Application proof passed: ${value.applicationProof.command}`
537
1211
  : null,
1212
+ nextAction ? "" : null,
1213
+ nextAction,
538
1214
  ]
539
- .filter(Boolean)
1215
+ .filter((entry) => entry !== null)
540
1216
  .join("\n");
541
1217
  },
542
1218
  });