@rehearsal-db/core 0.1.0-beta.6 → 0.1.0-beta.8

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.
@@ -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
+ };
@@ -22,6 +22,12 @@ export interface RehearsalConfig {
22
22
  artifactDirectory?: string;
23
23
  sanitizationPolicy: string;
24
24
  };
25
+ containerRuntime?: {
26
+ autoStartColima?: boolean;
27
+ };
28
+ cleanup?: {
29
+ retainBaselineGenerations?: number;
30
+ };
25
31
  application: {
26
32
  startCommand: string;
27
33
  proofCommand: string;
@@ -23,6 +23,8 @@ export const REHEARSAL_CONFIG_VERSION = 1;
23
23
 
24
24
  export const REHEARSAL_DEFAULTS = Object.freeze({
25
25
  baseline: Object.freeze({ artifactDirectory: ".rehearsal" }),
26
+ containerRuntime: Object.freeze({ autoStartColima: true }),
27
+ cleanup: Object.freeze({ retainBaselineGenerations: 2 }),
26
28
  runtime: Object.freeze({
27
29
  applicationUrl: "http://localhost:5175",
28
30
  projectId: "rehearsal-local",
@@ -111,6 +113,20 @@ const assertPort = (value, path) => {
111
113
  return value;
112
114
  };
113
115
 
116
+ const assertBoolean = (value, path) => {
117
+ if (typeof value !== "boolean") {
118
+ throw new Error(`${path} must be true or false.`);
119
+ }
120
+ return value;
121
+ };
122
+
123
+ const assertPositiveInteger = (value, path) => {
124
+ if (!Number.isSafeInteger(value) || value < 1) {
125
+ throw new Error(`${path} must be a positive integer.`);
126
+ }
127
+ return value;
128
+ };
129
+
114
130
  const assertStringArray = (value, path) => {
115
131
  if (
116
132
  !Array.isArray(value) ||
@@ -151,6 +167,8 @@ const normalizeConfig = (input) => {
151
167
  "supabase",
152
168
  "postgresql",
153
169
  "baseline",
170
+ "containerRuntime",
171
+ "cleanup",
154
172
  "application",
155
173
  "runtime",
156
174
  "safety",
@@ -219,6 +237,17 @@ const normalizeConfig = (input) => {
219
237
  ["artifactDirectory", "sanitizationPolicy"],
220
238
  "config.baseline",
221
239
  );
240
+ const containerRuntime = assertPlainObject(
241
+ root.containerRuntime ?? {},
242
+ "config.containerRuntime",
243
+ );
244
+ assertKnownKeys(
245
+ containerRuntime,
246
+ ["autoStartColima"],
247
+ "config.containerRuntime",
248
+ );
249
+ const cleanup = assertPlainObject(root.cleanup ?? {}, "config.cleanup");
250
+ assertKnownKeys(cleanup, ["retainBaselineGenerations"], "config.cleanup");
222
251
  const application = assertPlainObject(root.application, "config.application");
223
252
  assertKnownKeys(
224
253
  application,
@@ -426,6 +455,20 @@ const normalizeConfig = (input) => {
426
455
  "config.baseline.sanitizationPolicy",
427
456
  ),
428
457
  }),
458
+ containerRuntime: Object.freeze({
459
+ autoStartColima: assertBoolean(
460
+ containerRuntime.autoStartColima ??
461
+ REHEARSAL_DEFAULTS.containerRuntime.autoStartColima,
462
+ "config.containerRuntime.autoStartColima",
463
+ ),
464
+ }),
465
+ cleanup: Object.freeze({
466
+ retainBaselineGenerations: assertPositiveInteger(
467
+ cleanup.retainBaselineGenerations ??
468
+ REHEARSAL_DEFAULTS.cleanup.retainBaselineGenerations,
469
+ "config.cleanup.retainBaselineGenerations",
470
+ ),
471
+ }),
429
472
  application: Object.freeze({
430
473
  startCommand: assertCommand(
431
474
  application.startCommand,
@@ -649,27 +692,60 @@ export const renderDetectedConfig = (
649
692
  applicationUrl = "http://localhost:5175",
650
693
  ports = { api: 58321, database: 58322, studio: 58323 },
651
694
  } = {},
652
- ) => `// @ts-check
695
+ ) =>
696
+ `// @ts-check
697
+ /**
698
+ * Rehearsal configuration for ${detected.projectName}.
699
+ * Generated from this project by \`npx rehearsal\` and safe to commit.
700
+ * Review the CHECK comments. Keep passwords, tokens, and production URLs out of this file.
701
+ * Docs: https://github.com/Ddupasquier/rehearsal-db/blob/main/docs/configuration.md
702
+ */
653
703
  import { defineRehearsalConfig } from "@rehearsal-db/core";
654
704
 
655
705
  export default defineRehearsalConfig({
706
+ // Configuration format. Rehearsal will explain if an upgrade is ever needed.
656
707
  schemaVersion: 1,
708
+ // Stable local name used in Rehearsal labels and reports.
657
709
  project: { name: ${JSON.stringify(detected.projectName)} },
710
+
711
+ // Project files Rehearsal reads. Every path stays inside this repository.
658
712
  supabase: {
659
713
  workdir: ".",
660
714
  migrationDirectory: "supabase/migrations",
661
715
  rehearsalConfig: "infrastructure/rehearsal/supabase/config.toml",
662
716
  runtimeWorkdir: ".rehearsal/runtime",
717
+
718
+ // Optional local-only identity-provider credentials. Enable both keys together.
719
+ // serviceEnvironmentFile: ".env.rehearsal-service.local",
720
+ // serviceEnvironmentVariables: ["LOCAL_IDP_CLIENT_ID", "LOCAL_IDP_SECRET"],
663
721
  },
722
+
723
+ // Immutable local baseline files. Rehearsal never reads production on your behalf.
664
724
  baseline: {
665
725
  artifactDirectory: ".rehearsal",
666
726
  sanitizationPolicy: "infrastructure/rehearsal/sanitization-policy.json",
667
727
  },
728
+
729
+ // Reuse any running Docker-compatible engine; start Colima only when needed.
730
+ containerRuntime: {
731
+ autoStartColima: true,
732
+ },
733
+
734
+ // Keep this many baseline generations, always including the active one.
735
+ cleanup: {
736
+ retainBaselineGenerations: 2,
737
+ },
738
+
668
739
  application: {
740
+ // CHECK: commands detected from package.json. Change them if they are not correct.
669
741
  startCommand: ${JSON.stringify(detected.applicationCommand)},
670
742
  proofCommand: ${JSON.stringify(detected.verificationCommand)},
671
743
  environmentFile: ".rehearsal/runtime.env",
744
+ // Optional project-specific restore hook:
745
+ // runtimeAdapter: "infrastructure/rehearsal/runtime-adapter.mjs",
672
746
  },
747
+
748
+ // Disposable local runtime identity, URL, and dedicated ports.
673
749
  runtime: {
674
750
  target: "supabase",
675
751
  applicationUrl: ${JSON.stringify(applicationUrl)},
@@ -678,6 +754,8 @@ export default defineRehearsalConfig({
678
754
  databasePort: ${ports.database},
679
755
  studioPort: ${ports.studio},
680
756
  },
757
+
758
+ // Only local runtime URLs are accepted; common hosted credentials are quarantined.
681
759
  safety: {
682
760
  allowedHosts: ["127.0.0.1", "::1", "localhost"],
683
761
  blockedEnvironmentVariables: [
@@ -688,19 +766,29 @@ export default defineRehearsalConfig({
688
766
  hostedAccess: "disabled",
689
767
  outboundNetwork: "deny",
690
768
  },
691
- verification: { commands: [${JSON.stringify(detected.verificationCommand)}] },
692
769
  });
693
- `;
770
+ `.replaceAll("\t", " ");
694
771
 
695
772
  export const renderDetectedPostgresqlConfig = (
696
773
  detected,
697
774
  { applicationUrl = "http://localhost:5175", databasePort = 58322 } = {},
698
- ) => `// @ts-check
775
+ ) =>
776
+ `// @ts-check
777
+ /**
778
+ * Rehearsal configuration for ${detected.projectName}.
779
+ * Generated from this project by \`npx rehearsal\` and safe to commit.
780
+ * Review the CHECK comments. Keep passwords, tokens, and production URLs out of this file.
781
+ * Docs: https://github.com/Ddupasquier/rehearsal-db/blob/main/docs/configuration.md
782
+ */
699
783
  import { defineRehearsalConfig } from "@rehearsal-db/core";
700
784
 
701
785
  export default defineRehearsalConfig({
786
+ // Configuration format. Rehearsal will explain if an upgrade is ever needed.
702
787
  schemaVersion: 1,
788
+ // Stable local name used in Rehearsal labels and reports.
703
789
  project: { name: ${JSON.stringify(detected.projectName)} },
790
+
791
+ // Disposable PostgreSQL settings. The image must already exist locally.
704
792
  postgresql: {
705
793
  migrationDirectory: ${JSON.stringify(detected.postgresqlMigrationDirectory)},
706
794
  runtimeWorkdir: ".rehearsal/runtime",
@@ -708,21 +796,41 @@ export default defineRehearsalConfig({
708
796
  database: "postgres",
709
797
  user: "postgres",
710
798
  },
799
+
800
+ // Immutable local baseline files. Rehearsal never reads production on your behalf.
711
801
  baseline: {
712
802
  artifactDirectory: ".rehearsal",
713
803
  sanitizationPolicy: "infrastructure/rehearsal/sanitization-policy.json",
714
804
  },
805
+
806
+ // Reuse any running Docker-compatible engine; start Colima only when needed.
807
+ containerRuntime: {
808
+ autoStartColima: true,
809
+ },
810
+
811
+ // Keep this many baseline generations, always including the active one.
812
+ cleanup: {
813
+ retainBaselineGenerations: 2,
814
+ },
815
+
715
816
  application: {
817
+ // CHECK: commands detected from package.json. Change them if they are not correct.
716
818
  startCommand: ${JSON.stringify(detected.applicationCommand)},
717
819
  proofCommand: ${JSON.stringify(detected.verificationCommand)},
718
820
  environmentFile: ".rehearsal/runtime.env",
821
+ // Optional project-specific restore hook:
822
+ // runtimeAdapter: "infrastructure/rehearsal/runtime-adapter.mjs",
719
823
  },
824
+
825
+ // Disposable local runtime identity, URL, and dedicated database port.
720
826
  runtime: {
721
827
  target: "postgresql",
722
828
  applicationUrl: ${JSON.stringify(applicationUrl)},
723
829
  projectId: "${detected.projectName}-rehearsal",
724
830
  databasePort: ${databasePort},
725
831
  },
832
+
833
+ // Only local runtime URLs are accepted; common hosted credentials are quarantined.
726
834
  safety: {
727
835
  allowedHosts: ["127.0.0.1", "::1", "localhost"],
728
836
  blockedEnvironmentVariables: [
@@ -735,6 +843,5 @@ export default defineRehearsalConfig({
735
843
  hostedAccess: "disabled",
736
844
  outboundNetwork: "deny",
737
845
  },
738
- verification: { commands: [${JSON.stringify(detected.verificationCommand)}] },
739
846
  });
740
- `;
847
+ `.replaceAll("\t", " ");
@@ -7,7 +7,7 @@
7
7
  import { createHash } from "node:crypto";
8
8
 
9
9
  export const REHEARSAL_RESULT_VERSION = 1;
10
- export const REHEARSAL_VERSION = "0.1.0-beta.6";
10
+ export const REHEARSAL_VERSION = "0.1.0-beta.8";
11
11
 
12
12
  export const REHEARSAL_EXIT_CODES = Object.freeze({
13
13
  success: 0,
@@ -237,7 +237,7 @@ export const buildRehearsalPlan = async (options = {}) => {
237
237
  "versioned configuration accepts loopback hosts only",
238
238
  `runtime uses a dedicated local ${config.runtime.target === "supabase" ? "Supabase workdir" : "PostgreSQL container and volume"} and project id`,
239
239
  "child processes receive an allowlisted environment",
240
- "application egress policy denies hosted targets and side effects",
240
+ "configuration accepts only loopback runtime URLs and quarantines common hosted credentials",
241
241
  ...(config.safety.authenticationProviders.length
242
242
  ? [
243
243
  `external identity is limited to: ${config.safety.authenticationProviders.join(", ")}`,
@@ -651,7 +651,7 @@ export const runRehearsalDoctor = async (options = {}) => {
651
651
  },
652
652
  {
653
653
  id: "safety-policy",
654
- label: "Hosted access and side effects",
654
+ label: "Hosted target configuration",
655
655
  run: async () => {
656
656
  if (
657
657
  config.safety.hostedAccess !== "disabled" ||
@@ -660,8 +660,8 @@ export const runRehearsalDoctor = async (options = {}) => {
660
660
  throw new Error("The safety policy is not fail closed.");
661
661
  }
662
662
  return config.safety.authenticationProviders.length
663
- ? `hosted access disabled; application egress denied; identity providers: ${config.safety.authenticationProviders.join(", ")}`
664
- : "hosted access disabled; outbound network denied";
663
+ ? `hosted database targets disabled; hosted credentials quarantined; local identity providers: ${config.safety.authenticationProviders.join(", ")}`
664
+ : "hosted database targets disabled; common hosted credentials quarantined";
665
665
  },
666
666
  remediation: "Restore the version-1 fail-closed safety policy.",
667
667
  },