@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.
- package/CHANGELOG.md +28 -1
- package/COMPATIBILITY.md +9 -0
- package/README.md +18 -7
- package/docs/README.md +25 -16
- package/docs/commands.md +10 -4
- package/docs/configuration.md +39 -9
- package/docs/getting-started.md +13 -4
- package/docs/index.md +47 -0
- package/docs/runtime-policies.md +65 -5
- package/docs/sanitization.md +104 -3
- package/docs/standalone-workflow.md +2 -0
- package/docs/troubleshooting.md +25 -0
- package/package.json +10 -2
- package/scripts/install/create_root_config.mjs +36 -0
- package/src/baseline/privacy_engine.mjs +358 -30
- package/src/cli/init_command.mjs +77 -0
- package/src/cli/rehearsal.mjs +6 -51
- package/src/cli/renderers.mjs +7 -2
- package/src/identity/claim.mjs +232 -44
- package/src/project/configuration.mjs +4 -2
- package/src/project/install_scaffold.mjs +291 -0
- package/src/project/setup.mjs +157 -52
|
@@ -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
|
+
};
|
package/src/project/setup.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
220
|
-
|
|
299
|
+
const existingConfigPath = await findExistingConfigPath({
|
|
300
|
+
projectRoot: root,
|
|
301
|
+
configPath,
|
|
221
302
|
});
|
|
222
|
-
const
|
|
223
|
-
|
|
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 =
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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:
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
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:
|
|
261
|
-
absolutePath:
|
|
262
|
-
|
|
362
|
+
path: localSupabasePath,
|
|
363
|
+
absolutePath:
|
|
364
|
+
rehearsalConfigPath ?? join(root, LOCAL_SUPABASE_CONFIG_PATH),
|
|
365
|
+
action: localSupabaseExists ? "unchanged" : "create",
|
|
263
366
|
mode: 0o600,
|
|
264
|
-
content:
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
|
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, {
|