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/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}\``),
@@ -817,7 +839,7 @@ export function createPackageJson({
817
839
  version: "0.0.0",
818
840
  scripts: {
819
841
  dev: "next dev",
820
- build: "next build",
842
+ build: "next build --webpack",
821
843
  start: "next start",
822
844
  lint: "tsc --noEmit",
823
845
  },
@@ -875,6 +897,7 @@ export async function createPlatformGlobalsCss(selectedModules) {
875
897
  const sourcePackages = [
876
898
  "@brightweblabs/ui",
877
899
  "@brightweblabs/app-shell",
900
+ "@brightweblabs/core-auth",
878
901
  ...SELECTABLE_MODULES
879
902
  .filter((moduleDefinition) => selectedModules.includes(moduleDefinition.key))
880
903
  .map((moduleDefinition) => moduleDefinition.packageName),
@@ -996,6 +1019,211 @@ export function createShellConfig(selectedModules) {
996
1019
  ].join("\n");
997
1020
  }
998
1021
 
1022
+ export function createModuleToolbarControlsConfig(selectedModules) {
1023
+ const imports = [];
1024
+ const branches = [];
1025
+
1026
+ if (selectedModules.includes("admin")) {
1027
+ imports.push('import { AdminToolbarControls } from "@brightweblabs/module-admin/ui";');
1028
+ branches.push(' if (pathname === "/admin/users") return <AdminToolbarControls />;');
1029
+ }
1030
+ if (selectedModules.includes("crm")) {
1031
+ imports.push('import { CrmToolbarControls } from "@brightweblabs/module-crm/ui";');
1032
+ branches.push(' if (pathname === "/crm") return <CrmToolbarControls />;');
1033
+ }
1034
+ if (selectedModules.includes("projects")) {
1035
+ imports.push('import { ProjectsToolbarControls } from "@brightweblabs/module-projects/ui";');
1036
+ branches.push(' if (pathname === projectsBaseHref) return <ProjectsToolbarControls />;');
1037
+ }
1038
+
1039
+ return [
1040
+ '"use client";',
1041
+ "",
1042
+ "// MANAGED BY BRIGHTWEB — regenerated when modules are added, removed, or updated.",
1043
+ ...imports,
1044
+ imports.length > 0 ? "" : null,
1045
+ "export function getModuleToolbarControls(pathname: string, projectsBaseHref: string) {",
1046
+ ...branches,
1047
+ " return null;",
1048
+ "}",
1049
+ "",
1050
+ ].filter((line) => line !== null).join("\n");
1051
+ }
1052
+
1053
+ function createOrganizationRoute(methods, enabled) {
1054
+ const lines = ['export const dynamic = "force-dynamic";', ""];
1055
+ for (const { method, handler, context = false } of methods) {
1056
+ if (enabled) {
1057
+ lines.push(
1058
+ `export async function ${method}(request: Request${context ? ', context: { params: Promise<{ id: string }> }' : ""}) {`,
1059
+ ` const { ${handler} } = await import("@brightweblabs/module-orgs");`,
1060
+ ` return ${handler}(request${context ? ", context" : ""});`,
1061
+ "}",
1062
+ "",
1063
+ );
1064
+ } else {
1065
+ lines.push(
1066
+ `export async function ${method}(_request: Request${context ? ', _context: { params: Promise<{ id: string }> }' : ""}) {`,
1067
+ ' return new Response(null, { status: 404 });',
1068
+ "}",
1069
+ "",
1070
+ );
1071
+ }
1072
+ }
1073
+ return lines.join("\n");
1074
+ }
1075
+
1076
+ export function createOptionalModuleRouteFiles(selectedModules) {
1077
+ const adminEnabled = selectedModules.includes("admin");
1078
+ const crmEnabled = selectedModules.includes("crm");
1079
+ const orgsEnabled = selectedModules.includes("orgs")
1080
+ || crmEnabled
1081
+ || selectedModules.includes("marketing")
1082
+ || selectedModules.includes("projects");
1083
+ const dependencyImports = [
1084
+ 'import { requireServerUserAccess } from "@brightweblabs/core-auth/server";',
1085
+ 'import type { InvitationHttpDependencies } from "@brightweblabs/core-auth/routes";',
1086
+ 'import { requireServiceRoleClient } from "@brightweblabs/infra/server";',
1087
+ ];
1088
+
1089
+ if (adminEnabled) {
1090
+ dependencyImports.push(
1091
+ 'import { getAdminUserInvitationDetails, registerUserFromAdminInvitation } from "@brightweblabs/module-admin";',
1092
+ );
1093
+ }
1094
+ if (orgsEnabled) {
1095
+ dependencyImports.push(
1096
+ 'import { acceptOrganizationInvitation, getOrganizationInvitationDetails, registerUserFromOrganizationInvitation } from "@brightweblabs/module-orgs";',
1097
+ );
1098
+ }
1099
+ if (crmEnabled) {
1100
+ dependencyImports.push(
1101
+ 'import { ensureCrmContactForProfile } from "@brightweblabs/module-crm";',
1102
+ );
1103
+ }
1104
+
1105
+ const invitationDependencies = adminEnabled && orgsEnabled && crmEnabled
1106
+ ? [
1107
+ 'import { requireServerUserAccess } from "@brightweblabs/core-auth/server";',
1108
+ 'import { requireServiceRoleClient } from "@brightweblabs/infra/server";',
1109
+ "import {",
1110
+ " getAdminUserInvitationDetails,",
1111
+ " registerUserFromAdminInvitation,",
1112
+ '} from "@brightweblabs/module-admin";',
1113
+ 'import { ensureCrmContactForProfile } from "@brightweblabs/module-crm";',
1114
+ "import {",
1115
+ " acceptOrganizationInvitation,",
1116
+ " getOrganizationInvitationDetails,",
1117
+ " registerUserFromOrganizationInvitation,",
1118
+ '} from "@brightweblabs/module-orgs";',
1119
+ "",
1120
+ "export const invitationHttpDependencies = {",
1121
+ " getServiceClient: requireServiceRoleClient,",
1122
+ " getAccess: requireServerUserAccess,",
1123
+ " getOrganizationInvitation: getOrganizationInvitationDetails,",
1124
+ " getAdminInvitation: getAdminUserInvitationDetails,",
1125
+ " registerOrganizationInvitation: (client: never, input: {",
1126
+ " invitationId: string;",
1127
+ " firstName: string;",
1128
+ " lastName: string;",
1129
+ " password: string;",
1130
+ " }) => registerUserFromOrganizationInvitation(client, {",
1131
+ " ...input,",
1132
+ " ensureCrmContactForProfile,",
1133
+ " }),",
1134
+ " registerAdminInvitation: registerUserFromAdminInvitation,",
1135
+ " acceptOrganizationInvitation: (client: never, input: {",
1136
+ " invitationId: string;",
1137
+ " profileId: string;",
1138
+ " userEmail: string;",
1139
+ " }) => acceptOrganizationInvitation(client, {",
1140
+ " ...input,",
1141
+ " ensureCrmContactForProfile,",
1142
+ " }),",
1143
+ "};",
1144
+ "",
1145
+ ].join("\n")
1146
+ : [
1147
+ ...dependencyImports,
1148
+ "",
1149
+ ...(!adminEnabled ? [
1150
+ "const getAdminUserInvitationDetails = async (_client: never, _invitationId: string) => null;",
1151
+ 'const registerUserFromAdminInvitation = async (_client: never, _input: unknown): Promise<never> => { throw new Error("INVITATION_NOT_FOUND"); };',
1152
+ ] : []),
1153
+ ...(!orgsEnabled ? [
1154
+ "const getOrganizationInvitationDetails = async (_client: never, _invitationId: string) => null;",
1155
+ 'const registerUserFromOrganizationInvitation = async (_client: never, _input: unknown): Promise<never> => { throw new Error("INVITATION_NOT_FOUND"); };',
1156
+ 'const acceptOrganizationInvitation = async (_client: never, _input: unknown): Promise<never> => { throw new Error("Convite não encontrado."); };',
1157
+ ] : []),
1158
+ ...(!crmEnabled ? [
1159
+ "const ensureCrmContactForProfile = async () => ({ success: true as const });",
1160
+ ] : []),
1161
+ (!adminEnabled || !orgsEnabled || !crmEnabled) ? "" : null,
1162
+ "export const invitationHttpDependencies = {",
1163
+ " getServiceClient: requireServiceRoleClient,",
1164
+ " getAccess: requireServerUserAccess,",
1165
+ " getOrganizationInvitation: getOrganizationInvitationDetails,",
1166
+ " getAdminInvitation: getAdminUserInvitationDetails,",
1167
+ " registerOrganizationInvitation: (client: never, input: {",
1168
+ " invitationId: string;",
1169
+ " firstName: string;",
1170
+ " lastName: string;",
1171
+ " password: string;",
1172
+ " }) => registerUserFromOrganizationInvitation(client, {",
1173
+ " ...input,",
1174
+ " ensureCrmContactForProfile,",
1175
+ " }),",
1176
+ " registerAdminInvitation: registerUserFromAdminInvitation,",
1177
+ " acceptOrganizationInvitation: (client: never, input: {",
1178
+ " invitationId: string;",
1179
+ " profileId: string;",
1180
+ " userEmail: string;",
1181
+ " }) => acceptOrganizationInvitation(client, {",
1182
+ " ...input,",
1183
+ " ensureCrmContactForProfile,",
1184
+ " }),",
1185
+ "} satisfies InvitationHttpDependencies;",
1186
+ "",
1187
+ ].filter((line) => line !== null).join("\n");
1188
+
1189
+ return {
1190
+ "app/api/invitations/_dependencies.ts": invitationDependencies,
1191
+ "app/api/organizations/route.ts": createOrganizationRoute([
1192
+ { method: "POST", handler: "handleOrganizationsPostRequest" },
1193
+ ], orgsEnabled),
1194
+ "app/api/organizations/[id]/route.ts": createOrganizationRoute([
1195
+ { method: "PATCH", handler: "handleOrganizationPatchRequest", context: true },
1196
+ ], orgsEnabled),
1197
+ "app/api/organizations/[id]/invitations/route.ts": createOrganizationRoute([
1198
+ { method: "GET", handler: "handleOrganizationInvitationsGetRequest", context: true },
1199
+ { method: "POST", handler: "handleOrganizationInvitationsPostRequest", context: true },
1200
+ ], orgsEnabled),
1201
+ "app/api/organizations/[id]/invitations/[invitationId]/route.ts": orgsEnabled
1202
+ ? [
1203
+ 'export const dynamic = "force-dynamic";',
1204
+ "",
1205
+ "export async function DELETE(",
1206
+ " request: Request,",
1207
+ " context: { params: Promise<{ id: string; invitationId: string }> },",
1208
+ ") {",
1209
+ ' const { handleOrganizationInvitationDeleteRequest } = await import("@brightweblabs/module-orgs");',
1210
+ " return handleOrganizationInvitationDeleteRequest(request, context);",
1211
+ "}",
1212
+ "",
1213
+ ].join("\n")
1214
+ : [
1215
+ 'export const dynamic = "force-dynamic";',
1216
+ "",
1217
+ 'type RouteContext = { params: Promise<{ id: string; invitationId: string }> };',
1218
+ "",
1219
+ "export async function DELETE(_request: Request, _context: RouteContext) {",
1220
+ " return new Response(null, { status: 404 });",
1221
+ "}",
1222
+ "",
1223
+ ].join("\n"),
1224
+ };
1225
+ }
1226
+
999
1227
  function createSiteConfigFile(slug) {
1000
1228
  const siteName = titleizeSlug(slug);
1001
1229
 
@@ -1080,6 +1308,10 @@ async function writeClientStack(baseRoot, slug, dbInstallPlan, options = {}) {
1080
1308
  historyMode: "greenfield-modular",
1081
1309
  futureMode: "forward-only-modular",
1082
1310
  enabledModules,
1311
+ infrastructure: {
1312
+ supabaseRegion: normalizeSupabaseRegion(options.supabaseRegion),
1313
+ vercelRegion: nearestVercelRegion(options.supabaseRegion),
1314
+ },
1083
1315
  clientMigrationPath: `supabase/clients/${slug}/migrations`,
1084
1316
  notes: [
1085
1317
  generatedInWorkspaceMode
@@ -1126,7 +1358,13 @@ async function writeSupabaseCliMigrations({ targetDir, dbInstallPlan }) {
1126
1358
  }
1127
1359
  }
1128
1360
 
1129
- async function writeBundledSupabaseBaseline({ targetDir, slug, dbInstallPlan, registry }) {
1361
+ async function writeBundledSupabaseBaseline({
1362
+ targetDir,
1363
+ slug,
1364
+ dbInstallPlan,
1365
+ registry,
1366
+ supabaseRegion,
1367
+ }) {
1130
1368
  const shippedModuleKeys = dbInstallPlan.resolvedOrder;
1131
1369
  if (shippedModuleKeys.length === 0) {
1132
1370
  return;
@@ -1155,7 +1393,7 @@ async function writeBundledSupabaseBaseline({ targetDir, slug, dbInstallPlan, re
1155
1393
  );
1156
1394
  }
1157
1395
 
1158
- await writeClientStack(targetDir, slug, dbInstallPlan);
1396
+ await writeClientStack(targetDir, slug, dbInstallPlan, { supabaseRegion });
1159
1397
  await writeSupabaseCliMigrations({ targetDir, dbInstallPlan });
1160
1398
  }
1161
1399
 
@@ -1188,6 +1426,7 @@ function renderPlanSummary({
1188
1426
  install,
1189
1427
  template,
1190
1428
  dbInstallPlan,
1429
+ supabaseRegion,
1191
1430
  }) {
1192
1431
  const installLocation = workspaceMode ? "workspace root" : "project directory";
1193
1432
  const templateLabel = TEMPLATE_OPTIONS.find((templateOption) => templateOption.key === template)?.label || template;
@@ -1209,6 +1448,12 @@ function renderPlanSummary({
1209
1448
  ...dbInstallPlan.notes.map((note) => `- ${note}`),
1210
1449
  ]
1211
1450
  : []),
1451
+ ...(template === "platform"
1452
+ ? [
1453
+ `Supabase region: ${normalizeSupabaseRegion(supabaseRegion) || "unknown"}`,
1454
+ `Vercel Functions region: ${nearestVercelRegion(supabaseRegion) || "placeholder (not pinned)"}`,
1455
+ ]
1456
+ : []),
1212
1457
  `Install dependencies: ${install ? `yes (${packageManager} in ${installLocation})` : "no"}`,
1213
1458
  ].join("\n");
1214
1459
  }
@@ -1243,6 +1488,7 @@ async function collectAnswers(argvOptions, runtimeOptions) {
1243
1488
  }
1244
1489
 
1245
1490
  let selectedModules = [];
1491
+ let supabaseRegion = null;
1246
1492
 
1247
1493
  if (template === "platform") {
1248
1494
  const askedModules = [];
@@ -1264,6 +1510,15 @@ async function collectAnswers(argvOptions, runtimeOptions) {
1264
1510
  : argvOptions.yes
1265
1511
  ? []
1266
1512
  : askedModules;
1513
+
1514
+ supabaseRegion = normalizeSupabaseRegion(
1515
+ argvOptions.supabaseRegion
1516
+ || (argvOptions.yes
1517
+ ? null
1518
+ : await promptInput({
1519
+ message: "What Supabase region is the project in? (for example eu-west-3; leave blank if unknown)",
1520
+ })),
1521
+ );
1267
1522
  }
1268
1523
 
1269
1524
  const install =
@@ -1280,6 +1535,7 @@ async function collectAnswers(argvOptions, runtimeOptions) {
1280
1535
  slug: resolvedSlug,
1281
1536
  template,
1282
1537
  selectedModules,
1538
+ supabaseRegion,
1283
1539
  install,
1284
1540
  };
1285
1541
  }
@@ -1311,6 +1567,10 @@ async function scaffoldPlatformProject({
1311
1567
  await ensureDirectory(path.dirname(targetDir));
1312
1568
  await copyDirectory(baseTemplateDir, targetDir);
1313
1569
 
1570
+ if (!workspaceMode && packageManager === "pnpm") {
1571
+ await fs.writeFile(path.join(targetDir, "pnpm-workspace.yaml"), "allowBuilds:\n sharp: true\n");
1572
+ }
1573
+
1314
1574
  for (const moduleDefinition of SELECTABLE_MODULES) {
1315
1575
  if (!selectedModules.includes(moduleDefinition.key)) continue;
1316
1576
  const moduleTemplateDir = path.join(TEMPLATE_ROOT, "modules", moduleDefinition.templateFolder);
@@ -1340,7 +1600,14 @@ async function scaffoldPlatformProject({
1340
1600
  createPlatformBrandConfigFile({ slug: answers.slug, brandValues }),
1341
1601
  );
1342
1602
  await fs.writeFile(path.join(targetDir, "config", "modules.ts"), createPlatformModulesConfigFile(selectedModules));
1603
+ await fs.writeFile(
1604
+ path.join(targetDir, "config", "module-toolbar-controls.tsx"),
1605
+ createModuleToolbarControlsConfig(selectedModules),
1606
+ );
1343
1607
  await fs.writeFile(path.join(targetDir, "config", "shell.ts"), createShellConfig(selectedModules));
1608
+ for (const [relativePath, content] of Object.entries(createOptionalModuleRouteFiles(selectedModules))) {
1609
+ await fs.writeFile(path.join(targetDir, relativePath), content);
1610
+ }
1344
1611
  await fs.writeFile(
1345
1612
  path.join(targetDir, "docs", "ai", "app-context.json"),
1346
1613
  createAppContextFile({
@@ -1351,9 +1618,10 @@ async function scaffoldPlatformProject({
1351
1618
  }),
1352
1619
  );
1353
1620
 
1354
- const envFileContent = createEnvFileContent();
1621
+ const vercelConfig = createVercelConfig(answers.supabaseRegion);
1355
1622
 
1356
- await fs.writeFile(path.join(targetDir, ".env.local"), envFileContent);
1623
+ await fs.writeFile(path.join(targetDir, ".env.local"), createEnvFileContent(answers.supabaseRegion));
1624
+ await fs.writeFile(path.join(targetDir, "vercel.json"), `${JSON.stringify(vercelConfig.config, null, 2)}\n`);
1357
1625
  await fs.writeFile(path.join(targetDir, ".gitignore"), createGitignore());
1358
1626
  await fs.writeFile(
1359
1627
  path.join(targetDir, "README.md"),
@@ -1363,11 +1631,15 @@ async function scaffoldPlatformProject({
1363
1631
  workspaceMode,
1364
1632
  packageManager,
1365
1633
  dbInstallPlan,
1634
+ supabaseRegion: answers.supabaseRegion,
1366
1635
  }),
1367
1636
  );
1368
1637
 
1369
1638
  if (workspaceMode) {
1370
- await writeClientStack(workspaceRoot, answers.slug, dbInstallPlan, { workspaceMode: true });
1639
+ await writeClientStack(workspaceRoot, answers.slug, dbInstallPlan, {
1640
+ workspaceMode: true,
1641
+ supabaseRegion: answers.supabaseRegion,
1642
+ });
1371
1643
  await writeSupabaseCliMigrations({ targetDir, dbInstallPlan });
1372
1644
  } else {
1373
1645
  await writeBundledSupabaseBaseline({
@@ -1375,6 +1647,7 @@ async function scaffoldPlatformProject({
1375
1647
  slug: answers.slug,
1376
1648
  dbInstallPlan,
1377
1649
  registry: dbRegistry,
1650
+ supabaseRegion: answers.supabaseRegion,
1378
1651
  });
1379
1652
  }
1380
1653
 
@@ -1387,6 +1660,10 @@ async function scaffoldPlatformProject({
1387
1660
  versionMap,
1388
1661
  dbInstallPlan,
1389
1662
  cliVersion: cliPackage?.version || "0.0.0",
1663
+ infrastructure: {
1664
+ supabaseRegion: vercelConfig.supabaseRegion,
1665
+ vercelRegion: vercelConfig.vercelRegion,
1666
+ },
1390
1667
  }));
1391
1668
  }
1392
1669
 
@@ -1404,6 +1681,9 @@ async function scaffoldSiteProject({
1404
1681
 
1405
1682
  await ensureDirectory(path.dirname(targetDir));
1406
1683
  await copyDirectory(baseTemplateDir, targetDir);
1684
+ if (!workspaceMode && packageManager === "pnpm") {
1685
+ await fs.writeFile(path.join(targetDir, "pnpm-workspace.yaml"), "allowBuilds:\n sharp: true\n");
1686
+ }
1407
1687
  await ensureDirectory(path.join(targetDir, "config"));
1408
1688
  await ensureDirectory(path.join(targetDir, "docs", "ai"));
1409
1689
 
@@ -1515,6 +1795,7 @@ export async function createBrightwebClientApp(argvOptions, runtimeOptions = {})
1515
1795
  install: answers.install,
1516
1796
  template: answers.template,
1517
1797
  dbInstallPlan,
1798
+ supabaseRegion: answers.supabaseRegion,
1518
1799
  })}\n\n`);
1519
1800
  return {
1520
1801
  answers,
@@ -1535,6 +1816,7 @@ export async function createBrightwebClientApp(argvOptions, runtimeOptions = {})
1535
1816
  install: answers.install,
1536
1817
  template: answers.template,
1537
1818
  dbInstallPlan,
1819
+ supabaseRegion: answers.supabaseRegion,
1538
1820
  })}\n\n`);
1539
1821
 
1540
1822
  if (answers.template === "site") {
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { resolveSafeRelativePath } from "./safe-path.mjs";
3
4
  import { TEMPLATE_ROOT, pathExists } from "./generator.mjs";
4
5
 
5
6
  export async function findAppMigrationsDirectory(targetDir) {
@@ -16,7 +17,9 @@ export async function findAppMigrationsDirectory(targetDir) {
16
17
  export async function getModuleMigrations(moduleKey, catalogEntry = {}) {
17
18
  const candidates = [];
18
19
  const configuredPath = catalogEntry.manifest?.database?.migrations;
19
- if (catalogEntry.packageRoot && configuredPath) candidates.push(path.resolve(catalogEntry.packageRoot, configuredPath));
20
+ if (catalogEntry.packageRoot && configuredPath) {
21
+ candidates.push(resolveSafeRelativePath(catalogEntry.packageRoot, configuredPath, `${catalogEntry.key || "Module"} migration manifest path`));
22
+ }
20
23
  if (catalogEntry.packageRoot) candidates.push(path.join(catalogEntry.packageRoot, "migrations"));
21
24
  candidates.push(path.join(TEMPLATE_ROOT, "supabase", "modules", moduleKey, "migrations"));
22
25
  for (const directory of candidates) {
@@ -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
+ }
package/src/remove.mjs CHANGED
@@ -12,7 +12,9 @@ import {
12
12
  import {
13
13
  createAppContextFile,
14
14
  createDbInstallPlan,
15
+ createModuleToolbarControlsConfig,
15
16
  createNextConfig,
17
+ createOptionalModuleRouteFiles,
16
18
  createPlatformGlobalsCss,
17
19
  createPlatformModulesConfigFile,
18
20
  createShellConfig,
@@ -20,6 +22,7 @@ import {
20
22
  pathExists,
21
23
  readJsonIfPresent,
22
24
  } from "./generator.mjs";
25
+ import { resolveSafeRelativePath } from "./safe-path.mjs";
23
26
 
24
27
  const HELP = `Usage: bw remove <moduleKey> [options]\n\nOptions:\n --target-dir <path> App directory (defaults to cwd)\n --workspace-root <path> BrightWeb workspace root\n --dry-run Print the removal plan without writing\n --yes Apply the removal plan\n --help Show this help`;
25
28
 
@@ -61,16 +64,18 @@ export async function removeBrightwebModule(moduleKey, argvOptions = {}, runtime
61
64
  const managedWrites = {
62
65
  "next.config.ts": createNextConfig({ template: "platform", selectedModules: remainingModules }),
63
66
  "app/globals.css": await createPlatformGlobalsCss(remainingModules),
67
+ "config/module-toolbar-controls.tsx": createModuleToolbarControlsConfig(remainingModules),
64
68
  "config/modules.ts": createPlatformModulesConfigFile(remainingModules),
65
69
  "config/shell.ts": createShellConfig(remainingModules),
66
70
  "docs/ai/app-context.json": createAppContextFile({ slug: appManifest.app.slug, template: "platform", selectedModules: remainingModules.filter((key) => key !== "orgs"), dbInstallPlan }),
71
+ ...createOptionalModuleRouteFiles(remainingModules),
67
72
  };
68
73
 
69
74
  const cleanFiles = [];
70
75
  const driftedFiles = [];
71
76
  for (const [relativePath, record] of Object.entries(appManifest.scaffoldFiles || {})) {
72
77
  if (record.module !== moduleKey) continue;
73
- const filePath = path.join(targetDir, relativePath);
78
+ const filePath = resolveSafeRelativePath(targetDir, relativePath, "Manifest scaffold file path");
74
79
  if (!(await pathExists(filePath))) continue;
75
80
  if ((record.intent || "managed") === "managed" && await hashFile(filePath) === record.hash) cleanFiles.push(relativePath);
76
81
  else driftedFiles.push(relativePath);
@@ -86,14 +91,25 @@ export async function removeBrightwebModule(moduleKey, argvOptions = {}, runtime
86
91
  if (!apply) return { dryRun: true, moduleKey, cleanFiles, driftedFiles, notice };
87
92
 
88
93
  await fs.writeFile(packagePath, `${JSON.stringify(nextPackageJson, null, 2)}\n`, "utf8");
89
- for (const relativePath of cleanFiles) await fs.rm(path.join(targetDir, relativePath));
94
+ for (const relativePath of cleanFiles) await fs.rm(resolveSafeRelativePath(targetDir, relativePath, "Manifest scaffold file path"));
90
95
  for (const [relativePath, content] of Object.entries(managedWrites)) {
91
96
  const targetPath = path.join(targetDir, relativePath);
92
97
  await fs.mkdir(path.dirname(targetPath), { recursive: true });
93
98
  await fs.writeFile(targetPath, content, "utf8");
94
99
  }
95
100
  delete appManifest.modules[moduleKey];
101
+ if (appManifest.modules.orgs) {
102
+ appManifest.modules.orgs.exposed = remainingModules.some((key) => ["crm", "marketing", "projects"].includes(key));
103
+ }
96
104
  for (const [relativePath, record] of Object.entries(appManifest.scaffoldFiles || {})) if (record.module === moduleKey) delete appManifest.scaffoldFiles[relativePath];
105
+ for (const relativePath of Object.keys(managedWrites)) {
106
+ const record = appManifest.scaffoldFiles[relativePath];
107
+ if (!record) continue;
108
+ const targetPath = resolveSafeRelativePath(targetDir, relativePath, "Manifest scaffold file path");
109
+ if (!(await pathExists(targetPath))) continue;
110
+ record.hash = await hashFile(targetPath);
111
+ record.status = "current";
112
+ }
97
113
  await writeAppManifest(targetDir, appManifest);
98
114
  output.write(`Removed ${moduleKey} package wiring and ${cleanFiles.length} clean scaffold file${cleanFiles.length === 1 ? "" : "s"}. Install dependencies next.\n`);
99
115
  return { dryRun: false, moduleKey, cleanFiles, driftedFiles, notice };