@rehearsal-db/core 0.1.0-beta.10 → 0.1.0-beta.12

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.
@@ -3,11 +3,22 @@
3
3
  * contacting hosted services or overwriting project-owned files.
4
4
  */
5
5
 
6
+ import { randomUUID } from "node:crypto";
6
7
  import { createConnection, createServer } from "node:net";
7
- import { access, mkdir, readFile, writeFile } from "node:fs/promises";
8
- import { dirname, join, relative, resolve } from "node:path";
9
8
  import {
9
+ access,
10
+ mkdir,
11
+ open,
12
+ readFile,
13
+ rename,
14
+ rm,
15
+ writeFile,
16
+ } from "node:fs/promises";
17
+ import { basename, dirname, join, relative, resolve } from "node:path";
18
+ import {
19
+ findRehearsalConfigPath,
10
20
  inspectDetectedProject,
21
+ loadRehearsalConfig,
11
22
  renderDetectedConfig,
12
23
  renderDetectedPostgresqlConfig,
13
24
  } from "./configuration.mjs";
@@ -178,7 +189,7 @@ enabled = false
178
189
  enabled = false
179
190
  `;
180
191
 
181
- const appendGitignoreEntries = (source) => {
192
+ export const appendRehearsalGitignoreEntries = (source) => {
182
193
  const original = source ?? "";
183
194
  const lines = new Set(
184
195
  original
@@ -200,6 +211,75 @@ const appendGitignoreEntries = (source) => {
200
211
  return `${prefix}${separator}# Rehearsal local artifacts\n${missing.join("\n")}\n`;
201
212
  };
202
213
 
