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/README.md
CHANGED
|
@@ -33,11 +33,13 @@ From a generated app, use `bw` to manage the machine-readable `.brightweb/app-ma
|
|
|
33
33
|
bw add projects
|
|
34
34
|
bw upgrade
|
|
35
35
|
bw doctor
|
|
36
|
+
bw admin create --email owner@example.com
|
|
36
37
|
```
|
|
37
38
|
|
|
38
39
|
- `bw add <moduleKey>` resolves requirements, installs thin package mounts and module wiring, and appends migrations.
|
|
39
40
|
- `bw upgrade [moduleKey]` includes the existing managed update flow plus forward-only module migrations.
|
|
40
|
-
- `bw doctor` checks package, config, scaffold, environment-name, and
|
|
41
|
+
- `bw doctor` checks package, config, scaffold, environment-name, migration, configured function-region, and deployed function-region consistency. Pass `--deployment-url` to inspect the deployed `x-vercel-id`; add `--report` to stamp the result in the app manifest.
|
|
42
|
+
- `bw admin create --email <email>` creates a passwordless Supabase Auth user, transactionally ensures its profile and `admin` assignment, then sends the Core Auth `/reset-password` flow. It 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.
|
|
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
|
-
|
|
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 };
|
package/src/app-manifest.mjs
CHANGED
|
@@ -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 =
|
|
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({
|
|
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.
|
|
174
|
-
"@brightweblabs/core-auth": "^0.7.
|
|
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.
|
|
177
|
-
"@brightweblabs/module-crm": "^0.10.
|
|
178
|
-
"@brightweblabs/module-marketing": "^0.2.
|
|
179
|
-
"@brightweblabs/module-orgs": "^0.3.
|
|
180
|
-
"@brightweblabs/module-projects": "^0.9.
|
|
181
|
-
"@brightweblabs/theme": "^0.5.
|
|
182
|
-
"@brightweblabs/ui": "^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 =
|
|
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 =
|
|
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
|
|
111
|
-
const
|
|
112
|
-
if (await pathExists(envPath)) {
|
|
113
|
-
for (const line of (await fs.readFile(envPath, "utf8")).split(/\r?\n/)) {
|
|
114
|
-
const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/);
|
|
115
|
-
if (match) envNames.add(match[1]);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
249
|
+
const environment = await loadAppEnvironment(targetDir, runtimeOptions.env || process.env);
|
|
250
|
+
const envNames = new Set(Object.keys(environment));
|
|
118
251
|
const missingEnv = [];
|
|
119
252
|
for (const key of Object.keys(appManifest.modules || {})) for (const entry of catalog[key]?.manifest?.env || []) if (entry.required && !envNames.has(entry.name)) missingEnv.push(`${key}:${entry.name}`);
|
|
120
253
|
add(missingEnv.length ? "FAIL" : "PASS", "env", missingEnv.length ? `Missing required names: ${missingEnv.join(", ")}` : "Required environment variable names are present.");
|
|
121
254
|
|
|
255
|
+
await inspectFunctionRegion({
|
|
256
|
+
targetDir,
|
|
257
|
+
options: argvOptions,
|
|
258
|
+
runtimeOptions,
|
|
259
|
+
appManifest,
|
|
260
|
+
environment,
|
|
261
|
+
add,
|
|
262
|
+
});
|
|
263
|
+
|
|
122
264
|
const migrationProblems = [];
|
|
123
265
|
const migrationKeys = appManifest.app.template === "platform"
|
|
124
266
|
? Array.from(new Set(["core", "admin", ...Object.keys(appManifest.modules || {})]))
|