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

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,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
+ };
@@ -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, {