@rehearsal-db/core 0.1.0-beta.1

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.
Files changed (35) hide show
  1. package/BENCHMARKS.md +61 -0
  2. package/CHANGELOG.md +59 -0
  3. package/COMPATIBILITY.md +22 -0
  4. package/LICENSE +21 -0
  5. package/README.md +375 -0
  6. package/SECURITY.md +19 -0
  7. package/SUPPORT.md +15 -0
  8. package/docs/adapters.md +23 -0
  9. package/docs/baselines.md +33 -0
  10. package/docs/commands.md +35 -0
  11. package/docs/configuration.md +98 -0
  12. package/docs/getting-started.md +247 -0
  13. package/docs/glossary.md +33 -0
  14. package/docs/production-source.md +33 -0
  15. package/docs/releasing.md +79 -0
  16. package/docs/sanitization.md +69 -0
  17. package/docs/security-model.md +40 -0
  18. package/docs/troubleshooting.md +50 -0
  19. package/docs/tutorial.md +96 -0
  20. package/package.json +77 -0
  21. package/scripts/lib/environment/local_supabase.mjs +197 -0
  22. package/scripts/lib/rehearsal/baseline_artifact.mjs +536 -0
  23. package/scripts/lib/rehearsal/baseline_builder.mjs +155 -0
  24. package/scripts/lib/rehearsal/configuration.d.mts +85 -0
  25. package/scripts/lib/rehearsal/configuration.mjs +559 -0
  26. package/scripts/lib/rehearsal/diagnostics.mjs +193 -0
  27. package/scripts/lib/rehearsal/migration_history.mjs +220 -0
  28. package/scripts/lib/rehearsal/plan.mjs +587 -0
  29. package/scripts/lib/rehearsal/process_environment.mjs +64 -0
  30. package/scripts/lib/rehearsal/runtime_restore.mjs +310 -0
  31. package/scripts/lib/rehearsal/sanitization_policy.mjs +169 -0
  32. package/scripts/lib/rehearsal/schema_snapshot.mjs +113 -0
  33. package/scripts/lib/rehearsal/service_environment.mjs +82 -0
  34. package/scripts/operations/database/manage_rehearsal_database.mjs +784 -0
  35. package/scripts/operations/rehearsal/rehearsal_cli.mjs +565 -0
