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