create-bw-app 0.18.6 → 0.20.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 always refuses when the project already has an admin (use the in-app admin role controls to add administrators) 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.20.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/add.mjs CHANGED
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { stdout as output } from "node:process";
4
4
  import { SELECTABLE_MODULES } from "./constants.mjs";
5
- import { TEMPLATE_ROOT, createAppContextFile, createDbInstallPlan, createNextConfig, createPlatformGlobalsCss, createPlatformModulesConfigFile, createShellConfig, getDbModuleRegistry, getVersionMap, pathExists, readJsonIfPresent } from "./generator.mjs";
5
+ import { TEMPLATE_ROOT, createAppContextFile, createDbInstallPlan, createModuleToolbarControlsConfig, createNextConfig, createOptionalModuleRouteFiles, createPlatformGlobalsCss, createPlatformModulesConfigFile, createShellConfig, getDbModuleRegistry, getVersionMap, pathExists, readJsonIfPresent } from "./generator.mjs";
6
6
  import { collectScaffoldFiles, findWorkspaceRoot, loadModuleCatalog, MODULE_PACKAGES, readAppManifest, resolveModuleClosure, satisfiesVersion, writeAppManifest } from "./app-manifest.mjs";
7
7
  import { applyMigrationWrites, planMigrationAppends } from "./migrations.mjs";
8
8
 
