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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/tutorial.md CHANGED
@@ -1,96 +1,107 @@
1
- # Tutorial: rehearse a migration in a new project
1
+ # Safe hands-on tutorial
2
2
 
3
- This walkthrough uses a fictional `widgets` application. It demonstrates the complete
4
- consumer workflow without importing private or hosted data.
3
+ This tutorial runs a complete PostgreSQL rehearsal in a temporary fictional project. It
4
+ does not use your application, production data, or a hosted database.
5
5
 
6
- ## Create the application migration history
6
+ Allow about five minutes. You need Node.js 24 and a running Docker-compatible engine.
7
7
 
8
- The project begins with one historical migration:
8
+ ## 1. Prepare Rehearsal
9
9
 
10
- ```sql
11
- create table public.widgets (
12
- id bigint generated always as identity primary key,
13
- name text not null
14
- );
10
+ If you do not already have this repository, clone it:
11
+
12
+ ```bash
13
+ git clone https://github.com/Ddupasquier/rehearsal-db.git
14
+ cd rehearsal-db
15
15
  ```
16
16
 
17
- Place it at `supabase/migrations/20260101000000_create_widgets.sql`. Add a second,
18
- candidate migration:
17
+ Then run:
19
18
 
20
- ```sql
21
- alter table public.widgets add column description text;
19
+ ```bash
20
+ npm ci --ignore-scripts
21
+ docker pull postgres:17-alpine
22
22
  ```
23
23
 
24
- Place that at `supabase/migrations/20260101000100_add_widget_description.sql`.
24
+ If you already have this repository open, use that checkout.
25
25
 
26
- ## Configure the isolated runtime
26
+ ## 2. Make a temporary project
27
27
 
28
- Run `npx rehearsal init --write`, then edit the result. Give the runtime unique local
29
- ports and a unique project ID. Its `rehearsalConfig` must point at a dedicated, unlinked
30
- Supabase config. Never reuse a hosted project reference or production environment file.
28
+ From the Rehearsal repository root, run this block exactly:
31
29
 
32
- Keep the generated `.rehearsal` directory ignored. It contains local artifacts and
33
- runtime state, not source code.
30
+ ```bash
31
+ rehearsal_repo=$PWD
32
+ tutorial_dir=$(mktemp -d)
33
+ cp -R tests/fixtures/postgresql-project "$tutorial_dir/app"
34
+ cd "$tutorial_dir/app"
35
+ npm install --ignore-scripts --no-save "$rehearsal_repo"
36
+ ```
34
37
 
35
- ## Build a synthetic baseline
38
+ The temporary project contains:
36
39
 
37
- The baseline contains the historical migration bundle, a one-row sanitized data stream,
38
- and a manifest binding their checksums. Baseline construction is deliberately separate
39
- from runtime execution: the application owns extraction and policy; the engine accepts
40
- only a completed, verified artifact.
40
+ - one historical migration that creates a `widgets` table;
41
+ - one candidate migration that adds a `description` column;
42
+ - one synthetic row;
43
+ - a reviewed sanitization policy;
44
+ - an application proof that checks the migrated database.
41
45
 
42
- Write the safe rows to `rehearsal/synthetic-data.ndjson` and the exact represented
43
- statements to `rehearsal/migration-ledger.json`, then run:
46
+ ## 3. Create the baseline
47
+
48
+ Open the guide:
44
49
 
45
50
  ```bash
46
- npx rehearsal baseline create \
47
- --records=rehearsal/synthetic-data.ndjson \
48
- --ledger=rehearsal/migration-ledger.json
51
+ npx rehearsal
49
52
  ```
50
53
 
51
- For an executable sample, inspect `tests/fixtures/rehearsal-project` in the repository.
52
- The maintained fixture proof invokes this public command through the packed npm tarball
53
- and never contacts a hosted service.
54
+ The project begins at Stage 3 of 4. Choose **Create the baseline**. Accept the detected
55
+ record and ledger files, review the summary, then confirm creation.
54
56
 
