@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.
- package/CHANGELOG.md +77 -1
- package/COMPATIBILITY.md +15 -3
- package/README.md +31 -13
- package/docs/README.md +29 -0
- package/docs/adapters.md +15 -6
- package/docs/architecture.md +58 -0
- package/docs/baselines.md +22 -1
- package/docs/commands.md +81 -14
- package/docs/configuration.md +144 -11
- package/docs/getting-started.md +12 -2
- package/docs/glossary.md +4 -0
- package/docs/production-source.md +176 -33
- package/docs/roadmap.md +30 -28
- package/docs/runtime-policies.md +191 -0
- package/docs/sanitization.md +24 -8
- package/docs/security-model.md +29 -8
- package/docs/standalone-workflow.md +107 -0
- package/docs/troubleshooting.md +8 -0
- package/package.json +25 -22
- package/scripts/runtime/manage_database.mjs +18 -0
- package/src/README.md +17 -0
- package/src/application/session.mjs +313 -0
- package/{scripts/lib/rehearsal/baseline_artifact.mjs → src/baseline/artifact.mjs} +110 -14
- package/{scripts/lib/rehearsal/baseline_builder.mjs → src/baseline/builder.mjs} +8 -4
- package/{scripts/lib/rehearsal/baseline_preparation.mjs → src/baseline/preparation.mjs} +11 -4
- package/src/baseline/privacy_engine.mjs +413 -0
- package/{scripts/lib/rehearsal → src/baseline}/sanitization_policy.mjs +27 -7
- package/{scripts/lib/rehearsal → src/baseline}/schema_snapshot.mjs +1 -1
- package/src/cli/arguments.mjs +120 -0
- package/src/cli/guided.mjs +807 -0
- package/src/cli/rehearsal.mjs +933 -0
- package/src/cli/renderers.mjs +584 -0
- package/src/cli/runtime_commands.mjs +553 -0
- package/src/cli/source_commands.mjs +326 -0
- package/src/cli/terminal.mjs +275 -0
- package/src/identity/claim.mjs +975 -0
- package/src/identity/storage.mjs +165 -0
- package/{scripts/lib/rehearsal → src/project}/configuration.d.mts +34 -0
- package/{scripts/lib/rehearsal → src/project}/configuration.mjs +353 -3
- package/{scripts/lib/rehearsal → src/project}/setup.mjs +1 -1
- package/{scripts/lib/rehearsal → src/project}/support_report.mjs +5 -2
- package/{scripts/lib/rehearsal → src/runtime}/cleanup.mjs +3 -3
- package/{scripts/lib/rehearsal → src/runtime}/plan.mjs +5 -5
- package/src/runtime/policy.mjs +438 -0
- package/{scripts/lib/rehearsal/runtime_restore.mjs → src/runtime/restore.mjs} +40 -25
- package/src/runtime/topology.mjs +177 -0
- package/{scripts/lib/rehearsal → src/shared}/diagnostics.mjs +1 -1
- package/src/shared/operation_guard.mjs +162 -0
- package/{scripts/lib/rehearsal → src/shared}/process_environment.mjs +5 -1
- package/src/source/access.mjs +578 -0
- package/src/source/asset_transfer.mjs +177 -0
- package/src/source/baseline.mjs +446 -0
- package/src/source/postgresql_access.mjs +480 -0
- package/{scripts/lib/runtime/postgresql_runtime.mjs → src/targets/postgresql.mjs} +49 -13
- package/{scripts/lib/runtime/supabase_runtime.mjs → src/targets/supabase.mjs} +54 -16
- package/{scripts/lib/environment/local_supabase.mjs → src/targets/supabase_environment.mjs} +52 -20
- package/{scripts/lib/runtime/runtime_target.mjs → src/targets/target.mjs} +27 -2
- package/scripts/operations/database/manage_rehearsal_database.mjs +0 -11
- package/scripts/operations/rehearsal/rehearsal_cli.mjs +0 -2166
- /package/{scripts/lib/rehearsal → src/baseline}/input_discovery.mjs +0 -0
- /package/{scripts/lib/rehearsal → src/baseline}/policy_review.mjs +0 -0
- /package/{scripts/lib/rehearsal → src/runtime}/migration_history.mjs +0 -0
- /package/{scripts/lib/rehearsal → src/runtime}/service_environment.mjs +0 -0
- /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
|
-
}
|