@rehearsal-db/core 0.1.0-beta.2 → 0.1.0-beta.4
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 +49 -1
- package/README.md +27 -2
- package/docs/commands.md +33 -2
- package/docs/getting-started.md +41 -7
- package/docs/releasing.md +1 -1
- package/docs/sanitization.md +16 -0
- package/docs/troubleshooting.md +18 -2
- package/package.json +5 -2
- package/scripts/lib/rehearsal/baseline_builder.mjs +9 -4
- package/scripts/lib/rehearsal/baseline_preparation.mjs +201 -0
- package/scripts/lib/rehearsal/configuration.d.mts +14 -1
- package/scripts/lib/rehearsal/configuration.mjs +21 -10
- package/scripts/lib/rehearsal/diagnostics.mjs +7 -3
- package/scripts/lib/rehearsal/human_output.mjs +4 -0
- package/scripts/lib/rehearsal/plan.mjs +25 -2
- package/scripts/lib/rehearsal/policy_review.mjs +52 -0
- package/scripts/lib/rehearsal/sanitization_policy.mjs +110 -0
- package/scripts/lib/rehearsal/setup.mjs +347 -0
- package/scripts/operations/database/manage_rehearsal_database.mjs +12 -6
- package/scripts/operations/rehearsal/rehearsal_cli.mjs +1060 -23
|
@@ -6,10 +6,12 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { spawnSync } from "node:child_process";
|
|
9
|
-
import { writeFile } from "node:fs/promises";
|
|
9
|
+
import { access, readFile, writeFile } from "node:fs/promises";
|
|
10
10
|
import { join, relative } from "node:path";
|
|
11
11
|
import { performance } from "node:perf_hooks";
|
|
12
|
+
import { createInterface } from "node:readline/promises";
|
|
12
13
|
import { fileURLToPath } from "node:url";
|
|
14
|
+
import * as prompts from "@clack/prompts";
|
|
13
15
|
import {
|
|
14
16
|
RehearsalError,
|
|
15
17
|
createRehearsalResult,
|
|
@@ -23,6 +25,7 @@ import {
|
|
|
23
25
|
findRehearsalConfigPath,
|
|
24
26
|
loadRehearsalConfig,
|
|
25
27
|
renderDetectedConfig,
|
|
28
|
+
validateRuntimeSanitizationPolicy,
|
|
26
29
|
} from "../../lib/rehearsal/configuration.mjs";
|
|
27
30
|
import {
|
|
28
31
|
buildRehearsalPlan,
|
|
@@ -31,6 +34,23 @@ import {
|
|
|
31
34
|
runRehearsalDoctor,
|
|
32
35
|
} from "../../lib/rehearsal/plan.mjs";
|
|
33
36
|
import { createSyntheticBaselineFromFiles } from "../../lib/rehearsal/baseline_builder.mjs";
|
|
37
|
+
import {
|
|
38
|
+
applyBaselinePreparation,
|
|
39
|
+
planBaselinePreparation,
|
|
40
|
+
summarizeBaselinePreparation,
|
|
41
|
+
} from "../../lib/rehearsal/baseline_preparation.mjs";
|
|
42
|
+
import {
|
|
43
|
+
applyRehearsalSetup,
|
|
44
|
+
inspectRehearsalNodeRuntime,
|
|
45
|
+
planRehearsalSetup,
|
|
46
|
+
summarizeRehearsalSetup,
|
|
47
|
+
} from "../../lib/rehearsal/setup.mjs";
|
|
48
|
+
import { formatCount } from "../../lib/rehearsal/human_output.mjs";
|
|
49
|
+
import {
|
|
50
|
+
applyReviewedPolicy,
|
|
51
|
+
completePolicyDraft,
|
|
52
|
+
readReviewablePolicyDraft,
|
|
53
|
+
} from "../../lib/rehearsal/policy_review.mjs";
|
|
34
54
|
|
|
35
55
|
const packageRoot = fileURLToPath(new URL("../../..", import.meta.url));
|
|
36
56
|
const projectRoot = process.cwd();
|
|
@@ -40,6 +60,7 @@ const managerPath = join(
|
|
|
40
60
|
packageRoot,
|
|
41
61
|
"scripts/operations/database/manage_rehearsal_database.mjs",
|
|
42
62
|
);
|
|
63
|
+
let lastGuidedDetails;
|
|
43
64
|
|
|
44
65
|
const parseArguments = (arguments_) => {
|
|
45
66
|
const flags = {
|
|
@@ -48,6 +69,7 @@ const parseArguments = (arguments_) => {
|
|
|
48
69
|
verbosity: "normal",
|
|
49
70
|
dryRun: false,
|
|
50
71
|
write: false,
|
|
72
|
+
plain: false,
|
|
51
73
|
configPath: undefined,
|
|
52
74
|
confirmation: undefined,
|
|
53
75
|
recordsPath: undefined,
|
|
@@ -62,6 +84,7 @@ const parseArguments = (arguments_) => {
|
|
|
62
84
|
else if (argument === "--debug") flags.verbosity = "debug";
|
|
63
85
|
else if (argument === "--dry-run") flags.dryRun = true;
|
|
64
86
|
else if (argument === "--write") flags.write = true;
|
|
87
|
+
else if (argument === "--plain") flags.plain = true;
|
|
65
88
|
else if (argument.startsWith("--config=")) {
|
|
66
89
|
flags.configPath = argument.slice("--config=".length);
|
|
67
90
|
} else if (argument.startsWith("--confirm-candidates=")) {
|
|
@@ -109,7 +132,7 @@ const renderPlan = (plan, verbosity) => {
|
|
|
109
132
|
"",
|
|
110
133
|
"Baseline",
|
|
111
134
|
` ✓ ${plan.baseline.generationId}`,
|
|
112
|
-
` ✓ ${plan.baseline.tableCount}
|
|
135
|
+
` ✓ ${formatCount(plan.baseline.tableCount, "table")}; ${formatCount(plan.baseline.rowCount, "row")}`,
|
|
113
136
|
` ✓ ${plan.baseline.verification}`,
|
|
114
137
|
"",
|
|
115
138
|
"Migrations",
|
|
@@ -121,6 +144,10 @@ const renderPlan = (plan, verbosity) => {
|
|
|
121
144
|
...verbose,
|
|
122
145
|
"",
|
|
123
146
|
plan.guarantee,
|
|
147
|
+
"",
|
|
148
|
+
plan.migrations.candidateCount
|
|
149
|
+
? "Next: run rehearsal run to review and confirm this exact candidate set."
|
|
150
|
+
: "Next: run rehearsal run to verify the baseline and application proof.",
|
|
124
151
|
].join("\n");
|
|
125
152
|
};
|
|
126
153
|
|
|
@@ -138,12 +165,20 @@ const renderDoctor = (doctor, verbosity) => {
|
|
|
138
165
|
)) {
|
|
139
166
|
if (check.remediation) lines.push(` Try: ${check.remediation}`);
|
|
140
167
|
}
|
|
141
|
-
if (doctor.ambientHostedVariables
|
|
168
|
+
if (doctor.ambientHostedVariables?.presentButQuarantined.length) {
|
|
142
169
|
lines.push(
|
|
143
170
|
`! ${doctor.ambientHostedVariables.presentButQuarantined.length} ambient hosted credential variables detected and quarantined.`,
|
|
144
171
|
);
|
|
145
172
|
}
|
|
146
|
-
return [
|
|
173
|
+
return [
|
|
174
|
+
...lines,
|
|
175
|
+
"",
|
|
176
|
+
doctor.state,
|
|
177
|
+
"",
|
|
178
|
+
doctor.state === "READY"
|
|
179
|
+
? "Next: run rehearsal explain to review the migration plan."
|
|
180
|
+
: "Next: fix the items above, then run rehearsal doctor again.",
|
|
181
|
+
].join("\n");
|
|
147
182
|
};
|
|
148
183
|
|
|
149
184
|
const renderBaseline = (baseline) =>
|
|
@@ -201,8 +236,705 @@ const renderCandidates = (summary, verbosity) =>
|
|
|
201
236
|
: ` → ${migration.filename} (${migration.sha256})`,
|
|
202
237
|
)
|
|
203
238
|
: [" ✓ No candidate migrations"]),
|
|
239
|
+
"",
|
|
240
|
+
summary.candidateCount
|
|
241
|
+
? "Next: run rehearsal run for a guided confirmation, or pass the full digest in automation."
|
|
242
|
+
: "Next: run rehearsal run to verify the baseline and application proof.",
|
|
204
243
|
].join("\n");
|
|
205
244
|
|
|
245
|
+
const pathExists = (path) =>
|
|
246
|
+
access(path)
|
|
247
|
+
.then(() => true)
|
|
248
|
+
.catch((error) => {
|
|
249
|
+
if (error?.code === "ENOENT") return false;
|
|
250
|
+
throw error;
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const useColor = (flags) =>
|
|
254
|
+
!flags.plain &&
|
|
255
|
+
process.stdout.isTTY &&
|
|
256
|
+
process.env.NO_COLOR === undefined &&
|
|
257
|
+
process.env.TERM !== "dumb";
|
|
258
|
+
|
|
259
|
+
const terminalStyle = (flags, code, value) =>
|
|
260
|
+
useColor(flags) ? `\u001B[${code}m${value}\u001B[0m` : value;
|
|
261
|
+
|
|
262
|
+
const isHumanTerminal = (flags) =>
|
|
263
|
+
!flags.json && process.stdin.isTTY && process.stdout.isTTY;
|
|
264
|
+
|
|
265
|
+
const useStyledPrompts = (flags) => isHumanTerminal(flags) && useColor(flags);
|
|
266
|
+
|
|
267
|
+
const formatDuration = (durationMs) =>
|
|
268
|
+
durationMs < 1_000
|
|
269
|
+
? `${Math.round(durationMs)}ms`
|
|
270
|
+
: `${(durationMs / 1_000).toFixed(1)}s`;
|
|
271
|
+
|
|
272
|
+
const promptForChoice = async ({ message, options, flags = {} }) => {
|
|
273
|
+
if (useStyledPrompts(flags)) {
|
|
274
|
+
const selected = await prompts.select({
|
|
275
|
+
message,
|
|
276
|
+
options: options.map((option) => ({
|
|
277
|
+
value: option,
|
|
278
|
+
label: option.label,
|
|
279
|
+
hint: option.hint,
|
|
280
|
+
})),
|
|
281
|
+
});
|
|
282
|
+
return prompts.isCancel(selected)
|
|
283
|
+
? options.find((option) => option.command === "exit")
|
|
284
|
+
: selected;
|
|
285
|
+
}
|
|
286
|
+
const prompt = createInterface({
|
|
287
|
+
input: process.stdin,
|
|
288
|
+
output: process.stdout,
|
|
289
|
+
});
|
|
290
|
+
try {
|
|
291
|
+
while (true) {
|
|
292
|
+
const answer = (await prompt.question(`${message} [1]: `)).trim();
|
|
293
|
+
const selected = answer === "" ? 1 : Number(answer);
|
|
294
|
+
if (
|
|
295
|
+
Number.isInteger(selected) &&
|
|
296
|
+
selected >= 1 &&
|
|
297
|
+
selected <= options.length
|
|
298
|
+
) {
|
|
299
|
+
return options[selected - 1];
|
|
300
|
+
}
|
|
301
|
+
console.log(`Choose a number from 1 to ${options.length}.`);
|
|
302
|
+
}
|
|
303
|
+
} finally {
|
|
304
|
+
prompt.close();
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const promptForConfirmation = async (
|
|
309
|
+
message,
|
|
310
|
+
flags = {},
|
|
311
|
+
{ cancelValue = false } = {},
|
|
312
|
+
) => {
|
|
313
|
+
if (useStyledPrompts(flags)) {
|
|
314
|
+
const accepted = await prompts.confirm({ message, initialValue: false });
|
|
315
|
+
return prompts.isCancel(accepted) ? cancelValue : accepted;
|
|
316
|
+
}
|
|
317
|
+
const prompt = createInterface({
|
|
318
|
+
input: process.stdin,
|
|
319
|
+
output: process.stdout,
|
|
320
|
+
});
|
|
321
|
+
try {
|
|
322
|
+
const answer = (await prompt.question(`${message} (y/N) `))
|
|
323
|
+
.trim()
|
|
324
|
+
.toLowerCase();
|
|
325
|
+
return answer === "y" || answer === "yes";
|
|
326
|
+
} finally {
|
|
327
|
+
prompt.close();
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
const promptForPath = async ({
|
|
332
|
+
message,
|
|
333
|
+
defaultValue,
|
|
334
|
+
optional = false,
|
|
335
|
+
flags = {},
|
|
336
|
+
validate,
|
|
337
|
+
}) => {
|
|
338
|
+
if (useStyledPrompts(flags)) {
|
|
339
|
+
const answer = await prompts.text({
|
|
340
|
+
message,
|
|
341
|
+
placeholder: defaultValue ?? (optional ? "Leave blank for none" : ""),
|
|
342
|
+
defaultValue,
|
|
343
|
+
validate: (value) => {
|
|
344
|
+
const resolved = String(value ?? defaultValue ?? "").trim();
|
|
345
|
+
if (!optional && !resolved) return "Enter a value.";
|
|
346
|
+
return resolved && validate ? validate(resolved) : undefined;
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
if (prompts.isCancel(answer)) return undefined;
|
|
350
|
+
return String(answer || defaultValue || "").trim() || undefined;
|
|
351
|
+
}
|
|
352
|
+
const prompt = createInterface({
|
|
353
|
+
input: process.stdin,
|
|
354
|
+
output: process.stdout,
|
|
355
|
+
});
|
|
356
|
+
try {
|
|
357
|
+
while (true) {
|
|
358
|
+
const suffix = defaultValue
|
|
359
|
+
? ` [${defaultValue}]`
|
|
360
|
+
: optional
|
|
361
|
+
? ""
|
|
362
|
+
: " (required)";
|
|
363
|
+
const answer = (await prompt.question(`${message}${suffix}: `)).trim();
|
|
364
|
+
const value = answer || defaultValue || "";
|
|
365
|
+
const invalid = value && validate ? validate(value) : undefined;
|
|
366
|
+
if (invalid) {
|
|
367
|
+
console.log(invalid);
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
if (value || optional) return value || undefined;
|
|
371
|
+
console.log("Enter a project-relative path.");
|
|
372
|
+
}
|
|
373
|
+
} finally {
|
|
374
|
+
prompt.close();
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
const promptForSelection = async ({ message, options, flags }) => {
|
|
379
|
+
const selected = await promptForChoice({ message, options, flags });
|
|
380
|
+
return selected?.value;
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
const firstExistingPath = async (candidates) => {
|
|
384
|
+
for (const candidate of candidates) {
|
|
385
|
+
if (await pathExists(join(projectRoot, candidate))) return candidate;
|
|
386
|
+
}
|
|
387
|
+
return undefined;
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
const inspectGuidedProject = async (planOptions) => {
|
|
391
|
+
const detected = await inspectDetectedProject({ projectRoot });
|
|
392
|
+
const node = inspectRehearsalNodeRuntime();
|
|
393
|
+
let configPath;
|
|
394
|
+
try {
|
|
395
|
+
configPath = await findRehearsalConfigPath(planOptions);
|
|
396
|
+
} catch (error) {
|
|
397
|
+
if (
|
|
398
|
+
!String(error?.message ?? error).startsWith("No Rehearsal configuration")
|
|
399
|
+
) {
|
|
400
|
+
throw error;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
if (!configPath) {
|
|
404
|
+
return {
|
|
405
|
+
detected,
|
|
406
|
+
node,
|
|
407
|
+
config: "missing",
|
|
408
|
+
baseline: false,
|
|
409
|
+
runtime: false,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
let loaded;
|
|
413
|
+
try {
|
|
414
|
+
loaded = await loadRehearsalConfig(planOptions);
|
|
415
|
+
} catch (error) {
|
|
416
|
+
return {
|
|
417
|
+
detected,
|
|
418
|
+
node,
|
|
419
|
+
config: "invalid",
|
|
420
|
+
configPath,
|
|
421
|
+
configError: redactDiagnosticValue(String(error?.message ?? error)),
|
|
422
|
+
baseline: false,
|
|
423
|
+
runtime: false,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
let migrationSummary;
|
|
427
|
+
try {
|
|
428
|
+
migrationSummary = candidateSummary(
|
|
429
|
+
await inspectRehearsalMigrations(planOptions),
|
|
430
|
+
);
|
|
431
|
+
} catch {
|
|
432
|
+
// Doctor owns the detailed, safely redacted explanation for an invalid or
|
|
433
|
+
// absent baseline. The home screen only needs enough state to guide users.
|
|
434
|
+
}
|
|
435
|
+
let policy = "missing";
|
|
436
|
+
if (await pathExists(loaded.paths.sanitizationPolicy)) {
|
|
437
|
+
try {
|
|
438
|
+
validateRuntimeSanitizationPolicy(
|
|
439
|
+
JSON.parse(await readFile(loaded.paths.sanitizationPolicy, "utf8")),
|
|
440
|
+
);
|
|
441
|
+
policy = "reviewed";
|
|
442
|
+
} catch {
|
|
443
|
+
policy = "needs-review";
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return {
|
|
447
|
+
detected,
|
|
448
|
+
node,
|
|
449
|
+
config: "valid",
|
|
450
|
+
configPath,
|
|
451
|
+
baseline: Boolean(migrationSummary),
|
|
452
|
+
policy,
|
|
453
|
+
policyPath: relative(projectRoot, loaded.paths.sanitizationPolicy),
|
|
454
|
+
policyAbsolutePath: loaded.paths.sanitizationPolicy,
|
|
455
|
+
migrationSummary,
|
|
456
|
+
runtime: await pathExists(
|
|
457
|
+
join(loaded.paths.runtimeWorkdir, "baseline.json"),
|
|
458
|
+
),
|
|
459
|
+
};
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
const reviewPolicyInteractively = async ({ state, flags }) => {
|
|
463
|
+
const { source, draft } = await readReviewablePolicyDraft(
|
|
464
|
+
state.policyAbsolutePath,
|
|
465
|
+
);
|
|
466
|
+
const totalColumns = draft.tables.reduce(
|
|
467
|
+
(total, table) => total + (table.columns?.length ?? 0),
|
|
468
|
+
0,
|
|
469
|
+
);
|
|
470
|
+
let reviewedColumns = 0;
|
|
471
|
+
if (useStyledPrompts(flags)) {
|
|
472
|
+
prompts.note(
|
|
473
|
+
[
|
|
474
|
+
`${formatCount(totalColumns, "column")} require explicit review.`,
|
|
475
|
+
"No row values are displayed or changed by this step.",
|
|
476
|
+
"The safest replacement-oriented choices appear first.",
|
|
477
|
+
].join("\n"),
|
|
478
|
+
"Review the script",
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
const policy = await completePolicyDraft({
|
|
482
|
+
draft,
|
|
483
|
+
reviewColumn: async ({ table, column }) => {
|
|
484
|
+
reviewedColumns += 1;
|
|
485
|
+
const label = `${table}.${column} (${reviewedColumns}/${totalColumns})`;
|
|
486
|
+
const action = await promptForSelection({
|
|
487
|
+
message: `${label}: how should this value be handled?`,
|
|
488
|
+
flags,
|
|
489
|
+
options: [
|
|
490
|
+
{
|
|
491
|
+
label: "Replace with synthetic data",
|
|
492
|
+
hint: "Safest default",
|
|
493
|
+
value: "REPLACE WITH SYNTHETIC",
|
|
494
|
+
},
|
|
495
|
+
{
|
|
496
|
+
label: "Pseudonymize consistently",
|
|
497
|
+
hint: "Stable links without original values",
|
|
498
|
+
value: "PSEUDONYMIZE",
|
|
499
|
+
},
|
|
500
|
+
{
|
|
501
|
+
label: "Derive a safe value",
|
|
502
|
+
value: "DERIVE",
|
|
503
|
+
},
|
|
504
|
+
{ label: "Exclude this field", value: "EXCLUDE" },
|
|
505
|
+
{
|
|
506
|
+
label: "Keep exactly",
|
|
507
|
+
hint: "Only for values explicitly safe to retain",
|
|
508
|
+
value: "KEEP EXACTLY",
|
|
509
|
+
},
|
|
510
|
+
],
|
|
511
|
+
});
|
|
512
|
+
if (!action) throw new Error("Policy review was cancelled.");
|
|
513
|
+
const generated = await promptForSelection({
|
|
514
|
+
message: `${label}: is this column always database-generated?`,
|
|
515
|
+
flags,
|
|
516
|
+
options: [
|
|
517
|
+
{ label: "No", value: "NEVER" },
|
|
518
|
+
{ label: "Yes", value: "ALWAYS" },
|
|
519
|
+
],
|
|
520
|
+
});
|
|
521
|
+
if (!generated) throw new Error("Policy review was cancelled.");
|
|
522
|
+
const identity = await promptForSelection({
|
|
523
|
+
message: `${label}: is this an identity column?`,
|
|
524
|
+
flags,
|
|
525
|
+
options: [
|
|
526
|
+
{ label: "No", value: "NO" },
|
|
527
|
+
{ label: "Yes", value: "YES" },
|
|
528
|
+
],
|
|
529
|
+
});
|
|
530
|
+
if (!identity) throw new Error("Policy review was cancelled.");
|
|
531
|
+
const hasForeignKey = await promptForConfirmation(
|
|
532
|
+
`${label}: does this column reference another table?`,
|
|
533
|
+
flags,
|
|
534
|
+
{ cancelValue: null },
|
|
535
|
+
);
|
|
536
|
+
if (hasForeignKey === null) {
|
|
537
|
+
throw new Error("Policy review was cancelled.");
|
|
538
|
+
}
|
|
539
|
+
let foreignKey = null;
|
|
540
|
+
if (hasForeignKey) {
|
|
541
|
+
const schema = await promptForPath({
|
|
542
|
+
message: `${label}: referenced schema`,
|
|
543
|
+
defaultValue: "public",
|
|
544
|
+
flags,
|
|
545
|
+
validate: (value) =>
|
|
546
|
+
/^[a-z][a-z0-9_]{0,62}$/u.test(value)
|
|
547
|
+
? undefined
|
|
548
|
+
: "Use a lowercase PostgreSQL identifier.",
|
|
549
|
+
});
|
|
550
|
+
const referencedTable = await promptForPath({
|
|
551
|
+
message: `${label}: referenced table`,
|
|
552
|
+
flags,
|
|
553
|
+
validate: (value) =>
|
|
554
|
+
/^[a-z][a-z0-9_]{0,62}$/u.test(value)
|
|
555
|
+
? undefined
|
|
556
|
+
: "Use a lowercase PostgreSQL identifier.",
|
|
557
|
+
});
|
|
558
|
+
const referencedColumn = await promptForPath({
|
|
559
|
+
message: `${label}: referenced column`,
|
|
560
|
+
flags,
|
|
561
|
+
validate: (value) =>
|
|
562
|
+
/^[a-z][a-z0-9_]{0,62}$/u.test(value)
|
|
563
|
+
? undefined
|
|
564
|
+
: "Use a lowercase PostgreSQL identifier.",
|
|
565
|
+
});
|
|
566
|
+
if (!schema || !referencedTable || !referencedColumn) {
|
|
567
|
+
throw new Error("Policy review was cancelled.");
|
|
568
|
+
}
|
|
569
|
+
foreignKey = {
|
|
570
|
+
schema,
|
|
571
|
+
table: referencedTable,
|
|
572
|
+
column: referencedColumn,
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
return { action, generated, identity, foreignKey };
|
|
576
|
+
},
|
|
577
|
+
});
|
|
578
|
+
const accepted = await promptForConfirmation(
|
|
579
|
+
`Save ${formatCount(totalColumns, "reviewed column")} and activate this policy?`,
|
|
580
|
+
flags,
|
|
581
|
+
);
|
|
582
|
+
if (!accepted) return false;
|
|
583
|
+
await applyReviewedPolicy({
|
|
584
|
+
path: state.policyAbsolutePath,
|
|
585
|
+
originalSource: source,
|
|
586
|
+
policy,
|
|
587
|
+
});
|
|
588
|
+
if (useStyledPrompts(flags)) {
|
|
589
|
+
prompts.log.success(
|
|
590
|
+
`${formatCount(totalColumns, "column")} reviewed; the policy is ready for baseline creation.`,
|
|
591
|
+
);
|
|
592
|
+
} else {
|
|
593
|
+
console.log(
|
|
594
|
+
`Reviewed policy saved: ${formatCount(totalColumns, "column")} classified.`,
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
return true;
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
const runGuidedHome = async ({ flags, planOptions }) => {
|
|
601
|
+
const state = await inspectGuidedProject(planOptions);
|
|
602
|
+
const marker = (complete) =>
|
|
603
|
+
complete ? terminalStyle(flags, "32", "✓") : terminalStyle(flags, "2", "○");
|
|
604
|
+
const options = [];
|
|
605
|
+
|
|
606
|
+
if (!state.node.supported) {
|
|
607
|
+
options.push({
|
|
608
|
+
label: "Show Node.js 24 setup instructions",
|
|
609
|
+
hint: "Required before Rehearsal can change this project",
|
|
610
|
+
command: "node-help",
|
|
611
|
+
});
|
|
612
|
+
} else if (state.config === "missing") {
|
|
613
|
+
options.push({
|
|
614
|
+
label: "Set the stage",
|
|
615
|
+
hint: "Create safe local configuration",
|
|
616
|
+
command: "setup-write",
|
|
617
|
+
});
|
|
618
|
+
} else if (state.baseline) {
|
|
619
|
+
options.push(
|
|
620
|
+
{ label: "Run a rehearsal", command: "run" },
|
|
621
|
+
{ label: "Review the migration plan", command: "explain" },
|
|
622
|
+
{ label: "Show candidate migrations", command: "candidates" },
|
|
623
|
+
{ label: "Check readiness", command: "doctor" },
|
|
624
|
+
);
|
|
625
|
+
} else if (state.node.supported && state.config === "valid") {
|
|
626
|
+
if (state.policy === "missing") {
|
|
627
|
+
options.push({
|
|
628
|
+
label: "Prepare the script",
|
|
629
|
+
hint: "Create a reviewable policy draft",
|
|
630
|
+
command: "baseline-prepare",
|
|
631
|
+
});
|
|
632
|
+
} else if (state.policy === "needs-review") {
|
|
633
|
+
options.push({
|
|
634
|
+
label: "Review the script",
|
|
635
|
+
hint: "Classify every column interactively",
|
|
636
|
+
command: "policy-review",
|
|
637
|
+
});
|
|
638
|
+
} else {
|
|
639
|
+
options.push({
|
|
640
|
+
label: "Create the baseline",
|
|
641
|
+
hint: "Activate reviewed local inputs",
|
|
642
|
+
command: "baseline-guide",
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
options.push({ label: "Check readiness", command: "doctor" });
|
|
646
|
+
} else {
|
|
647
|
+
options.push({ label: "Check readiness", command: "doctor" });
|
|
648
|
+
}
|
|
649
|
+
if (state.node.supported && state.runtime) {
|
|
650
|
+
options.push({
|
|
651
|
+
label: "Manage the local runtime",
|
|
652
|
+
hint: "Status, verification, and cleanup",
|
|
653
|
+
command: "manage-runtime",
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
if (lastGuidedDetails) {
|
|
657
|
+
options.push({
|
|
658
|
+
label: "Show details from the last action",
|
|
659
|
+
command: "last-details",
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
options.push(
|
|
663
|
+
{ label: "Show all commands", command: "help" },
|
|
664
|
+
{ label: "Exit", command: "exit" },
|
|
665
|
+
);
|
|
666
|
+
|
|
667
|
+
const statusLines = [
|
|
668
|
+
`${marker(state.node.supported)} Node.js ${state.node.version}${state.node.supported ? "" : " (Node.js 24 required)"}`,
|
|
669
|
+
`${marker(state.detected.hasSupabaseConfig)} Supabase project detected`,
|
|
670
|
+
`${marker(state.detected.hasMigrations)} Migration history detected`,
|
|
671
|
+
`${marker(state.config === "valid")} Rehearsal configuration${state.config === "invalid" ? " needs attention" : ""}`,
|
|
672
|
+
...(state.config === "valid"
|
|
673
|
+
? [
|
|
674
|
+
`${marker(state.policy === "reviewed")} ${state.policy === "needs-review" ? "Baseline policy needs review" : state.policy === "reviewed" ? "Baseline policy reviewed" : "Baseline policy needed"}`,
|
|
675
|
+
]
|
|
676
|
+
: []),
|
|
677
|
+
`${marker(state.baseline)} Verified baseline`,
|
|
678
|
+
...(state.runtime ? [`${marker(true)} Disposable runtime created`] : []),
|
|
679
|
+
...(state.configError
|
|
680
|
+
? ["", terminalStyle(flags, "33", `! ${state.configError}`)]
|
|
681
|
+
: []),
|
|
682
|
+
];
|
|
683
|
+
const stage =
|
|
684
|
+
state.config !== "valid"
|
|
685
|
+
? 1
|
|
686
|
+
: state.policy !== "reviewed"
|
|
687
|
+
? 2
|
|
688
|
+
: !state.baseline
|
|
689
|
+
? 3
|
|
690
|
+
: 4;
|
|
691
|
+
if (useStyledPrompts(flags)) {
|
|
692
|
+
prompts.note(
|
|
693
|
+
statusLines.join("\n"),
|
|
694
|
+
`${state.detected.projectName} · Stage ${stage} of 4`,
|
|
695
|
+
);
|
|
696
|
+
} else {
|
|
697
|
+
console.log(
|
|
698
|
+
[
|
|
699
|
+
"",
|
|
700
|
+
terminalStyle(flags, "1;36", "REHEARSAL"),
|
|
701
|
+
"Safe local migration testing",
|
|
702
|
+
"",
|
|
703
|
+
terminalStyle(flags, "1", state.detected.projectName),
|
|
704
|
+
`Stage ${stage} of 4`,
|
|
705
|
+
...statusLines,
|
|
706
|
+
"",
|
|
707
|
+
"What would you like to do?",
|
|
708
|
+
"",
|
|
709
|
+
...options.map(
|
|
710
|
+
(option, index) =>
|
|
711
|
+
` ${index === 0 ? terminalStyle(flags, "36", "›") : " "} ${index + 1}. ${option.label}`,
|
|
712
|
+
),
|
|
713
|
+
"",
|
|
714
|
+
].join("\n"),
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const selected = await promptForChoice({
|
|
719
|
+
message: "What would you like to do?",
|
|
720
|
+
options,
|
|
721
|
+
flags,
|
|
722
|
+
});
|
|
723
|
+
if (selected.command === "exit") {
|
|
724
|
+
return null;
|
|
725
|
+
}
|
|
726
|
+
if (selected.command === "node-help") {
|
|
727
|
+
console.log(
|
|
728
|
+
[
|
|
729
|
+
"",
|
|
730
|
+
terminalStyle(flags, "1", "NODE.JS 24 REQUIRED"),
|
|
731
|
+
`This shell is using Node.js ${state.node.version}. Rehearsal has not changed your project.`,
|
|
732
|
+
"",
|
|
733
|
+
"With nvm:",
|
|
734
|
+
" nvm install 24",
|
|
735
|
+
" nvm use 24",
|
|
736
|
+
"",
|
|
737
|
+
"Then reinstall Rehearsal in your scratch project and run npx rehearsal again.",
|
|
738
|
+
].join("\n"),
|
|
739
|
+
);
|
|
740
|
+
return "refresh";
|
|
741
|
+
}
|
|
742
|
+
if (selected.command === "policy-review") {
|
|
743
|
+
try {
|
|
744
|
+
await reviewPolicyInteractively({ state, flags });
|
|
745
|
+
} catch (error) {
|
|
746
|
+
if (String(error?.message ?? error) !== "Policy review was cancelled.") {
|
|
747
|
+
throw error;
|
|
748
|
+
}
|
|
749
|
+
if (useStyledPrompts(flags)) {
|
|
750
|
+
prompts.cancel("Policy review cancelled; the draft was not changed.");
|
|
751
|
+
} else {
|
|
752
|
+
console.log("Policy review cancelled; the draft was not changed.");
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
return "refresh";
|
|
756
|
+
}
|
|
757
|
+
if (selected.command === "last-details") {
|
|
758
|
+
if (useStyledPrompts(flags)) {
|
|
759
|
+
prompts.note(lastGuidedDetails, "Technical details");
|
|
760
|
+
} else {
|
|
761
|
+
console.log(`\nTECHNICAL DETAILS\n${lastGuidedDetails}`);
|
|
762
|
+
}
|
|
763
|
+
return "refresh";
|
|
764
|
+
}
|
|
765
|
+
if (selected.command === "manage-runtime") {
|
|
766
|
+
const runtimeAction = await promptForChoice({
|
|
767
|
+
message: "Manage the disposable local runtime",
|
|
768
|
+
flags,
|
|
769
|
+
options: [
|
|
770
|
+
{ label: "Show status", command: "status" },
|
|
771
|
+
{ label: "Verify runtime", command: "verify" },
|
|
772
|
+
{ label: "Reset to the immutable baseline", command: "reset" },
|
|
773
|
+
{ label: "Stop runtime", command: "stop" },
|
|
774
|
+
{ label: "Discard runtime", command: "discard" },
|
|
775
|
+
{ label: "Back", command: "exit" },
|
|
776
|
+
],
|
|
777
|
+
});
|
|
778
|
+
if (!runtimeAction || runtimeAction.command === "exit") return "refresh";
|
|
779
|
+
if (["reset", "discard"].includes(runtimeAction.command)) {
|
|
780
|
+
const accepted = await promptForConfirmation(
|
|
781
|
+
runtimeAction.command === "reset"
|
|
782
|
+
? "Reset the disposable runtime and remove its current changes?"
|
|
783
|
+
: "Discard the disposable runtime? The immutable baseline will remain.",
|
|
784
|
+
flags,
|
|
785
|
+
);
|
|
786
|
+
if (!accepted) return "refresh";
|
|
787
|
+
}
|
|
788
|
+
return runtimeAction.command;
|
|
789
|
+
}
|
|
790
|
+
if (["baseline-guide", "baseline-prepare"].includes(selected.command)) {
|
|
791
|
+
console.log(
|
|
792
|
+
[
|
|
793
|
+
"",
|
|
794
|
+
terminalStyle(flags, "1", "CREATE A SAFE BASELINE"),
|
|
795
|
+
"Rehearsal only reads the local files you name here.",
|
|
796
|
+
"It does not extract data or contact a hosted Supabase project.",
|
|
797
|
+
"",
|
|
798
|
+
].join("\n"),
|
|
799
|
+
);
|
|
800
|
+
const recordsDefault = await firstExistingPath([
|
|
801
|
+
"rehearsal/sanitized-data.ndjson",
|
|
802
|
+
"rehearsal/synthetic-data.ndjson",
|
|
803
|
+
]);
|
|
804
|
+
const ledgerDefault = await firstExistingPath([
|
|
805
|
+
"rehearsal/migration-ledger.json",
|
|
806
|
+
]);
|
|
807
|
+
flags.recordsPath = await promptForPath({
|
|
808
|
+
message: "Sanitized NDJSON records",
|
|
809
|
+
defaultValue: recordsDefault,
|
|
810
|
+
flags,
|
|
811
|
+
});
|
|
812
|
+
flags.ledgerPath = await promptForPath({
|
|
813
|
+
message: "Migration ledger",
|
|
814
|
+
defaultValue: ledgerDefault,
|
|
815
|
+
flags,
|
|
816
|
+
});
|
|
817
|
+
if (selected.command === "baseline-prepare") {
|
|
818
|
+
const preparationPlan = await planBaselinePreparation({
|
|
819
|
+
...planOptions,
|
|
820
|
+
recordsPath: flags.recordsPath,
|
|
821
|
+
ledgerPath: flags.ledgerPath,
|
|
822
|
+
});
|
|
823
|
+
const preview = {
|
|
824
|
+
...summarizeBaselinePreparation(preparationPlan, {
|
|
825
|
+
mode: "preview",
|
|
826
|
+
}),
|
|
827
|
+
nextAction:
|
|
828
|
+
"Review this schema-only summary. No file is written unless you approve below.",
|
|
829
|
+
};
|
|
830
|
+
console.log(`\n${renderBaselinePreparation(preview, flags)}\n`);
|
|
831
|
+
const accepted = await promptForConfirmation(
|
|
832
|
+
`Create the REVIEW REQUIRED draft at ${preview.destination}?`,
|
|
833
|
+
flags,
|
|
834
|
+
);
|
|
835
|
+
if (!accepted) {
|
|
836
|
+
console.log("No changes made.");
|
|
837
|
+
return "refresh";
|
|
838
|
+
}
|
|
839
|
+
flags.preparationPlan = preparationPlan;
|
|
840
|
+
flags.write = true;
|
|
841
|
+
return "baseline prepare";
|
|
842
|
+
}
|
|
843
|
+
flags.assetsPath = await promptForPath({
|
|
844
|
+
message: "Storage asset manifest (optional)",
|
|
845
|
+
optional: true,
|
|
846
|
+
flags,
|
|
847
|
+
});
|
|
848
|
+
return "baseline create";
|
|
849
|
+
}
|
|
850
|
+
if (selected.command !== "setup-write") return selected.command;
|
|
851
|
+
|
|
852
|
+
const setupPlan = await planRehearsalSetup({ projectRoot });
|
|
853
|
+
const preview = {
|
|
854
|
+
...summarizeRehearsalSetup(setupPlan, { mode: "preview" }),
|
|
855
|
+
nextAction:
|
|
856
|
+
"Review this plan. No files are written unless you approve below.",
|
|
857
|
+
};
|
|
858
|
+
console.log(`\n${renderSetup(preview)}\n`);
|
|
859
|
+
const accepted = await promptForConfirmation(
|
|
860
|
+
"Create these project-local files?",
|
|
861
|
+
flags,
|
|
862
|
+
);
|
|
863
|
+
if (!accepted) {
|
|
864
|
+
console.log("No changes made.");
|
|
865
|
+
return "refresh";
|
|
866
|
+
}
|
|
867
|
+
flags.setupPlan = setupPlan;
|
|
868
|
+
flags.write = true;
|
|
869
|
+
return "setup";
|
|
870
|
+
};
|
|
871
|
+
|
|
872
|
+
const prepareCandidateConfirmation = async ({
|
|
873
|
+
command,
|
|
874
|
+
flags,
|
|
875
|
+
planOptions,
|
|
876
|
+
}) => {
|
|
877
|
+
if (!["run", "migrate"].includes(command) || flags.dryRun) {
|
|
878
|
+
return true;
|
|
879
|
+
}
|
|
880
|
+
const summary = candidateSummary(
|
|
881
|
+
await inspectRehearsalMigrations(planOptions),
|
|
882
|
+
);
|
|
883
|
+
if (summary.candidateCount === 0) return true;
|
|
884
|
+
if (flags.confirmation === summary.candidateSha256) return true;
|
|
885
|
+
const interactive =
|
|
886
|
+
!flags.json && process.stdin.isTTY && process.stdout.isTTY;
|
|
887
|
+
if (!interactive || flags.confirmation) {
|
|
888
|
+
throw new RehearsalError({
|
|
889
|
+
category: "migration_candidate_failure",
|
|
890
|
+
code: "CANDIDATE_CONFIRMATION_REQUIRED",
|
|
891
|
+
message: flags.confirmation
|
|
892
|
+
? "The candidate migration confirmation does not match the current candidate set."
|
|
893
|
+
: "Candidate migration confirmation is required in noninteractive use.",
|
|
894
|
+
expected: summary.candidateSha256,
|
|
895
|
+
actual: flags.confirmation ?? "missing",
|
|
896
|
+
context: {
|
|
897
|
+
command,
|
|
898
|
+
candidateCount: summary.candidateCount,
|
|
899
|
+
candidates: summary.candidates.map((migration) => migration.filename),
|
|
900
|
+
},
|
|
901
|
+
refused:
|
|
902
|
+
"Rehearsal did not reset, start, or modify the disposable runtime.",
|
|
903
|
+
suggestions: [
|
|
904
|
+
"Run rehearsal candidates, review every file, then pass the current digest with --confirm-candidates=.",
|
|
905
|
+
],
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
console.log(
|
|
909
|
+
[
|
|
910
|
+
"",
|
|
911
|
+
terminalStyle(
|
|
912
|
+
flags,
|
|
913
|
+
"1",
|
|
914
|
+
`${summary.candidateCount} candidate migration${summary.candidateCount === 1 ? "" : "s"}`,
|
|
915
|
+
),
|
|
916
|
+
"",
|
|
917
|
+
...summary.candidates.map((migration) => ` → ${migration.filename}`),
|
|
918
|
+
"",
|
|
919
|
+
`Candidate set: ${summary.candidateSha256.slice(0, 12)}`,
|
|
920
|
+
"",
|
|
921
|
+
"Rehearsal will apply only this exact set to the disposable local runtime.",
|
|
922
|
+
].join("\n"),
|
|
923
|
+
);
|
|
924
|
+
const accepted = await promptForConfirmation(
|
|
925
|
+
`Apply exactly ${summary.candidateCount === 1 ? "this migration" : "these migrations"}?`,
|
|
926
|
+
flags,
|
|
927
|
+
);
|
|
928
|
+
if (!accepted) {
|
|
929
|
+
console.log("No changes made.");
|
|
930
|
+
return false;
|
|
931
|
+
}
|
|
932
|
+
// The manager independently recomputes this digest. A file changed after this
|
|
933
|
+
// review is therefore rejected before candidate SQL is applied.
|
|
934
|
+
flags.confirmation = summary.candidateSha256;
|
|
935
|
+
return true;
|
|
936
|
+
};
|
|
937
|
+
|
|
206
938
|
const emit = ({ command, data, flags, render, status = "success" }) => {
|
|
207
939
|
const result = createRehearsalResult({
|
|
208
940
|
command,
|
|
@@ -238,6 +970,25 @@ const runManager = ({ action, flags }) => {
|
|
|
238
970
|
process.env[key] === undefined ? [] : [[key, process.env[key]]],
|
|
239
971
|
),
|
|
240
972
|
);
|
|
973
|
+
const runtimeStartedMs = performance.now();
|
|
974
|
+
const actionDescription = {
|
|
975
|
+
run: "Restoring the baseline and applying reviewed migrations",
|
|
976
|
+
start: "Starting the disposable local runtime",
|
|
977
|
+
migrate: "Applying reviewed migrations to the current runtime",
|
|
978
|
+
reset: "Restoring the immutable baseline",
|
|
979
|
+
status: "Inspecting the disposable local runtime",
|
|
980
|
+
stop: "Stopping the disposable local runtime",
|
|
981
|
+
discard: "Removing the disposable local runtime",
|
|
982
|
+
verify: "Verifying the current local runtime",
|
|
983
|
+
}[action];
|
|
984
|
+
const spinner = useStyledPrompts(flags) ? prompts.spinner() : null;
|
|
985
|
+
if (spinner) {
|
|
986
|
+
spinner.start(`${actionDescription}. This can take a moment.`);
|
|
987
|
+
} else if (isHumanTerminal(flags)) {
|
|
988
|
+
console.log(
|
|
989
|
+
`\n${terminalStyle(flags, "1;36", action === "run" ? "REHEARSING" : "REHEARSAL")}\n${terminalStyle(flags, "36", "→")} ${actionDescription}. This can take a moment.`,
|
|
990
|
+
);
|
|
991
|
+
}
|
|
241
992
|
const result = spawnSync(process.execPath, args, {
|
|
242
993
|
cwd: projectRoot,
|
|
243
994
|
encoding: "utf8",
|
|
@@ -245,8 +996,12 @@ const runManager = ({ action, flags }) => {
|
|
|
245
996
|
stdio: ["inherit", "pipe", "pipe"],
|
|
246
997
|
maxBuffer: 16 * 1024 * 1024,
|
|
247
998
|
});
|
|
248
|
-
if (result.error)
|
|
999
|
+
if (result.error) {
|
|
1000
|
+
if (spinner) spinner.stop(`${actionDescription} did not complete.`);
|
|
1001
|
+
throw result.error;
|
|
1002
|
+
}
|
|
249
1003
|
if (result.status !== 0) {
|
|
1004
|
+
if (spinner) spinner.stop(`${actionDescription} did not complete.`);
|
|
250
1005
|
const completeOutput = [
|
|
251
1006
|
String(result.stdout ?? ""),
|
|
252
1007
|
String(result.stderr ?? ""),
|
|
@@ -280,8 +1035,19 @@ const runManager = ({ action, flags }) => {
|
|
|
280
1035
|
cause: new Error(completeOutput || `exit ${result.status}`),
|
|
281
1036
|
});
|
|
282
1037
|
}
|
|
1038
|
+
const durationMs = performance.now() - runtimeStartedMs;
|
|
1039
|
+
if (spinner) {
|
|
1040
|
+
spinner.stop(
|
|
1041
|
+
`${actionDescription} completed in ${formatDuration(durationMs)}.`,
|
|
1042
|
+
);
|
|
1043
|
+
} else if (isHumanTerminal(flags)) {
|
|
1044
|
+
console.log(
|
|
1045
|
+
`${terminalStyle(flags, "32", "✓")} Local runtime step completed in ${formatDuration(durationMs)}.`,
|
|
1046
|
+
);
|
|
1047
|
+
}
|
|
283
1048
|
return {
|
|
284
1049
|
action,
|
|
1050
|
+
durationMs: Math.round(durationMs * 100) / 100,
|
|
285
1051
|
output: redactDiagnosticValue(String(result.stdout ?? "").trim()),
|
|
286
1052
|
};
|
|
287
1053
|
};
|
|
@@ -412,10 +1178,149 @@ const renderInit = (result) =>
|
|
|
412
1178
|
result.nextAction,
|
|
413
1179
|
].join("\n");
|
|
414
1180
|
|
|
1181
|
+
const runSetup = async ({ flags, planOptions }) => {
|
|
1182
|
+
const plan = flags.setupPlan ?? (await planRehearsalSetup({ projectRoot }));
|
|
1183
|
+
if (flags.write) await applyRehearsalSetup(plan);
|
|
1184
|
+
const result = summarizeRehearsalSetup(plan, {
|
|
1185
|
+
mode: flags.write ? "written" : "preview",
|
|
1186
|
+
});
|
|
1187
|
+
if (!flags.write) return result;
|
|
1188
|
+
const readiness = await runRehearsalDoctor(planOptions);
|
|
1189
|
+
const needsBaselinePolicy = readiness.checks.some(
|
|
1190
|
+
(check) => check.id === "sanitization-policy" && check.status === "fail",
|
|
1191
|
+
);
|
|
1192
|
+
return {
|
|
1193
|
+
...result,
|
|
1194
|
+
readiness,
|
|
1195
|
+
nextAction:
|
|
1196
|
+
readiness.state === "READY"
|
|
1197
|
+
? "Next: run rehearsal explain to review the migration plan."
|
|
1198
|
+
: needsBaselinePolicy
|
|
1199
|
+
? flags.guided
|
|
1200
|
+
? "Next: choose Prepare the script to create a reviewable policy draft."
|
|
1201
|
+
: "Next: run rehearsal again and choose Prepare a reviewable baseline policy draft."
|
|
1202
|
+
: "Next: complete the remaining readiness items shown above.",
|
|
1203
|
+
};
|
|
1204
|
+
};
|
|
1205
|
+
|
|
1206
|
+
const summarizeSetupReadinessChecks = (readiness) => {
|
|
1207
|
+
const failures = readiness.checks.filter((check) => check.status === "fail");
|
|
1208
|
+
const policyMissing = failures.some(
|
|
1209
|
+
(check) =>
|
|
1210
|
+
check.id === "sanitization-policy" &&
|
|
1211
|
+
/ENOENT|no such file/iu.test(check.detail),
|
|
1212
|
+
);
|
|
1213
|
+
const baselineMissing = failures.some(
|
|
1214
|
+
(check) =>
|
|
1215
|
+
check.id === "baseline" && /ENOENT|no such file/iu.test(check.detail),
|
|
1216
|
+
);
|
|
1217
|
+
const hidden = new Set([
|
|
1218
|
+
...(policyMissing ? ["sanitization-policy"] : []),
|
|
1219
|
+
...(baselineMissing
|
|
1220
|
+
? ["baseline", "baseline-permissions", "migration-history"]
|
|
1221
|
+
: []),
|
|
1222
|
+
]);
|
|
1223
|
+
if (
|
|
1224
|
+
policyMissing &&
|
|
1225
|
+
failures.some(
|
|
1226
|
+
(check) =>
|
|
1227
|
+
check.id === "paths" && /sanitization-policy/iu.test(check.detail),
|
|
1228
|
+
)
|
|
1229
|
+
) {
|
|
1230
|
+
hidden.add("paths");
|
|
1231
|
+
}
|
|
1232
|
+
const passed = readiness.checks.filter(
|
|
1233
|
+
(check) => check.status === "pass",
|
|
1234
|
+
).length;
|
|
1235
|
+
return [
|
|
1236
|
+
...(passed ? [` ✓ ${passed} environment and safety checks passed`] : []),
|
|
1237
|
+
...failures
|
|
1238
|
+
.filter((check) => !hidden.has(check.id))
|
|
1239
|
+
.map((check) => ` ○ ${check.label}: ${check.detail}`),
|
|
1240
|
+
...(policyMissing
|
|
1241
|
+
? [" ○ Baseline policy: not created yet (next guided step)"]
|
|
1242
|
+
: []),
|
|
1243
|
+
...(baselineMissing
|
|
1244
|
+
? [" ○ Verified baseline: not created yet (after policy review)"]
|
|
1245
|
+
: []),
|
|
1246
|
+
];
|
|
1247
|
+
};
|
|
1248
|
+
|
|
1249
|
+
const renderSetup = (result) => {
|
|
1250
|
+
const actionMarker = { create: "+", update: "~", unchanged: "=" };
|
|
1251
|
+
return [
|
|
1252
|
+
`REHEARSAL SETUP — ${result.mode.toUpperCase()}`,
|
|
1253
|
+
`Project: ${result.project}`,
|
|
1254
|
+
`Runtime ID: ${result.projectId}`,
|
|
1255
|
+
`Application: ${result.applicationUrl}`,
|
|
1256
|
+
`Ports: API ${result.ports.api}, database ${result.ports.database}, Studio ${result.ports.studio}`,
|
|
1257
|
+
"",
|
|
1258
|
+
"Files",
|
|
1259
|
+
...result.files.map(
|
|
1260
|
+
(file) => ` ${actionMarker[file.action]} ${file.action} ${file.path}`,
|
|
1261
|
+
),
|
|
1262
|
+
"",
|
|
1263
|
+
"Safety boundaries",
|
|
1264
|
+
...result.safety.map((barrier) => ` ✓ ${barrier}`),
|
|
1265
|
+
...(result.readiness
|
|
1266
|
+
? [
|
|
1267
|
+
"",
|
|
1268
|
+
`Readiness — ${result.readiness.state}`,
|
|
1269
|
+
...summarizeSetupReadinessChecks(result.readiness),
|
|
1270
|
+
]
|
|
1271
|
+
: []),
|
|
1272
|
+
"",
|
|
1273
|
+
result.nextAction,
|
|
1274
|
+
].join("\n");
|
|
1275
|
+
};
|
|
1276
|
+
|
|
1277
|
+
const runBaselinePreparation = async ({ flags, planOptions }) => {
|
|
1278
|
+
const plan =
|
|
1279
|
+
flags.preparationPlan ??
|
|
1280
|
+
(await planBaselinePreparation({
|
|
1281
|
+
...planOptions,
|
|
1282
|
+
recordsPath: flags.recordsPath,
|
|
1283
|
+
ledgerPath: flags.ledgerPath,
|
|
1284
|
+
}));
|
|
1285
|
+
if (flags.write) await applyBaselinePreparation(plan);
|
|
1286
|
+
const result = summarizeBaselinePreparation(plan, {
|
|
1287
|
+
mode: flags.write ? "written" : "preview",
|
|
1288
|
+
});
|
|
1289
|
+
return flags.guided && flags.write
|
|
1290
|
+
? {
|
|
1291
|
+
...result,
|
|
1292
|
+
nextAction:
|
|
1293
|
+
"Next: choose Review the script to classify every column interactively.",
|
|
1294
|
+
}
|
|
1295
|
+
: result;
|
|
1296
|
+
};
|
|
1297
|
+
|
|
1298
|
+
const renderBaselinePreparation = (result, flags = {}) =>
|
|
1299
|
+
[
|
|
1300
|
+
`BASELINE POLICY — ${result.mode.toUpperCase()}`,
|
|
1301
|
+
`Destination: ${result.destination}`,
|
|
1302
|
+
`Records: ${formatCount(result.rowCount, "row")} from ${result.recordsPath}`,
|
|
1303
|
+
`Migrations: ${formatCount(result.migrationCount, "migration")} through ${result.migrationCutoff}`,
|
|
1304
|
+
"",
|
|
1305
|
+
"Detected shape (values are never printed)",
|
|
1306
|
+
...result.tables.map(
|
|
1307
|
+
(table) =>
|
|
1308
|
+
` ${terminalStyle(flags, "36", "→")} ${table.name}: ${formatCount(table.rowCount, "row")}; ${table.columns.join(", ")}`,
|
|
1309
|
+
),
|
|
1310
|
+
"",
|
|
1311
|
+
"Every column is marked REVIEW REQUIRED for action, generated status, identity, and foreign key metadata.",
|
|
1312
|
+
"The draft cannot be activated until those decisions are completed.",
|
|
1313
|
+
"",
|
|
1314
|
+
result.nextAction,
|
|
1315
|
+
].join("\n");
|
|
1316
|
+
|
|
415
1317
|
const usage = () => `Usage: rehearsal <command> [options]
|
|
416
1318
|
|
|
417
1319
|
Commands:
|
|
1320
|
+
guide Open the interactive, state-aware home screen
|
|
1321
|
+
setup [--write] Preview or create safe first-run scaffolding
|
|
418
1322
|
init [--write] Preview or explicitly write safe starter config
|
|
1323
|
+
baseline prepare --records= --ledger= [--write] Create a fail-closed policy draft
|
|
419
1324
|
baseline create --records= --ledger= [--assets=] Create a baseline from safe local inputs
|
|
420
1325
|
doctor Check whether Rehearsal is safe and ready
|
|
421
1326
|
explain Show the immutable execution plan
|
|
@@ -432,24 +1337,33 @@ Commands:
|
|
|
432
1337
|
discard Remove only this project's disposable runtime
|
|
433
1338
|
verify Verify the current local Rehearsal runtime
|
|
434
1339
|
|
|
435
|
-
Options: --json --verbose --debug --config=<path>`;
|
|
1340
|
+
Options: --json --verbose --debug --plain --config=<path>`;
|
|
436
1341
|
|
|
437
|
-
const
|
|
438
|
-
const { flags, positionals } = parseArguments(process.argv.slice(2));
|
|
439
|
-
const command = positionals.join(" ") || "help";
|
|
440
|
-
const planOptions = {
|
|
441
|
-
projectRoot,
|
|
442
|
-
configPath: flags.configPath,
|
|
443
|
-
};
|
|
1342
|
+
const executeCommand = async ({ command, flags, planOptions, guided }) => {
|
|
444
1343
|
if (command === "help" || flags.help) {
|
|
445
1344
|
console.log(usage());
|
|
446
1345
|
return;
|
|
447
1346
|
}
|
|
1347
|
+
if (command === "setup") {
|
|
1348
|
+
const data = await runSetup({ flags, planOptions });
|
|
1349
|
+
emit({ command, data, flags, render: renderSetup });
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
448
1352
|
if (command === "init") {
|
|
449
1353
|
const data = await runInit({ flags });
|
|
450
1354
|
emit({ command, data, flags, render: renderInit });
|
|
451
1355
|
return;
|
|
452
1356
|
}
|
|
1357
|
+
if (command === "baseline prepare") {
|
|
1358
|
+
const data = await runBaselinePreparation({ flags, planOptions });
|
|
1359
|
+
emit({
|
|
1360
|
+
command,
|
|
1361
|
+
data,
|
|
1362
|
+
flags,
|
|
1363
|
+
render: (result) => renderBaselinePreparation(result, flags),
|
|
1364
|
+
});
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
453
1367
|
if (command === "baseline create") {
|
|
454
1368
|
const data = await createSyntheticBaselineFromFiles({
|
|
455
1369
|
...planOptions,
|
|
@@ -462,7 +1376,13 @@ const main = async () => {
|
|
|
462
1376
|
data,
|
|
463
1377
|
flags,
|
|
464
1378
|
render: (baseline) =>
|
|
465
|
-
|
|
1379
|
+
[
|
|
1380
|
+
`Activated synthetic baseline ${baseline.generationId}: ${formatCount(baseline.rowCount, "row")} across ${formatCount(baseline.tableCount, "table")}; ${formatCount(baseline.migrationCount, "migration")} through ${baseline.migrationCutoff}.`,
|
|
1381
|
+
"",
|
|
1382
|
+
flags.guided
|
|
1383
|
+
? "Next: choose Run a rehearsal when you are ready."
|
|
1384
|
+
: "Next: run rehearsal doctor to check readiness.",
|
|
1385
|
+
].join("\n"),
|
|
466
1386
|
});
|
|
467
1387
|
return;
|
|
468
1388
|
}
|
|
@@ -475,7 +1395,7 @@ const main = async () => {
|
|
|
475
1395
|
render: renderDoctor,
|
|
476
1396
|
status: data.state === "READY" ? "success" : "not_ready",
|
|
477
1397
|
});
|
|
478
|
-
if (data.state !== "READY") process.exitCode = 1;
|
|
1398
|
+
if (data.state !== "READY" && !guided) process.exitCode = 1;
|
|
479
1399
|
return;
|
|
480
1400
|
}
|
|
481
1401
|
if (command === "explain" || (command === "run" && flags.dryRun)) {
|
|
@@ -515,28 +1435,88 @@ const main = async () => {
|
|
|
515
1435
|
if (flags.dryRun && command !== "run") {
|
|
516
1436
|
throw new Error("--dry-run is supported only by rehearsal run.");
|
|
517
1437
|
}
|
|
1438
|
+
if (
|
|
1439
|
+
!(await prepareCandidateConfirmation({ command, flags, planOptions }))
|
|
1440
|
+
) {
|
|
1441
|
+
return;
|
|
1442
|
+
}
|
|
518
1443
|
const runtime = runManager({ action: command, flags });
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
1444
|
+
let applicationProof;
|
|
1445
|
+
if (command === "run") {
|
|
1446
|
+
const proofSpinner = useStyledPrompts(flags) ? prompts.spinner() : null;
|
|
1447
|
+
if (proofSpinner) {
|
|
1448
|
+
proofSpinner.start("Running the project-owned application proof.");
|
|
1449
|
+
} else if (isHumanTerminal(flags)) {
|
|
1450
|
+
console.log(
|
|
1451
|
+
`${terminalStyle(flags, "36", "→")} Running the project-owned application proof.`,
|
|
1452
|
+
);
|
|
1453
|
+
}
|
|
1454
|
+
try {
|
|
1455
|
+
applicationProof = await runApplicationProof(planOptions);
|
|
1456
|
+
} catch (error) {
|
|
1457
|
+
if (proofSpinner) proofSpinner.stop("Application proof failed.");
|
|
1458
|
+
throw error;
|
|
1459
|
+
}
|
|
1460
|
+
if (proofSpinner) {
|
|
1461
|
+
proofSpinner.stop("Application proof passed.");
|
|
1462
|
+
} else if (isHumanTerminal(flags)) {
|
|
1463
|
+
console.log(
|
|
1464
|
+
`${terminalStyle(flags, "32", "✓")} Application proof passed.`,
|
|
1465
|
+
);
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
const data = command === "run" ? { runtime, applicationProof } : runtime;
|
|
1469
|
+
if (guided) {
|
|
1470
|
+
lastGuidedDetails = [runtime.output, applicationProof?.output]
|
|
1471
|
+
.filter(Boolean)
|
|
1472
|
+
.join("\n");
|
|
1473
|
+
}
|
|
526
1474
|
emit({
|
|
527
1475
|
command,
|
|
528
1476
|
data,
|
|
529
1477
|
flags,
|
|
530
1478
|
render: (value) => {
|
|
531
1479
|
const runtimeResult = value.runtime ?? value;
|
|
1480
|
+
const nextAction = {
|
|
1481
|
+
run: "Next: exercise the local application, then run rehearsal verify.",
|
|
1482
|
+
start:
|
|
1483
|
+
"Next: exercise the local application or run rehearsal status.",
|
|
1484
|
+
migrate: "Next: run rehearsal verify to prove the current runtime.",
|
|
1485
|
+
reset: "Next: run rehearsal verify or continue testing locally.",
|
|
1486
|
+
status: "Next: run rehearsal verify, reset, stop, or discard.",
|
|
1487
|
+
stop: "Next: run rehearsal start when you want to resume.",
|
|
1488
|
+
discard:
|
|
1489
|
+
"The disposable runtime was removed; the immutable baseline remains.",
|
|
1490
|
+
verify:
|
|
1491
|
+
"Next: continue testing, reset to the baseline, or stop the runtime.",
|
|
1492
|
+
}[runtimeResult.action];
|
|
1493
|
+
if (guided) {
|
|
1494
|
+
const title =
|
|
1495
|
+
runtimeResult.action === "run"
|
|
1496
|
+
? "REHEARSAL PASSED"
|
|
1497
|
+
: "ACTION COMPLETE";
|
|
1498
|
+
return [
|
|
1499
|
+
title,
|
|
1500
|
+
`✓ ${runtimeResult.action} completed in ${formatDuration(runtimeResult.durationMs)}`,
|
|
1501
|
+
value.applicationProof
|
|
1502
|
+
? `✓ Application proof passed: ${value.applicationProof.command}`
|
|
1503
|
+
: null,
|
|
1504
|
+
"",
|
|
1505
|
+
"Technical output is available from Show details in the guide.",
|
|
1506
|
+
]
|
|
1507
|
+
.filter(Boolean)
|
|
1508
|
+
.join("\n");
|
|
1509
|
+
}
|
|
532
1510
|
return [
|
|
533
1511
|
runtimeResult.output ||
|
|
534
1512
|
`Rehearsal ${runtimeResult.action} completed.`,
|
|
535
1513
|
value.applicationProof
|
|
536
1514
|
? `Application proof passed: ${value.applicationProof.command}`
|
|
537
1515
|
: null,
|
|
1516
|
+
nextAction ? "" : null,
|
|
1517
|
+
nextAction,
|
|
538
1518
|
]
|
|
539
|
-
.filter(
|
|
1519
|
+
.filter((entry) => entry !== null)
|
|
540
1520
|
.join("\n");
|
|
541
1521
|
},
|
|
542
1522
|
});
|
|
@@ -545,6 +1525,63 @@ const main = async () => {
|
|
|
545
1525
|
throw new Error(`Unknown Rehearsal command: ${command}.\n\n${usage()}`);
|
|
546
1526
|
};
|
|
547
1527
|
|
|
1528
|
+
const resetGuidedFlags = (flags) => {
|
|
1529
|
+
flags.dryRun = false;
|
|
1530
|
+
flags.write = false;
|
|
1531
|
+
flags.confirmation = undefined;
|
|
1532
|
+
flags.recordsPath = undefined;
|
|
1533
|
+
flags.ledgerPath = undefined;
|
|
1534
|
+
flags.assetsPath = undefined;
|
|
1535
|
+
flags.setupPlan = undefined;
|
|
1536
|
+
flags.preparationPlan = undefined;
|
|
1537
|
+
};
|
|
1538
|
+
|
|
1539
|
+
const main = async () => {
|
|
1540
|
+
const { flags, positionals } = parseArguments(process.argv.slice(2));
|
|
1541
|
+
const command = positionals.join(" ") || "help";
|
|
1542
|
+
const planOptions = {
|
|
1543
|
+
projectRoot,
|
|
1544
|
+
configPath: flags.configPath,
|
|
1545
|
+
};
|
|
1546
|
+
const wantsAutomaticGuide =
|
|
1547
|
+
positionals.length === 0 &&
|
|
1548
|
+
!flags.help &&
|
|
1549
|
+
!flags.json &&
|
|
1550
|
+
process.stdin.isTTY &&
|
|
1551
|
+
process.stdout.isTTY;
|
|
1552
|
+
const guided = command === "guide" || wantsAutomaticGuide;
|
|
1553
|
+
if (!guided) {
|
|
1554
|
+
await executeCommand({ command, flags, planOptions, guided: false });
|
|
1555
|
+
return;
|
|
1556
|
+
}
|
|
1557
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
1558
|
+
throw new Error(
|
|
1559
|
+
"The guided home screen requires an interactive terminal. Run rehearsal --help to list scriptable commands.",
|
|
1560
|
+
);
|
|
1561
|
+
}
|
|
1562
|
+
flags.guided = true;
|
|
1563
|
+
if (useStyledPrompts(flags)) {
|
|
1564
|
+
prompts.intro("REHEARSAL · Safe local migration testing");
|
|
1565
|
+
}
|
|
1566
|
+
while (true) {
|
|
1567
|
+
resetGuidedFlags(flags);
|
|
1568
|
+
const selected = await runGuidedHome({ flags, planOptions });
|
|
1569
|
+
if (!selected) break;
|
|
1570
|
+
if (selected === "refresh") continue;
|
|
1571
|
+
await executeCommand({
|
|
1572
|
+
command: selected,
|
|
1573
|
+
flags,
|
|
1574
|
+
planOptions,
|
|
1575
|
+
guided: true,
|
|
1576
|
+
});
|
|
1577
|
+
}
|
|
1578
|
+
if (useStyledPrompts(flags)) {
|
|
1579
|
+
prompts.outro("See you at the next rehearsal.");
|
|
1580
|
+
} else {
|
|
1581
|
+
console.log("See you at the next rehearsal.");
|
|
1582
|
+
}
|
|
1583
|
+
};
|
|
1584
|
+
|
|
548
1585
|
try {
|
|
549
1586
|
await main();
|
|
550
1587
|
} catch (error) {
|