55
- ## Prove planning is non-mutating
57
+ The project should advance to Stage 4 of 4 with every item checked.
56
58
 
57
- ```bash
58
- npx rehearsal doctor
59
- npx rehearsal explain
60
- npx rehearsal run --dry-run
61
- npx rehearsal candidates --json
59
+ ## 4. Run the migration
60
+
61
+ Choose **Run a rehearsal**. The guide should show exactly one candidate:
62
+
63
+ ```text
64
+ 20260101000100_add_widget_description.sql
62
65
  ```
63
66
 
64
- `explain` and `run --dry-run` return the same execution plan. At this point no local
65
- database has been restored.
67
+ Confirm it. Rehearsal creates a loopback-only PostgreSQL container, restores the baseline,
68
+ applies the candidate, and runs the fixture proof.
66
69
 
67
- ## Run and inspect
70
+ Success ends with an application-proof message and suggests exercising the local
71
+ application before verification.
68
72
 
69
- Copy the exact digest from `candidates`:
73
+ ## 5. Try the runtime commands
74
+
75
+ Exit the guide, then run:
70
76
 
71
77
  ```bash
72
- npx rehearsal run --confirm-candidates=<sha256>
73
- npx rehearsal inspect migrations
74
78
  npx rehearsal status
79
+ npx rehearsal verify
80
+ npx rehearsal reset
81
+ npx rehearsal stop
82
+ npx rehearsal discard
75
83
  ```
76
84
 
77
- The historical migration should be `represented_by_baseline`; the description migration
78
- should be `applied_to_current_runtime`. Test normal creates, updates, and deletes through
79
- your local application. They affect only this disposable database.
85
+ `discard` removes only the labeled tutorial container and volume. The temporary project
86
+ directory remains on disk and can be deleted when you no longer need it.
87
+
88
+ ## What you proved
80
89
 
81
- ## Prove failure behavior
90
+ You used the same packaged CLI a normal project installs. Rehearsal verified the baseline,
91
+ approved an exact migration, ran it in a disposable database, tested the result, and
92
+ cleaned up only its own runtime.
82
93
 
83
- Add a timestamped migration containing invalid SQL, rerun `candidates`, and use its new
84
- digest. The command must fail with `migration_candidate_failure`, remove the untrusted
85
- runtime, and never produce a successful receipt.
94
+ Next, follow [Getting started](getting-started.md) in your own project. Start with synthetic
95
+ rows until the workflow and application proof are reliable.
86
96
 
87
- Then restore the valid migration set and rerun:
97
+ ## Supabase check
98
+
99
+ If Supabase CLI 2.117.0 is installed, the repository also has a fully automated Supabase
100
+ proof:
88
101
 
89
102
  ```bash