@@ -66,9 +66,11 @@ export async function addBrightwebModule(moduleKey, argvOptions = {}, runtimeOpt
66
66
  const managedWrites = {
67
67
  "next.config.ts": createNextConfig({ template: "platform", selectedModules: installedModuleKeys }),
68
68
  "app/globals.css": await createPlatformGlobalsCss(installedModuleKeys),
69
+ "config/module-toolbar-controls.tsx": createModuleToolbarControlsConfig(installedModuleKeys),
69
70
  "config/modules.ts": createPlatformModulesConfigFile(installedModuleKeys),
70
71
  "config/shell.ts": createShellConfig(installedModuleKeys),
71
72
  "docs/ai/app-context.json": createAppContextFile({ slug: appManifest.app.slug, template: "platform", selectedModules: installedModuleKeys.filter((key) => key !== "orgs"), dbInstallPlan }),
73
+ ...createOptionalModuleRouteFiles(installedModuleKeys),
72
74
  };
73
75
 
74
76
  const summary = [
@@ -93,7 +95,13 @@ export async function addBrightwebModule(moduleKey, argvOptions = {}, runtimeOpt
93
95
  for (const key of newModules) appManifest.modules[key] = { version: catalog[key].version, installedAt: now, exposed: true };
94
96
  appManifest.migrationCursor = migrationPlan.nextCursor;
95
97
  const collectedScaffoldFiles = await collectScaffoldFiles(targetDir, installedModuleKeys);
96
- appManifest.scaffoldFiles = { ...collectedScaffoldFiles, ...appManifest.scaffoldFiles };
98
+ const refreshedScaffoldFiles = Object.fromEntries(
99
+ Object.entries(collectedScaffoldFiles).map(([relativePath, record]) => {
100
+ const intent = appManifest.scaffoldFiles[relativePath]?.intent;
101
+ return [relativePath, intent ? { ...record, intent } : record];
102
+ }),
103
+ );
104
+ appManifest.scaffoldFiles = { ...appManifest.scaffoldFiles, ...refreshedScaffoldFiles };
97
105
  await writeAppManifest(targetDir, appManifest);
98
106
  output.write(`Installed ${newModules.length} module${newModules.length === 1 ? "" : "s"}. ${migrationPlan.writes.length > 0 ? "Run your Supabase migration apply command. " : ""}Run your package manager install command next.\n`);
99
107
  return { dryRun: false, newModules, migrationPlan };
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
+ --dry-run Validate and inspect without creating the user
12
+ --help Show this help
13
+
14
+ The command never accepts or prompts for a password. It sends the user through
15
+ the scaffolded core-auth /reset-password recovery flow.`;
16
+
17
+ function normalizeEmail(value) {
18
+ return typeof value === "string" ? value.trim().toLowerCase() : "";
19
+ }
20
+
21
+ function validateEmail(email) {
22
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
23
+ }
24
+
25
+ function resolveAdminEnvironment(environment) {
26
+ const supabaseUrl = readFirstEnvironmentValue(environment, ["NEXT_PUBLIC_SUPABASE_URL"]);
27
+ const secretKey = readFirstEnvironmentValue(environment, [
28
+ "SUPABASE_SECRET_DEFAULT_KEY",
29
+ "SUPABASE_SERVICE_ROLE_KEY",
30
+ ]);
31
+ const appUrl = readFirstEnvironmentValue(environment, [
32
+ "NEXT_PUBLIC_APP_URL",
33
+ "PUBLIC_APP_URL",
34
+ ]);
35
+
36
+ if (!supabaseUrl) {
37
+ throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL in the process environment or .env.local.");
38
+ }
39
+ if (!secretKey) {
40
+ throw new Error(
41
+ "Missing SUPABASE_SECRET_DEFAULT_KEY (or legacy SUPABASE_SERVICE_ROLE_KEY) in the process environment or .env.local.",
42
+ );
43
+ }
44
+ if (!secretKey.startsWith("sb_secret_")) {
45
+ throw new Error(
46
+ "Invalid Supabase secret: SUPABASE_SECRET_DEFAULT_KEY must use an sb_secret_ key.",
47
+ );
48
+ }
49
+ if (!appUrl) {
50
+ throw new Error("Missing NEXT_PUBLIC_APP_URL in the process environment or .env.local.");
51
+ }
52
+
53
+ let resetPasswordUrl;
54
+ try {
55
+ resetPasswordUrl = new URL("/reset-password", appUrl).toString();
56
+ } catch {
57
+ throw new Error("NEXT_PUBLIC_APP_URL must be an absolute URL.");
58
+ }
59
+
60
+ return { supabaseUrl, secretKey, resetPasswordUrl };
61
+ }
62
+
63
+ async function findAuthUserByEmail(supabase, email) {
64
+ const perPage = 1000;
65
+ for (let page = 1; ; page += 1) {
66
+ const { data, error } = await supabase.auth.admin.listUsers({ page, perPage });
67
+ if (error) throw new Error(`Could not inspect Supabase Auth users: ${error.message}`);
68
+ const match = data.users.find((user) => normalizeEmail(user.email) === email);
69
+ if (match) return match;
70
+ if (data.users.length < perPage) return null;
71
+ }
72
+ }
73
+
74
+ async function projectHasAdmin(supabase) {
75
+ const { data, error } = await supabase
76
+ .from("user_role_assignments")
77
+ .select("profile_id")
78
+ .eq("role_code", "admin")
79
+ .limit(1);
80
+ if (error) {
81
+ throw new Error(
82
+ `Could not inspect administrator assignments: ${error.message}. Apply the generated Supabase migrations first.`,
83
+ );
84
+ }
85
+ return Array.isArray(data) && data.length > 0;
86
+ }
87
+
88
+ async function deleteCreatedAuthUser(supabase, userId) {
89
+ const { error } = await supabase.auth.admin.deleteUser(userId);
90
+ return error || null;
91
+ }
92
+
93
+ function bootstrapErrorMessage(error) {
94
+ const message = error?.message || "Unknown database error";
95
+ if (message.toLowerCase().includes("administrator already exists")) {
96
+ return "A project administrator already exists. Use the in-app admin role controls to add administrators.";
97
+ }
98
+ return `Could not create the administrator profile and role: ${message}`;
99
+ }
100
+
101
+ export async function createFirstAdmin(action, argvOptions = {}, runtimeOptions = {}) {
102
+ if (argvOptions.help) {
103
+ (runtimeOptions.output || output).write(`${HELP}\n`);
104
+ return { help: true };
105
+ }
106
+ if (action !== "create") {
107
+ throw new Error(`Unknown admin command: ${action || "(missing)"}\n\n${HELP}`);
108
+ }
109
+ if ("password" in argvOptions) {
110
+ throw new Error("Passwords are not accepted by bw admin create. The command sends a password-set email instead.");
111
+ }
112
+ if ("force" in argvOptions) {
113
+ throw new Error("--force has been removed; use the in-app admin role controls to add administrators.");
114
+ }
115
+
116
+ const email = normalizeEmail(argvOptions.email);
117
+ if (!validateEmail(email)) {
118
+ throw new Error("A valid --email <email> is required.");
119
+ }
120
+
121
+ const targetDir = path.resolve(runtimeOptions.targetDir || argvOptions.targetDir || process.cwd());
122
+ const environment = await loadAppEnvironment(targetDir, runtimeOptions.env || process.env);
123
+ const { supabaseUrl, secretKey, resetPasswordUrl } = resolveAdminEnvironment(environment);
124
+ const clientFactory = runtimeOptions.createClient || createClient;
125
+ const supabase = clientFactory(supabaseUrl, secretKey, {
126
+ auth: {
127
+ autoRefreshToken: false,
128
+ persistSession: false,
129
+ detectSessionInUrl: false,
130
+ },
131
+ });
132
+
133
+ const hasAdmin = await projectHasAdmin(supabase);
134
+ if (hasAdmin) {
135
+ throw new Error(
136
+ "A project administrator already exists. Refusing bootstrap; use the in-app admin role controls to add administrators.",
137
+ );
138
+ }
139
+
140
+ const existingUser = await findAuthUserByEmail(supabase, email);
141
+ if (existingUser) {
142
+ throw new Error(
143
+ `Auth user ${email} already exists (${existingUser.id}). Refusing to promote an existing account; use the in-app admin role controls.`,
144
+ );
145
+ }
146
+
147
+ if (argvOptions.dryRun) {
148
+ (runtimeOptions.output || output).write(
149
+ `DRY RUN ${email} can be created as the first administrator.\n`,
150
+ );
151
+ return { dryRun: true, email };
152
+ }
153
+
154
+ const { data: created, error: createError } = await supabase.auth.admin.createUser({
155
+ email,
156
+ email_confirm: true,
157
+ });
158
+ if (createError || !created.user?.id) {
159
+ throw new Error(`Could not create Supabase Auth user ${email}: ${createError?.message || "missing user id"}`);
160
+ }
161
+
162
+ const userId = created.user.id;
163
+ let profileId = null;
164
+ try {
165
+ const { data: bootstrapRows, error: bootstrapError } = await supabase.rpc(
166
+ "bootstrap_first_admin",
167
+ {
168
+ p_user_id: userId,
169
+ p_email: email,
170
+ },
171
+ );
172
+ if (bootstrapError) throw new Error(bootstrapErrorMessage(bootstrapError));
173
+ profileId = Array.isArray(bootstrapRows) ? bootstrapRows[0]?.profile_id ?? null : null;
174
+ if (!profileId) throw new Error("The bootstrap transaction did not return a profile id.");
175
+
176
+ const { error: recoveryError } = await supabase.auth.resetPasswordForEmail(email, {
177
+ redirectTo: resetPasswordUrl,
178
+ });
179
+ if (recoveryError) {
180
+ throw new Error(`Could not send the password-set email: ${recoveryError.message}`);
181
+ }
182
+ } catch (error) {
183
+ const rollbackError = await deleteCreatedAuthUser(supabase, userId);
184
+ const message = error instanceof Error ? error.message : "Unknown bootstrap error";
185
+ if (rollbackError) {
186
+ throw new Error(
187
+ `${message} Automatic rollback also failed: ${rollbackError.message}. Auth user ${userId} may require manual cleanup.`,
188
+ );
189
+ }
190
+ throw new Error(`${message} The newly created Auth user and its cascaded profile/role were rolled back.`);
191
+ }
192
+
193
+ (runtimeOptions.output || output).write(
194
+ `Created administrator ${email}. A password-set link was sent for ${resetPasswordUrl}.\n`,
195
+ );
196
+ return {
197
+ email,
198
+ userId,
199
+ profileId,
200
+ resetPasswordUrl,
201
+ };
202
+ }
203
+
204
+ export { HELP as ADMIN_HELP };
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { APP_DEPENDENCY_DEFAULTS, MODULE_STARTER_FILES, PLATFORM_STARTER_FILES, SELECTABLE_MODULES } from "./constants.mjs";
6
+ import { normalizeSafeRelativePath, resolveSafeRelativePath } from "./safe-path.mjs";
6
7
 
7
8
  const TEMPLATE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "template");
8
9
 
@@ -86,6 +87,10 @@ export async function readAppManifest(targetDir, { required = true } = {}) {
86
87
  if (!manifest && required) {
87
88
  throw new Error(`No BrightWeb app manifest found at ${APP_MANIFEST_PATH}. Pre-manifest apps must be adopted before using bw.`);
88
89
  }
90
+ if (manifest) {
91
+ const errors = validateAppManifest(manifest);
92
+ if (errors.length > 0) throw new Error(`Invalid BrightWeb app manifest: ${errors.join("; ")}`);
93
+ }
89
94
  return manifest;
90
95
  }
91
96
 
@@ -104,6 +109,7 @@ export async function writeAppManifest(targetDir, manifest) {
104
109
 
105
110
  export function validateAppManifest(manifest) {
106
111
  const errors = [];
112
+ const isNullableString = (value) => value === null || typeof value === "string";
107
113
  if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) return ["manifest must be an object"];
108
114
  if (manifest.contractVersion !== 1) errors.push("contractVersion must equal 1");
109
115
  if (!manifest.app || typeof manifest.app.slug !== "string" || !["platform", "site"].includes(manifest.app.template) || typeof manifest.app.scaffoldedWith !== "string") {
@@ -113,13 +119,26 @@ export function validateAppManifest(manifest) {
113
119
  if (!manifest[key] || typeof manifest[key] !== "object" || Array.isArray(manifest[key])) errors.push(`${key} must be an object`);
114
120
  }
115
121
  if (!Array.isArray(manifest.managedFiles) || manifest.managedFiles.some((entry) => typeof entry !== "string")) errors.push("managedFiles must be an array of paths");
122
+ else for (const [index, relativePath] of manifest.managedFiles.entries()) {
123
+ try { normalizeSafeRelativePath(relativePath, `managedFiles[${index}]`); } catch (error) { errors.push(error.message); }
124
+ }
116
125
  for (const [key, entry] of Object.entries(manifest.modules || {})) {
117
126
  if (!entry || !cleanVersion(entry.version) || typeof entry.installedAt !== "string" || typeof entry.exposed !== "boolean") errors.push(`modules.${key} is invalid`);
118
127
  }
119
128
  for (const [relativePath, entry] of Object.entries(manifest.scaffoldFiles || {})) {
129
+ try { normalizeSafeRelativePath(relativePath, `scaffoldFiles path`); } catch (error) { errors.push(error.message); }
120
130
  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
131
  }
122
132
  if (manifest.lastDoctor != null && (typeof manifest.lastDoctor.at !== "string" || typeof manifest.lastDoctor.ok !== "boolean")) errors.push("lastDoctor is invalid");
133
+ if (
134
+ manifest.infrastructure != null
135
+ && (
136
+ typeof manifest.infrastructure !== "object"
137
+ || Array.isArray(manifest.infrastructure)
138
+ || !isNullableString(manifest.infrastructure.supabaseRegion)
139
+ || !isNullableString(manifest.infrastructure.vercelRegion)
140
+ )
141
+ ) errors.push("infrastructure is invalid");
123
142
  return errors;
124
143
  }
125
144
 
@@ -150,13 +169,22 @@ export async function collectScaffoldFiles(targetDir, selectedModules) {
150
169
  }
151
170
  const result = {};
152
171
  for (const [relativePath, moduleKey] of Array.from(files.entries()).sort()) {
153
- const targetPath = path.join(targetDir, relativePath);
172
+ const targetPath = resolveSafeRelativePath(targetDir, relativePath, "Scaffold file path");
154
173
  if (await pathExists(targetPath)) result[relativePath] = { module: moduleKey, hash: await hashFile(targetPath), status: "current" };
155
174
  }
156
175
  return result;
157
176
  }
158
177
 
159
- export async function createInitialAppManifest({ targetDir, slug, template, selectedModules, versionMap, dbInstallPlan, cliVersion }) {
178
+ export async function createInitialAppManifest({
179
+ targetDir,
180
+ slug,
181
+ template,
182
+ selectedModules,
183
+ versionMap,
184
+ dbInstallPlan,
185
+ cliVersion,
186
+ infrastructure,
187
+ }) {
160
188
  const now = new Date().toISOString();
161
189
  const modules = {};
162
190
  if (template === "platform") {
@@ -179,6 +207,7 @@ export async function createInitialAppManifest({ targetDir, slug, template, sele
179
207
  scaffoldFiles: template === "platform" ? await collectScaffoldFiles(targetDir, selectedModules) : {},
180
208
  managedFiles: template === "platform" ? MANAGED_APP_FILES : ["docs/ai/app-context.json"],
181
209
  migrationCursor,
210
+ ...(infrastructure ? { infrastructure } : {}),
182
211
  };
183
212
  }
184
213
 
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
@@ -154,6 +154,7 @@ export const PLATFORM_STARTER_FILES = [
154
154
  "app/(shell)/dashboard/dashboard-live-mount.tsx",
155
155
  "app/(shell)/dashboard/page.tsx",
156
156
  "app/api/account/route.ts",
157
+ "app/api/cron/keepalive/route.ts",
157
158
  "app/api/invitations/_dependencies.ts",
158
159
  "app/api/invitations/[invitationId]/route.ts",
159
160
  "app/api/invitations/[invitationId]/accept/route.ts",
@@ -170,16 +171,16 @@ export const PLATFORM_STARTER_FILES = [
170
171
  ];
171
172
 
172
173
  export const APP_DEPENDENCY_DEFAULTS = {
173
- "@brightweblabs/app-shell": "^0.7.4",
174
- "@brightweblabs/core-auth": "^0.7.2",
174
+ "@brightweblabs/app-shell": "^0.8.0",
175
+ "@brightweblabs/core-auth": "^0.7.3",
175
176
  "@brightweblabs/infra": "^0.4.0",
176
- "@brightweblabs/module-admin": "^0.5.10",
177
- "@brightweblabs/module-crm": "^0.10.0",
178
- "@brightweblabs/module-marketing": "^0.2.9",
179
- "@brightweblabs/module-orgs": "^0.3.10",
180
- "@brightweblabs/module-projects": "^0.9.1",
181
- "@brightweblabs/theme": "^0.5.0",
182
- "@brightweblabs/ui": "^1.1.1",
177
+ "@brightweblabs/module-admin": "^0.5.11",
178
+ "@brightweblabs/module-crm": "^0.10.1",
179
+ "@brightweblabs/module-marketing": "^0.2.10",
180
+ "@brightweblabs/module-orgs": "^0.3.11",
181
+ "@brightweblabs/module-projects": "^0.9.2",
182
+ "@brightweblabs/theme": "^0.5.1",
183
+ "@brightweblabs/ui": "^1.2.0",
183
184
  "geist": "1.7.2",
184
185
  "lucide-react": "^1.8.0",
185
186
  "next": "^16.0.0",
@@ -230,6 +231,7 @@ Scaffold options:
230
231
  --target-dir <path> Exact output directory, bypassing slug folder creation
231
232
  --workspace-root <path> BrightWeb workspace root for local mode
232
233
  --dependency-mode <mode> "workspace" or "published"
234
+ --supabase-region <region> Supabase project region used to place Vercel Functions
233
235
  --install Install dependencies after scaffolding
234
236
  --no-install Skip dependency installation
235
237
  --yes Accept defaults for any missing optional prompt
package/src/diff.mjs CHANGED
@@ -4,6 +4,7 @@ import { stdout as output } from "node:process";
4
4
  import { findWorkspaceRoot, readAppManifest } from "./app-manifest.mjs";
5
5
  import { pathExists } from "./generator.mjs";
6
6
  import { findTrackedTemplate, scaffoldDrift } from "./scaffold.mjs";
7
+ import { normalizeSafeRelativePath, resolveSafeRelativePath } from "./safe-path.mjs";
7
8
 
8
9
  const HELP = `Usage: bw diff <relpath> [options]\n bw diff --list [options]\n\nOptions:\n --target-dir <path> App directory (defaults to cwd)\n --workspace-root <path> BrightWeb workspace root\n --list Print tracked scaffold drift status\n --help Show this help`;
9
10
 
@@ -58,8 +59,7 @@ export async function diffBrightwebScaffold(relativePath, argvOptions = {}, runt
58
59
  return { list: true, drift };
59
60
  }
60
61
  if (!relativePath) throw new Error("bw diff requires a tracked scaffold <relpath>, or pass --list.");
61
- const normalized = path.normalize(relativePath).replace(/^\.\//, "");
62
- if (path.isAbsolute(relativePath) || normalized.startsWith(`..${path.sep}`)) throw new Error(`Scaffold path must be relative to the app: ${relativePath}`);
62
+ const normalized = normalizeSafeRelativePath(relativePath, "Scaffold path");
63
63
  const workspaceRoot = runtimeOptions.workspaceRoot || argvOptions.workspaceRoot || await findWorkspaceRoot(targetDir);
64
64
  const located = await findTrackedTemplate({ relativePath: normalized, manifest, targetDir, workspaceRoot });
65
65
  if (!located.record) throw new Error(`${normalized} is not a tracked scaffold file.`);
@@ -68,7 +68,7 @@ export async function diffBrightwebScaffold(relativePath, argvOptions = {}, runt
68
68
  output.write(`${warning}\n`);
69
69
  return { supported: false, warning };
70
70
  }
71
- const appPath = path.join(targetDir, normalized);
71
+ const appPath = resolveSafeRelativePath(targetDir, normalized, "Scaffold path");
72
72
  const [templateContent, appContent] = await Promise.all([
73
73
  fs.readFile(located.templatePath, "utf8"),
74
74
  pathExists(appPath) ? fs.readFile(appPath, "utf8") : Promise.resolve(""),
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 || {})]))