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

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.
@@ -56,6 +56,10 @@ import {
56
56
  suggestPolicyExceptionColumns,
57
57
  } from "../../lib/rehearsal/policy_review.mjs";
58
58
  import { collectRehearsalSupportReport } from "../../lib/rehearsal/support_report.mjs";
59
+ import {
60
+ applyRehearsalCleanup,
61
+ planRehearsalCleanup,
62
+ } from "../../lib/rehearsal/cleanup.mjs";
59
63
 
60
64
  const packageRoot = fileURLToPath(new URL("../../..", import.meta.url));
61
65
  const projectRoot = process.cwd();
@@ -80,6 +84,10 @@ const parseArguments = (arguments_) => {
80
84
  recordsPath: undefined,
81
85
  ledgerPath: undefined,
82
86
  assetsPath: undefined,
87
+ target: undefined,
88
+ includeRuntime: false,
89
+ includeImages: false,
90
+ cleanupConfirmation: undefined,
83
91
  };
84
92
  const positionals = [];
85
93
  for (const argument of arguments_) {
@@ -94,12 +102,20 @@ const parseArguments = (arguments_) => {
94
102
  flags.configPath = argument.slice("--config=".length);
95
103
  } else if (argument.startsWith("--confirm-candidates=")) {
96
104
  flags.confirmation = argument.slice("--confirm-candidates=".length);
105
+ } else if (argument.startsWith("--confirm-cleanup=")) {
106
+ flags.cleanupConfirmation = argument.slice("--confirm-cleanup=".length);
97
107
  } else if (argument.startsWith("--records=")) {
98
108
  flags.recordsPath = argument.slice("--records=".length);
99
109
  } else if (argument.startsWith("--ledger=")) {
100
110
  flags.ledgerPath = argument.slice("--ledger=".length);
101
111
  } else if (argument.startsWith("--assets=")) {
102
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;
103
119
  } else if (argument.startsWith("--")) {
104
120
  throw new Error(`Unknown Rehearsal option: ${argument}.`);
105
121
  } else positionals.push(argument);
@@ -128,7 +144,7 @@ const renderPlan = (plan, verbosity) => {
128
144
  "Environment",
129
145
  ` ✓ ${plan.environment.kind}`,
130
146
  ` ✓ Hosted access ${plan.environment.hostedAccess}`,
131
- ` ✓ Application/provider-data egress ${plan.environment.outboundNetwork}`,
147
+ ` ✓ Hosted target configuration ${plan.environment.outboundNetwork}`,
132
148
  ...(plan.environment.authenticationProviders.length
133
149
  ? [
134
150
  ` ✓ External identity providers ${plan.environment.authenticationProviders.join(", ")}`,
@@ -274,7 +290,12 @@ const formatDuration = (durationMs) =>
274
290
  ? `${Math.round(durationMs)}ms`
275
291
  : `${(durationMs / 1_000).toFixed(1)}s`;
276
292
 
277
- const promptForChoice = async ({ message, options, flags = {} }) => {
293
+ const promptForChoice = async ({
294
+ message,
295
+ options,
296
+ flags = {},
297
+ optionsAlreadyShown = false,
298
+ }) => {
278
299
  if (useStyledPrompts(flags)) {
279
300
  const selected = await prompts.select({
280
301
  message,
@@ -289,6 +310,18 @@ const promptForChoice = async ({ message, options, flags = {} }) => {
289
310
  ? options.find((option) => option.command === "exit")
290
311
  : selected;
291
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
+ }
292
325
  const prompt = createInterface({
293
326
  input: process.stdin,
294
327
  output: process.stdout,
@@ -502,6 +535,7 @@ const inspectGuidedProject = async (planOptions) => {
502
535
  detected,
503
536
  node,
504
537
  config: "missing",
538
+ target: detected.hasSupabaseConfig ? "supabase" : "postgresql",
505
539
  baseline: false,
506
540
  runtime: false,
507
541
  };
@@ -514,6 +548,7 @@ const inspectGuidedProject = async (planOptions) => {
514
548
  detected,
515
549
  node,
516
550
  config: "invalid",
551
+ target: undefined,
517
552
  configPath,
518
553
  configError: redactDiagnosticValue(String(error?.message ?? error)),
519
554
  baseline: false,
@@ -544,6 +579,7 @@ const inspectGuidedProject = async (planOptions) => {
544
579
  detected,
545
580
  node,
546
581
  config: "valid",
582
+ target: loaded.config.runtime.target,
547
583
  configPath,
548
584
  baseline: Boolean(migrationSummary),
549
585
  policy,
@@ -811,6 +847,13 @@ const runGuidedHome = async ({ flags, planOptions }) => {
811
847
  command: "manage-runtime",
812
848
  });
813
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
+ }
814
857
  if (lastGuidedDetails) {
815
858
  options.push({
816
859
  label: "Show details from the last action",
@@ -829,8 +872,16 @@ const runGuidedHome = async ({ flags, planOptions }) => {
829
872
 
830
873
  const statusLines = [
831
874
  `${marker(state.node.supported)} Node.js ${state.node.version}${state.node.supported ? "" : " (Node.js 24 required)"}`,
832
- `${marker(state.detected.hasSupabaseConfig)} Supabase project detected`,
833
- `${marker(state.detected.hasMigrations)} Migration history detected`,
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`,
834
885
  `${marker(state.config === "valid")} Rehearsal configuration${state.config === "invalid" ? " needs attention" : ""}`,
835
886
  ...(state.config === "valid"
836
887
  ? [
@@ -882,6 +933,7 @@ const runGuidedHome = async ({ flags, planOptions }) => {
882
933
  message: "What would you like to do?",
883
934
  options,
884
935
  flags,
936
+ optionsAlreadyShown: !useStyledPrompts(flags),
885
937
  });
886
938
  if (selected.command === "exit") {
887
939
  return null;
@@ -950,13 +1002,67 @@ const runGuidedHome = async ({ flags, planOptions }) => {
950
1002
  }
951
1003
  return runtimeAction.command;
952
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
+ }
953
1059
  if (["baseline-guide", "baseline-prepare"].includes(selected.command)) {
954
1060
  console.log(
955
1061
  [
956
1062
  "",
957
1063
  terminalStyle(flags, "1", "CREATE A SAFE BASELINE"),
958
1064
  "Rehearsal only reads the local files you name here.",
959
- "It does not extract data or contact a hosted Supabase project.",
1065
+ "It does not extract data or contact a hosted database.",
960
1066
  "",
961
1067
  ].join("\n"),
962
1068
  );
@@ -968,9 +1074,13 @@ const runGuidedHome = async ({ flags, planOptions }) => {
968
1074
  discovered.ledgers.length
969
1075
  ? `Migration ledgers: ${discovered.ledgers.join(", ")}`
970
1076
  : "Migration ledgers: none detected",
971
- discovered.assetManifests.length
972
- ? `Storage manifests: ${discovered.assetManifests.join(", ")}`
973
- : "Storage manifests: none detected",
1077
+ ...(state.target === "supabase"
1078
+ ? [
1079
+ discovered.assetManifests.length
1080
+ ? `Storage manifests: ${discovered.assetManifests.join(", ")}`
1081
+ : "Storage manifests: none detected",
1082
+ ]
1083
+ : []),
974
1084
  "Only paths and file structure were inspected; row values stay hidden.",
975
1085
  ];
976
1086
  if (useStyledPrompts(flags)) {
@@ -1017,12 +1127,15 @@ const runGuidedHome = async ({ flags, planOptions }) => {
1017
1127
  flags.write = true;
1018
1128
  return "baseline prepare";
1019
1129
  }
1020
- flags.assetsPath = await promptForDiscoveredPath({
1021
- message: "Storage assets",
1022
- candidates: discovered.assetManifests,
1023
- optional: true,
1024
- flags,
1025
- });
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;
1026
1139
  const inspection = await inspectBaselineInputFiles({
1027
1140
  ...planOptions,
1028
1141
  recordsPath: flags.recordsPath,
@@ -1052,7 +1165,26 @@ const runGuidedHome = async ({ flags, planOptions }) => {
1052
1165
  }
1053
1166
  if (selected.command !== "setup-write") return selected.command;
1054
1167
 
1055
- const setupPlan = await planRehearsalSetup({ projectRoot });
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
+ });
1056
1188
  const preview = {
1057
1189
  ...summarizeRehearsalSetup(setupPlan, { mode: "preview" }),
1058
1190
  nextAction:
@@ -1215,6 +1347,14 @@ const runManager = ({ action, flags }) => {
1215
1347
  const inferred = normalizeRehearsalError(
1216
1348
  new Error(completeOutput || `Rehearsal ${action} failed.`),
1217
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;
1218
1358
  const conciseOutput = completeOutput
1219
1359
  .replaceAll(String.fromCodePoint(27), "")
1220
1360
  .split("\n")
@@ -1224,7 +1364,7 @@ const runManager = ({ action, flags }) => {
1224
1364
  .join("\n")
1225
1365
  .slice(-2_000);
1226
1366
  throw new RehearsalError({
1227
- category: inferred.category,
1367
+ category,
1228
1368
  code: `MANAGER_${action.toUpperCase()}_FAILED`,
1229
1369
  message: `The local Rehearsal ${action} operation did not complete.`,
1230
1370
  expected: "the isolated runtime operation to finish and verify",
@@ -1382,7 +1522,12 @@ const renderInit = (result) =>
1382
1522
  ].join("\n");
1383
1523
 
1384
1524
  const runSetup = async ({ flags, planOptions }) => {
1385
- const plan = flags.setupPlan ?? (await planRehearsalSetup({ projectRoot }));
1525
+ const plan =
1526
+ flags.setupPlan ??
1527
+ (await planRehearsalSetup({
1528
+ projectRoot,
1529
+ target: flags.target ?? "supabase",
1530
+ }));
1386
1531
  if (flags.write) await applyRehearsalSetup(plan);
1387
1532
  const result = summarizeRehearsalSetup(plan, {
1388
1533
  mode: flags.write ? "written" : "preview",
@@ -1454,9 +1599,12 @@ const renderSetup = (result) => {
1454
1599
  return [
1455
1600
  `REHEARSAL SETUP — ${result.mode.toUpperCase()}`,
1456
1601
  `Project: ${result.project}`,
1602
+ `Database: ${result.target === "postgresql" ? "PostgreSQL" : "Supabase"}`,
1457
1603
  `Runtime ID: ${result.projectId}`,
1458
1604
  `Application: ${result.applicationUrl}`,
1459
- `Ports: API ${result.ports.api}, database ${result.ports.database}, Studio ${result.ports.studio}`,
1605
+ result.target === "postgresql"
1606
+ ? `Port: database ${result.ports.database}`
1607
+ : `Ports: API ${result.ports.api}, database ${result.ports.database}, Studio ${result.ports.studio}`,
1460
1608
  "",
1461
1609
  "Files",
1462
1610
  ...result.files.map(
@@ -1567,11 +1715,117 @@ const renderSupportReport = (report) => {
1567
1715
  ].join("\n");
1568
1716
  };
1569
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
+
1570
1824
  const usage = () => `Usage: rehearsal <command> [options]
1571
1825
 
1572
1826
  Commands:
1573
1827
  guide Open the interactive, state-aware home screen
1574
- setup [--write] Preview or create safe first-run scaffolding
1828
+ setup [--target=] [--write] Preview or create safe first-run scaffolding
1575
1829
  init [--write] Preview or explicitly write safe starter config
1576
1830
  baseline prepare --records= --ledger= [--write] Create a fail-closed policy draft
1577
1831
  baseline create --records= --ledger= [--assets=] Create a baseline from safe local inputs
@@ -1589,9 +1843,11 @@ Commands:
1589
1843
  status Report the disposable local runtime state
1590
1844
  stop Stop only this project's local runtime
1591
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
1592
1848
  verify Verify the current local Rehearsal runtime
1593
1849
 
1594
- Options: --json --verbose --debug --plain --config=<path>`;
1850
+ Options: --json --verbose --debug --plain --config=<path> --target=supabase|postgresql`;
1595
1851
 
1596
1852
  const executeCommand = async ({ command, flags, planOptions, guided }) => {
1597
1853
  if (command === "help" || flags.help) {
@@ -1663,6 +1919,11 @@ const executeCommand = async ({ command, flags, planOptions, guided }) => {
1663
1919
  emit({ command, data, flags, render: renderSupportReport });
1664
1920
  return;
1665
1921
  }
1922
+ if (command === "cleanup") {
1923
+ const data = await runCleanup({ flags, planOptions });
1924
+ emit({ command, data, flags, render: renderCleanup });
1925
+ return;
1926
+ }
1666
1927
  if (command === "explain" || (command === "run" && flags.dryRun)) {
1667
1928
  const data = await buildRehearsalPlan(planOptions);
1668
1929
  emit({ command, data, flags, render: renderPlan });
@@ -1797,8 +2058,41 @@ const resetGuidedFlags = (flags) => {
1797
2058
  flags.recordsPath = undefined;
1798
2059
  flags.ledgerPath = undefined;
1799
2060
  flags.assetsPath = undefined;
2061
+ flags.target = undefined;
2062
+ flags.includeRuntime = false;
2063
+ flags.includeImages = false;
2064
+ flags.cleanupConfirmation = undefined;
1800
2065
  flags.setupPlan = undefined;
1801
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
+ };
1802
2096
  };
1803
2097
 
1804
2098
  const main = async () => {
@@ -1825,20 +2119,25 @@ const main = async () => {
1825
2119
  );
1826
2120
  }
1827
2121
  flags.guided = true;
2122
+ const removeGuidedExitShortcut = installGuidedExitShortcut(flags);
1828
2123
  if (useStyledPrompts(flags)) {
1829
2124
  prompts.intro("REHEARSAL · Safe local migration testing");
1830
2125
  }
1831
- while (true) {
1832
- resetGuidedFlags(flags);
1833
- const selected = await runGuidedHome({ flags, planOptions });
1834
- if (!selected) break;
1835
- if (selected === "refresh") continue;
1836
- await executeCommand({
1837
- command: selected,
1838
- flags,
1839
- planOptions,
1840
- guided: true,
1841
- });
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();
1842
2141
  }
1843
2142
  if (useStyledPrompts(flags)) {
1844
2143
  prompts.outro("See you at the next rehearsal.");