@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
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI orchestration for one or more isolated database runtimes and their
|
|
3
|
+
* project-owned proof commands.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { performance } from "node:perf_hooks";
|
|
8
|
+
import * as prompts from "@clack/prompts";
|
|
9
|
+
import { summarizeProjectCommandFailure } from "../application/session.mjs";
|
|
10
|
+
import { loadRehearsalConfig } from "../project/configuration.mjs";
|
|
11
|
+
import { buildRehearsalPlan, runRehearsalDoctor } from "../runtime/plan.mjs";
|
|
12
|
+
import {
|
|
13
|
+
inspectRuntimeTopologyCandidates,
|
|
14
|
+
loadRuntimeTopology,
|
|
15
|
+
} from "../runtime/topology.mjs";
|
|
16
|
+
import {
|
|
17
|
+
RehearsalError,
|
|
18
|
+
normalizeRehearsalError,
|
|
19
|
+
redactDiagnosticValue,
|
|
20
|
+
} from "../shared/diagnostics.mjs";
|
|
21
|
+
import { assertInstalledPackageFingerprint } from "../shared/operation_guard.mjs";
|
|
22
|
+
import { buildRuntimeManagerArguments } from "../targets/target.mjs";
|
|
23
|
+
import {
|
|
24
|
+
formatDuration,
|
|
25
|
+
isHumanTerminal,
|
|
26
|
+
promptForConfirmation,
|
|
27
|
+
terminalStyle,
|
|
28
|
+
useStyledPrompts,
|
|
29
|
+
} from "./terminal.mjs";
|
|
30
|
+
|
|
31
|
+
export const buildRuntimeStackPlan = async (planOptions) => {
|
|
32
|
+
const topology = await loadRuntimeTopology(planOptions);
|
|
33
|
+
if (topology.targets.length === 1) return buildRehearsalPlan(planOptions);
|
|
34
|
+
const targets = [];
|
|
35
|
+
for (const target of topology.targets) {
|
|
36
|
+
targets.push({
|
|
37
|
+
name: target.name,
|
|
38
|
+
declaration: target.declaration,
|
|
39
|
+
plan: await buildRehearsalPlan({
|
|
40
|
+
projectRoot: topology.projectRoot,
|
|
41
|
+
configPath: target.configPath,
|
|
42
|
+
}),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
const candidates = await inspectRuntimeTopologyCandidates(planOptions);
|
|
46
|
+
return {
|
|
47
|
+
targets,
|
|
48
|
+
migrations: {
|
|
49
|
+
candidateCount: candidates.candidateCount,
|
|
50
|
+
candidateSha256: candidates.candidateSha256,
|
|
51
|
+
},
|
|
52
|
+
execution: [
|
|
53
|
+
"verify every immutable baseline and migration prefix",
|
|
54
|
+
"restore and migrate the primary runtime",
|
|
55
|
+
"restore and migrate each dependent runtime in declared order",
|
|
56
|
+
...topology.dependents
|
|
57
|
+
.filter((target) => target.declaration.prepareCommand)
|
|
58
|
+
.map(
|
|
59
|
+
(target) =>
|
|
60
|
+
`run ${target.name} preparation: ${target.declaration.prepareCommand}`,
|
|
61
|
+
),
|
|
62
|
+
...topology.dependents.map(
|
|
63
|
+
(target) =>
|
|
64
|
+
`run ${target.name} proof: ${target.config.application.proofCommand}`,
|
|
65
|
+
),
|
|
66
|
+
`run primary proof: ${topology.primary.config.application.proofCommand}`,
|
|
67
|
+
],
|
|
68
|
+
guarantee:
|
|
69
|
+
"Every database target is isolated by its own project ID, ports, artifact directory, and local-only safety policy. No production resources will be contacted.",
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const runRuntimeStackDoctor = async (planOptions) => {
|
|
74
|
+
let topology;
|
|
75
|
+
try {
|
|
76
|
+
topology = await loadRuntimeTopology(planOptions);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (
|
|
79
|
+
String(error?.message ?? error).startsWith(
|
|
80
|
+
"No Rehearsal configuration was found",
|
|
81
|
+
)
|
|
82
|
+
) {
|
|
83
|
+
return runRehearsalDoctor(planOptions);
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
state: "NOT READY",
|
|
87
|
+
checks: [
|
|
88
|
+
{
|
|
89
|
+
id: "runtime-topology",
|
|
90
|
+
label: "Dependent runtime isolation",
|
|
91
|
+
status: "fail",
|
|
92
|
+
detail: redactDiagnosticValue(String(error?.message ?? error)),
|
|
93
|
+
remediation:
|
|
94
|
+
"Give every target a readable config with unique ports, project ID, environment file, and .rehearsal artifact directory.",
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
ambientHostedVariables: {
|
|
98
|
+
presentButQuarantined: [],
|
|
99
|
+
note: "Topology validation stopped before any runtime action.",
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
if (topology.targets.length === 1) return runRehearsalDoctor(planOptions);
|
|
104
|
+
const reports = [];
|
|
105
|
+
for (const target of topology.targets) {
|
|
106
|
+
reports.push({
|
|
107
|
+
target: target.name,
|
|
108
|
+
report: await runRehearsalDoctor({
|
|
109
|
+
projectRoot: topology.projectRoot,
|
|
110
|
+
configPath: target.configPath,
|
|
111
|
+
}),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
state: reports.every(({ report }) => report.state === "READY")
|
|
116
|
+
? "READY"
|
|
117
|
+
: "NOT READY",
|
|
118
|
+
checks: reports.flatMap(({ target, report }) =>
|
|
119
|
+
report.checks.map((check) => ({
|
|
120
|
+
...check,
|
|
121
|
+
id: `${target}:${check.id}`,
|
|
122
|
+
label: `${target}: ${check.label}`,
|
|
123
|
+
})),
|
|
124
|
+
),
|
|
125
|
+
ambientHostedVariables: {
|
|
126
|
+
presentButQuarantined: [
|
|
127
|
+
...new Set(
|
|
128
|
+
reports.flatMap(
|
|
129
|
+
({ report }) =>
|
|
130
|
+
report.ambientHostedVariables?.presentButQuarantined ?? [],
|
|
131
|
+
),
|
|
132
|
+
),
|
|
133
|
+
],
|
|
134
|
+
note: "Blocked ambient values are not inherited by Rehearsal child processes.",
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export const topologyCandidateSummary = async (planOptions) => {
|
|
140
|
+
const inspection = await inspectRuntimeTopologyCandidates(planOptions);
|
|
141
|
+
const targets = inspection.targets.map((target) => ({
|
|
142
|
+
name: target.name,
|
|
143
|
+
configPath: target.configPath,
|
|
144
|
+
baselineGenerationId: target.summary.baselineGenerationId,
|
|
145
|
+
candidateSha256: target.summary.candidateSha256,
|
|
146
|
+
candidateCount: target.summary.candidateCount,
|
|
147
|
+
candidates: target.summary.candidates,
|
|
148
|
+
}));
|
|
149
|
+
return {
|
|
150
|
+
baselineGenerationId: targets[0].baselineGenerationId,
|
|
151
|
+
candidateSha256: inspection.candidateSha256,
|
|
152
|
+
candidateCount: inspection.candidateCount,
|
|
153
|
+
candidates: targets.flatMap((target) =>
|
|
154
|
+
target.candidates.map((candidate) => ({
|
|
155
|
+
...candidate,
|
|
156
|
+
target: target.name,
|
|
157
|
+
})),
|
|
158
|
+
),
|
|
159
|
+
targets,
|
|
160
|
+
topology: inspection.topology,
|
|
161
|
+
};
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const parseCommand = (source) => {
|
|
165
|
+
const values = [];
|
|
166
|
+
let current = "";
|
|
167
|
+
let quote = null;
|
|
168
|
+
for (const character of source) {
|
|
169
|
+
if (quote) {
|
|
170
|
+
if (character === quote) quote = null;
|
|
171
|
+
else current += character;
|
|
172
|
+
} else if (character === '"' || character === "'") quote = character;
|
|
173
|
+
else if (/\s/u.test(character)) {
|
|
174
|
+
if (current) values.push(current);
|
|
175
|
+
current = "";
|
|
176
|
+
} else current += character;
|
|
177
|
+
}
|
|
178
|
+
if (quote)
|
|
179
|
+
throw new Error("Application proof command contains an unmatched quote.");
|
|
180
|
+
if (current) values.push(current);
|
|
181
|
+
if (values.length === 0)
|
|
182
|
+
throw new Error("Application proof command is empty.");
|
|
183
|
+
return values;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const projectCommandEnvironment = (additional = {}) => ({
|
|
187
|
+
...Object.fromEntries(
|
|
188
|
+
[
|
|
189
|
+
"CI",
|
|
190
|
+
"COLORTERM",
|
|
191
|
+
"FORCE_COLOR",
|
|
192
|
+
"HOME",
|
|
193
|
+
"LANG",
|
|
194
|
+
"LC_ALL",
|
|
195
|
+
"NO_COLOR",
|
|
196
|
+
"PATH",
|
|
197
|
+
"SHELL",
|
|
198
|
+
"TERM",
|
|
199
|
+
"TMPDIR",
|
|
200
|
+
"USER",
|
|
201
|
+
].flatMap((key) =>
|
|
202
|
+
process.env[key] === undefined ? [] : [[key, process.env[key]]],
|
|
203
|
+
),
|
|
204
|
+
),
|
|
205
|
+
...additional,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
export const topologyCommandEnvironment = (topology) =>
|
|
209
|
+
Object.fromEntries(
|
|
210
|
+
topology.targets.map((target) => [
|
|
211
|
+
target.primary
|
|
212
|
+
? "REHEARSAL_PRIMARY_ENV_FILE"
|
|
213
|
+
: `REHEARSAL_DEPENDENT_${target.name.toUpperCase().replaceAll("-", "_")}_ENV_FILE`,
|
|
214
|
+
target.paths.applicationEnvironment,
|
|
215
|
+
]),
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
export const topologyEnvironmentFiles = (topology) =>
|
|
219
|
+
Object.fromEntries(
|
|
220
|
+
topology.targets.map((target) => [
|
|
221
|
+
target.primary ? "primary" : target.name,
|
|
222
|
+
target.paths.applicationEnvironment,
|
|
223
|
+
]),
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
const runProjectCommand = ({
|
|
227
|
+
source,
|
|
228
|
+
configuredRoot,
|
|
229
|
+
label,
|
|
230
|
+
project,
|
|
231
|
+
environment = {},
|
|
232
|
+
}) => {
|
|
233
|
+
const [command, ...args] = parseCommand(source);
|
|
234
|
+
const result = spawnSync(command, args, {
|
|
235
|
+
cwd: configuredRoot,
|
|
236
|
+
encoding: "utf8",
|
|
237
|
+
env: projectCommandEnvironment(environment),
|
|
238
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
239
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
240
|
+
});
|
|
241
|
+
if (result.error || result.status !== 0) {
|
|
242
|
+
throw new RehearsalError({
|
|
243
|
+
category: "application_proof_failure",
|
|
244
|
+
code: "PROJECT_COMMAND_FAILED",
|
|
245
|
+
message: `The configured ${label} did not pass.`,
|
|
246
|
+
expected: `${source} exits successfully`,
|
|
247
|
+
actual: summarizeProjectCommandFailure(result),
|
|
248
|
+
context: { project, label },
|
|
249
|
+
refused:
|
|
250
|
+
"Rehearsal did not report the local runtime stack as fully verified.",
|
|
251
|
+
suggestions: [
|
|
252
|
+
"Run the command directly in the same project and correct the failure.",
|
|
253
|
+
],
|
|
254
|
+
cause: result.error,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
return {
|
|
258
|
+
command: source,
|
|
259
|
+
output: redactDiagnosticValue(String(result.stdout ?? "").trim()),
|
|
260
|
+
};
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
export const createRuntimeCommands = ({
|
|
264
|
+
packageRoot,
|
|
265
|
+
projectRoot,
|
|
266
|
+
managerPath,
|
|
267
|
+
getActivePackageFingerprint,
|
|
268
|
+
}) => {
|
|
269
|
+
const runManager = ({
|
|
270
|
+
action,
|
|
271
|
+
flags,
|
|
272
|
+
configPath = flags.configPath,
|
|
273
|
+
confirmation = flags.confirmation,
|
|
274
|
+
targetName,
|
|
275
|
+
}) => {
|
|
276
|
+
const fingerprint = getActivePackageFingerprint();
|
|
277
|
+
if (fingerprint) {
|
|
278
|
+
assertInstalledPackageFingerprint({
|
|
279
|
+
packageRoot,
|
|
280
|
+
expected: fingerprint,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
const args = buildRuntimeManagerArguments({
|
|
284
|
+
managerPath,
|
|
285
|
+
action,
|
|
286
|
+
configPath,
|
|
287
|
+
confirmation,
|
|
288
|
+
});
|
|
289
|
+
const runtimeStartedMs = performance.now();
|
|
290
|
+
const actionDescription = {
|
|
291
|
+
run: "Restoring the baseline and applying reviewed migrations",
|
|
292
|
+
start: "Starting the disposable local runtime",
|
|
293
|
+
migrate: "Applying reviewed migrations to the current runtime",
|
|
294
|
+
reset: "Restoring the immutable baseline",
|
|
295
|
+
status: "Inspecting the disposable local runtime",
|
|
296
|
+
stop: "Stopping the disposable local runtime",
|
|
297
|
+
discard: "Removing the disposable local runtime",
|
|
298
|
+
verify: "Verifying the current local runtime",
|
|
299
|
+
}[action];
|
|
300
|
+
const describedAction = targetName
|
|
301
|
+
? `${actionDescription} for ${targetName}`
|
|
302
|
+
: actionDescription;
|
|
303
|
+
const spinner = useStyledPrompts(flags) ? prompts.spinner() : null;
|
|
304
|
+
if (spinner) {
|
|
305
|
+
spinner.start(`${describedAction}. This can take a moment.`);
|
|
306
|
+
} else if (isHumanTerminal(flags)) {
|
|
307
|
+
console.log(
|
|
308
|
+
`\n${terminalStyle(flags, "1;36", action === "run" ? "REHEARSING" : "REHEARSAL")}\n${terminalStyle(flags, "36", "→")} ${describedAction}. This can take a moment.`,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
const result = spawnSync(process.execPath, args, {
|
|
312
|
+
cwd: projectRoot,
|
|
313
|
+
encoding: "utf8",
|
|
314
|
+
env: projectCommandEnvironment(),
|
|
315
|
+
stdio: ["inherit", "pipe", "pipe"],
|
|
316
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
317
|
+
});
|
|
318
|
+
const currentFingerprint = getActivePackageFingerprint();
|
|
319
|
+
if (currentFingerprint) {
|
|
320
|
+
assertInstalledPackageFingerprint({
|
|
321
|
+
packageRoot,
|
|
322
|
+
expected: currentFingerprint,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
if (result.error) {
|
|
326
|
+
if (spinner) spinner.stop(`${describedAction} did not complete.`);
|
|
327
|
+
throw result.error;
|
|
328
|
+
}
|
|
329
|
+
if (result.status !== 0) {
|
|
330
|
+
if (spinner) spinner.stop(`${describedAction} did not complete.`);
|
|
331
|
+
const completeOutput = [
|
|
332
|
+
String(result.stdout ?? ""),
|
|
333
|
+
String(result.stderr ?? ""),
|
|
334
|
+
]
|
|
335
|
+
.filter(Boolean)
|
|
336
|
+
.join("\n")
|
|
337
|
+
.trim();
|
|
338
|
+
const inferred = normalizeRehearsalError(
|
|
339
|
+
new Error(completeOutput || `Rehearsal ${action} failed.`),
|
|
340
|
+
);
|
|
341
|
+
const category =
|
|
342
|
+
action === "migrate" ||
|
|
343
|
+
(action === "run" &&
|
|
344
|
+
/migrateRuntime|candidate migration|migration up/iu.test(
|
|
345
|
+
completeOutput,
|
|
346
|
+
))
|
|
347
|
+
? "migration_candidate_failure"
|
|
348
|
+
: inferred.category;
|
|
349
|
+
const conciseOutput = completeOutput
|
|
350
|
+
.replaceAll(String.fromCodePoint(27), "")
|
|
351
|
+
.split("\n")
|
|
352
|
+
.map((line) => line.trim())
|
|
353
|
+
.filter(Boolean)
|
|
354
|
+
.slice(-8)
|
|
355
|
+
.join("\n")
|
|
356
|
+
.slice(-2_000);
|
|
357
|
+
throw new RehearsalError({
|
|
358
|
+
category,
|
|
359
|
+
code: `MANAGER_${action.toUpperCase()}_FAILED`,
|
|
360
|
+
message: `The local Rehearsal ${action} operation did not complete.`,
|
|
361
|
+
expected: "the isolated runtime operation to finish and verify",
|
|
362
|
+
actual: conciseOutput || `exit ${result.status}`,
|
|
363
|
+
context: { action },
|
|
364
|
+
refused: "The runtime was not reported as trusted.",
|
|
365
|
+
suggestions: [
|
|
366
|
+
"Review the concise failure above, then rerun rehearsal doctor before retrying.",
|
|
367
|
+
"Use --debug only when the safe diagnostic detail is needed.",
|
|
368
|
+
],
|
|
369
|
+
cause: new Error(completeOutput || `exit ${result.status}`),
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
const durationMs = performance.now() - runtimeStartedMs;
|
|
373
|
+
if (spinner) {
|
|
374
|
+
spinner.stop(
|
|
375
|
+
`${describedAction} completed in ${formatDuration(durationMs)}.`,
|
|
376
|
+
);
|
|
377
|
+
} else if (isHumanTerminal(flags)) {
|
|
378
|
+
console.log(
|
|
379
|
+
`${terminalStyle(flags, "32", "✓")} Local runtime step completed in ${formatDuration(durationMs)}.`,
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
return {
|
|
383
|
+
action,
|
|
384
|
+
target: targetName ?? "primary",
|
|
385
|
+
durationMs: Math.round(durationMs * 100) / 100,
|
|
386
|
+
output: redactDiagnosticValue(String(result.stdout ?? "").trim()),
|
|
387
|
+
};
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
const runRuntimeStack = async ({ command, flags, planOptions }) => {
|
|
391
|
+
const topology =
|
|
392
|
+
flags.runtimeTopology ?? (await loadRuntimeTopology(planOptions));
|
|
393
|
+
const reverse = ["stop", "discard"].includes(command);
|
|
394
|
+
const targets = reverse
|
|
395
|
+
? [...topology.targets].reverse()
|
|
396
|
+
: topology.targets;
|
|
397
|
+
const results = targets.map((target) =>
|
|
398
|
+
runManager({
|
|
399
|
+
action: command,
|
|
400
|
+
flags,
|
|
401
|
+
configPath: target.configPath,
|
|
402
|
+
confirmation: flags.targetConfirmations?.get(target.configPath),
|
|
403
|
+
targetName: topology.targets.length > 1 ? target.name : undefined,
|
|
404
|
+
}),
|
|
405
|
+
);
|
|
406
|
+
if (results.length === 1) return { topology, runtime: results[0] };
|
|
407
|
+
return {
|
|
408
|
+
topology,
|
|
409
|
+
runtime: {
|
|
410
|
+
action: command,
|
|
411
|
+
durationMs:
|
|
412
|
+
Math.round(
|
|
413
|
+
results.reduce((total, result) => total + result.durationMs, 0) *
|
|
414
|
+
100,
|
|
415
|
+
) / 100,
|
|
416
|
+
output: results
|
|
417
|
+
.map((result) => `[${result.target}]\n${result.output}`)
|
|
418
|
+
.join("\n\n"),
|
|
419
|
+
targets: results,
|
|
420
|
+
},
|
|
421
|
+
};
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
const runApplicationProof = async (planOptions, options = {}) => {
|
|
425
|
+
const { config, projectRoot: configuredRoot } =
|
|
426
|
+
await loadRehearsalConfig(planOptions);
|
|
427
|
+
return runProjectCommand({
|
|
428
|
+
source: config.application.proofCommand,
|
|
429
|
+
configuredRoot,
|
|
430
|
+
label: options.targetName
|
|
431
|
+
? `application proof for ${options.targetName}`
|
|
432
|
+
: "application proof",
|
|
433
|
+
project: config.project.name,
|
|
434
|
+
environment: options.environment,
|
|
435
|
+
});
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
const prepareDependentTargets = ({ topology, environment }) =>
|
|
439
|
+
topology.dependents.flatMap((target) => {
|
|
440
|
+
if (!target.declaration.prepareCommand) return [];
|
|
441
|
+
return [
|
|
442
|
+
{
|
|
443
|
+
target: target.name,
|
|
444
|
+
...runProjectCommand({
|
|
445
|
+
source: target.declaration.prepareCommand,
|
|
446
|
+
configuredRoot: topology.projectRoot,
|
|
447
|
+
label: `preparation command for ${target.name}`,
|
|
448
|
+
project: topology.primary.config.project.name,
|
|
449
|
+
environment,
|
|
450
|
+
}),
|
|
451
|
+
},
|
|
452
|
+
];
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
const proveRuntimeStack = async ({ topology, planOptions, environment }) => {
|
|
456
|
+
const dependentProofs = [];
|
|
457
|
+
for (const target of topology.dependents) {
|
|
458
|
+
dependentProofs.push({
|
|
459
|
+
target: target.name,
|
|
460
|
+
...(await runApplicationProof(
|
|
461
|
+
{ ...planOptions, configPath: target.configPath },
|
|
462
|
+
{ targetName: target.name, environment },
|
|
463
|
+
)),
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
const applicationProof = await runApplicationProof(planOptions, {
|
|
467
|
+
targetName: topology.targets.length > 1 ? "primary" : undefined,
|
|
468
|
+
environment,
|
|
469
|
+
});
|
|
470
|
+
return { applicationProof, dependentProofs };
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
const prepareCandidateConfirmation = async ({
|
|
474
|
+
command,
|
|
475
|
+
flags,
|
|
476
|
+
planOptions,
|
|
477
|
+
}) => {
|
|
478
|
+
if (!["run", "migrate"].includes(command) || flags.dryRun) return true;
|
|
479
|
+
const summary = await topologyCandidateSummary(planOptions);
|
|
480
|
+
flags.runtimeTopology = summary.topology;
|
|
481
|
+
flags.targetConfirmations = new Map(
|
|
482
|
+
summary.targets.map((target) => [
|
|
483
|
+
target.configPath,
|
|
484
|
+
target.candidateSha256,
|
|
485
|
+
]),
|
|
486
|
+
);
|
|
487
|
+
if (summary.candidateCount === 0) return true;
|
|
488
|
+
if (flags.confirmation === summary.candidateSha256) return true;
|
|
489
|
+
const interactive =
|
|
490
|
+
!flags.json && process.stdin.isTTY && process.stdout.isTTY;
|
|
491
|
+
if (!interactive || flags.confirmation) {
|
|
492
|
+
throw new RehearsalError({
|
|
493
|
+
category: "migration_candidate_failure",
|
|
494
|
+
code: "CANDIDATE_CONFIRMATION_REQUIRED",
|
|
495
|
+
message: flags.confirmation
|
|
496
|
+
? "The candidate migration confirmation does not match the current candidate set."
|
|
497
|
+
: "Candidate migration confirmation is required in noninteractive use.",
|
|
498
|
+
expected: summary.candidateSha256,
|
|
499
|
+
actual: flags.confirmation ?? "missing",
|
|
500
|
+
context: {
|
|
501
|
+
command,
|
|
502
|
+
candidateCount: summary.candidateCount,
|
|
503
|
+
candidates: summary.candidates.map(
|
|
504
|
+
(migration) => `${migration.target}: ${migration.filename}`,
|
|
505
|
+
),
|
|
506
|
+
},
|
|
507
|
+
refused:
|
|
508
|
+
"Rehearsal did not reset, start, or modify the disposable runtime.",
|
|
509
|
+
suggestions: [
|
|
510
|
+
"Run rehearsal candidates, review every file, then pass the current digest with --confirm-candidates=.",
|
|
511
|
+
],
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
console.log(
|
|
515
|
+
[
|
|
516
|
+
"",
|
|
517
|
+
terminalStyle(
|
|
518
|
+
flags,
|
|
519
|
+
"1",
|
|
520
|
+
`${summary.candidateCount} candidate migration${summary.candidateCount === 1 ? "" : "s"}`,
|
|
521
|
+
),
|
|
522
|
+
"",
|
|
523
|
+
...summary.candidates.map((migration) =>
|
|
524
|
+
summary.targets.length > 1
|
|
525
|
+
? ` → ${migration.target}: ${migration.filename}`
|
|
526
|
+
: ` → ${migration.filename}`,
|
|
527
|
+
),
|
|
528
|
+
"",
|
|
529
|
+
`Candidate set: ${summary.candidateSha256.slice(0, 12)}`,
|
|
530
|
+
"",
|
|
531
|
+
`Rehearsal will apply only this exact set to ${summary.targets.length === 1 ? "the disposable local runtime" : "the isolated local runtime stack"}.`,
|
|
532
|
+
].join("\n"),
|
|
533
|
+
);
|
|
534
|
+
const accepted = await promptForConfirmation(
|
|
535
|
+
`Apply exactly ${summary.candidateCount === 1 ? "this migration" : "these migrations"}?`,
|
|
536
|
+
flags,
|
|
537
|
+
);
|
|
538
|
+
if (!accepted) {
|
|
539
|
+
console.log("No changes made.");
|
|
540
|
+
return false;
|
|
541
|
+
}
|
|
542
|
+
flags.confirmation = summary.candidateSha256;
|
|
543
|
+
return true;
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
return {
|
|
547
|
+
prepareCandidateConfirmation,
|
|
548
|
+
prepareDependentTargets,
|
|
549
|
+
proveRuntimeStack,
|
|
550
|
+
runManager,
|
|
551
|
+
runRuntimeStack,
|
|
552
|
+
};
|
|
553
|
+
};
|