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 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 migration consistency. Add `--report` to stamp the result in the app manifest.
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.18.6",
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 };
@@ -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({ targetDir, slug, template, selectedModules, versionMap, dbInstallPlan, cliVersion }) {
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 envNames = new Set(Object.keys(process.env));
111
- const envPath = path.join(targetDir, ".env.local");
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
- `3. Run \`pnpm --filter ${slug} dev\`.`,
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
- `3. Run \`${packageManager} dev\`.`,
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({ targetDir, slug, dbInstallPlan, registry }) {
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 envFileContent = createEnvFileContent();
1404
+ const vercelConfig = createVercelConfig(answers.supabaseRegion);
1355
1405
 
1356
- await fs.writeFile(path.join(targetDir, ".env.local"), envFileContent);
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, { workspaceMode: true });
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") {
@@ -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;