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 +7 -1
- package/package.json +3 -2
- package/src/add.mjs +10 -2
- package/src/admin.mjs +204 -0
- package/src/app-manifest.mjs +31 -2
- package/src/bw.mjs +3 -1
- package/src/constants.mjs +11 -9
- package/src/diff.mjs +3 -3
- package/src/doctor.mjs +151 -9
- package/src/env.mjs +43 -0
- package/src/generator.mjs +291 -9
- package/src/migrations.mjs +4 -1
- package/src/regions.mjs +60 -0
- package/src/remove.mjs +18 -2
- package/src/safe-path.mjs +46 -0
- package/src/scaffold-cmd.mjs +3 -10
- package/src/scaffold.mjs +3 -2
- package/src/update.mjs +35 -0
- package/src/upgrade.mjs +5 -3
- package/template/base/app/(auth)/login/page.tsx +5 -1
- package/template/base/app/(shell)/shell-layout-client.tsx +29 -15
- package/template/base/app/layout.tsx +12 -4
- package/template/base/config/module-toolbar-controls.tsx +6 -0
- package/template/supabase/modules/admin/migrations/20260729120000_bootstrap_first_admin.sql +114 -0
- package/template/supabase/modules/admin/migrations/20260729150000_bootstrap_first_admin_remove_force.sql +116 -0
- package/template/supabase/modules/marketing/migrations/20260729170000_marketing_webhook_transaction.sql +106 -0
package/src/env.mjs
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { pathExists } from "./generator.mjs";
|
|
4
|
+
|
|
5
|
+
function parseValue(rawValue) {
|
|
6
|
+
const trimmed = rawValue.trim();
|
|
7
|
+
if (
|
|
8
|
+
(trimmed.startsWith('"') && trimmed.endsWith('"'))
|
|
9
|
+
|| (trimmed.startsWith("'") && trimmed.endsWith("'"))
|
|
10
|
+
) {
|
|
11
|
+
return trimmed.slice(1, -1);
|
|
12
|
+
}
|
|
13
|
+
return trimmed.replace(/\s+#.*$/, "").trim();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function parseDotEnv(content) {
|
|
17
|
+
const values = {};
|
|
18
|
+
for (const line of content.split(/\r?\n/)) {
|
|
19
|
+
const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/);
|
|
20
|
+
if (!match) continue;
|
|
21
|
+
values[match[1]] = parseValue(match[2]);
|
|
22
|
+
}
|
|
23
|
+
return values;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function loadAppEnvironment(targetDir, runtimeEnv = process.env) {
|
|
27
|
+
const envPath = path.join(targetDir, ".env.local");
|
|
28
|
+
const fileValues = await pathExists(envPath)
|
|
29
|
+
? parseDotEnv(await fs.readFile(envPath, "utf8"))
|
|
30
|
+
: {};
|
|
31
|
+
const runtimeValues = Object.fromEntries(
|
|
32
|
+
Object.entries(runtimeEnv || {}).filter(([, value]) => typeof value === "string"),
|
|
33
|
+
);
|
|
34
|
+
return { ...fileValues, ...runtimeValues };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function readFirstEnvironmentValue(environment, names) {
|
|
38
|
+
for (const name of names) {
|
|
39
|
+
const value = environment[name]?.trim();
|
|
40
|
+
if (value) return value;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
package/src/generator.mjs
CHANGED
|
@@ -17,6 +17,12 @@ import {
|
|
|
17
17
|
TEMPLATE_OPTIONS,
|
|
18
18
|
} from "./constants.mjs";
|
|
19
19
|
import { createInitialAppManifest, writeAppManifest } from "./app-manifest.mjs";
|
|
20
|
+
import {
|
|
21
|
+
createVercelConfig,
|
|
22
|
+
nearestVercelRegion,
|
|
23
|
+
normalizeSupabaseRegion,
|
|
24
|
+
regionSetupNote,
|
|
25
|
+
} from "./regions.mjs";
|
|
20
26
|
|
|
21
27
|
export const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
22
28
|
export const TEMPLATE_ROOT = path.join(PACKAGE_ROOT, "template");
|
|
@@ -416,13 +422,14 @@ export function createPlatformModulesConfigFile(selectedModules) {
|
|
|
416
422
|
].join("\n");
|
|
417
423
|
}
|
|
418
424
|
|
|
419
|
-
function createEnvFileContent() {
|
|
425
|
+
function createEnvFileContent(supabaseRegion) {
|
|
420
426
|
return [
|
|
421
427
|
"NEXT_PUBLIC_APP_URL=http://localhost:3000",
|
|
422
428
|
"PUBLIC_APP_URL=http://localhost:3000",
|
|
423
429
|
"NEXT_PUBLIC_SUPABASE_URL=",
|
|
424
430
|
"NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY=",
|
|
425
431
|
"SUPABASE_SECRET_DEFAULT_KEY=",
|
|
432
|
+
`SUPABASE_PROJECT_REGION=${normalizeSupabaseRegion(supabaseRegion) || ""}`,
|
|
426
433
|
"RESEND_API_KEY=",
|
|
427
434
|
"RESEND_WEBHOOK_SECRET=",
|
|
428
435
|
"RESEND_FROM_TRANSACTIONAL=",
|
|
@@ -512,6 +519,7 @@ function createPlatformReadme({
|
|
|
512
519
|
workspaceMode,
|
|
513
520
|
packageManager,
|
|
514
521
|
dbInstallPlan,
|
|
522
|
+
supabaseRegion,
|
|
515
523
|
}) {
|
|
516
524
|
const moduleLines = SELECTABLE_MODULES.map((moduleDefinition) => {
|
|
517
525
|
const enabled = selectedModules.includes(moduleDefinition.key);
|
|
@@ -524,12 +532,16 @@ function createPlatformReadme({
|
|
|
524
532
|
? [
|
|
525
533
|
"1. Review `.env.local` and fill in real service credentials.",
|
|
526
534
|
"2. Run `pnpm install` from the BrightWeb workspace root.",
|
|
527
|
-
|
|
535
|
+
"3. Link the Supabase project and run `supabase db push` from this app.",
|
|
536
|
+
"4. Run `bw admin create --email you@example.com` to create the first administrator.",
|
|
537
|
+
`5. Run \`pnpm --filter ${slug} dev\`.`,
|
|
528
538
|
]
|
|
529
539
|
: [
|
|
530
540
|
"1. Review `.env.local` and fill in real service credentials.",
|
|
531
541
|
`2. Run \`${packageManager} install\`.`,
|
|
532
|
-
|
|
542
|
+
"3. Link the Supabase project and run `supabase db push`.",
|
|
543
|
+
"4. Run `bw admin create --email you@example.com` to create the first administrator.",
|
|
544
|
+
`5. Run \`${packageManager} dev\`.`,
|
|
533
545
|
];
|
|
534
546
|
|
|
535
547
|
return [
|
|
@@ -563,6 +575,16 @@ function createPlatformReadme({
|
|
|
563
575
|
"",
|
|
564
576
|
]
|
|
565
577
|
: []),
|
|
578
|
+
"## First administrator",
|
|
579
|
+
"",
|
|
580
|
+
"Run `bw admin create --email you@example.com` after the generated migrations and `.env.local` values are in place.",
|
|
581
|
+
"The command refuses projects that already contain an admin unless `--force` is explicit, refuses to promote an existing Auth user, never accepts a password, and sends the new user through the Core Auth `/reset-password` flow.",
|
|
582
|
+
"",
|
|
583
|
+
"## Function region",
|
|
584
|
+
"",
|
|
585
|
+
regionSetupNote(supabaseRegion),
|
|
586
|
+
"Keep `SUPABASE_PROJECT_REGION` current if the Supabase project is replaced, then run `bw doctor --deployment-url https://your-app.example` to compare it with the deployed Vercel Function region.",
|
|
587
|
+
"",
|
|
566
588
|
"## Package mounts",
|
|
567
589
|
"",
|
|
568
590
|
...getPlatformStarterRoutes(selectedModules).map((route) => `- \`${route}\``),
|
|
@@ -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({
|
|
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
|
|
1621
|
+
const vercelConfig = createVercelConfig(answers.supabaseRegion);
|
|
1355
1622
|
|
|
1356
|
-
await fs.writeFile(path.join(targetDir, ".env.local"),
|
|
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, {
|
|
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") {
|
package/src/migrations.mjs
CHANGED
|
@@ -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)
|
|
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) {
|
package/src/regions.mjs
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Supabase: https://supabase.com/docs/guides/platform/regions
|
|
2
|
+
// Vercel: https://vercel.com/docs/regions
|
|
3
|
+
// Verified 2026-07-29. Keep this list explicit; unknown regions must remain unpinned.
|
|
4
|
+
export const SUPABASE_TO_VERCEL_REGION = Object.freeze({
|
|
5
|
+
americas: "iad1",
|
|
6
|
+
emea: "fra1",
|
|
7
|
+
apac: "sin1",
|
|
8
|
+
"us-west-1": "sfo1",
|
|
9
|
+
"us-west-2": "pdx1",
|
|
10
|
+
"us-east-1": "iad1",
|
|
11
|
+
"us-east-2": "cle1",
|
|
12
|
+
"ca-central-1": "yul1",
|
|
13
|
+
"eu-west-1": "dub1",
|
|
14
|
+
"eu-west-2": "lhr1",
|
|
15
|
+
"eu-west-3": "cdg1",
|
|
16
|
+
"eu-central-1": "fra1",
|
|
17
|
+
"eu-central-2": "fra1",
|
|
18
|
+
"eu-north-1": "arn1",
|
|
19
|
+
"ap-south-1": "bom1",
|
|
20
|
+
"ap-southeast-1": "sin1",
|
|
21
|
+
"ap-northeast-1": "hnd1",
|
|
22
|
+
"ap-northeast-2": "icn1",
|
|
23
|
+
"ap-southeast-2": "syd1",
|
|
24
|
+
"sa-east-1": "gru1",
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export function normalizeSupabaseRegion(value) {
|
|
28
|
+
const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
29
|
+
return normalized || null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function nearestVercelRegion(supabaseRegion) {
|
|
33
|
+
const normalized = normalizeSupabaseRegion(supabaseRegion);
|
|
34
|
+
return normalized ? SUPABASE_TO_VERCEL_REGION[normalized] ?? null : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createVercelConfig(supabaseRegion) {
|
|
38
|
+
const vercelRegion = nearestVercelRegion(supabaseRegion);
|
|
39
|
+
return {
|
|
40
|
+
config: vercelRegion
|
|
41
|
+
? {
|
|
42
|
+
$schema: "https://openapi.vercel.sh/vercel.json",
|
|
43
|
+
regions: [vercelRegion],
|
|
44
|
+
}
|
|
45
|
+
: {
|
|
46
|
+
$schema: "https://openapi.vercel.sh/vercel.json",
|
|
47
|
+
},
|
|
48
|
+
supabaseRegion: normalizeSupabaseRegion(supabaseRegion),
|
|
49
|
+
vercelRegion,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function regionSetupNote(supabaseRegion) {
|
|
54
|
+
const normalized = normalizeSupabaseRegion(supabaseRegion);
|
|
55
|
+
const vercelRegion = nearestVercelRegion(normalized);
|
|
56
|
+
if (vercelRegion) {
|
|
57
|
+
return `Supabase region \`${normalized}\` maps to Vercel Functions region \`${vercelRegion}\` in \`vercel.json\`.`;
|
|
58
|
+
}
|
|
59
|
+
return "<!-- vercel.json region placeholder: once the Supabase region is known, set SUPABASE_PROJECT_REGION and add the nearest verified Vercel region as `\"regions\": [\"<region>\"]`. -->";
|
|
60
|
+
}
|
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 =
|
|
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(
|
|
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 };
|