90
- npx rehearsal reset
91
- npx rehearsal verify
92
- npx rehearsal stop
103
+ cd "$rehearsal_repo"
104
+ npm run test:fixture
93
105
  ```
94
106
 
95
- That cycle—plan, confirm exact bytes, run, exercise the app, reset—is the normal Rehearsal
96
- workflow.
107
+ The PostgreSQL equivalent is `npm run test:fixture:postgresql`.
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@rehearsal-db/core",
3
- "version": "0.1.0-beta.5",
3
+ "version": "0.1.0-beta.7",
4
4
  "private": false,
5
- "description": "Safely rehearse Supabase migrations against sanitized, production-shaped PostgreSQL data.",
5
+ "description": "Safely rehearse PostgreSQL and Supabase migrations against sanitized, production-shaped data.",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/Ddupasquier/rehearsal-db.git"
@@ -39,6 +39,7 @@
39
39
  "files": [
40
40
  "scripts/lib/environment/local_supabase.mjs",
41
41
  "scripts/lib/rehearsal",
42
+ "scripts/lib/runtime",
42
43
  "scripts/operations/database/manage_rehearsal_database.mjs",
43
44
  "scripts/operations/rehearsal/rehearsal_cli.mjs",
44
45
  "docs/*.md",
@@ -55,6 +56,7 @@
55
56
  "test:unit": "vitest run tests/scripts",
56
57
  "test:docs": "vitest run tests/contracts/documentationCommands.test.mjs",
57
58
  "test:fixture": "node scripts/operations/rehearsal/prove_independent_fixture.mjs",
59
+ "test:fixture:postgresql": "node scripts/operations/rehearsal/prove_postgresql_fixture.mjs",
58
60
  "check": "npm run check:syntax && npm run test && npm run format:check && npm run package:audit",
59
61
  "check:syntax": "find scripts tests -type f -name '*.mjs' -exec node --check {} +",
60
62
  "format": "prettier --write .",
@@ -62,14 +62,13 @@ export const localCommandSucceeds = (
62
62
  stdio: "ignore",
63
63
  }).status === 0;
64
64
 
65
- export const ensureLocalContainerRuntime = ({ cwd = process.cwd() } = {}) => {
65
+ export const ensureLocalContainerRuntime = ({
66
+ cwd = process.cwd(),
67
+ autoStartColima = true,
68
+ } = {}) => {
66
69
  if (localCommandSucceeds("docker", ["info"], { cwd })) return;
67
- if (localCommandSucceeds("colima", ["version"], { cwd })) {
68
- runLocalCommand(
69
- "colima",
70
- ["start", "--cpu", "4", "--memory", "4", "--disk", "40"],
71
- { cwd },
72
- );
70
+ if (autoStartColima && localCommandSucceeds("colima", ["version"], { cwd })) {
71
+ runLocalCommand("colima", ["start"], { cwd });
73
72
  }
74
73
  if (!localCommandSucceeds("docker", ["info"], { cwd })) {
75
74
  throw new Error(
@@ -118,8 +117,9 @@ export const startLocalSupabase = ({
118
117
  exclude = [],
119
118
  environment = {},
120
119
  applyMigrations = true,
120
+ autoStartColima = true,
121
121
  } = {}) => {
122
- ensureLocalContainerRuntime({ cwd });
122
+ ensureLocalContainerRuntime({ cwd, autoStartColima });
123
123
  const startArguments = withWorkdir(
124
124
  ["start", ...(exclude.length ? ["--exclude", exclude.join(",")] : [])],
125
125
  workdir,
@@ -478,7 +478,7 @@ export const listIncompleteBaselineBuilds = async ({ artifactRoot }) => {
478
478
  }
479
479
  };
480
480
 
481
- export const pruneBaselineGenerations = async ({
481
+ export const planBaselineGenerationPrune = async ({
482
482
  artifactRoot,
483
483
  retain = 2,
484
484
  }) => {
@@ -516,7 +516,36 @@ export const pruneBaselineGenerations = async ({
516
516
  const removed = entries
517
517
  .map((entry) => entry.name)
518
518
  .filter((name) => !retained.has(name));
519
- for (const name of removed) {
519
+ return { retained: [...retained], removed };
520
+ };
521
+
522
+ export const pruneBaselineGenerations = async ({
523
+ artifactRoot,
524
+ retain = 2,
525
+ expectedRemoved,
526
+ }) => {
527
+ const root = assertArtifactRoot(artifactRoot);
528
+ const plan = await planBaselineGenerationPrune({
529
+ artifactRoot: root,
530
+ retain,
531
+ });
532
+ const generationsRoot = join(root, generationsDirectoryName);
533
+ if (
534
+ expectedRemoved &&
535
+ (expectedRemoved.length !== plan.removed.length ||
536
+ expectedRemoved.some((name, index) => name !== plan.removed[index]))
537
+ ) {
538
+ throw new Error(
539
+ "Rehearsal baseline generations changed after the cleanup preview.",
540
+ );
541
+ }
542
+ for (const name of plan.removed) {
543
+ const active = await resolveActiveBaselinePaths({ artifactRoot: root });
544
+ if (basename(active.generationDirectory) === name) {
545
+ throw new Error(
546
+ "Refusing to prune the active Rehearsal baseline generation.",
547
+ );
548
+ }
520
549
  const target = assertInsideRoot(
521
550
  generationsRoot,
522
551
  join(generationsRoot, name),
@@ -525,7 +554,7 @@ export const pruneBaselineGenerations = async ({
525
554
  await makeArtifactTreeWritable(target);
526
555
  await rm(target, { recursive: true, force: true });
527
556
  }
528
- return { retained: [...retained], removed };
557
+ return plan;
529
558
  };
530
559
 
531
560
  export const removeBaselineArtifactRoot = async ({ artifactRoot }) => {
@@ -0,0 +1,381 @@
1
+ /**
2
+ * Purpose: Preview and apply narrowly scoped Rehearsal cleanup without global
3
+ * Docker pruning or deletion of unowned database resources.
4
+ */
5
+
6
+ import { spawnSync } from "node:child_process";
7
+ import { createHash } from "node:crypto";
8
+ import { stat } from "node:fs/promises";
9
+ import {
10
+ planBaselineGenerationPrune,
11
+ pruneBaselineGenerations,
12
+ } from "./baseline_artifact.mjs";
13
+ import { loadRehearsalConfig } from "./configuration.mjs";
14
+ import { createCleanProcessEnvironment } from "./process_environment.mjs";
15
+
16
+ const SUPABASE_IMAGE_PREFIX = "public.ecr.aws/supabase/";
17
+
18
+ const commandResult = (command, args, { projectRoot }) =>
19
+ spawnSync(command, args, {
20
+ cwd: projectRoot,
21
+ encoding: "utf8",
22
+ env: createCleanProcessEnvironment(),
23
+ maxBuffer: 32 * 1024 * 1024,
24
+ stdio: ["ignore", "pipe", "pipe"],
25
+ });
26
+
27
+ const runDocker = (args, { projectRoot, allowFailure = false }) => {
28
+ const result = commandResult("docker", args, { projectRoot });
29
+ if (!allowFailure && (result.error || result.status !== 0)) {
30
+ const detail = [result.stdout, result.stderr]
31
+ .filter(Boolean)
32
+ .join("\n")
33
+ .trim();
34
+ throw new Error(
35
+ `docker ${args.join(" ")} failed${detail ? `:\n${detail}` : "."}`,
36
+ );
37
+ }
38
+ return result;
39
+ };
40
+
41
+ const nonEmptyLines = (value) =>
42
+ String(value ?? "")
43
+ .split("\n")
44
+ .map((line) => line.trim())
45
+ .filter(Boolean);
46
+
47
+ const dockerAvailable = (projectRoot) =>
48
+ runDocker(["info"], { projectRoot, allowFailure: true }).status === 0;
49
+
50
+ export const parsePosixDiskUsage = (output) => {
51
+ const columns = nonEmptyLines(output).at(-1)?.split(/\s+/u) ?? [];
52
+ if (columns.length < 6) return null;
53
+ const totalKiB = Number(columns[1]);
54
+ const usedKiB = Number(columns[2]);
55
+ const availableKiB = Number(columns[3]);
56
+ const usedPercent = Number.parseInt(columns[4], 10);
57
+ if (![totalKiB, usedKiB, availableKiB, usedPercent].every(Number.isFinite)) {
58
+ return null;
59
+ }
60
+ return {
61
+ totalBytes: totalKiB * 1024,
62
+ usedBytes: usedKiB * 1024,
63
+ availableBytes: availableKiB * 1024,
64
+ usedPercent,
65
+ };
66
+ };
67
+
68
+ const inspectContainerDisk = (projectRoot) => {
69
+ const context = commandResult("docker", ["context", "show"], {
70
+ projectRoot,
71
+ });
72
+ if (context.status !== 0 || context.stdout.trim() !== "colima") return null;
73
+ const result = commandResult(
74
+ "colima",
75
+ ["ssh", "--", "df", "-Pk", "/var/lib/docker"],
76
+ { projectRoot },
77
+ );
78
+ const usage = result.status === 0 ? parsePosixDiskUsage(result.stdout) : null;
79
+ return usage ? { backend: "colima", ...usage } : null;
80
+ };
81
+
82
+ const inspectDockerUsage = (projectRoot) => {
83
+ if (!dockerAvailable(projectRoot)) {
84
+ return { available: false, resources: [], disk: null };
85
+ }
86
+ const result = runDocker(["system", "df", "--format", "{{json .}}"], {
87
+ projectRoot,
88
+ });
89
+ return {
90
+ available: true,
91
+ resources: nonEmptyLines(result.stdout).map((line) => JSON.parse(line)),
92
+ disk: inspectContainerDisk(projectRoot),
93
+ };
94
+ };
95
+
96
+ const inspectUsedImageIds = (projectRoot) => {
97
+ const containers = nonEmptyLines(
98
+ runDocker(["container", "ls", "--all", "--quiet"], { projectRoot }).stdout,
99
+ );
100
+ if (containers.length === 0) return new Set();
101
+ return new Set(
102
+ nonEmptyLines(
103
+ runDocker(
104
+ ["container", "inspect", "--format", "{{.Image}}", ...containers],
105
+ { projectRoot },
106
+ ).stdout,
107
+ ),
108
+ );
109
+ };
110
+
111
+ const splitImageTag = (value) => {
112
+ const separator = value.lastIndexOf(":");
113
+ if (separator <= value.lastIndexOf("/")) return null;
114
+ return {
115
+ repository: value.slice(0, separator),
116
+ tag: value.slice(separator + 1),
117
+ };
118
+ };
119
+
120
+ export const selectOlderUnusedSupabaseImages = ({ images, usedImageIds }) => {
121
+ const byRepository = new Map();
122
+ for (const image of images) {
123
+ for (const fullTag of image.RepoTags ?? []) {
124
+ const parsed = splitImageTag(fullTag);
125
+ if (!parsed?.repository.startsWith(SUPABASE_IMAGE_PREFIX)) continue;
126
+ const entries = byRepository.get(parsed.repository) ?? [];
127
+ entries.push({
128
+ id: image.Id,
129
+ createdAt: image.Created,
130
+ bytes: image.Size,
131
+ fullTag,
132
+ });
133
+ byRepository.set(parsed.repository, entries);
134
+ }
135
+ }
136
+
137
+ const candidatesById = new Map();
138
+ for (const entries of byRepository.values()) {
139
+ const newestId = [...entries].sort(
140
+ (left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt),
141
+ )[0]?.id;
142
+ for (const entry of entries) {
143
+ if (entry.id === newestId || usedImageIds.has(entry.id)) continue;
144
+ const existing = candidatesById.get(entry.id) ?? {
145
+ id: entry.id,
146
+ createdAt: entry.createdAt,
147
+ bytes: entry.bytes,
148
+ tags: [],
149
+ };
150
+ existing.tags.push(entry.fullTag);
151
+ candidatesById.set(entry.id, existing);
152
+ }
153
+ }
154
+ return [...candidatesById.values()]
155
+ .map((entry) => ({ ...entry, tags: [...new Set(entry.tags)].sort() }))
156
+ .sort((left, right) => left.createdAt.localeCompare(right.createdAt));
157
+ };
158
+
159
+ const inspectUnusedImages = (projectRoot) => {
160
+ const imageIds = [
161
+ ...new Set(
162
+ nonEmptyLines(
163
+ runDocker(["image", "ls", "--quiet", "--no-trunc"], {
164
+ projectRoot,
165
+ }).stdout,
166
+ ),
167
+ ),
168
+ ];
169
+ if (imageIds.length === 0) return [];
170
+ const images = nonEmptyLines(
171
+ runDocker(["image", "inspect", "--format", "{{json .}}", ...imageIds], {
172
+ projectRoot,
173
+ }).stdout,
174
+ ).map((line) => JSON.parse(line));
175
+ return selectOlderUnusedSupabaseImages({
176
+ images,
177
+ usedImageIds: inspectUsedImageIds(projectRoot),
178
+ });
179
+ };
180
+
181
+ const runtimeDetected = async ({ config, paths, projectRoot }) => {
182
+ try {
183
+ await stat(paths.runtimeWorkdir);
184
+ return true;
185
+ } catch (error) {
186
+ if (error?.code !== "ENOENT") throw error;
187
+ }
188
+ if (!dockerAvailable(projectRoot)) return false;
189
+ if (config.runtime.target === "postgresql") {
190
+ const containers = runDocker(
191
+ [
192
+ "container",
193
+ "ls",
194
+ "--all",
195
+ "--filter",
196
+ `label=com.rehearsal-db.project=${config.runtime.projectId}`,
197
+ "--quiet",
198
+ ],
199
+ { projectRoot },
200
+ );
201
+ const volumes = runDocker(
202
+ [
203
+ "volume",
204
+ "ls",
205
+ "--filter",
206
+ `label=com.rehearsal-db.project=${config.runtime.projectId}`,
207
+ "--quiet",
208
+ ],
209
+ { projectRoot },
210
+ );
211
+ return (
212
+ nonEmptyLines(containers.stdout).length > 0 ||
213
+ nonEmptyLines(volumes.stdout).length > 0
214
+ );
215
+ }
216
+ const containers = runDocker(
217
+ [
218
+ "container",
219
+ "ls",
220
+ "--all",
221
+ "--filter",
222
+ `label=com.supabase.cli.project=${config.runtime.projectId}`,
223
+ "--quiet",
224
+ ],
225
+ { projectRoot },
226
+ );
227
+ const volumes = nonEmptyLines(
228
+ runDocker(["volume", "ls", "--format", "{{.Name}}"], { projectRoot })
229
+ .stdout,
230
+ );
231
+ return (
232
+ nonEmptyLines(containers.stdout).length > 0 ||
233
+ volumes.some((name) => name.endsWith(`_${config.runtime.projectId}`))
234
+ );
235
+ };
236
+
237
+ const inspectBaselineCleanup = async ({ artifactRoot, retain }) => {
238
+ try {
239
+ return {
240
+ available: true,
241
+ ...(await planBaselineGenerationPrune({ artifactRoot, retain })),
242
+ };
243
+ } catch (error) {
244
+ if (error?.code === "ENOENT") {
245
+ return { available: false, retained: [], removed: [] };
246
+ }
247
+ throw error;
248
+ }
249
+ };
250
+
251
+ const cleanupIdentity = (plan) => ({
252
+ projectId: plan.projectId,
253
+ retainBaselineGenerations: plan.retainBaselineGenerations,
254
+ baselineGenerations: plan.baselines.removed,
255
+ runtime: plan.runtime.included && plan.runtime.detected,
256
+ images: plan.images.candidates.map(({ id, tags }) => ({ id, tags })),
257
+ });
258
+
259
+ const cleanupDigest = (plan) =>
260
+ createHash("sha256")
261
+ .update(JSON.stringify(cleanupIdentity(plan)))
262
+ .digest("hex");
263
+
264
+ export const planRehearsalCleanup = async ({
265
+ projectRoot = process.cwd(),
266
+ configPath,
267
+ includeRuntime = false,
268
+ includeImages = false,
269
+ inspection = { inspectDockerUsage, runtimeDetected },
270
+ } = {}) => {
271
+ const loaded = await loadRehearsalConfig({ projectRoot, configPath });
272
+ const docker = inspection.inspectDockerUsage(loaded.projectRoot);
273
+ const baselines = await inspectBaselineCleanup({
274
+ artifactRoot: loaded.paths.artifactDirectory,
275
+ retain: loaded.config.cleanup.retainBaselineGenerations,
276
+ });
277
+ const images = {
278
+ requested: includeImages,
279
+ inspected: includeImages && docker.available,
280
+ candidates:
281
+ includeImages && docker.available
282
+ ? inspectUnusedImages(loaded.projectRoot)
283
+ : [],
284
+ };
285
+ const plan = {
286
+ project: loaded.config.project.name,
287
+ projectId: loaded.config.runtime.projectId,
288
+ target: loaded.config.runtime.target,
289
+ retainBaselineGenerations: loaded.config.cleanup.retainBaselineGenerations,
290
+ baselines,
291
+ runtime: {
292
+ included: includeRuntime,
293
+ detected: await inspection.runtimeDetected({ ...loaded }),
294
+ },
295
+ images,
296
+ docker,
297
+ };
298
+ return Object.freeze({ ...plan, digest: cleanupDigest(plan) });
299
+ };
300
+
301
+ const removePlannedImages = ({ candidates, projectRoot }) => {
302
+ if (candidates.length === 0) return [];
303
+ const usedImageIds = inspectUsedImageIds(projectRoot);
304
+ const removed = [];
305
+ for (const candidate of candidates) {
306
+ if (usedImageIds.has(candidate.id)) {
307
+ throw new Error(
308
+ `Cleanup stopped because a container began using ${candidate.id}. Preview the cleanup again.`,
309
+ );
310
+ }
311
+ for (const tag of candidate.tags) {
312
+ const inspection = runDocker(
313
+ ["image", "inspect", "--format", "{{.Id}}", tag],
314
+ { projectRoot, allowFailure: true },
315
+ );
316
+ if (inspection.status !== 0) continue;
317
+ if (inspection.stdout.trim() !== candidate.id) {
318
+ throw new Error(
319
+ `Cleanup stopped because ${tag} changed after the preview. Preview the cleanup again.`,
320
+ );
321
+ }
322
+ runDocker(["image", "rm", tag], { projectRoot });
323
+ removed.push(tag);
324
+ }
325
+ }
326
+ return removed;
327
+ };
328
+
329
+ export const applyRehearsalCleanup = async ({
330
+ plan,
331
+ confirmation,
332
+ projectRoot = process.cwd(),
333
+ configPath,
334
+ removeRuntime = async () => undefined,
335
+ inspection,
336
+ } = {}) => {
337
+ if (confirmation !== plan?.digest) {
338
+ throw new Error(
339
+ "Cleanup requires the exact --confirm-cleanup digest from a fresh preview. Nothing was removed.",
340
+ );
341
+ }
342
+ const refreshed = await planRehearsalCleanup({
343
+ projectRoot,
344
+ configPath,
345
+ includeRuntime: plan.runtime.included,
346
+ includeImages: plan.images.inspected,
347
+ ...(inspection ? { inspection } : {}),
348
+ });
349
+ if (refreshed.digest !== plan.digest) {
350
+ throw new Error(
351
+ "Cleanup targets changed after the preview. Nothing was removed; preview the cleanup again.",
352
+ );
353
+ }
354
+ if (plan.images.requested && !refreshed.images.inspected) {
355
+ throw new Error(
356
+ "Docker is unavailable, so Rehearsal cannot verify or remove unused images.",
357
+ );
358
+ }
359
+ if (refreshed.runtime.included && refreshed.runtime.detected) {
360
+ await removeRuntime();
361
+ }
362
+ const loaded = await loadRehearsalConfig({ projectRoot, configPath });
363
+ const baselines = refreshed.baselines.available
364
+ ? await pruneBaselineGenerations({
365
+ artifactRoot: loaded.paths.artifactDirectory,
366
+ retain: refreshed.retainBaselineGenerations,
367
+ expectedRemoved: refreshed.baselines.removed,
368
+ })
369
+ : { retained: [], removed: [] };
370
+ const images = removePlannedImages({
371
+ candidates: refreshed.images.candidates,
372
+ projectRoot: loaded.projectRoot,
373
+ });
374
+ return {
375
+ project: refreshed.project,
376
+ projectId: refreshed.projectId,
377
+ baselines,
378
+ runtimeRemoved: refreshed.runtime.included && refreshed.runtime.detected,
379
+ imagesRemoved: images,
380
+ };
381
+ };