@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.
@@ -8,6 +8,17 @@ export interface RehearsalConfig {
8
8
  migrationDirectory: string;
9
9
  rehearsalConfig: string;
10
10
  runtimeWorkdir: string;
11
+ authentication?: {
12
+ enableLocalSignup: true;
13
+ environmentFile: string;
14
+ providers: Array<{
15
+ name: "google" | "github";
16
+ clientIdEnvironmentVariable: string;
17
+ clientSecretEnvironmentVariable: string;
18
+ skipNonceCheck?: boolean;
19
+ emailOptional?: boolean;
20
+ }>;
21
+ };
11
22
  serviceEnvironmentFile?: string;
12
23
  serviceEnvironmentVariables?: string[];
13
24
  };
@@ -61,6 +61,7 @@ const POSTGRES_IMAGE_PATTERN = /^postgres:\d+(?:\.\d+)?-alpine$/u;
61
61
  const SAFE_COMMAND_PATTERN = /^[^\n\r\0]+$/u;
62
62
  const ENVIRONMENT_KEY_PATTERN = /^[A-Z][A-Z0-9_]*$/u;
63
63
  const ENVIRONMENT_REFERENCE_PATTERN = /^[a-z0-9][a-z0-9-]*:[A-Z][A-Z0-9_]*$/u;
64
+ const SUPPORTED_SUPABASE_OAUTH_PROVIDERS = new Set(["github", "google"]);
64
65
 
65
66
  const isPlainObject = (value) =>
66
67
  value !== null &&
@@ -251,6 +252,7 @@ const normalizeConfig = (input) => {
251
252
  "migrationDirectory",
252
253
  "rehearsalConfig",
253
254
  "runtimeWorkdir",
255
+ "authentication",
254
256
  "serviceEnvironmentFile",
255
257
  "serviceEnvironmentVariables",
256
258
  ],