214
+ const findExistingConfigPath = async ({ projectRoot, configPath }) => {
215
+ try {
216
+ return await findRehearsalConfigPath({ projectRoot, configPath });
217
+ } catch (error) {
218
+ if (
219
+ String(error?.message ?? error).startsWith("No Rehearsal configuration")
220
+ ) {
221
+ return null;
222
+ }
223
+ throw error;
224
+ }
225
+ };
226
+
227
+ const deriveSupabasePorts = (runtimePorts) => ({
228
+ shadow: runtimePorts.database - 2,
229
+ api: runtimePorts.api,
230
+ database: runtimePorts.database,
231
+ studio: runtimePorts.studio,
232
+ smtp: runtimePorts.database + 2,
233
+ pooler: runtimePorts.database + 7,
234
+ });
235
+
236
+ const applyGitignoreChange = async (file) => {
237
+ if (file.action === "unchanged") return;
238
+ if (file.action === "create") {
239
+ await writeFile(file.absolutePath, file.content, {
240
+ flag: "wx",
241
+ mode: file.mode,
242
+ });
243
+ return;
244
+ }
245
+
246
+ const lockPath = `${file.absolutePath}.rehearsal.lock`;
247
+ let lock;
248
+ let temporaryPath;
249
+ try {
250
+ lock = await open(lockPath, "wx", 0o600);
251
+ const current = await readOptionalFile(file.absolutePath);
252
+ if (current !== file.originalContent) {
253
+ throw new Error(
254
+ ".gitignore changed after the setup preview; rerun rehearsal setup before writing.",
255
+ );
256
+ }
257
+ temporaryPath = join(
258
+ dirname(file.absolutePath),
259
+ `.${basename(file.absolutePath)}.rehearsal-${process.pid}-${randomUUID()}.tmp`,
260
+ );
261
+ await writeFile(temporaryPath, file.content, {
262
+ flag: "wx",
263
+ mode: file.mode,
264
+ });
265
+ await rename(temporaryPath, file.absolutePath);
266
+ temporaryPath = null;
267
+ } catch (error) {
268
+ if (error?.code === "EEXIST" && !lock) {
269
+ throw new Error(
270
+ "Another Rehearsal process is updating .gitignore; rerun setup after it finishes.",
271
+ );
272
+ }
273
+ throw error;
274
+ } finally {
275
+ if (temporaryPath) await rm(temporaryPath, { force: true });
276
+ if (lock) {
277
+ await lock.close();
278
+ await rm(lockPath, { force: true });
279
+ }
280
+ }
281
+ };
282
+
203
283
  const assertCreateTargetAvailable = async (file) => {
204
284
  if (file.action === "create" && (await pathExists(file.absolutePath))) {
205
285
  throw new Error(
@@ -210,41 +290,63 @@ const assertCreateTargetAvailable = async (file) => {
210
290
 
211
291
  export const planRehearsalSetup = async ({
212
292
  projectRoot = process.cwd(),
293
+ configPath,
213
294
  isPortAvailable,
214
- target = "supabase",
295
+ target,
215
296
  } = {}) => {
216
297
  const root = resolve(projectRoot);
217
- const runtimeTarget = resolveRuntimeTarget(target).id;
218
298
  const detected = await inspectDetectedProject({ projectRoot: root });
219
- const ports = await findAvailableRehearsalPorts({
220
- ...(isPortAvailable ? { isAvailable: isPortAvailable } : {}),
299
+ const existingConfigPath = await findExistingConfigPath({
300
+ projectRoot: root,
301
+ configPath,
221
302
  });
222
- const applicationUrl = "http://localhost:5175";
223
- const projectId = `${detected.projectName}-rehearsal`;
303
+ const existing = existingConfigPath
304
+ ? await loadRehearsalConfig({ projectRoot: root, configPath })
305
+ : null;
306
+ const requestedTarget = resolveRuntimeTarget(
307
+ target ?? existing?.config.runtime.target ?? "supabase",
308
+ ).id;
309
+ const runtimeTarget = existing?.config.runtime.target ?? requestedTarget;
310
+ if (existing && target && runtimeTarget !== requestedTarget) {
311
+ throw new Error(
312
+ `The existing Rehearsal configuration targets ${runtimeTarget}; setup will not replace it with ${requestedTarget}.`,
313
+ );
314
+ }
315
+ const ports = existing
316
+ ? runtimeTarget === "supabase"
317
+ ? deriveSupabasePorts(existing.config.runtime.ports)
318
+ : { database: existing.config.runtime.ports.database }
319
+ : await findAvailableRehearsalPorts({
320
+ ...(isPortAvailable ? { isAvailable: isPortAvailable } : {}),
321
+ });
322
+ const applicationUrl =
323
+ existing?.config.runtime.applicationUrl ?? "http://localhost:5175";
324
+ const projectId =
325
+ existing?.config.runtime.projectId ?? `${detected.projectName}-rehearsal`;
224
326
  const sourceSupabaseConfig =
225
327
  runtimeTarget === "supabase"
226
328
  ? await readOptionalFile(join(root, "supabase/config.toml"))
227
329
  : null;
228
330
  const originalGitignore = await readOptionalFile(join(root, GITIGNORE_PATH));
229
- const nextGitignore = appendGitignoreEntries(originalGitignore);
230
- for (const path of [
231
- CONFIG_PATH,
232
- ...(runtimeTarget === "supabase" ? [LOCAL_SUPABASE_CONFIG_PATH] : []),
233
- ]) {
234
- if (await pathExists(join(root, path))) {
235
- throw new Error(
236
- `Rehearsal setup will not overwrite the existing file at ${path}.`,
237
- );
238
- }
239
- }
331
+ const nextGitignore = appendRehearsalGitignoreEntries(originalGitignore);
332
+ const rehearsalConfigPath = existing?.paths.rehearsalConfig;
333
+ const localSupabasePath = rehearsalConfigPath
334
+ ? relative(root, rehearsalConfigPath)
335
+ : LOCAL_SUPABASE_CONFIG_PATH;
336
+ const localSupabaseExists = rehearsalConfigPath
337
+ ? await pathExists(rehearsalConfigPath)
338
+ : false;
240
339
  const files = [
241
340
  {
242
- path: CONFIG_PATH,
243
- absolutePath: join(root, CONFIG_PATH),
244
- action: "create",
341
+ path: existingConfigPath
342
+ ? relative(root, existingConfigPath)
343
+ : CONFIG_PATH,
344
+ absolutePath: existingConfigPath ?? join(root, CONFIG_PATH),
345
+ action: existingConfigPath ? "unchanged" : "create",
245
346
  mode: 0o600,
246
- content:
247
- runtimeTarget === "supabase"
347
+ content: existingConfigPath
348
+ ? null
349
+ : runtimeTarget === "supabase"
248
350
  ? renderDetectedConfig(detected, {
249
351
  applicationUrl,
250
352
  ports,
@@ -257,17 +359,20 @@ export const planRehearsalSetup = async ({
257
359
  ...(runtimeTarget === "supabase"
258
360
  ? [
259
361
  {
260
- path: LOCAL_SUPABASE_CONFIG_PATH,
261
- absolutePath: join(root, LOCAL_SUPABASE_CONFIG_PATH),
262
- action: "create",
362
+ path: localSupabasePath,
363
+ absolutePath:
364
+ rehearsalConfigPath ?? join(root, LOCAL_SUPABASE_CONFIG_PATH),
365
+ action: localSupabaseExists ? "unchanged" : "create",
263
366
  mode: 0o600,
264
- content: renderSafeLocalSupabaseConfig({
265
- projectId,
266
- applicationUrl,
267
- ports,
268
- databaseMajorVersion:
269
- databaseMajorVersionFrom(sourceSupabaseConfig),
270
- }),
367
+ content: localSupabaseExists
368
+ ? null
369
+ : renderSafeLocalSupabaseConfig({
370
+ projectId,
371
+ applicationUrl,
372
+ ports,
373
+ databaseMajorVersion:
374
+ databaseMajorVersionFrom(sourceSupabaseConfig),
375
+ }),
271
376
  },
272
377
  ]
273
378
  : []),
@@ -293,6 +398,11 @@ export const planRehearsalSetup = async ({
293
398
  applicationUrl,
294
399
  detected,
295
400
  ports: runtimeTarget === "supabase" ? ports : { database: ports.database },
401
+ requiresAvailablePorts: files.some(
402
+ (file) =>
403
+ file.action === "create" &&
404
+ [CONFIG_PATH, localSupabasePath].includes(file.path),
405
+ ),
296
406
  isPortAvailable: isPortAvailable ?? isRehearsalPortAvailable,
297
407
  files,
298
408
  safety: [
@@ -314,14 +424,16 @@ export const planRehearsalSetup = async ({
314
424
 
315
425
  export const applyRehearsalSetup = async (plan) => {
316
426
  assertSupportedRehearsalNodeRuntime();
317
- const unavailablePorts = (
318
- await Promise.all(
319
- Object.values(plan.ports).map(async (port) => ({
320
- port,
321
- available: await plan.isPortAvailable(port),
322
- })),
323
- )
324
- ).filter(({ available }) => !available);
427
+ const unavailablePorts = plan.requiresAvailablePorts
428
+ ? (
429
+ await Promise.all(
430
+ Object.values(plan.ports).map(async (port) => ({
431
+ port,
432
+ available: await plan.isPortAvailable(port),
433
+ })),
434
+ )
435
+ ).filter(({ available }) => !available)
436
+ : [];
325
437
  if (unavailablePorts.length) {
326
438
  throw new Error(
327
439
  `Rehearsal setup ports became occupied after the preview (${unavailablePorts.map(({ port }) => port).join(", ")}); rerun setup to choose another local port block.`,
@@ -329,16 +441,9 @@ export const applyRehearsalSetup = async (plan) => {
329
441
  }
330
442
  for (const file of plan.files) await assertCreateTargetAvailable(file);
331
443
  const gitignore = plan.files.find((file) => file.path === GITIGNORE_PATH);
332
- if (gitignore?.action === "update") {
333
- const current = await readOptionalFile(gitignore.absolutePath);
334
- if (current !== gitignore.originalContent) {
335
- throw new Error(
336
- ".gitignore changed after the setup preview; rerun rehearsal setup before writing.",
337
- );
338
- }
339
- }
444
+ if (gitignore) await applyGitignoreChange(gitignore);
340
445
  for (const file of plan.files.filter(
341
- (entry) => entry.action !== "unchanged",
446
+ (entry) => entry.action !== "unchanged" && entry.path !== GITIGNORE_PATH,
342
447
  )) {
343
448
  await mkdir(dirname(file.absolutePath), { recursive: true, mode: 0o700 });
344
449
  await writeFile(file.absolutePath, file.content, {
@@ -11,6 +11,10 @@ import { join } from "node:path";
11
11
  import { pathToFileURL } from "node:url";
12
12
  import { performance } from "node:perf_hooks";
13
13
  import { verifyActiveBaseline } from "../baseline/artifact.mjs";
14
+ import {
15
+ applySupabaseAuthenticationProviders,
16
+ inspectSupabaseAuthenticationProviderTransition,
17
+ } from "../identity/provider_configuration.mjs";
14
18
  import {
15
19
  inspectDetectedProject,
16
20
  loadRehearsalConfig,
@@ -233,6 +237,9 @@ export const buildRehearsalPlan = async (options = {}) => {
233
237
  hostedAccess: config.safety.hostedAccess,
234
238
  outboundNetwork: config.safety.outboundNetwork,
235
239
  authenticationProviders: config.safety.authenticationProviders,
240
+ authenticationCallback: config.supabase?.authentication
241
+ ? `http://127.0.0.1:${config.runtime.ports.api}/auth/v1/callback`
242
+ : null,
236
243
  barriers: [
237
244
  "versioned configuration accepts loopback hosts only",
238
245
  `runtime uses a dedicated local ${config.runtime.target === "supabase" ? "Supabase workdir" : "PostgreSQL container and volume"} and project id`,
@@ -459,6 +466,32 @@ export const runRehearsalDoctor = async (options = {}) => {
459
466
  remediation:
460
467
  "Correct the missing project path in the Rehearsal configuration.",
461
468
  },
469
+ ...(config.supabase?.authentication
470
+ ? [
471
+ {
472
+ id: "authentication-runtime-config",
473
+ label: "Local authentication configuration",
474
+ run: async () => {
475
+ const source = await readFile(paths.rehearsalConfig, "utf8");
476
+ const transition =
477
+ inspectSupabaseAuthenticationProviderTransition({
478
+ source,
479
+ authentication: config.supabase.authentication,
480
+ });
481
+ applySupabaseAuthenticationProviders({
482
+ source,
483
+ authentication: config.supabase.authentication,
484
+ apiPort: config.runtime.ports.api,
485
+ });
486
+ return transition.replacedProviders.length
487
+ ? `compatible legacy ${transition.replacedProviders.join(", ")} provider configuration will be replaced only in the disposable runtime copy`
488
+ : "declarative provider configuration can be generated without changing the tracked template";
489
+ },
490
+ remediation:
491
+ "Review the named provider section and make skipNonceCheck or emailOptional match explicitly in config.supabase.authentication; otherwise keep the documented manual provider configuration.",
492
+ },
493
+ ]
494
+ : []),
462
495
  {
463
496
  id: "sanitization-policy",
464
497
  label: "Reviewed sanitization policy",
@@ -44,6 +44,7 @@ import {
44
44
  } from "../runtime/migration_history.mjs";
45
45
  import { loadRehearsalConfig } from "../project/configuration.mjs";
46
46
  import { readRehearsalServiceEnvironment } from "../runtime/service_environment.mjs";
47
+ import { applySupabaseAuthenticationProviders } from "../identity/provider_configuration.mjs";
47
48
  import { readBoundRuntimeSanitizationPolicy } from "../baseline/sanitization_policy.mjs";
48
49
  import {
49
50
  captureLocalPublicSchema,
@@ -315,7 +316,16 @@ const prepareRuntimeWorkdir = async ({ baseline, paths }) => {
315
316
  recursive: true,
316
317
  mode: 0o700,
317
318
  });
318
- await cp(configTemplatePath, join(runtimeSupabaseDirectory, "config.toml"));
319
+ const configTemplate = await readFile(configTemplatePath, "utf8");
320
+ await writeFile(
321
+ join(runtimeSupabaseDirectory, "config.toml"),
322
+ applySupabaseAuthenticationProviders({
323
+ source: configTemplate,
324
+ authentication: config.supabase.authentication,
325
+ apiPort: config.runtime.ports.api,
326
+ }),
327
+ { mode: 0o600 },
328
+ );
319
329
  for (const filename of Object.keys(baseline.migrations).sort()) {
320
330
  await cp(
321
331
  join(paths.migrationsDirectory, filename),