@@ -0,0 +1,85 @@
1
+ export type RehearsalConfigVersion = 1;
2
+
3
+ export interface RehearsalConfig {
4
+ schemaVersion: RehearsalConfigVersion;
5
+ project: { name: string };
6
+ supabase: {
7
+ workdir: string;
8
+ migrationDirectory: string;
9
+ rehearsalConfig: string;
10
+ runtimeWorkdir: string;
11
+ serviceEnvironmentFile?: string;
12
+ serviceEnvironmentVariables?: string[];
13
+ };
14
+ baseline: {
15
+ artifactDirectory?: string;
16
+ sanitizationPolicy: string;
17
+ };
18
+ application: {
19
+ startCommand: string;
20
+ proofCommand: string;
21
+ environmentFile?: string;
22
+ runtimeAdapter?: string;
23
+ };
24
+ runtime: {
25
+ applicationUrl?: string;
26
+ projectId?: string;
27
+ apiPort: number;
28
+ databasePort: number;
29
+ studioPort: number;
30
+ };
31
+ safety?: {
32
+ allowedHosts?: string[];
33
+ blockedEnvironmentVariables?: string[];
34
+ authenticationProviders?: string[];
35
+ hostedAccess?: "disabled";
36
+ outboundNetwork?: "deny";
37
+ };
38
+ verification?: { commands?: string[] };
39
+ }
40
+
41
+ export declare const REHEARSAL_CONFIG_VERSION: 1;
42
+ export declare const defineRehearsalConfig: <
43
+ const Config extends RehearsalConfig,
44
+ >(
45
+ config: Config,
46
+ ) => Config;
47
+ export declare const loadRehearsalConfig: (options?: {
48
+ projectRoot?: string;
49
+ configPath?: string;
50
+ }) => Promise<unknown>;
51
+ export declare const inspectDetectedProject: (options?: {
52
+ projectRoot?: string;
53
+ }) => Promise<unknown>;
54
+ export declare const renderDetectedConfig: (detected: unknown) => string;
55
+ export declare const SANITIZATION_ACTIONS: Readonly<{
56
+ KEEP: "KEEP";
57
+ PSEUDONYMIZE: "PSEUDONYMIZE";
58
+ REPLACE: "REPLACE";
59
+ EXCLUDE: "EXCLUDE";
60
+ DERIVE: "DERIVE";
61
+ }>;
62
+ export declare const EXCLUDED_VALUE: unique symbol;
63
+ export declare const normalizeSanitizationAction: (
64
+ action: string,
65
+ ) => "KEEP" | "PSEUDONYMIZE" | "REPLACE" | "EXCLUDE" | "DERIVE";
66
+ export declare const validateSanitizationCoverage: (options: {
67
+ policy: { policyVersion?: unknown; tables: Array<Record<string, unknown>> };
68
+ schemaTables: Array<{
69
+ name: string;
70
+ columns: Array<string | { name: string }>;
71
+ }>;
72
+ }) => Readonly<{
73
+ policyVersion: unknown;
74
+ tableCount: number;
75
+ columnCount: number;
76
+ tables: ReadonlyArray<Record<string, unknown>>;
77
+ }>;
78
+ export declare const applySanitizationAction: (options: {
79
+ action: string;
80
+ value: unknown;
81
+ replace?: (value: unknown, context: Record<string, unknown>) => unknown;
82
+ pseudonymize?: (value: unknown, context: Record<string, unknown>) => unknown;
83
+ derive?: (value: unknown, context: Record<string, unknown>) => unknown;
84
+ context?: Record<string, unknown>;
85
+ }) => unknown | typeof EXCLUDED_VALUE;
@@ -0,0 +1,559 @@
1
+ /**
2
+ * Purpose: Load and strictly validate the versioned, project-owned Rehearsal
3
+ * configuration contract. Do not run directly; this module is reusable script
4
+ * infrastructure.
5
+ */
6
+
7
+ import { access, readFile, stat } from "node:fs/promises";
8
+ import { basename, isAbsolute, join, relative, resolve } from "node:path";
9
+ import { pathToFileURL } from "node:url";
10
+
11
+ export {
12
+ EXCLUDED_VALUE,
13
+ SANITIZATION_ACTIONS,
14
+ applySanitizationAction,
15
+ normalizeSanitizationAction,
16
+ validateSanitizationCoverage,
17
+ } from "./sanitization_policy.mjs";
18
+
19
+ export const REHEARSAL_CONFIG_VERSION = 1;
20
+
21
+ export const REHEARSAL_DEFAULTS = Object.freeze({
22
+ baseline: Object.freeze({ artifactDirectory: ".rehearsal" }),
23
+ runtime: Object.freeze({
24
+ applicationUrl: "http://localhost:5175",
25
+ projectId: "rehearsal-local",
26
+ }),
27
+ safety: Object.freeze({
28
+ allowedHosts: Object.freeze(["127.0.0.1", "::1", "localhost"]),
29
+ blockedEnvironmentVariables: Object.freeze([
30
+ "SUPABASE_ACCESS_TOKEN",
31
+ "SUPABASE_DB_PASSWORD",
32
+ "SUPABASE_PROJECT_ID",
33
+ ]),
34
+ hostedAccess: "disabled",
35
+ outboundNetwork: "deny",
36
+ }),
37
+ });
38
+
39
+ const CONFIG_FILENAMES = Object.freeze([
40
+ "rehearsal.config.mjs",
41
+ "rehearsal.config.js",
42
+ "rehearsal.config.ts",
43
+ join("infrastructure", "rehearsal", "rehearsal.config.mjs"),
44
+ join("infrastructure", "rehearsal", "rehearsal.config.ts"),
45
+ ]);
46
+
47
+ const LOOPBACK_HOSTS = new Set(REHEARSAL_DEFAULTS.safety.allowedHosts);
48
+ const IDENTIFIER_PATTERN = /^[a-z0-9][a-z0-9-]{1,62}$/u;
49
+ const SAFE_COMMAND_PATTERN = /^[^\n\r\0]+$/u;
50
+
51
+ const isPlainObject = (value) =>
52
+ value !== null &&
53
+ typeof value === "object" &&
54
+ !Array.isArray(value) &&
55
+ Object.getPrototypeOf(value) === Object.prototype;
56
+
57
+ const assertPlainObject = (value, path) => {
58
+ if (!isPlainObject(value)) {
59
+ throw new Error(`${path} must be an object.`);
60
+ }
61
+ return value;
62
+ };
63
+
64
+ const assertKnownKeys = (value, keys, path) => {
65
+ for (const key of Object.keys(value)) {
66
+ if (!keys.includes(key)) {
67
+ throw new Error(
68
+ `Unknown Rehearsal configuration property: ${path}.${key}.`,
69
+ );
70
+ }
71
+ }
72
+ };
73
+
74
+ const assertNonEmptyString = (value, path) => {
75
+ if (typeof value !== "string" || !value.trim()) {
76
+ throw new Error(`${path} must be a non-empty string.`);
77
+ }
78
+ return value.trim();
79
+ };
80
+
81
+ const assertRelativePath = (value, path) => {
82
+ const normalized = assertNonEmptyString(value, path);
83
+ if (isAbsolute(normalized) || normalized.split(/[\\/]/u).includes("..")) {
84
+ throw new Error(`${path} must stay inside the project root.`);
85
+ }
86
+ return normalized;
87
+ };
88
+
89
+ const assertCommand = (value, path) => {
90
+ const command = assertNonEmptyString(value, path);
91
+ if (!SAFE_COMMAND_PATTERN.test(command)) {
92
+ throw new Error(`${path} contains an unsafe control character.`);
93
+ }
94
+ return command;
95
+ };
96
+
97
+ const assertPort = (value, path) => {
98
+ if (!Number.isSafeInteger(value) || value < 1024 || value > 65_535) {
99
+ throw new Error(`${path} must be a non-privileged TCP port.`);
100
+ }
101
+ return value;
102
+ };
103
+
104
+ const assertStringArray = (value, path) => {
105
+ if (
106
+ !Array.isArray(value) ||
107
+ value.length === 0 ||
108
+ value.some((entry) => typeof entry !== "string" || !entry.trim())
109
+ ) {
110
+ throw new Error(`${path} must be a non-empty string array.`);
111
+ }
112
+ return [...new Set(value.map((entry) => entry.trim()))];
113
+ };
114
+
115
+ const assertLoopbackUrl = (value, path, allowedHosts) => {
116
+ let parsed;
117
+ try {
118
+ parsed = new URL(assertNonEmptyString(value, path));
119
+ } catch {
120
+ throw new Error(`${path} must be a valid loopback URL.`);
121
+ }
122
+ if (
123
+ !allowedHosts.includes(parsed.hostname) ||
124
+ !LOOPBACK_HOSTS.has(parsed.hostname)
125
+ ) {
126
+ throw new Error(`${path} must use an explicitly allowed loopback host.`);
127
+ }
128
+ if (!["http:", "https:"].includes(parsed.protocol)) {
129
+ throw new Error(`${path} must use HTTP or HTTPS.`);
130
+ }
131
+ return parsed.toString().replace(/\/$/u, "");
132
+ };
133
+
134
+ const normalizeConfig = (input) => {
135
+ const root = assertPlainObject(input, "config");
136
+ assertKnownKeys(
137
+ root,
138
+ [
139
+ "schemaVersion",
140
+ "project",
141
+ "supabase",
142
+ "baseline",
143
+ "application",
144
+ "runtime",
145
+ "safety",
146
+ "verification",
147
+ ],
148
+ "config",
149
+ );
150
+ if (root.schemaVersion !== REHEARSAL_CONFIG_VERSION) {
151
+ throw new Error(
152
+ `Unsupported Rehearsal configuration version: ${String(root.schemaVersion)}. Expected ${REHEARSAL_CONFIG_VERSION}.`,
153
+ );
154
+ }
155
+
156
+ const project = assertPlainObject(root.project, "config.project");
157
+ assertKnownKeys(project, ["name"], "config.project");
158
+ const projectName = assertNonEmptyString(project.name, "config.project.name");
159
+ if (!IDENTIFIER_PATTERN.test(projectName)) {
160
+ throw new Error(
161
+ "config.project.name must contain only lowercase letters, digits, and hyphens.",
162
+ );
163
+ }
164
+
165
+ const supabase = assertPlainObject(root.supabase, "config.supabase");
166
+ assertKnownKeys(
167
+ supabase,
168
+ [
169
+ "workdir",
170
+ "migrationDirectory",
171
+ "rehearsalConfig",
172
+ "runtimeWorkdir",
173
+ "serviceEnvironmentFile",
174
+ "serviceEnvironmentVariables",
175
+ ],
176
+ "config.supabase",
177
+ );
178
+ const baseline = assertPlainObject(root.baseline, "config.baseline");
179
+ assertKnownKeys(
180
+ baseline,
181
+ ["artifactDirectory", "sanitizationPolicy"],
182
+ "config.baseline",
183
+ );
184
+ const application = assertPlainObject(root.application, "config.application");
185
+ assertKnownKeys(
186
+ application,
187
+ ["startCommand", "proofCommand", "environmentFile", "runtimeAdapter"],
188
+ "config.application",
189
+ );
190
+ const runtime = assertPlainObject(root.runtime ?? {}, "config.runtime");
191
+ assertKnownKeys(
192
+ runtime,
193
+ ["applicationUrl", "projectId", "apiPort", "databasePort", "studioPort"],
194
+ "config.runtime",
195
+ );
196
+ const safety = assertPlainObject(root.safety ?? {}, "config.safety");
197
+ assertKnownKeys(
198
+ safety,
199
+ [
200
+ "allowedHosts",
201
+ "blockedEnvironmentVariables",
202
+ "authenticationProviders",
203
+ "hostedAccess",
204
+ "outboundNetwork",
205
+ ],
206
+ "config.safety",
207
+ );
208
+ const verification = assertPlainObject(
209
+ root.verification ?? {},
210
+ "config.verification",
211
+ );
212
+ assertKnownKeys(verification, ["commands"], "config.verification");
213
+
214
+ const allowedHosts = assertStringArray(
215
+ safety.allowedHosts ?? REHEARSAL_DEFAULTS.safety.allowedHosts,
216
+ "config.safety.allowedHosts",
217
+ );
218
+ if (allowedHosts.some((host) => !LOOPBACK_HOSTS.has(host))) {
219
+ throw new Error(
220
+ "config.safety.allowedHosts may contain loopback hosts only in configuration version 1.",
221
+ );
222
+ }
223
+ if ((safety.hostedAccess ?? "disabled") !== "disabled") {
224
+ throw new Error(
225
+ "config.safety.hostedAccess must remain disabled in configuration version 1.",
226
+ );
227
+ }
228
+ if ((safety.outboundNetwork ?? "deny") !== "deny") {
229
+ throw new Error(
230
+ "config.safety.outboundNetwork must remain deny in configuration version 1.",
231
+ );
232
+ }
233
+
234
+ const ports = {
235
+ api: assertPort(runtime.apiPort, "config.runtime.apiPort"),
236
+ database: assertPort(runtime.databasePort, "config.runtime.databasePort"),
237
+ studio: assertPort(runtime.studioPort, "config.runtime.studioPort"),
238
+ };
239
+ if (new Set(Object.values(ports)).size !== Object.values(ports).length) {
240
+ throw new Error("Rehearsal runtime ports must be unique.");
241
+ }
242
+
243
+ const commands = verification.commands ?? [];
244
+ if (!Array.isArray(commands)) {
245
+ throw new Error("config.verification.commands must be an array.");
246
+ }
247
+ const serviceEnvironmentVariables =
248
+ supabase.serviceEnvironmentVariables ?? [];
249
+ if (!Array.isArray(serviceEnvironmentVariables)) {
250
+ throw new Error(
251
+ "config.supabase.serviceEnvironmentVariables must be an array.",
252
+ );
253
+ }
254
+ const normalizedServiceEnvironmentVariables = serviceEnvironmentVariables.map(
255
+ (value, index) =>
256
+ assertNonEmptyString(
257
+ value,
258
+ `config.supabase.serviceEnvironmentVariables[${index}]`,
259
+ ),
260
+ );
261
+ if (
262
+ new Set(normalizedServiceEnvironmentVariables).size !==
263
+ normalizedServiceEnvironmentVariables.length
264
+ ) {
265
+ throw new Error(
266
+ "config.supabase.serviceEnvironmentVariables must not contain duplicates.",
267
+ );
268
+ }
269
+ if (
270
+ Boolean(supabase.serviceEnvironmentFile) !==
271
+ Boolean(normalizedServiceEnvironmentVariables.length)
272
+ ) {
273
+ throw new Error(
274
+ "config.supabase.serviceEnvironmentFile and serviceEnvironmentVariables must be configured together.",
275
+ );
276
+ }
277
+
278
+ return Object.freeze({
279
+ schemaVersion: REHEARSAL_CONFIG_VERSION,
280
+ project: Object.freeze({ name: projectName }),
281
+ supabase: Object.freeze({
282
+ workdir: assertRelativePath(supabase.workdir, "config.supabase.workdir"),
283
+ migrationDirectory: assertRelativePath(
284
+ supabase.migrationDirectory,
285
+ "config.supabase.migrationDirectory",
286
+ ),
287
+ rehearsalConfig: assertRelativePath(
288
+ supabase.rehearsalConfig,
289
+ "config.supabase.rehearsalConfig",
290
+ ),
291
+ runtimeWorkdir: assertRelativePath(
292
+ supabase.runtimeWorkdir,
293
+ "config.supabase.runtimeWorkdir",
294
+ ),
295
+ serviceEnvironmentFile: supabase.serviceEnvironmentFile
296
+ ? assertRelativePath(
297
+ supabase.serviceEnvironmentFile,
298
+ "config.supabase.serviceEnvironmentFile",
299
+ )
300
+ : null,
301
+ serviceEnvironmentVariables: Object.freeze(
302
+ normalizedServiceEnvironmentVariables,
303
+ ),
304
+ }),
305
+ baseline: Object.freeze({
306
+ artifactDirectory: assertRelativePath(
307
+ baseline.artifactDirectory ??
308
+ REHEARSAL_DEFAULTS.baseline.artifactDirectory,
309
+ "config.baseline.artifactDirectory",
310
+ ),
311
+ sanitizationPolicy: assertRelativePath(
312
+ baseline.sanitizationPolicy,
313
+ "config.baseline.sanitizationPolicy",
314
+ ),
315
+ }),
316
+ application: Object.freeze({
317
+ startCommand: assertCommand(
318
+ application.startCommand,
319
+ "config.application.startCommand",
320
+ ),
321
+ proofCommand: assertCommand(
322
+ application.proofCommand,
323
+ "config.application.proofCommand",
324
+ ),
325
+ environmentFile: assertRelativePath(
326
+ application.environmentFile ?? ".rehearsal/runtime.env",
327
+ "config.application.environmentFile",
328
+ ),
329
+ runtimeAdapter:
330
+ application.runtimeAdapter === undefined
331
+ ? null
332
+ : assertRelativePath(
333
+ application.runtimeAdapter,
334
+ "config.application.runtimeAdapter",
335
+ ),
336
+ }),
337
+ runtime: Object.freeze({
338
+ applicationUrl: assertLoopbackUrl(
339
+ runtime.applicationUrl ?? REHEARSAL_DEFAULTS.runtime.applicationUrl,
340
+ "config.runtime.applicationUrl",
341
+ allowedHosts,
342
+ ),
343
+ projectId: assertNonEmptyString(
344
+ runtime.projectId ?? REHEARSAL_DEFAULTS.runtime.projectId,
345
+ "config.runtime.projectId",
346
+ ),
347
+ ports: Object.freeze(ports),
348
+ }),
349
+ safety: Object.freeze({
350
+ allowedHosts: Object.freeze(allowedHosts),
351
+ blockedEnvironmentVariables: Object.freeze(
352
+ assertStringArray(
353
+ safety.blockedEnvironmentVariables ??
354
+ REHEARSAL_DEFAULTS.safety.blockedEnvironmentVariables,
355
+ "config.safety.blockedEnvironmentVariables",
356
+ ),
357
+ ),
358
+ authenticationProviders: Object.freeze(
359
+ safety.authenticationProviders
360
+ ? assertStringArray(
361
+ safety.authenticationProviders,
362
+ "config.safety.authenticationProviders",
363
+ )
364
+ : [],
365
+ ),
366
+ hostedAccess: "disabled",
367
+ outboundNetwork: "deny",
368
+ }),
369
+ verification: Object.freeze({
370
+ commands: Object.freeze(
371
+ commands.map((command, index) =>
372
+ assertCommand(command, `config.verification.commands[${index}]`),
373
+ ),
374
+ ),
375
+ }),
376
+ });
377
+ };
378
+
379
+ export const defineRehearsalConfig = (config) => config;
380
+
381
+ export const findRehearsalConfigPath = async ({ projectRoot, configPath }) => {
382
+ const root = resolve(projectRoot);
383
+ if (configPath) {
384
+ const explicit = resolve(root, configPath);
385
+ if (relative(root, explicit).startsWith("..")) {
386
+ throw new Error(
387
+ "The Rehearsal configuration must stay inside the project root.",
388
+ );
389
+ }
390
+ await access(explicit);
391
+ return explicit;
392
+ }
393
+ for (const filename of CONFIG_FILENAMES) {
394
+ const candidate = join(root, filename);
395
+ try {
396
+ await access(candidate);
397
+ return candidate;
398
+ } catch (error) {
399
+ if (error?.code !== "ENOENT") throw error;
400
+ }
401
+ }
402
+ throw new Error(
403
+ `No Rehearsal configuration was found. Checked: ${CONFIG_FILENAMES.join(", ")}.`,
404
+ );
405
+ };
406
+
407
+ export const loadRehearsalConfig = async ({
408
+ projectRoot = process.cwd(),
409
+ configPath,
410
+ } = {}) => {
411
+ const root = resolve(projectRoot);
412
+ const path = await findRehearsalConfigPath({ projectRoot: root, configPath });
413
+ const module = await import(
414
+ `${pathToFileURL(path).href}?loaded=${Date.now()}`
415
+ );
416
+ const config = normalizeConfig(module.default);
417
+ const resolveOwnedPath = (value) => {
418
+ const resolved = resolve(root, value);
419
+ if (relative(root, resolved).startsWith("..")) {
420
+ throw new Error(`A Rehearsal path escaped the project root: ${value}.`);
421
+ }
422
+ return resolved;
423
+ };
424
+ const paths = Object.freeze({
425
+ artifactDirectory: resolveOwnedPath(config.baseline.artifactDirectory),
426
+ applicationEnvironment: resolveOwnedPath(
427
+ config.application.environmentFile,
428
+ ),
429
+ migrationDirectory: resolveOwnedPath(config.supabase.migrationDirectory),
430
+ rehearsalConfig: resolveOwnedPath(config.supabase.rehearsalConfig),
431
+ runtimeWorkdir: resolveOwnedPath(config.supabase.runtimeWorkdir),
432
+ serviceEnvironment: config.supabase.serviceEnvironmentFile
433
+ ? resolveOwnedPath(config.supabase.serviceEnvironmentFile)
434
+ : null,
435
+ sanitizationPolicy: resolveOwnedPath(config.baseline.sanitizationPolicy),
436
+ supabaseWorkdir: resolveOwnedPath(config.supabase.workdir),
437
+ runtimeAdapter: config.application.runtimeAdapter
438
+ ? resolveOwnedPath(config.application.runtimeAdapter)
439
+ : null,
440
+ });
441
+ if (basename(paths.artifactDirectory) !== ".rehearsal") {
442
+ throw new Error(
443
+ 'config.baseline.artifactDirectory must resolve to a directory named ".rehearsal" so destructive reset and cleanup operations have an explicit safety boundary.',
444
+ );
445
+ }
446
+ const requiredRuntimeWorkdir = join(paths.artifactDirectory, "runtime");
447
+ if (paths.runtimeWorkdir !== requiredRuntimeWorkdir) {
448
+ throw new Error(
449
+ "config.supabase.runtimeWorkdir must resolve to the runtime directory inside config.baseline.artifactDirectory.",
450
+ );
451
+ }
452
+ const environmentRelativePath = relative(
453
+ paths.artifactDirectory,
454
+ paths.applicationEnvironment,
455
+ );
456
+ if (
457
+ !environmentRelativePath ||
458
+ environmentRelativePath.startsWith("..") ||
459
+ isAbsolute(environmentRelativePath)
460
+ ) {
461
+ throw new Error(
462
+ "config.application.environmentFile must resolve to a file inside config.baseline.artifactDirectory.",
463
+ );
464
+ }
465
+ if (!IDENTIFIER_PATTERN.test(config.runtime.projectId)) {
466
+ throw new Error(
467
+ "config.runtime.projectId must contain only lowercase letters, digits, and hyphens.",
468
+ );
469
+ }
470
+ return Object.freeze({
471
+ config,
472
+ configPath: path,
473
+ projectRoot: root,
474
+ paths,
475
+ });
476
+ };
477
+
478
+ export const inspectDetectedProject = async ({
479
+ projectRoot = process.cwd(),
480
+ } = {}) => {
481
+ const root = resolve(projectRoot);
482
+ const packageJsonPath = join(root, "package.json");
483
+ const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8"));
484
+ const hasPath = async (path) =>
485
+ stat(join(root, path))
486
+ .then(() => true)
487
+ .catch((error) => {
488
+ if (error?.code === "ENOENT") return false;
489
+ throw error;
490
+ });
491
+ const packageManager = (await hasPath("pnpm-lock.yaml"))
492
+ ? "pnpm"
493
+ : (await hasPath("yarn.lock"))
494
+ ? "yarn"
495
+ : "npm";
496
+ const scripts = packageJson.scripts ?? {};
497
+ return {
498
+ projectName:
499
+ String(packageJson.name ?? basename(root))
500
+ .toLowerCase()
501
+ .replace(/[^a-z0-9-]+/gu, "-")
502
+ .replace(/^-|-$/gu, "") || "rehearsal-project",
503
+ packageManager,
504
+ hasSupabaseConfig: await hasPath("supabase/config.toml"),
505
+ hasMigrations: await hasPath("supabase/migrations"),
506
+ applicationCommand:
507
+ ["dev:rehearsal", "dev", "start"]
508
+ .find((name) => typeof scripts[name] === "string")
509
+ ?.replace(/^/u, `${packageManager} run `) ??
510
+ `${packageManager} run dev`,
511
+ verificationCommand:
512
+ ["verify:feature", "test", "check"]
513
+ .find((name) => typeof scripts[name] === "string")
514
+ ?.replace(/^/u, `${packageManager} run `) ?? `${packageManager} test`,
515
+ };
516
+ };
517
+
518
+ export const renderDetectedConfig = (
519
+ detected,
520
+ ) => `import { defineRehearsalConfig } from "@rehearsal-db/core";
521
+
522
+ export default defineRehearsalConfig({
523
+ schemaVersion: 1,
524
+ project: { name: ${JSON.stringify(detected.projectName)} },
525
+ supabase: {
526
+ workdir: ".",
527
+ migrationDirectory: "supabase/migrations",
528
+ rehearsalConfig: "infrastructure/rehearsal/supabase/config.toml",
529
+ runtimeWorkdir: ".rehearsal/runtime",
530
+ },
531
+ baseline: {
532
+ artifactDirectory: ".rehearsal",
533
+ sanitizationPolicy: "infrastructure/rehearsal/sanitization-policy.json",
534
+ },
535
+ application: {
536
+ startCommand: ${JSON.stringify(detected.applicationCommand)},
537
+ proofCommand: ${JSON.stringify(detected.verificationCommand)},
538
+ environmentFile: ".rehearsal/runtime.env",
539
+ },
540
+ runtime: {
541
+ applicationUrl: "http://localhost:5175",
542
+ projectId: "${detected.projectName}-rehearsal",
543
+ apiPort: 58321,
544
+ databasePort: 58322,
545
+ studioPort: 58323,
546
+ },
547
+ safety: {
548
+ allowedHosts: ["127.0.0.1", "::1", "localhost"],
549
+ blockedEnvironmentVariables: [
550
+ "SUPABASE_ACCESS_TOKEN",
551
+ "SUPABASE_DB_PASSWORD",
552
+ "SUPABASE_PROJECT_ID",
553
+ ],
554
+ hostedAccess: "disabled",
555
+ outboundNetwork: "deny",
556
+ },
557
+ verification: { commands: [${JSON.stringify(detected.verificationCommand)}] },
558
+ });
559
+ `;