create-bw-app 0.18.6 → 0.19.0
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/README.md +7 -1
- package/package.json +3 -2
- package/src/admin.mjs +204 -0
- package/src/app-manifest.mjs +21 -1
- package/src/bw.mjs +3 -1
- package/src/constants.mjs +1 -0
- package/src/doctor.mjs +151 -9
- package/src/env.mjs +43 -0
- package/src/generator.mjs +70 -8
- package/src/regions.mjs +60 -0
- package/template/supabase/modules/admin/migrations/20260729120000_bootstrap_first_admin.sql +114 -0
package/README.md
CHANGED
|
@@ -33,11 +33,13 @@ From a generated app, use `bw` to manage the machine-readable `.brightweb/app-ma
|
|
|
33
33
|
bw add projects
|
|
34
34
|
bw upgrade
|
|
35
35
|
bw doctor
|
|
36
|
+
bw admin create --email owner@example.com
|
|
36
37
|
```
|
|
37
38
|
|
|
38
39
|
- `bw add <moduleKey>` resolves requirements, installs thin package mounts and module wiring, and appends migrations.
|
|
39
40
|
- `bw upgrade [moduleKey]` includes the existing managed update flow plus forward-only module migrations.
|
|
40
|
-
- `bw doctor` checks package, config, scaffold, environment-name, and
|
|
41
|
+
- `bw doctor` checks package, config, scaffold, environment-name, migration, configured function-region, and deployed function-region consistency. Pass `--deployment-url` to inspect the deployed `x-vercel-id`; add `--report` to stamp the result in the app manifest.
|
|
42
|
+
- `bw admin create --email <email>` creates a passwordless Supabase Auth user, transactionally ensures its profile and `admin` assignment, then sends the Core Auth `/reset-password` flow. It refuses an existing admin unless `--force` is passed and never promotes an existing Auth user.
|
|
41
43
|
- All mutating commands support `--dry-run`.
|
|
42
44
|
|
|
43
45
|
## Update existing apps
|
|
@@ -67,6 +69,8 @@ Current updater behavior:
|
|
|
67
69
|
- prompts for app type: `platform` or `site`
|
|
68
70
|
- prompts for project name
|
|
69
71
|
- prompts for optional platform modules: `admin`, `crm`, `marketing`, and `projects`
|
|
72
|
+
- accepts or prompts for the Supabase project region and writes the nearest verified Vercel Functions region to `vercel.json`
|
|
73
|
+
- leaves `vercel.json` valid but unpinned, with a commented setup placeholder in the generated runbook, when the Supabase region is absent or unknown
|
|
70
74
|
- prompts to install dependencies immediately
|
|
71
75
|
- copies a clean Next.js App Router starter template
|
|
72
76
|
- platform apps include BrightWeb auth, shell wiring, and optional direct package mounts
|
|
@@ -90,6 +94,8 @@ When this package runs in BrightWeb workspace mode, it can:
|
|
|
90
94
|
|
|
91
95
|
Platform mode always resolves to the `Core + Admin` database baseline. Selecting `admin` affects the Admin package mount and wiring, not whether the Admin database layer exists.
|
|
92
96
|
|
|
97
|
+
The scaffold records `SUPABASE_PROJECT_REGION` in `.env.local` and infrastructure metadata. Keep it aligned when replacing a Supabase project so `bw doctor --deployment-url <url>` can compare the current database location with the region observed from Vercel's `x-vercel-id` response header.
|
|
98
|
+
|
|
93
99
|
## Related references
|
|
94
100
|
|
|
95
101
|
- `packages/create-bw-app/src/generator.mjs`
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-bw-app",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.19.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"create-bw-app": "bin/create-bw-app.mjs",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"node": ">=20"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@inquirer/prompts": "^7.10.1"
|
|
28
|
+
"@inquirer/prompts": "^7.10.1",
|
|
29
|
+
"@supabase/supabase-js": "^2.110.8"
|
|
29
30
|
}
|
|
30
31
|
}
|
package/src/admin.mjs
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { stdout as output } from "node:process";
|
|
3
|
+
import { createClient } from "@supabase/supabase-js";
|
|
4
|
+
import { loadAppEnvironment, readFirstEnvironmentValue } from "./env.mjs";
|
|
5
|
+
|
|
6
|
+
const HELP = `Usage: bw admin create --email <email> [options]
|
|
7
|
+
|
|
8
|
+
Options:
|
|
9
|
+
--email <email> Email address for the first administrator
|
|
10
|
+
--target-dir <path> App directory (defaults to cwd)
|
|
11
|
+
--force Allow creation when the project already has an admin
|
|
12
|
+
--dry-run Validate and inspect without creating the user
|
|
13
|
+
--help Show this help
|
|
14
|
+
|
|
15
|
+
The command never accepts or prompts for a password. It sends the user through
|
|
16
|
+
the scaffolded core-auth /reset-password recovery flow.`;
|
|
17
|
+
|
|
18
|
+
function normalizeEmail(value) {
|
|
19
|
+
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function validateEmail(email) {
|
|
23
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function resolveAdminEnvironment(environment) {
|
|
27
|
+
const supabaseUrl = readFirstEnvironmentValue(environment, ["NEXT_PUBLIC_SUPABASE_URL"]);
|
|
28
|
+
const secretKey = readFirstEnvironmentValue(environment, [
|
|
29
|
+
"SUPABASE_SECRET_DEFAULT_KEY",
|
|
30
|
+
"SUPABASE_SERVICE_ROLE_KEY",
|
|
31
|
+
]);
|
|
32
|
+
const appUrl = readFirstEnvironmentValue(environment, [
|
|
33
|
+
"NEXT_PUBLIC_APP_URL",
|
|
34
|
+
"PUBLIC_APP_URL",
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
if (!supabaseUrl) {
|
|
38
|
+
throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL in the process environment or .env.local.");
|
|
39
|
+
}
|
|
40
|
+
if (!secretKey) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
"Missing SUPABASE_SECRET_DEFAULT_KEY (or legacy SUPABASE_SERVICE_ROLE_KEY) in the process environment or .env.local.",
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
if (!secretKey.startsWith("sb_secret_")) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
"Invalid Supabase secret: SUPABASE_SECRET_DEFAULT_KEY must use an sb_secret_ key.",
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
if (!appUrl) {
|
|
51
|
+
throw new Error("Missing NEXT_PUBLIC_APP_URL in the process environment or .env.local.");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let resetPasswordUrl;
|
|
55
|
+
try {
|
|
56
|
+
resetPasswordUrl = new URL("/reset-password", appUrl).toString();
|
|
57
|
+
} catch {
|
|
58
|
+
throw new Error("NEXT_PUBLIC_APP_URL must be an absolute URL.");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { supabaseUrl, secretKey, resetPasswordUrl };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function findAuthUserByEmail(supabase, email) {
|
|
65
|
+
const perPage = 1000;
|
|
66
|
+
for (let page = 1; ; page += 1) {
|
|
67
|
+
const { data, error } = await supabase.auth.admin.listUsers({ page, perPage });
|
|
68
|
+
if (error) throw new Error(`Could not inspect Supabase Auth users: ${error.message}`);
|
|
69
|
+
const match = data.users.find((user) => normalizeEmail(user.email) === email);
|
|
70
|
+
if (match) return match;
|
|
71
|
+
if (data.users.length < perPage) return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function projectHasAdmin(supabase) {
|
|
76
|
+
const { data, error } = await supabase
|
|
77
|
+
.from("user_role_assignments")
|
|
78
|
+
.select("profile_id")
|
|
79
|
+
.eq("role_code", "admin")
|
|
80
|
+
.limit(1);
|
|
81
|
+
if (error) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`Could not inspect administrator assignments: ${error.message}. Apply the generated Supabase migrations first.`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return Array.isArray(data) && data.length > 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function deleteCreatedAuthUser(supabase, userId) {
|
|
90
|
+
const { error } = await supabase.auth.admin.deleteUser(userId);
|
|
91
|
+
return error || null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function bootstrapErrorMessage(error) {
|
|
95
|
+
const message = error?.message || "Unknown database error";
|
|
96
|
+
if (message.toLowerCase().includes("administrator already exists")) {
|
|
97
|
+
return "A project administrator already exists. Re-run with --force only when intentionally adding another new admin.";
|
|
98
|
+
}
|
|
99
|
+
return `Could not create the administrator profile and role: ${message}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function createFirstAdmin(action, argvOptions = {}, runtimeOptions = {}) {
|
|
103
|
+
if (argvOptions.help) {
|
|
104
|
+
(runtimeOptions.output || output).write(`${HELP}\n`);
|
|
105
|
+
return { help: true };
|
|
106
|
+
}
|
|
107
|
+
if (action !== "create") {
|
|
108
|
+
throw new Error(`Unknown admin command: ${action || "(missing)"}\n\n${HELP}`);
|
|
109
|
+
}
|
|
110
|
+
if ("password" in argvOptions) {
|
|
111
|
+
throw new Error("Passwords are not accepted by bw admin create. The command sends a password-set email instead.");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const email = normalizeEmail(argvOptions.email);
|
|
115
|
+
if (!validateEmail(email)) {
|
|
116
|
+
throw new Error("A valid --email <email> is required.");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const targetDir = path.resolve(runtimeOptions.targetDir || argvOptions.targetDir || process.cwd());
|
|
120
|
+
const environment = await loadAppEnvironment(targetDir, runtimeOptions.env || process.env);
|
|
121
|
+
const { supabaseUrl, secretKey, resetPasswordUrl } = resolveAdminEnvironment(environment);
|
|
122
|
+
const clientFactory = runtimeOptions.createClient || createClient;
|
|
123
|
+
const supabase = clientFactory(supabaseUrl, secretKey, {
|
|
124
|
+
auth: {
|
|
125
|
+
autoRefreshToken: false,
|
|
126
|
+
persistSession: false,
|
|
127
|
+
detectSessionInUrl: false,
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const hasAdmin = await projectHasAdmin(supabase);
|
|
132
|
+
if (hasAdmin && !argvOptions.force) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
"A project administrator already exists. Refusing bootstrap; pass --force only when intentionally adding another new admin.",
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const existingUser = await findAuthUserByEmail(supabase, email);
|
|
139
|
+
if (existingUser) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
`Auth user ${email} already exists (${existingUser.id}). Refusing to promote an existing account; use the in-app admin role controls.`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (argvOptions.dryRun) {
|
|
146
|
+
(runtimeOptions.output || output).write(
|
|
147
|
+
`DRY RUN ${email} can be created${hasAdmin ? " with --force" : " as the first administrator"}.\n`,
|
|
148
|
+
);
|
|
149
|
+
return { dryRun: true, email, forced: Boolean(argvOptions.force) };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const { data: created, error: createError } = await supabase.auth.admin.createUser({
|
|
153
|
+
email,
|
|
154
|
+
email_confirm: true,
|
|
155
|
+
});
|
|
156
|
+
if (createError || !created.user?.id) {
|
|
157
|
+
throw new Error(`Could not create Supabase Auth user ${email}: ${createError?.message || "missing user id"}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const userId = created.user.id;
|
|
161
|
+
let profileId = null;
|
|
162
|
+
try {
|
|
163
|
+
const { data: bootstrapRows, error: bootstrapError } = await supabase.rpc(
|
|
164
|
+
"bootstrap_first_admin",
|
|
165
|
+
{
|
|
166
|
+
p_user_id: userId,
|
|
167
|
+
p_email: email,
|
|
168
|
+
p_force: Boolean(argvOptions.force),
|
|
169
|
+
},
|
|
170
|
+
);
|
|
171
|
+
if (bootstrapError) throw new Error(bootstrapErrorMessage(bootstrapError));
|
|
172
|
+
profileId = Array.isArray(bootstrapRows) ? bootstrapRows[0]?.profile_id ?? null : null;
|
|
173
|
+
if (!profileId) throw new Error("The bootstrap transaction did not return a profile id.");
|
|
174
|
+
|
|
175
|
+
const { error: recoveryError } = await supabase.auth.resetPasswordForEmail(email, {
|
|
176
|
+
redirectTo: resetPasswordUrl,
|
|
177
|
+
});
|
|
178
|
+
if (recoveryError) {
|
|
179
|
+
throw new Error(`Could not send the password-set email: ${recoveryError.message}`);
|
|
180
|
+
}
|
|
181
|
+
} catch (error) {
|
|
182
|
+
const rollbackError = await deleteCreatedAuthUser(supabase, userId);
|
|
183
|
+
const message = error instanceof Error ? error.message : "Unknown bootstrap error";
|
|
184
|
+
if (rollbackError) {
|
|
185
|
+
throw new Error(
|
|
186
|
+
`${message} Automatic rollback also failed: ${rollbackError.message}. Auth user ${userId} may require manual cleanup.`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
throw new Error(`${message} The newly created Auth user and its cascaded profile/role were rolled back.`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
(runtimeOptions.output || output).write(
|
|
193
|
+
`Created administrator ${email}. A password-set link was sent for ${resetPasswordUrl}.\n`,
|
|
194
|
+
);
|
|
195
|
+
return {
|
|
196
|
+
email,
|
|
197
|
+
userId,
|
|
198
|
+
profileId,
|
|
199
|
+
forced: Boolean(argvOptions.force),
|
|
200
|
+
resetPasswordUrl,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export { HELP as ADMIN_HELP };
|
package/src/app-manifest.mjs
CHANGED
|
@@ -104,6 +104,7 @@ export async function writeAppManifest(targetDir, manifest) {
|
|
|
104
104
|
|
|
105
105
|
export function validateAppManifest(manifest) {
|
|
106
106
|
const errors = [];
|
|
107
|
+
const isNullableString = (value) => value === null || typeof value === "string";
|
|
107
108
|
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) return ["manifest must be an object"];
|
|
108
109
|
if (manifest.contractVersion !== 1) errors.push("contractVersion must equal 1");
|
|
109
110
|
if (!manifest.app || typeof manifest.app.slug !== "string" || !["platform", "site"].includes(manifest.app.template) || typeof manifest.app.scaffoldedWith !== "string") {
|
|
@@ -120,6 +121,15 @@ export function validateAppManifest(manifest) {
|
|
|
120
121
|
if (!entry || typeof entry.module !== "string" || !/^sha256:[a-f0-9]{64}$/.test(entry.hash || "") || !["current", "drifted", "missing"].includes(entry.status) || (entry.intent != null && !["managed", "owned", "skipped"].includes(entry.intent))) errors.push(`scaffoldFiles.${relativePath} is invalid`);
|
|
121
122
|
}
|
|
122
123
|
if (manifest.lastDoctor != null && (typeof manifest.lastDoctor.at !== "string" || typeof manifest.lastDoctor.ok !== "boolean")) errors.push("lastDoctor is invalid");
|
|
124
|
+
if (
|
|
125
|
+
manifest.infrastructure != null
|
|
126
|
+
&& (
|
|
127
|
+
typeof manifest.infrastructure !== "object"
|
|
128
|
+
|| Array.isArray(manifest.infrastructure)
|
|
129
|
+
|| !isNullableString(manifest.infrastructure.supabaseRegion)
|
|
130
|
+
|| !isNullableString(manifest.infrastructure.vercelRegion)
|
|
131
|
+
)
|
|
132
|
+
) errors.push("infrastructure is invalid");
|
|
123
133
|
return errors;
|
|
124
134
|
}
|
|
125
135
|
|
|
@@ -156,7 +166,16 @@ export async function collectScaffoldFiles(targetDir, selectedModules) {
|
|
|
156
166
|
return result;
|
|
157
167
|
}
|
|
158
168
|
|
|
159
|
-
export async function createInitialAppManifest({
|
|
169
|
+
export async function createInitialAppManifest({
|
|
170
|
+
targetDir,
|
|
171
|
+
slug,
|
|
172
|
+
template,
|
|
173
|
+
selectedModules,
|
|
174
|
+
versionMap,
|
|
175
|
+
dbInstallPlan,
|
|
176
|
+
cliVersion,
|
|
177
|
+
infrastructure,
|
|
178
|
+
}) {
|
|
160
179
|
const now = new Date().toISOString();
|
|
161
180
|
const modules = {};
|
|
162
181
|
if (template === "platform") {
|
|
@@ -179,6 +198,7 @@ export async function createInitialAppManifest({ targetDir, slug, template, sele
|
|
|
179
198
|
scaffoldFiles: template === "platform" ? await collectScaffoldFiles(targetDir, selectedModules) : {},
|
|
180
199
|
managedFiles: template === "platform" ? MANAGED_APP_FILES : ["docs/ai/app-context.json"],
|
|
181
200
|
migrationCursor,
|
|
201
|
+
...(infrastructure ? { infrastructure } : {}),
|
|
182
202
|
};
|
|
183
203
|
}
|
|
184
204
|
|
package/src/bw.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { addBrightwebModule } from "./add.mjs";
|
|
2
|
+
import { createFirstAdmin } from "./admin.mjs";
|
|
2
3
|
import { adoptBrightwebApp } from "./adopt.mjs";
|
|
3
4
|
import { diffBrightwebScaffold } from "./diff.mjs";
|
|
4
5
|
import { doctorBrightwebApp } from "./doctor.mjs";
|
|
@@ -7,7 +8,7 @@ import { scaffoldBrightwebApp } from "./scaffold-cmd.mjs";
|
|
|
7
8
|
import { updateBrightwebApp } from "./update.mjs";
|
|
8
9
|
import { upgradeBrightwebApp } from "./upgrade.mjs";
|
|
9
10
|
|
|
10
|
-
const HELP = `Usage: bw <command> [options]\n\nCommands:\n add <moduleKey> Install a module and its requirements\n adopt Create an honest manifest for a legacy app\n diff <relpath> Compare a tracked scaffold file with its template\n scaffold <action> List or record per-file scaffold intent\n remove <moduleKey> Conservatively remove module package wiring\n upgrade [moduleKey] Upgrade packages, managed files, and migrations\n update Alias for the legacy create-bw-app update flow\n doctor Validate app health and manifest consistency\n\nRun bw <command> --help for command-specific options.`;
|
|
11
|
+
const HELP = `Usage: bw <command> [options]\n\nCommands:\n add <moduleKey> Install a module and its requirements\n admin create Bootstrap a passwordless administrator safely\n adopt Create an honest manifest for a legacy app\n diff <relpath> Compare a tracked scaffold file with its template\n scaffold <action> List or record per-file scaffold intent\n remove <moduleKey> Conservatively remove module package wiring\n upgrade [moduleKey] Upgrade packages, managed files, and migrations\n update Alias for the legacy create-bw-app update flow\n doctor Validate app health and manifest consistency\n\nRun bw <command> --help for command-specific options.`;
|
|
11
12
|
|
|
12
13
|
function parseOptions(argv) {
|
|
13
14
|
const options = {};
|
|
@@ -36,6 +37,7 @@ export async function runBwCli(argv = process.argv.slice(2), runtimeOptions = {}
|
|
|
36
37
|
const { options, positionals } = parseOptions(argv.slice(1));
|
|
37
38
|
try {
|
|
38
39
|
if (command === "add") await addBrightwebModule(positionals[0], options, runtimeOptions);
|
|
40
|
+
else if (command === "admin") await createFirstAdmin(positionals[0], options, runtimeOptions);
|
|
39
41
|
else if (command === "adopt") await adoptBrightwebApp(options, runtimeOptions);
|
|
40
42
|
else if (command === "diff") await diffBrightwebScaffold(positionals[0], options, runtimeOptions);
|
|
41
43
|
else if (command === "scaffold") await scaffoldBrightwebApp(positionals[0], positionals.slice(1), options, runtimeOptions);
|
package/src/constants.mjs
CHANGED
|
@@ -230,6 +230,7 @@ Scaffold options:
|
|
|
230
230
|
--target-dir <path> Exact output directory, bypassing slug folder creation
|
|
231
231
|
--workspace-root <path> BrightWeb workspace root for local mode
|
|
232
232
|
--dependency-mode <mode> "workspace" or "published"
|
|
233
|
+
--supabase-region <region> Supabase project region used to place Vercel Functions
|
|
233
234
|
--install Install dependencies after scaffolding
|
|
234
235
|
--no-install Skip dependency installation
|
|
235
236
|
--yes Accept defaults for any missing optional prompt
|
package/src/doctor.mjs
CHANGED
|
@@ -3,12 +3,151 @@ import path from "node:path";
|
|
|
3
3
|
import { stdout as output } from "node:process";
|
|
4
4
|
import { cursorMigrationStatus } from "./migrations.mjs";
|
|
5
5
|
import { findWorkspaceRoot, loadModuleCatalog, MODULE_PACKAGES, readAppManifest, readConfiguredModuleFlags, satisfiesVersion, validateAppManifest, writeAppManifest } from "./app-manifest.mjs";
|
|
6
|
+
import { loadAppEnvironment, readFirstEnvironmentValue } from "./env.mjs";
|
|
6
7
|
import { pathExists, readJsonIfPresent } from "./generator.mjs";
|
|
8
|
+
import { nearestVercelRegion, normalizeSupabaseRegion } from "./regions.mjs";
|
|
7
9
|
import { scaffoldDrift } from "./scaffold.mjs";
|
|
8
10
|
|
|
9
|
-
const HELP = `Usage: bw doctor [options]\n\nOptions:\n --target-dir <path> App directory (defaults to cwd)\n --workspace-root <path> BrightWeb workspace root\n --strict Treat warnings as failures\n --report Stamp lastDoctor in the app manifest\n --help Show this help`;
|
|
11
|
+
const HELP = `Usage: bw doctor [options]\n\nOptions:\n --target-dir <path> App directory (defaults to cwd)\n --workspace-root <path> BrightWeb workspace root\n --deployment-url <url> Deployed app URL (defaults to PUBLIC_APP_URL/NEXT_PUBLIC_APP_URL)\n --supabase-region <id> Current Supabase project region override\n --strict Treat warnings as failures\n --report Stamp lastDoctor in the app manifest\n --help Show this help`;
|
|
10
12
|
const RUNTIME_PACKAGE_NAMES = ["react", "react-dom", "next"];
|
|
11
13
|
|
|
14
|
+
function isLocalDeploymentUrl(value) {
|
|
15
|
+
try {
|
|
16
|
+
const url = new URL(value);
|
|
17
|
+
return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1";
|
|
18
|
+
} catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function parseVercelFunctionRegion(vercelId) {
|
|
24
|
+
if (typeof vercelId !== "string") return null;
|
|
25
|
+
const regions = vercelId
|
|
26
|
+
.split("::")
|
|
27
|
+
.map((segment) => segment.match(/^([a-z]{3}\d)/)?.[1] ?? null)
|
|
28
|
+
.filter(Boolean);
|
|
29
|
+
return regions[1] || null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function inspectFunctionRegion({
|
|
33
|
+
targetDir,
|
|
34
|
+
options,
|
|
35
|
+
runtimeOptions,
|
|
36
|
+
appManifest,
|
|
37
|
+
environment,
|
|
38
|
+
add,
|
|
39
|
+
}) {
|
|
40
|
+
if (appManifest.app.template !== "platform") return;
|
|
41
|
+
|
|
42
|
+
const supabaseRegion = normalizeSupabaseRegion(
|
|
43
|
+
options.supabaseRegion
|
|
44
|
+
|| environment.SUPABASE_PROJECT_REGION
|
|
45
|
+
|| appManifest.infrastructure?.supabaseRegion,
|
|
46
|
+
);
|
|
47
|
+
const expectedVercelRegion = nearestVercelRegion(supabaseRegion);
|
|
48
|
+
const vercelConfig = await readJsonIfPresent(path.join(targetDir, "vercel.json"));
|
|
49
|
+
const configuredVercelRegions = Array.isArray(vercelConfig?.regions)
|
|
50
|
+
? vercelConfig.regions.filter((entry) => typeof entry === "string")
|
|
51
|
+
: [];
|
|
52
|
+
|
|
53
|
+
if (!supabaseRegion) {
|
|
54
|
+
add(
|
|
55
|
+
"WARN",
|
|
56
|
+
"function-region",
|
|
57
|
+
"Supabase region is unknown; set SUPABASE_PROJECT_REGION or pass --supabase-region before pinning Vercel Functions.",
|
|
58
|
+
);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (!expectedVercelRegion) {
|
|
62
|
+
add(
|
|
63
|
+
"WARN",
|
|
64
|
+
"function-region",
|
|
65
|
+
`Supabase region ${supabaseRegion} has no verified Vercel mapping; vercel.json was left unpinned.`,
|
|
66
|
+
);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (!configuredVercelRegions.includes(expectedVercelRegion)) {
|
|
70
|
+
add(
|
|
71
|
+
"WARN",
|
|
72
|
+
"function-region-config",
|
|
73
|
+
`Supabase ${supabaseRegion} maps to ${expectedVercelRegion}, but vercel.json has ${configuredVercelRegions.join(", ") || "no regions"}.`,
|
|
74
|
+
);
|
|
75
|
+
} else {
|
|
76
|
+
add(
|
|
77
|
+
"PASS",
|
|
78
|
+
"function-region-config",
|
|
79
|
+
`Supabase ${supabaseRegion} maps to configured Vercel region ${expectedVercelRegion}.`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const deploymentUrl = readFirstEnvironmentValue(
|
|
84
|
+
{
|
|
85
|
+
...environment,
|
|
86
|
+
...(options.deploymentUrl ? { BW_DOCTOR_DEPLOYMENT_URL: options.deploymentUrl } : {}),
|
|
87
|
+
},
|
|
88
|
+
["BW_DOCTOR_DEPLOYMENT_URL", "PUBLIC_APP_URL", "NEXT_PUBLIC_APP_URL"],
|
|
89
|
+
);
|
|
90
|
+
if (!deploymentUrl || isLocalDeploymentUrl(deploymentUrl)) {
|
|
91
|
+
add(
|
|
92
|
+
"INFO",
|
|
93
|
+
"function-region-deployed",
|
|
94
|
+
"SKIP deployed region check; pass --deployment-url with a non-local app URL.",
|
|
95
|
+
);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let endpoint;
|
|
100
|
+
try {
|
|
101
|
+
endpoint = new URL("/api/cron/keepalive", deploymentUrl).toString();
|
|
102
|
+
} catch {
|
|
103
|
+
add("WARN", "function-region-deployed", `Invalid deployment URL: ${deploymentUrl}.`);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const fetchImpl = runtimeOptions.fetchImpl || globalThis.fetch;
|
|
108
|
+
const controller = new AbortController();
|
|
109
|
+
const timeout = setTimeout(
|
|
110
|
+
() => controller.abort(),
|
|
111
|
+
runtimeOptions.regionCheckTimeoutMs || 5_000,
|
|
112
|
+
);
|
|
113
|
+
try {
|
|
114
|
+
const response = await fetchImpl(endpoint, {
|
|
115
|
+
method: "GET",
|
|
116
|
+
redirect: "follow",
|
|
117
|
+
signal: controller.signal,
|
|
118
|
+
});
|
|
119
|
+
const vercelId = response.headers.get("x-vercel-id");
|
|
120
|
+
const deployedRegion = parseVercelFunctionRegion(vercelId);
|
|
121
|
+
if (!deployedRegion) {
|
|
122
|
+
add(
|
|
123
|
+
"WARN",
|
|
124
|
+
"function-region-deployed",
|
|
125
|
+
`No Vercel Function region was present in x-vercel-id from ${endpoint}.`,
|
|
126
|
+
);
|
|
127
|
+
} else if (deployedRegion !== expectedVercelRegion) {
|
|
128
|
+
add(
|
|
129
|
+
"WARN",
|
|
130
|
+
"function-region-deployed",
|
|
131
|
+
`Deployed function region ${deployedRegion} does not match ${expectedVercelRegion} for Supabase ${supabaseRegion}.`,
|
|
132
|
+
);
|
|
133
|
+
} else {
|
|
134
|
+
add(
|
|
135
|
+
"PASS",
|
|
136
|
+
"function-region-deployed",
|
|
137
|
+
`Deployed function region ${deployedRegion} matches Supabase ${supabaseRegion}.`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
} catch (error) {
|
|
141
|
+
add(
|
|
142
|
+
"WARN",
|
|
143
|
+
"function-region-deployed",
|
|
144
|
+
`Could not inspect ${endpoint}: ${error instanceof Error ? error.message : String(error)}.`,
|
|
145
|
+
);
|
|
146
|
+
} finally {
|
|
147
|
+
clearTimeout(timeout);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
12
151
|
export async function findInstalledRuntimeVersions(targetDir) {
|
|
13
152
|
const storeDir = path.join(targetDir, "node_modules", ".pnpm");
|
|
14
153
|
if (!(await pathExists(storeDir))) {
|
|
@@ -107,18 +246,21 @@ export async function doctorBrightwebApp(argvOptions = {}, runtimeOptions = {})
|
|
|
107
246
|
add(scaffoldStatus, "scaffold", `${scaffoldGroups.current.length} current, ${scaffoldGroups.owned.length} owned, ${scaffoldGroups.skipped.length} skipped, ${scaffoldGroups.undecidedDrift.length} undecided-drift, ${scaffoldGroups.undecidedMissing.length} undecided-missing, ${scaffoldGroups.mismatched.length} intent-mismatch.`);
|
|
108
247
|
add("INFO", "owned-surfaces", `Owned surfaces: ${(appManifest.ownedSurfaces || []).join(", ") || "none"}.`);
|
|
109
248
|
|
|
110
|
-
const
|
|
111
|
-
const
|
|
112
|
-
if (await pathExists(envPath)) {
|
|
113
|
-
for (const line of (await fs.readFile(envPath, "utf8")).split(/\r?\n/)) {
|
|
114
|
-
const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/);
|
|
115
|
-
if (match) envNames.add(match[1]);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
249
|
+
const environment = await loadAppEnvironment(targetDir, runtimeOptions.env || process.env);
|
|
250
|
+
const envNames = new Set(Object.keys(environment));
|
|
118
251
|
const missingEnv = [];
|
|
119
252
|
for (const key of Object.keys(appManifest.modules || {})) for (const entry of catalog[key]?.manifest?.env || []) if (entry.required && !envNames.has(entry.name)) missingEnv.push(`${key}:${entry.name}`);
|
|
120
253
|
add(missingEnv.length ? "FAIL" : "PASS", "env", missingEnv.length ? `Missing required names: ${missingEnv.join(", ")}` : "Required environment variable names are present.");
|
|
121
254
|
|
|
255
|
+
await inspectFunctionRegion({
|
|
256
|
+
targetDir,
|
|
257
|
+
options: argvOptions,
|
|
258
|
+
runtimeOptions,
|
|
259
|
+
appManifest,
|
|
260
|
+
environment,
|
|
261
|
+
add,
|
|
262
|
+
});
|
|
263
|
+
|
|
122
264
|
const migrationProblems = [];
|
|
123
265
|
const migrationKeys = appManifest.app.template === "platform"
|
|
124
266
|
? Array.from(new Set(["core", "admin", ...Object.keys(appManifest.modules || {})]))
|
package/src/env.mjs
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { pathExists } from "./generator.mjs";
|
|
4
|
+
|
|
5
|
+
function parseValue(rawValue) {
|
|
6
|
+
const trimmed = rawValue.trim();
|
|
7
|
+
if (
|
|
8
|
+
(trimmed.startsWith('"') && trimmed.endsWith('"'))
|
|
9
|
+
|| (trimmed.startsWith("'") && trimmed.endsWith("'"))
|
|
10
|
+
) {
|
|
11
|
+
return trimmed.slice(1, -1);
|
|
12
|
+
}
|
|
13
|
+
return trimmed.replace(/\s+#.*$/, "").trim();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function parseDotEnv(content) {
|
|
17
|
+
const values = {};
|
|
18
|
+
for (const line of content.split(/\r?\n/)) {
|
|
19
|
+
const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/);
|
|
20
|
+
if (!match) continue;
|
|
21
|
+
values[match[1]] = parseValue(match[2]);
|
|
22
|
+
}
|
|
23
|
+
return values;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function loadAppEnvironment(targetDir, runtimeEnv = process.env) {
|
|
27
|
+
const envPath = path.join(targetDir, ".env.local");
|
|
28
|
+
const fileValues = await pathExists(envPath)
|
|
29
|
+
? parseDotEnv(await fs.readFile(envPath, "utf8"))
|
|
30
|
+
: {};
|
|
31
|
+
const runtimeValues = Object.fromEntries(
|
|
32
|
+
Object.entries(runtimeEnv || {}).filter(([, value]) => typeof value === "string"),
|
|
33
|
+
);
|
|
34
|
+
return { ...fileValues, ...runtimeValues };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function readFirstEnvironmentValue(environment, names) {
|
|
38
|
+
for (const name of names) {
|
|
39
|
+
const value = environment[name]?.trim();
|
|
40
|
+
if (value) return value;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
package/src/generator.mjs
CHANGED
|
@@ -17,6 +17,12 @@ import {
|
|
|
17
17
|
TEMPLATE_OPTIONS,
|
|
18
18
|
} from "./constants.mjs";
|
|
19
19
|
import { createInitialAppManifest, writeAppManifest } from "./app-manifest.mjs";
|
|
20
|
+
import {
|
|
21
|
+
createVercelConfig,
|
|
22
|
+
nearestVercelRegion,
|
|
23
|
+
normalizeSupabaseRegion,
|
|
24
|
+
regionSetupNote,
|
|
25
|
+
} from "./regions.mjs";
|
|
20
26
|
|
|
21
27
|
export const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
22
28
|
export const TEMPLATE_ROOT = path.join(PACKAGE_ROOT, "template");
|
|
@@ -416,13 +422,14 @@ export function createPlatformModulesConfigFile(selectedModules) {
|
|
|
416
422
|
].join("\n");
|
|
417
423
|
}
|
|
418
424
|
|
|
419
|
-
function createEnvFileContent() {
|
|
425
|
+
function createEnvFileContent(supabaseRegion) {
|
|
420
426
|
return [
|
|
421
427
|
"NEXT_PUBLIC_APP_URL=http://localhost:3000",
|
|
422
428
|
"PUBLIC_APP_URL=http://localhost:3000",
|
|
423
429
|
"NEXT_PUBLIC_SUPABASE_URL=",
|
|
424
430
|
"NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY=",
|
|
425
431
|
"SUPABASE_SECRET_DEFAULT_KEY=",
|
|
432
|
+
`SUPABASE_PROJECT_REGION=${normalizeSupabaseRegion(supabaseRegion) || ""}`,
|
|
426
433
|
"RESEND_API_KEY=",
|
|
427
434
|
"RESEND_WEBHOOK_SECRET=",
|
|
428
435
|
"RESEND_FROM_TRANSACTIONAL=",
|
|
@@ -512,6 +519,7 @@ function createPlatformReadme({
|
|
|
512
519
|
workspaceMode,
|
|
513
520
|
packageManager,
|
|
514
521
|
dbInstallPlan,
|
|
522
|
+
supabaseRegion,
|
|
515
523
|
}) {
|
|
516
524
|
const moduleLines = SELECTABLE_MODULES.map((moduleDefinition) => {
|
|
517
525
|
const enabled = selectedModules.includes(moduleDefinition.key);
|
|
@@ -524,12 +532,16 @@ function createPlatformReadme({
|
|
|
524
532
|
? [
|
|
525
533
|
"1. Review `.env.local` and fill in real service credentials.",
|
|
526
534
|
"2. Run `pnpm install` from the BrightWeb workspace root.",
|
|
527
|
-
|
|
535
|
+
"3. Link the Supabase project and run `supabase db push` from this app.",
|
|
536
|
+
"4. Run `bw admin create --email you@example.com` to create the first administrator.",
|
|
537
|
+
`5. Run \`pnpm --filter ${slug} dev\`.`,
|
|
528
538
|
]
|
|
529
539
|
: [
|
|
530
540
|
"1. Review `.env.local` and fill in real service credentials.",
|
|
531
541
|
`2. Run \`${packageManager} install\`.`,
|
|
532
|
-
|
|
542
|
+
"3. Link the Supabase project and run `supabase db push`.",
|
|
543
|
+
"4. Run `bw admin create --email you@example.com` to create the first administrator.",
|
|
544
|
+
`5. Run \`${packageManager} dev\`.`,
|
|
533
545
|
];
|
|
534
546
|
|
|
535
547
|
return [
|
|
@@ -563,6 +575,16 @@ function createPlatformReadme({
|
|
|
563
575
|
"",
|
|
564
576
|
]
|
|
565
577
|
: []),
|
|
578
|
+
"## First administrator",
|
|
579
|
+
"",
|
|
580
|
+
"Run `bw admin create --email you@example.com` after the generated migrations and `.env.local` values are in place.",
|
|
581
|
+
"The command refuses projects that already contain an admin unless `--force` is explicit, refuses to promote an existing Auth user, never accepts a password, and sends the new user through the Core Auth `/reset-password` flow.",
|
|
582
|
+
"",
|
|
583
|
+
"## Function region",
|
|
584
|
+
"",
|
|
585
|
+
regionSetupNote(supabaseRegion),
|
|
586
|
+
"Keep `SUPABASE_PROJECT_REGION` current if the Supabase project is replaced, then run `bw doctor --deployment-url https://your-app.example` to compare it with the deployed Vercel Function region.",
|
|
587
|
+
"",
|
|
566
588
|
"## Package mounts",
|
|
567
589
|
"",
|
|
568
590
|
...getPlatformStarterRoutes(selectedModules).map((route) => `- \`${route}\``),
|
|
@@ -1080,6 +1102,10 @@ async function writeClientStack(baseRoot, slug, dbInstallPlan, options = {}) {
|
|
|
1080
1102
|
historyMode: "greenfield-modular",
|
|
1081
1103
|
futureMode: "forward-only-modular",
|
|
1082
1104
|
enabledModules,
|
|
1105
|
+
infrastructure: {
|
|
1106
|
+
supabaseRegion: normalizeSupabaseRegion(options.supabaseRegion),
|
|
1107
|
+
vercelRegion: nearestVercelRegion(options.supabaseRegion),
|
|
1108
|
+
},
|
|
1083
1109
|
clientMigrationPath: `supabase/clients/${slug}/migrations`,
|
|
1084
1110
|
notes: [
|
|
1085
1111
|
generatedInWorkspaceMode
|
|
@@ -1126,7 +1152,13 @@ async function writeSupabaseCliMigrations({ targetDir, dbInstallPlan }) {
|
|
|
1126
1152
|
}
|
|
1127
1153
|
}
|
|
1128
1154
|
|
|
1129
|
-
async function writeBundledSupabaseBaseline({
|
|
1155
|
+
async function writeBundledSupabaseBaseline({
|
|
1156
|
+
targetDir,
|
|
1157
|
+
slug,
|
|
1158
|
+
dbInstallPlan,
|
|
1159
|
+
registry,
|
|
1160
|
+
supabaseRegion,
|
|
1161
|
+
}) {
|
|
1130
1162
|
const shippedModuleKeys = dbInstallPlan.resolvedOrder;
|
|
1131
1163
|
if (shippedModuleKeys.length === 0) {
|
|
1132
1164
|
return;
|
|
@@ -1155,7 +1187,7 @@ async function writeBundledSupabaseBaseline({ targetDir, slug, dbInstallPlan, re
|
|
|
1155
1187
|
);
|
|
1156
1188
|
}
|
|
1157
1189
|
|
|
1158
|
-
await writeClientStack(targetDir, slug, dbInstallPlan);
|
|
1190
|
+
await writeClientStack(targetDir, slug, dbInstallPlan, { supabaseRegion });
|
|
1159
1191
|
await writeSupabaseCliMigrations({ targetDir, dbInstallPlan });
|
|
1160
1192
|
}
|
|
1161
1193
|
|
|
@@ -1188,6 +1220,7 @@ function renderPlanSummary({
|
|
|
1188
1220
|
install,
|
|
1189
1221
|
template,
|
|
1190
1222
|
dbInstallPlan,
|
|
1223
|
+
supabaseRegion,
|
|
1191
1224
|
}) {
|
|
1192
1225
|
const installLocation = workspaceMode ? "workspace root" : "project directory";
|
|
1193
1226
|
const templateLabel = TEMPLATE_OPTIONS.find((templateOption) => templateOption.key === template)?.label || template;
|
|
@@ -1209,6 +1242,12 @@ function renderPlanSummary({
|
|
|
1209
1242
|
...dbInstallPlan.notes.map((note) => `- ${note}`),
|
|
1210
1243
|
]
|
|
1211
1244
|
: []),
|
|
1245
|
+
...(template === "platform"
|
|
1246
|
+
? [
|
|
1247
|
+
`Supabase region: ${normalizeSupabaseRegion(supabaseRegion) || "unknown"}`,
|
|
1248
|
+
`Vercel Functions region: ${nearestVercelRegion(supabaseRegion) || "placeholder (not pinned)"}`,
|
|
1249
|
+
]
|
|
1250
|
+
: []),
|
|
1212
1251
|
`Install dependencies: ${install ? `yes (${packageManager} in ${installLocation})` : "no"}`,
|
|
1213
1252
|
].join("\n");
|
|
1214
1253
|
}
|
|
@@ -1243,6 +1282,7 @@ async function collectAnswers(argvOptions, runtimeOptions) {
|
|
|
1243
1282
|
}
|
|
1244
1283
|
|
|
1245
1284
|
let selectedModules = [];
|
|
1285
|
+
let supabaseRegion = null;
|
|
1246
1286
|
|
|
1247
1287
|
if (template === "platform") {
|
|
1248
1288
|
const askedModules = [];
|
|
@@ -1264,6 +1304,15 @@ async function collectAnswers(argvOptions, runtimeOptions) {
|
|
|
1264
1304
|
: argvOptions.yes
|
|
1265
1305
|
? []
|
|
1266
1306
|
: askedModules;
|
|
1307
|
+
|
|
1308
|
+
supabaseRegion = normalizeSupabaseRegion(
|
|
1309
|
+
argvOptions.supabaseRegion
|
|
1310
|
+
|| (argvOptions.yes
|
|
1311
|
+
? null
|
|
1312
|
+
: await promptInput({
|
|
1313
|
+
message: "What Supabase region is the project in? (for example eu-west-3; leave blank if unknown)",
|
|
1314
|
+
})),
|
|
1315
|
+
);
|
|
1267
1316
|
}
|
|
1268
1317
|
|
|
1269
1318
|
const install =
|
|
@@ -1280,6 +1329,7 @@ async function collectAnswers(argvOptions, runtimeOptions) {
|
|
|
1280
1329
|
slug: resolvedSlug,
|
|
1281
1330
|
template,
|
|
1282
1331
|
selectedModules,
|
|
1332
|
+
supabaseRegion,
|
|
1283
1333
|
install,
|
|
1284
1334
|
};
|
|
1285
1335
|
}
|
|
@@ -1351,9 +1401,10 @@ async function scaffoldPlatformProject({
|
|
|
1351
1401
|
}),
|
|
1352
1402
|
);
|
|
1353
1403
|
|
|
1354
|
-
const
|
|
1404
|
+
const vercelConfig = createVercelConfig(answers.supabaseRegion);
|
|
1355
1405
|
|
|
1356
|
-
await fs.writeFile(path.join(targetDir, ".env.local"),
|
|
1406
|
+
await fs.writeFile(path.join(targetDir, ".env.local"), createEnvFileContent(answers.supabaseRegion));
|
|
1407
|
+
await fs.writeFile(path.join(targetDir, "vercel.json"), `${JSON.stringify(vercelConfig.config, null, 2)}\n`);
|
|
1357
1408
|
await fs.writeFile(path.join(targetDir, ".gitignore"), createGitignore());
|
|
1358
1409
|
await fs.writeFile(
|
|
1359
1410
|
path.join(targetDir, "README.md"),
|
|
@@ -1363,11 +1414,15 @@ async function scaffoldPlatformProject({
|
|
|
1363
1414
|
workspaceMode,
|
|
1364
1415
|
packageManager,
|
|
1365
1416
|
dbInstallPlan,
|
|
1417
|
+
supabaseRegion: answers.supabaseRegion,
|
|
1366
1418
|
}),
|
|
1367
1419
|
);
|
|
1368
1420
|
|
|
1369
1421
|
if (workspaceMode) {
|
|
1370
|
-
await writeClientStack(workspaceRoot, answers.slug, dbInstallPlan, {
|
|
1422
|
+
await writeClientStack(workspaceRoot, answers.slug, dbInstallPlan, {
|
|
1423
|
+
workspaceMode: true,
|
|
1424
|
+
supabaseRegion: answers.supabaseRegion,
|
|
1425
|
+
});
|
|
1371
1426
|
await writeSupabaseCliMigrations({ targetDir, dbInstallPlan });
|
|
1372
1427
|
} else {
|
|
1373
1428
|
await writeBundledSupabaseBaseline({
|
|
@@ -1375,6 +1430,7 @@ async function scaffoldPlatformProject({
|
|
|
1375
1430
|
slug: answers.slug,
|
|
1376
1431
|
dbInstallPlan,
|
|
1377
1432
|
registry: dbRegistry,
|
|
1433
|
+
supabaseRegion: answers.supabaseRegion,
|
|
1378
1434
|
});
|
|
1379
1435
|
}
|
|
1380
1436
|
|
|
@@ -1387,6 +1443,10 @@ async function scaffoldPlatformProject({
|
|
|
1387
1443
|
versionMap,
|
|
1388
1444
|
dbInstallPlan,
|
|
1389
1445
|
cliVersion: cliPackage?.version || "0.0.0",
|
|
1446
|
+
infrastructure: {
|
|
1447
|
+
supabaseRegion: vercelConfig.supabaseRegion,
|
|
1448
|
+
vercelRegion: vercelConfig.vercelRegion,
|
|
1449
|
+
},
|
|
1390
1450
|
}));
|
|
1391
1451
|
}
|
|
1392
1452
|
|
|
@@ -1515,6 +1575,7 @@ export async function createBrightwebClientApp(argvOptions, runtimeOptions = {})
|
|
|
1515
1575
|
install: answers.install,
|
|
1516
1576
|
template: answers.template,
|
|
1517
1577
|
dbInstallPlan,
|
|
1578
|
+
supabaseRegion: answers.supabaseRegion,
|
|
1518
1579
|
})}\n\n`);
|
|
1519
1580
|
return {
|
|
1520
1581
|
answers,
|
|
@@ -1535,6 +1596,7 @@ export async function createBrightwebClientApp(argvOptions, runtimeOptions = {})
|
|
|
1535
1596
|
install: answers.install,
|
|
1536
1597
|
template: answers.template,
|
|
1537
1598
|
dbInstallPlan,
|
|
1599
|
+
supabaseRegion: answers.supabaseRegion,
|
|
1538
1600
|
})}\n\n`);
|
|
1539
1601
|
|
|
1540
1602
|
if (answers.template === "site") {
|
package/src/regions.mjs
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Supabase: https://supabase.com/docs/guides/platform/regions
|
|
2
|
+
// Vercel: https://vercel.com/docs/regions
|
|
3
|
+
// Verified 2026-07-29. Keep this list explicit; unknown regions must remain unpinned.
|
|
4
|
+
export const SUPABASE_TO_VERCEL_REGION = Object.freeze({
|
|
5
|
+
americas: "iad1",
|
|
6
|
+
emea: "fra1",
|
|
7
|
+
apac: "sin1",
|
|
8
|
+
"us-west-1": "sfo1",
|
|
9
|
+
"us-west-2": "pdx1",
|
|
10
|
+
"us-east-1": "iad1",
|
|
11
|
+
"us-east-2": "cle1",
|
|
12
|
+
"ca-central-1": "yul1",
|
|
13
|
+
"eu-west-1": "dub1",
|
|
14
|
+
"eu-west-2": "lhr1",
|
|
15
|
+
"eu-west-3": "cdg1",
|
|
16
|
+
"eu-central-1": "fra1",
|
|
17
|
+
"eu-central-2": "fra1",
|
|
18
|
+
"eu-north-1": "arn1",
|
|
19
|
+
"ap-south-1": "bom1",
|
|
20
|
+
"ap-southeast-1": "sin1",
|
|
21
|
+
"ap-northeast-1": "hnd1",
|
|
22
|
+
"ap-northeast-2": "icn1",
|
|
23
|
+
"ap-southeast-2": "syd1",
|
|
24
|
+
"sa-east-1": "gru1",
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export function normalizeSupabaseRegion(value) {
|
|
28
|
+
const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
29
|
+
return normalized || null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function nearestVercelRegion(supabaseRegion) {
|
|
33
|
+
const normalized = normalizeSupabaseRegion(supabaseRegion);
|
|
34
|
+
return normalized ? SUPABASE_TO_VERCEL_REGION[normalized] ?? null : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createVercelConfig(supabaseRegion) {
|
|
38
|
+
const vercelRegion = nearestVercelRegion(supabaseRegion);
|
|
39
|
+
return {
|
|
40
|
+
config: vercelRegion
|
|
41
|
+
? {
|
|
42
|
+
$schema: "https://openapi.vercel.sh/vercel.json",
|
|
43
|
+
regions: [vercelRegion],
|
|
44
|
+
}
|
|
45
|
+
: {
|
|
46
|
+
$schema: "https://openapi.vercel.sh/vercel.json",
|
|
47
|
+
},
|
|
48
|
+
supabaseRegion: normalizeSupabaseRegion(supabaseRegion),
|
|
49
|
+
vercelRegion,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function regionSetupNote(supabaseRegion) {
|
|
54
|
+
const normalized = normalizeSupabaseRegion(supabaseRegion);
|
|
55
|
+
const vercelRegion = nearestVercelRegion(normalized);
|
|
56
|
+
if (vercelRegion) {
|
|
57
|
+
return `Supabase region \`${normalized}\` maps to Vercel Functions region \`${vercelRegion}\` in \`vercel.json\`.`;
|
|
58
|
+
}
|
|
59
|
+
return "<!-- vercel.json region placeholder: once the Supabase region is known, set SUPABASE_PROJECT_REGION and add the nearest verified Vercel region as `\"regions\": [\"<region>\"]`. -->";
|
|
60
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
-- Transactional database half of `bw admin create`.
|
|
2
|
+
|
|
3
|
+
CREATE OR REPLACE FUNCTION public.bootstrap_first_admin(
|
|
4
|
+
p_user_id uuid,
|
|
5
|
+
p_email text,
|
|
6
|
+
p_force boolean DEFAULT false
|
|
7
|
+
)
|
|
8
|
+
RETURNS TABLE (
|
|
9
|
+
profile_id uuid,
|
|
10
|
+
previous_role_code text
|
|
11
|
+
)
|
|
12
|
+
LANGUAGE plpgsql
|
|
13
|
+
SECURITY DEFINER
|
|
14
|
+
SET search_path = public, auth
|
|
15
|
+
AS $$
|
|
16
|
+
DECLARE
|
|
17
|
+
v_email text;
|
|
18
|
+
v_auth_email text;
|
|
19
|
+
v_profile_id uuid;
|
|
20
|
+
v_previous_role_code text;
|
|
21
|
+
BEGIN
|
|
22
|
+
IF COALESCE(auth.jwt() ->> 'role', '') <> 'service_role' THEN
|
|
23
|
+
RAISE EXCEPTION 'bootstrap_first_admin requires service_role'
|
|
24
|
+
USING ERRCODE = '42501';
|
|
25
|
+
END IF;
|
|
26
|
+
|
|
27
|
+
v_email := NULLIF(lower(trim(COALESCE(p_email, ''))), '');
|
|
28
|
+
IF p_user_id IS NULL OR v_email IS NULL THEN
|
|
29
|
+
RAISE EXCEPTION 'A user id and email are required'
|
|
30
|
+
USING ERRCODE = '22023';
|
|
31
|
+
END IF;
|
|
32
|
+
|
|
33
|
+
SELECT lower(trim(u.email))
|
|
34
|
+
INTO v_auth_email
|
|
35
|
+
FROM auth.users u
|
|
36
|
+
WHERE u.id = p_user_id;
|
|
37
|
+
|
|
38
|
+
IF v_auth_email IS NULL OR v_auth_email <> v_email THEN
|
|
39
|
+
RAISE EXCEPTION 'Auth user and email do not match'
|
|
40
|
+
USING ERRCODE = '22023';
|
|
41
|
+
END IF;
|
|
42
|
+
|
|
43
|
+
PERFORM pg_advisory_xact_lock(hashtextextended('brightweb:first-admin-bootstrap', 0));
|
|
44
|
+
|
|
45
|
+
IF NOT p_force AND EXISTS (
|
|
46
|
+
SELECT 1
|
|
47
|
+
FROM public.user_role_assignments ura
|
|
48
|
+
WHERE ura.role_code = 'admin'
|
|
49
|
+
) THEN
|
|
50
|
+
RAISE EXCEPTION 'A project administrator already exists'
|
|
51
|
+
USING ERRCODE = '42501';
|
|
52
|
+
END IF;
|
|
53
|
+
|
|
54
|
+
SELECT p.id
|
|
55
|
+
INTO v_profile_id
|
|
56
|
+
FROM public.profiles p
|
|
57
|
+
WHERE p.user_id = p_user_id
|
|
58
|
+
FOR UPDATE;
|
|
59
|
+
|
|
60
|
+
IF v_profile_id IS NULL THEN
|
|
61
|
+
IF EXISTS (
|
|
62
|
+
SELECT 1
|
|
63
|
+
FROM public.profiles p
|
|
64
|
+
WHERE lower(p.email) = v_email
|
|
65
|
+
) THEN
|
|
66
|
+
RAISE EXCEPTION 'A profile already exists for this email'
|
|
67
|
+
USING ERRCODE = '23505';
|
|
68
|
+
END IF;
|
|
69
|
+
|
|
70
|
+
INSERT INTO public.profiles (user_id, email)
|
|
71
|
+
VALUES (p_user_id, v_email)
|
|
72
|
+
RETURNING id INTO v_profile_id;
|
|
73
|
+
ELSE
|
|
74
|
+
UPDATE public.profiles
|
|
75
|
+
SET email = v_email,
|
|
76
|
+
updated_at = now()
|
|
77
|
+
WHERE id = v_profile_id;
|
|
78
|
+
END IF;
|
|
79
|
+
|
|
80
|
+
SELECT ura.role_code
|
|
81
|
+
INTO v_previous_role_code
|
|
82
|
+
FROM public.user_role_assignments ura
|
|
83
|
+
WHERE ura.profile_id = v_profile_id
|
|
84
|
+
FOR UPDATE;
|
|
85
|
+
|
|
86
|
+
INSERT INTO public.user_role_assignments (
|
|
87
|
+
profile_id,
|
|
88
|
+
role_code,
|
|
89
|
+
assigned_by_profile_id,
|
|
90
|
+
assigned_at,
|
|
91
|
+
reason
|
|
92
|
+
)
|
|
93
|
+
VALUES (
|
|
94
|
+
v_profile_id,
|
|
95
|
+
'admin',
|
|
96
|
+
NULL,
|
|
97
|
+
now(),
|
|
98
|
+
'bw_admin_create_bootstrap'
|
|
99
|
+
)
|
|
100
|
+
ON CONFLICT (profile_id)
|
|
101
|
+
DO UPDATE SET
|
|
102
|
+
role_code = EXCLUDED.role_code,
|
|
103
|
+
assigned_by_profile_id = NULL,
|
|
104
|
+
assigned_at = EXCLUDED.assigned_at,
|
|
105
|
+
reason = EXCLUDED.reason;
|
|
106
|
+
|
|
107
|
+
RETURN QUERY SELECT v_profile_id, v_previous_role_code;
|
|
108
|
+
END;
|
|
109
|
+
$$;
|
|
110
|
+
|
|
111
|
+
REVOKE ALL ON FUNCTION public.bootstrap_first_admin(uuid, text, boolean) FROM PUBLIC;
|
|
112
|
+
REVOKE ALL ON FUNCTION public.bootstrap_first_admin(uuid, text, boolean) FROM anon;
|
|
113
|
+
REVOKE ALL ON FUNCTION public.bootstrap_first_admin(uuid, text, boolean) FROM authenticated;
|
|
114
|
+
GRANT EXECUTE ON FUNCTION public.bootstrap_first_admin(uuid, text, boolean) TO service_role;
|