@@ -437,8 +439,123 @@ const normalizeConfig = (input) => {
437
439
  if (!Array.isArray(commands)) {
438
440
  throw new Error("config.verification.commands must be an array.");
439
441
  }
440
- const serviceEnvironmentVariables =
441
- supabase?.serviceEnvironmentVariables ?? [];
442
+ const authentication =
443
+ supabase?.authentication === undefined
444
+ ? null
445
+ : assertPlainObject(
446
+ supabase.authentication,
447
+ "config.supabase.authentication",
448
+ );
449
+ let normalizedAuthentication = null;
450
+ if (authentication) {
451
+ assertKnownKeys(
452
+ authentication,
453
+ ["enableLocalSignup", "environmentFile", "providers"],
454
+ "config.supabase.authentication",
455
+ );
456
+ if (authentication.enableLocalSignup !== true) {
457
+ throw new Error(
458
+ "config.supabase.authentication.enableLocalSignup must be true to create the disposable local identity used by an OAuth sign-in.",
459
+ );
460
+ }
461
+ if (
462
+ supabase.serviceEnvironmentFile !== undefined ||
463
+ supabase.serviceEnvironmentVariables !== undefined
464
+ ) {
465
+ throw new Error(
466
+ "config.supabase.authentication cannot be combined with the legacy serviceEnvironmentFile or serviceEnvironmentVariables fields.",
467
+ );
468
+ }
469
+ if (safety.authenticationProviders !== undefined) {
470
+ throw new Error(
471
+ "config.safety.authenticationProviders is derived from config.supabase.authentication and must be omitted.",
472
+ );
473
+ }
474
+ if (
475
+ !Array.isArray(authentication.providers) ||
476
+ authentication.providers.length === 0
477
+ ) {
478
+ throw new Error(
479
+ "config.supabase.authentication.providers must be a non-empty array.",
480
+ );
481
+ }
482
+ const providers = authentication.providers.map((entry, index) => {
483
+ const path = `config.supabase.authentication.providers[${index}]`;
484
+ const value = assertPlainObject(entry, path);
485
+ assertKnownKeys(
486
+ value,
487
+ [
488
+ "name",
489
+ "clientIdEnvironmentVariable",
490
+ "clientSecretEnvironmentVariable",
491
+ "skipNonceCheck",
492
+ "emailOptional",
493
+ ],
494
+ path,
495
+ );
496
+ const name = assertNonEmptyString(value.name, `${path}.name`);
497
+ if (!SUPPORTED_SUPABASE_OAUTH_PROVIDERS.has(name)) {
498
+ throw new Error(
499
+ `${path}.name must be one of: ${[...SUPPORTED_SUPABASE_OAUTH_PROVIDERS].join(", ")}.`,
500
+ );
501
+ }
502
+ const environmentVariable = (key) => {
503
+ const variable = assertNonEmptyString(value[key], `${path}.${key}`);
504
+ if (!ENVIRONMENT_KEY_PATTERN.test(variable)) {
505
+ throw new Error(
506
+ `${path}.${key} must be a safe environment variable name.`,
507
+ );
508
+ }
509
+ return variable;
510
+ };
511
+ const optionalBoolean = (key) => {
512
+ if (value[key] === undefined) return false;
513
+ if (typeof value[key] !== "boolean") {
514
+ throw new Error(`${path}.${key} must be a boolean.`);
515
+ }
516
+ return value[key];
517
+ };
518
+ return Object.freeze({
519
+ name,
520
+ clientIdEnvironmentVariable: environmentVariable(
521
+ "clientIdEnvironmentVariable",
522
+ ),
523
+ clientSecretEnvironmentVariable: environmentVariable(
524
+ "clientSecretEnvironmentVariable",
525
+ ),
526
+ skipNonceCheck: optionalBoolean("skipNonceCheck"),
527
+ emailOptional: optionalBoolean("emailOptional"),
528
+ });
529
+ });
530
+ if (new Set(providers.map(({ name }) => name)).size !== providers.length) {
531
+ throw new Error(
532
+ "config.supabase.authentication.providers must not contain duplicate providers.",
533
+ );
534
+ }
535
+ const credentialVariables = providers.flatMap((provider) => [
536
+ provider.clientIdEnvironmentVariable,
537
+ provider.clientSecretEnvironmentVariable,
538
+ ]);
539
+ if (new Set(credentialVariables).size !== credentialVariables.length) {
540
+ throw new Error(
541
+ "config.supabase.authentication provider environment variables must be unique.",
542
+ );
543
+ }
544
+ normalizedAuthentication = Object.freeze({
545
+ enableLocalSignup: true,
546
+ environmentFile: assertRelativePath(
547
+ authentication.environmentFile,
548
+ "config.supabase.authentication.environmentFile",
549
+ ),
550
+ providers: Object.freeze(providers),
551
+ });
552
+ }
553
+ const serviceEnvironmentVariables = normalizedAuthentication
554
+ ? normalizedAuthentication.providers.flatMap((provider) => [
555
+ provider.clientIdEnvironmentVariable,
556
+ provider.clientSecretEnvironmentVariable,
557
+ ])
558
+ : (supabase?.serviceEnvironmentVariables ?? []);
442
559
  if (!Array.isArray(serviceEnvironmentVariables)) {
443
560
  throw new Error(
444
561
  "config.supabase.serviceEnvironmentVariables must be an array.",
@@ -460,19 +577,23 @@ const normalizeConfig = (input) => {
460
577
  );
461
578
  }
462
579
  if (
463
- Boolean(supabase?.serviceEnvironmentFile) !==
464
- Boolean(normalizedServiceEnvironmentVariables.length)
580
+ Boolean(
581
+ normalizedAuthentication?.environmentFile ??
582
+ supabase?.serviceEnvironmentFile,
583
+ ) !== Boolean(normalizedServiceEnvironmentVariables.length)
465
584
  ) {
466
585
  throw new Error(
467
586
  "config.supabase.serviceEnvironmentFile and serviceEnvironmentVariables must be configured together.",
468
587
  );
469
588
  }
470
- const authenticationProviders = safety.authenticationProviders
471
- ? assertStringArray(
472
- safety.authenticationProviders,
473
- "config.safety.authenticationProviders",
474
- )
475
- : [];
589
+ const authenticationProviders = normalizedAuthentication
590
+ ? normalizedAuthentication.providers.map(({ name }) => name)
591
+ : safety.authenticationProviders
592
+ ? assertStringArray(
593
+ safety.authenticationProviders,
594
+ "config.safety.authenticationProviders",
595
+ )
596
+ : [];
476
597
  if (runtimeTarget === "postgresql" && authenticationProviders.length > 0) {
477
598
  throw new Error(
478
599
  "config.safety.authenticationProviders is available only for the Supabase target.",
@@ -500,12 +621,15 @@ const normalizeConfig = (input) => {
500
621
  supabase.runtimeWorkdir,
501
622
  "config.supabase.runtimeWorkdir",
502
623
  ),
503
- serviceEnvironmentFile: supabase.serviceEnvironmentFile
504
- ? assertRelativePath(
505
- supabase.serviceEnvironmentFile,
506
- "config.supabase.serviceEnvironmentFile",
507
- )
508
- : null,
624
+ authentication: normalizedAuthentication,
625
+ serviceEnvironmentFile: normalizedAuthentication
626
+ ? normalizedAuthentication.environmentFile
627
+ : supabase.serviceEnvironmentFile
628
+ ? assertRelativePath(
629
+ supabase.serviceEnvironmentFile,
630
+ "config.supabase.serviceEnvironmentFile",
631
+ )
632
+ : null,
509
633
  serviceEnvironmentVariables: Object.freeze(
510
634
  normalizedServiceEnvironmentVariables,
511
635
  ),
@@ -1005,9 +1129,21 @@ export default defineRehearsalConfig({
1005
1129
  rehearsalConfig: "infrastructure/rehearsal/supabase/config.toml",
1006
1130
  runtimeWorkdir: ".rehearsal/runtime",
1007
1131
 
1008
- // Optional local-only identity-provider credentials. Enable both keys together.
1009
- // serviceEnvironmentFile: ".env.rehearsal-service.local",
1010
- // serviceEnvironmentVariables: ["LOCAL_IDP_CLIENT_ID", "LOCAL_IDP_SECRET"],
1132
+ // Optional local OAuth connection. Secrets stay in the ignored owner-only file.
1133
+ // Register http://127.0.0.1:${ports.api}/auth/v1/callback with the provider.
1134
+ // authentication: {
1135
+ // enableLocalSignup: true,
1136
+ // environmentFile: ".env.rehearsal-service.local",
1137
+ // providers: [
1138
+ // {
1139
+ // name: "google",
1140
+ // clientIdEnvironmentVariable: "REHEARSAL_GOOGLE_CLIENT_ID",
1141
+ // clientSecretEnvironmentVariable: "REHEARSAL_GOOGLE_CLIENT_SECRET",
1142
+ // skipNonceCheck: false,
1143
+ // emailOptional: false,
1144
+ // },
1145
+ // ],
1146
+ // },
1011
1147
  },
1012
1148
 
1013
1149
  // Immutable local baseline files. Rehearsal never reads production on your behalf.
@@ -1028,7 +1164,8 @@ export default defineRehearsalConfig({
1028
1164
  // },
1029
1165
  // Optional reviewed schemas/extensions/triggers/local-only rows.
1030
1166
  // runtimePolicy: "infrastructure/rehearsal/runtime-policy.json",
1031
- // Optional signup defaults, role/reference transfer, Storage paths, and token checks.
1167
+ // Optional copied-account association. The JSON stores only reviewed matcher hashes;
1168
+ // raw emails/subjects and provider credentials stay in ignored environment files.
1032
1169
  // identityPolicy: "infrastructure/rehearsal/identity-policy.json",
1033
1170
 
1034
1171
  // Reuse any running Docker-compatible engine; start Colima only when needed.
@@ -1136,7 +1273,8 @@ export default defineRehearsalConfig({
1136
1273
  // },
1137
1274
  // Optional reviewed schemas/extensions/triggers/local-only rows.
1138
1275
  // runtimePolicy: "infrastructure/rehearsal/runtime-policy.json",
1139
- // Optional signup defaults, role/reference transfer, Storage paths, and token checks.
1276
+ // Optional copied-account association. The JSON stores only reviewed matcher hashes;
1277
+ // raw emails/subjects and provider credentials stay in ignored environment files.
1140
1278
  // identityPolicy: "infrastructure/rehearsal/identity-policy.json",
1141
1279
 
1142
1280
  // Reuse any running Docker-compatible engine; start Colima only when needed.
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Purpose: Resolve an npm consumer root conservatively and create the same
3
+ * project-owned scaffolding used by the guided setup command.
4
+ */
5
+
6
+ import { access, readFile, readdir, stat } from "node:fs/promises";
7
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
8
+ import { applyRehearsalSetup, planRehearsalSetup } from "./setup.mjs";
9
+ import {
10
+ findRehearsalConfigPath,
11
+ inspectDetectedProject,
12
+ loadRehearsalConfig,
13
+ } from "./configuration.mjs";
14
+
15
+ const PACKAGE_NAME = "@rehearsal-db/core";
16
+ const DEPENDENCY_GROUPS = Object.freeze([
17
+ "dependencies",
18
+ "devDependencies",
19
+ "optionalDependencies",
20
+ "peerDependencies",
21
+ ]);
22
+ const CACHE_SEGMENTS = new Set(["_cacache", "_npx", ".cache"]);
23
+
24
+ const pathExists = (path) =>
25
+ access(path)
26
+ .then(() => true)
27
+ .catch((error) => {
28
+ if (error?.code === "ENOENT") return false;
29
+ throw error;
30
+ });
31
+
32
+ const readJson = async (path) => JSON.parse(await readFile(path, "utf8"));
33
+
34
+ const isInside = (parent, child) => {
35
+ const path = relative(resolve(parent), resolve(child));
36
+ return path === "" || (!path.startsWith(`..${sep}`) && path !== "..");
37
+ };
38
+
39
+ const pathSegments = (path) => resolve(path).split(sep).filter(Boolean);
40
+
41
+ const isCachePath = (path) =>
42
+ pathSegments(path).some((segment) => CACHE_SEGMENTS.has(segment));
43
+
44
+ const isInstalledPackagePath = (packageRoot) => {
45
+ const segments = pathSegments(packageRoot);
46
+ return (
47
+ segments.at(-3) === "node_modules" &&
48
+ segments.at(-2) === "@rehearsal-db" &&
49
+ segments.at(-1) === "core"
50
+ );
51
+ };
52
+
53
+ const declaresRehearsal = (manifest) =>
54
+ DEPENDENCY_GROUPS.some((group) =>
55
+ Object.hasOwn(manifest[group] ?? {}, PACKAGE_NAME),
56
+ );
57
+
58
+ const readProject = async (root) => {
59
+ const path = join(root, "package.json");
60
+ if (!(await pathExists(path))) return null;
61
+ const metadata = await stat(path);
62
+ if (!metadata.isFile()) return null;
63
+ return { root: resolve(root), manifest: await readJson(path) };
64
+ };
65
+
66
+ const workspacePatterns = (manifest) => {
67
+ const value = manifest.workspaces;
68
+ if (Array.isArray(value)) return value;
69
+ if (value && Array.isArray(value.packages)) return value.packages;
70
+ return [];
71
+ };
72
+
73
+ const expandWorkspacePattern = async (root, pattern) => {
74
+ if (
75
+ typeof pattern !== "string" ||
76
+ !pattern ||
77
+ isAbsolute(pattern) ||
78
+ pattern.split(/[\\/]/u).includes("..")
79
+ ) {
80
+ return [];
81
+ }
82
+ if (!pattern.includes("*")) return [resolve(root, pattern)];
83
+ if (!pattern.endsWith("/*") || pattern.slice(0, -2).includes("*")) {
84
+ return [];
85
+ }
86
+ const parent = resolve(root, pattern.slice(0, -2));
87
+ const entries = await readdir(parent, { withFileTypes: true }).catch(
88
+ (error) => {
89
+ if (error?.code === "ENOENT") return [];
90
+ throw error;
91
+ },
92
+ );
93
+ return entries
94
+ .filter((entry) => entry.isDirectory() && entry.name !== "node_modules")
95
+ .map((entry) => join(parent, entry.name));
96
+ };
97
+
98
+ const workspaceProjectsFromManifest = async (project) => {
99
+ const roots = (
100
+ await Promise.all(
101
+ workspacePatterns(project.manifest).map((pattern) =>
102
+ expandWorkspacePattern(project.root, pattern),
103
+ ),
104
+ )
105
+ ).flat();
106
+ return (
107
+ await Promise.all(
108
+ roots.map((root) =>
109
+ isInside(project.root, root) ? readProject(root) : null,
110
+ ),
111
+ )
112
+ ).filter(Boolean);
113
+ };
114
+
115
+ const workspaceProjectsFromLock = async (root) => {
116
+ const path = join(root, "package-lock.json");
117
+ if (!(await pathExists(path))) return [];
118
+ const lock = await readJson(path);
119
+ return (
120
+ await Promise.all(
121
+ Object.entries(lock.packages ?? {})
122
+ .filter(
123
+ ([name, manifest]) =>
124
+ name &&
125
+ !name.split("/").includes("node_modules") &&
126
+ declaresRehearsal(manifest),
127
+ )
128
+ .map(([name]) => readProject(resolve(root, name))),
129
+ )
130
+ ).filter(Boolean);
131
+ };
132
+
133
+ const uniqueProjects = (projects) => [
134
+ ...new Map(
135
+ projects.filter(Boolean).map((entry) => [entry.root, entry]),
136
+ ).values(),
137
+ ];
138
+
139
+ const skip = (reason) => ({ status: "skipped", reason });
140
+
141
+ export const resolveRehearsalConsumerRoot = async ({
142
+ packageRoot,
143
+ initCwd,
144
+ localPrefix,
145
+ globalInstall = false,
146
+ workspace,
147
+ } = {}) => {
148
+ if (globalInstall === true || ["1", "true"].includes(globalInstall)) {
149
+ return skip("global installations do not own an application project");
150
+ }
151
+ if (!packageRoot || !isInstalledPackagePath(packageRoot)) {
152
+ return skip("the package is not running from a consumer node_modules tree");
153
+ }
154
+ if (isCachePath(packageRoot)) {
155
+ return skip(
156
+ "package-cache and temporary npx installs are not project roots",
157
+ );
158
+ }
159
+
160
+ const anchorRoots = [...new Set([initCwd, localPrefix].filter(Boolean))]
161
+ .map((path) => resolve(path))
162
+ .filter(
163
+ (path) =>
164
+ !pathSegments(path).includes("node_modules") && !isCachePath(path),
165
+ );
166
+ const anchors = uniqueProjects(
167
+ await Promise.all(anchorRoots.map((root) => readProject(root))),
168
+ );
169
+ if (anchors.length === 0) {
170
+ return skip("no application package.json was found for this installation");
171
+ }
172
+ const installationAnchors = anchors.filter((project) =>
173
+ isInside(project.root, packageRoot),
174
+ );
175
+ if (installationAnchors.length === 0) {
176
+ return skip(
177
+ "the installed package is outside the candidate application root",
178
+ );
179
+ }
180
+
181
+ const declared = [];
182
+ const workspaces = [];
183
+ for (const anchor of installationAnchors) {
184
+ if (declaresRehearsal(anchor.manifest)) declared.push(anchor);
185
+ const [manifestWorkspaces, lockWorkspaces] = await Promise.all([
186
+ workspaceProjectsFromManifest(anchor),
187
+ workspaceProjectsFromLock(anchor.root),
188
+ ]);
189
+ workspaces.push(...manifestWorkspaces, ...lockWorkspaces);
190
+ declared.push(
191
+ ...manifestWorkspaces.filter((project) =>
192
+ declaresRehearsal(project.manifest),
193
+ ),
194
+ ...lockWorkspaces,
195
+ );
196
+ }
197
+ if (workspace) {
198
+ const requested = String(workspace).trim().replace(/\\/gu, "/");
199
+ const selected = uniqueProjects(workspaces).filter(
200
+ (project) =>
201
+ project.manifest.name === requested ||
202
+ installationAnchors.some(
203
+ (anchor) =>
204
+ relative(anchor.root, project.root).replace(/\\/gu, "/") ===
205
+ requested.replace(/^\.\//u, ""),
206
+ ),
207
+ );
208
+ if (selected.length === 1) {
209
+ return { status: "resolved", projectRoot: selected[0].root };
210
+ }
211
+ if (selected.length > 1) {
212
+ return skip("the npm workspace selector matched multiple applications");
213
+ }
214
+ }
215
+ const candidates = uniqueProjects(declared);
216
+ if (candidates.length === 1) {
217
+ return { status: "resolved", projectRoot: candidates[0].root };
218
+ }
219
+ if (candidates.length > 1) {
220
+ return skip(
221
+ `multiple workspace applications declare ${PACKAGE_NAME}; run Rehearsal inside each application`,
222
+ );
223
+ }
224
+
225
+ const exact = initCwd ? await readProject(resolve(initCwd)) : null;
226
+ const workspaceRoot = installationAnchors.some(
227
+ (project) => workspacePatterns(project.manifest).length > 0,
228
+ );
229
+ if (
230
+ exact &&
231
+ installationAnchors.some((anchor) => isInside(anchor.root, exact.root)) &&
232
+ (!workspaceRoot || exact.root !== installationAnchors[0].root)
233
+ ) {
234
+ return { status: "resolved", projectRoot: exact.root };
235
+ }
236
+ if (!workspaceRoot && installationAnchors.length === 1) {
237
+ return {
238
+ status: "resolved",
239
+ projectRoot: installationAnchors[0].root,
240
+ };
241
+ }
242
+ return skip("the workspace application root is ambiguous");
243
+ };
244
+
245
+ export const createInstalledRehearsalScaffold = async ({
246
+ packageRoot,
247
+ environment = process.env,
248
+ isPortAvailable,
249
+ } = {}) => {
250
+ const resolution = await resolveRehearsalConsumerRoot({
251
+ packageRoot,
252
+ initCwd: environment.INIT_CWD,
253
+ localPrefix: environment.npm_config_local_prefix,
254
+ globalInstall: environment.npm_config_global,
255
+ workspace: environment.npm_config_workspace,
256
+ });
257
+ if (resolution.status !== "resolved") return resolution;
258
+
259
+ const detected = await inspectDetectedProject({
260
+ projectRoot: resolution.projectRoot,
261
+ });
262
+ const hasExistingConfig = await findRehearsalConfigPath({
263
+ projectRoot: resolution.projectRoot,
264
+ })
265
+ .then(() => true)
266
+ .catch((error) => {
267
+ if (
268
+ String(error?.message ?? error).startsWith("No Rehearsal configuration")
269
+ ) {
270
+ return false;
271
+ }
272
+ throw error;
273
+ });
274
+ const plan = await planRehearsalSetup({
275
+ projectRoot: resolution.projectRoot,
276
+ target: hasExistingConfig
277
+ ? undefined
278
+ : detected.hasSupabaseConfig
279
+ ? "supabase"
280
+ : "postgresql",
281
+ isPortAvailable,
282
+ });
283
+ const result = await applyRehearsalSetup(plan);
284
+ await loadRehearsalConfig({ projectRoot: resolution.projectRoot });
285
+ return {
286
+ status: "ready",
287
+ projectRoot: resolution.projectRoot,
288
+ target: plan.target,
289
+ files: result.files,
290
+ };
291
+ };