create-bw-app 0.10.0 → 0.11.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.
Files changed (45) hide show
  1. package/README.md +16 -1
  2. package/bin/bw.mjs +8 -0
  3. package/package.json +5 -2
  4. package/src/add.mjs +100 -0
  5. package/src/adopt.mjs +176 -0
  6. package/src/app-manifest.mjs +250 -0
  7. package/src/bw.mjs +53 -0
  8. package/src/constants.mjs +22 -11
  9. package/src/diff.mjs +85 -0
  10. package/src/doctor.mjs +99 -0
  11. package/src/generator.mjs +68 -12
  12. package/src/migrations.mjs +92 -0
  13. package/src/remove.mjs +100 -0
  14. package/src/scaffold.mjs +82 -0
  15. package/src/update.mjs +51 -3
  16. package/src/upgrade.mjs +73 -0
  17. package/template/base/AGENTS.md +2 -0
  18. package/template/base/app/globals.css +30 -14
  19. package/template/base/app/page.tsx +1 -0
  20. package/template/base/config/bootstrap.ts +1 -1
  21. package/template/base/config/brand.ts +0 -2
  22. package/template/base/config/modules.ts +2 -2
  23. package/template/base/config/shell.overrides.ts +16 -0
  24. package/template/base/docs/ai/README.md +6 -3
  25. package/template/base/docs/ai/examples.md +4 -2
  26. package/template/base/public/brand/logo-dark.svg +2 -2
  27. package/template/base/public/brand/logo-light.svg +2 -2
  28. package/template/base/public/brand/logo-mark.svg +2 -2
  29. package/template/module-manifests/admin/brightweb.module.json +6 -0
  30. package/template/module-manifests/crm/brightweb.module.json +7 -0
  31. package/template/module-manifests/orgs/brightweb.module.json +6 -0
  32. package/template/module-manifests/projects/brightweb.module.json +6 -0
  33. package/template/modules/crm/app/api/crm/contacts/route.ts +10 -0
  34. package/template/modules/crm/app/api/crm/timeline/route.ts +8 -0
  35. package/template/modules/crm/app/crm/layout.tsx +5 -0
  36. package/template/modules/crm/app/crm/page.tsx +5 -0
  37. package/template/supabase/module-registry.json +9 -3
  38. package/template/supabase/modules/crm/README.md +2 -0
  39. package/template/supabase/modules/crm/migrations/20260316092000_crm_v1.sql +3 -253
  40. package/template/supabase/modules/crm/migrations/20260316092010_crm_org_integration.sql +66 -0
  41. package/template/supabase/modules/crm/migrations/20260421201523_portal_read_indexes.sql +19 -0
  42. package/template/supabase/modules/orgs/README.md +4 -0
  43. package/template/supabase/modules/orgs/migrations/20260316091500_orgs_v1.sql +216 -0
  44. package/template/supabase/modules/projects/migrations/20260421201528_portal_read_indexes.sql +6 -0
  45. package/template/modules/crm/app/playground/crm/page.tsx +0 -103
package/src/constants.mjs CHANGED
@@ -39,11 +39,15 @@ export const CORE_PACKAGES = [
39
39
  "@brightweblabs/app-shell",
40
40
  "@brightweblabs/core-auth",
41
41
  "@brightweblabs/infra",
42
+ "@brightweblabs/theme",
42
43
  "@brightweblabs/ui",
43
44
  ];
44
45
 
46
+ export const ORGS_PACKAGE_NAME = "@brightweblabs/module-orgs";
47
+
45
48
  export const BRIGHTWEB_PACKAGE_NAMES = [
46
49
  ...CORE_PACKAGES,
50
+ ORGS_PACKAGE_NAME,
47
51
  ...SELECTABLE_MODULES.map((moduleDefinition) => moduleDefinition.packageName),
48
52
  ];
49
53
 
@@ -54,26 +58,34 @@ export const MODULE_STARTER_FILES = {
54
58
  "app/playground/admin/page.tsx",
55
59
  ],
56
60
  crm: [
61
+ "app/crm/layout.tsx",
62
+ "app/crm/page.tsx",
57
63
  "app/api/crm/contacts/route.ts",
58
64
  "app/api/crm/organizations/route.ts",
59
65
  "app/api/crm/owners/route.ts",
60
66
  "app/api/crm/stats/route.ts",
61
- "app/playground/crm/page.tsx",
67
+ "app/api/crm/timeline/route.ts",
62
68
  ],
63
69
  projects: [
64
70
  "app/playground/projects/page.tsx",
65
71
  ],
66
72
  };
67
73
 
74
+ export const PLATFORM_STARTER_FILES = [
75
+ "config/shell.overrides.ts",
76
+ ];
77
+
68
78
  export const APP_DEPENDENCY_DEFAULTS = {
69
- "@brightweblabs/app-shell": "^0.3.0",
70
- "@brightweblabs/core-auth": "^0.3.1",
71
- "@brightweblabs/infra": "^0.2.1",
72
- "@brightweblabs/module-admin": "^0.3.0",
73
- "@brightweblabs/module-crm": "^0.3.0",
74
- "@brightweblabs/module-projects": "^0.2.2",
75
- "@brightweblabs/ui": "^0.3.0",
76
- "lucide-react": "^0.562.0",
79
+ "@brightweblabs/app-shell": "^0.4.0",
80
+ "@brightweblabs/core-auth": "^0.3.4",
81
+ "@brightweblabs/infra": "^0.3.1",
82
+ "@brightweblabs/module-admin": "^0.3.4",
83
+ "@brightweblabs/module-crm": "^0.5.0",
84
+ "@brightweblabs/module-orgs": "^0.2.0",
85
+ "@brightweblabs/module-projects": "^0.4.2",
86
+ "@brightweblabs/theme": "^0.2.0",
87
+ "@brightweblabs/ui": "^1.0.0",
88
+ "lucide-react": "^1.8.0",
77
89
  "next": "16.1.6",
78
90
  "react": "19.2.3",
79
91
  "react-dom": "19.2.3",
@@ -82,7 +94,7 @@ export const APP_DEPENDENCY_DEFAULTS = {
82
94
  export const SITE_DEPENDENCY_DEFAULTS = {
83
95
  "class-variance-authority": "^0.7.1",
84
96
  "clsx": "^2.1.1",
85
- "lucide-react": "^0.562.0",
97
+ "lucide-react": "^1.8.0",
86
98
  "next": "16.1.6",
87
99
  "react": "19.2.3",
88
100
  "react-dom": "19.2.3",
@@ -110,7 +122,6 @@ export const DEFAULTS = {
110
122
  tagline: "A configurable Brightweb starter app for new client instances.",
111
123
  contactEmail: "hello@example.com",
112
124
  supportEmail: "support@example.com",
113
- primaryHex: "#1f7a45",
114
125
  };
115
126
 
116
127
  export const HELP_TEXT = `
package/src/diff.mjs ADDED
@@ -0,0 +1,85 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { stdout as output } from "node:process";
4
+ import { findWorkspaceRoot, readAppManifest } from "./app-manifest.mjs";
5
+ import { pathExists } from "./generator.mjs";
6
+ import { findTrackedTemplate, scaffoldDrift } from "./scaffold.mjs";
7
+
8
+ 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
+ function splitLines(content) {
11
+ const lines = content.split("\n");
12
+ if (lines.at(-1) === "") lines.pop();
13
+ return lines;
14
+ }
15
+
16
+ export function unifiedLineDiff(beforeContent, afterContent, beforeName, afterName) {
17
+ const before = splitLines(beforeContent);
18
+ const after = splitLines(afterContent);
19
+ const table = Array.from({ length: before.length + 1 }, () => new Uint32Array(after.length + 1));
20
+ for (let left = before.length - 1; left >= 0; left -= 1) {
21
+ for (let right = after.length - 1; right >= 0; right -= 1) {
22
+ table[left][right] = before[left] === after[right]
23
+ ? table[left + 1][right + 1] + 1
24
+ : Math.max(table[left + 1][right], table[left][right + 1]);
25
+ }
26
+ }
27
+ const body = [];
28
+ let left = 0;
29
+ let right = 0;
30
+ while (left < before.length || right < after.length) {
31
+ if (left < before.length && right < after.length && before[left] === after[right]) {
32
+ body.push(` ${before[left]}`); left += 1; right += 1;
33
+ } else if (right < after.length && (left === before.length || table[left][right + 1] >= table[left + 1][right])) {
34
+ body.push(`+${after[right]}`); right += 1;
35
+ } else {
36
+ body.push(`-${before[left]}`); left += 1;
37
+ }
38
+ }
39
+ return [
40
+ `--- a/${beforeName}`,
41
+ `+++ b/${afterName}`,
42
+ `@@ -1,${before.length} +1,${after.length} @@`,
43
+ ...body,
44
+ "",
45
+ ].join("\n");
46
+ }
47
+
48
+ export async function diffBrightwebScaffold(relativePath, argvOptions = {}, runtimeOptions = {}) {
49
+ if (argvOptions.help) { output.write(`${HELP}\n`); return { help: true }; }
50
+ const targetDir = path.resolve(runtimeOptions.targetDir || argvOptions.targetDir || process.cwd());
51
+ const manifest = await readAppManifest(targetDir);
52
+ if (argvOptions.list) {
53
+ const drift = await scaffoldDrift(targetDir, manifest.scaffoldFiles);
54
+ output.write("STATUS\tSCAFFOLD FILE\n");
55
+ for (const status of ["current", "drifted", "missing"]) {
56
+ for (const file of drift[status]) output.write(`${status}\t${file}\n`);
57
+ }
58
+ return { list: true, drift };
59
+ }
60
+ if (!relativePath) throw new Error("bw diff requires a tracked scaffold <relpath>, or pass --list.");
61
+ const normalized = path.normalize(relativePath).replace(/^\.\//, "");
62
+ if (path.isAbsolute(relativePath) || normalized.startsWith(`..${path.sep}`)) throw new Error(`Scaffold path must be relative to the app: ${relativePath}`);
63
+ const workspaceRoot = runtimeOptions.workspaceRoot || argvOptions.workspaceRoot || await findWorkspaceRoot(targetDir);
64
+ const located = await findTrackedTemplate({ relativePath: normalized, manifest, targetDir, workspaceRoot });
65
+ if (!located.record) throw new Error(`${normalized} is not a tracked scaffold file.`);
66
+ if (!located.templatePath) {
67
+ const warning = `WARN Installed-package template unavailable for ${normalized}; diff is unsupported.`;
68
+ output.write(`${warning}\n`);
69
+ return { supported: false, warning };
70
+ }
71
+ const appPath = path.join(targetDir, normalized);
72
+ const [templateContent, appContent] = await Promise.all([
73
+ fs.readFile(located.templatePath, "utf8"),
74
+ pathExists(appPath) ? fs.readFile(appPath, "utf8") : Promise.resolve(""),
75
+ ]);
76
+ if (templateContent === appContent) {
77
+ output.write(`${normalized}: identical\n`);
78
+ return { supported: true, identical: true, diff: "" };
79
+ }
80
+ const diff = unifiedLineDiff(templateContent, appContent, `template/${normalized}`, normalized);
81
+ output.write(diff);
82
+ return { supported: true, identical: false, diff };
83
+ }
84
+
85
+ export { HELP as DIFF_HELP };
package/src/doctor.mjs ADDED
@@ -0,0 +1,99 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { stdout as output } from "node:process";
4
+ import { cursorMigrationStatus } from "./migrations.mjs";
5
+ import { findWorkspaceRoot, loadModuleCatalog, MODULE_PACKAGES, readAppManifest, readConfiguredModuleFlags, satisfiesVersion, validateAppManifest, writeAppManifest } from "./app-manifest.mjs";
6
+ import { pathExists, readJsonIfPresent } from "./generator.mjs";
7
+ import { scaffoldDrift } from "./scaffold.mjs";
8
+
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`;
10
+
11
+ export async function doctorBrightwebApp(argvOptions = {}, runtimeOptions = {}) {
12
+ if (argvOptions.help) { output.write(`${HELP}\n`); return { help: true, ok: true }; }
13
+ const targetDir = path.resolve(runtimeOptions.targetDir || argvOptions.targetDir || process.cwd());
14
+ const checks = [];
15
+ const add = (status, id, message) => checks.push({ status, id, message });
16
+ let appManifest;
17
+ try { appManifest = await readAppManifest(targetDir); } catch (error) {
18
+ add("FAIL", "manifest", error instanceof Error ? error.message : String(error));
19
+ return finish(checks, argvOptions, null, targetDir);
20
+ }
21
+ const validationErrors = validateAppManifest(appManifest);
22
+ if (validationErrors.length > 0) add("FAIL", "manifest", validationErrors.join("; "));
23
+ else add("PASS", "manifest", "App manifest is valid.");
24
+
25
+ const packageJson = await readJsonIfPresent(path.join(targetDir, "package.json"));
26
+ const dependencyMap = { ...(packageJson?.dependencies || {}), ...(packageJson?.devDependencies || {}) };
27
+ const packageProblems = [];
28
+ for (const [key, entry] of Object.entries(appManifest.modules || {})) {
29
+ const packageName = MODULE_PACKAGES[key];
30
+ if (!packageName || !dependencyMap[packageName]) packageProblems.push(`${key}: ${packageName || "unknown package"} is missing`);
31
+ else if (!satisfiesVersion(entry.version, dependencyMap[packageName])) packageProblems.push(`${key}@${entry.version} does not satisfy package.json ${dependencyMap[packageName]}`);
32
+ }
33
+ for (const [key, packageName] of Object.entries(MODULE_PACKAGES)) {
34
+ if (dependencyMap[packageName] && !appManifest.modules[key]) packageProblems.push(`${packageName} is installed but absent from manifest.modules`);
35
+ }
36
+ add(packageProblems.length ? "FAIL" : "PASS", "packages", packageProblems.join("; ") || "Installed module packages agree with the manifest.");
37
+
38
+ const flags = await readConfiguredModuleFlags(targetDir);
39
+ const exposureProblems = Object.entries(appManifest.modules || {}).filter(([key, entry]) => typeof flags[key] === "boolean" && flags[key] !== entry.exposed).map(([key, entry]) => `${key}: manifest exposed=${entry.exposed}, config enabled=${String(flags[key])}`);
40
+ add(exposureProblems.length ? "FAIL" : "PASS", "exposure", exposureProblems.join("; ") || "Module exposure flags agree.");
41
+
42
+ const workspaceRoot = runtimeOptions.workspaceRoot || argvOptions.workspaceRoot || await findWorkspaceRoot(targetDir);
43
+ const catalog = await loadModuleCatalog({ targetDir, workspaceRoot });
44
+ const available = { core: catalog.core.version, admin: catalog.admin.version, ...Object.fromEntries(Object.entries(appManifest.modules || {}).map(([key, entry]) => [key, entry.version])) };
45
+ const topologyProblems = [];
46
+ for (const key of Object.keys(appManifest.modules || {})) {
47
+ for (const [requiredKey, range] of Object.entries(catalog[key]?.requires || {})) {
48
+ if (!available[requiredKey]) topologyProblems.push(`${key} requires missing ${requiredKey}@${range}`);
49
+ else if (!satisfiesVersion(available[requiredKey], range)) topologyProblems.push(`${key} requires ${requiredKey}@${range}, found ${available[requiredKey]}`);
50
+ }
51
+ }
52
+ add(topologyProblems.length ? "FAIL" : "PASS", "topology", topologyProblems.join("; ") || "Module requirements are satisfied.");
53
+
54
+ const scaffold = await scaffoldDrift(targetDir, appManifest.scaffoldFiles);
55
+ add(scaffold.drifted.length || scaffold.missing.length ? "FAIL" : "PASS", "scaffold", `${scaffold.current.length} current, ${scaffold.drifted.length} drifted, ${scaffold.missing.length} missing; drifted scaffold files: ${scaffold.drifted.length} (see bw diff --list)${scaffold.missing.length ? `; missing: ${scaffold.missing.join(", ")}` : ""}`);
56
+ add("INFO", "owned-surfaces", `Owned surfaces: ${(appManifest.ownedSurfaces || []).join(", ") || "none"}.`);
57
+
58
+ const envNames = new Set(Object.keys(process.env));
59
+ const envPath = path.join(targetDir, ".env.local");
60
+ if (await pathExists(envPath)) {
61
+ for (const line of (await fs.readFile(envPath, "utf8")).split(/\r?\n/)) {
62
+ const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/);
63
+ if (match) envNames.add(match[1]);
64
+ }
65
+ }
66
+ const missingEnv = [];
67
+ 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}`);
68
+ add(missingEnv.length ? "FAIL" : "PASS", "env", missingEnv.length ? `Missing required names: ${missingEnv.join(", ")}` : "Required environment variable names are present.");
69
+
70
+ const migrationProblems = [];
71
+ const migrationKeys = appManifest.app.template === "platform"
72
+ ? Array.from(new Set(["core", "admin", ...Object.keys(appManifest.modules || {})]))
73
+ : [];
74
+ for (const key of migrationKeys) {
75
+ const cursor = appManifest.migrationCursor?.[key];
76
+ const status = await cursorMigrationStatus({ targetDir, moduleKey: key, cursor, catalogEntry: catalog[key] });
77
+ if (status.shipsMigrations && cursor == null) {
78
+ if (appManifest.adoptionNotes?.allowUncursored) add("WARN", `migration-cursor-${key}`, `${key}: migration cursor is null; adoption explicitly allowed uncursored operation.`);
79
+ else migrationProblems.push(`${key}: migration cursor is null (run bw adopt --force --cursor ${key}=<migrationFilename>, or explicitly adopt with --allow-uncursored)`);
80
+ continue;
81
+ }
82
+ if (status.shipsMigrations && status.missing.length > 0) migrationProblems.push(`${key}: ${status.missing.join(", ")}`);
83
+ }
84
+ add(migrationProblems.length ? "FAIL" : "PASS", "migrations", migrationProblems.join("; ") || "Migration cursors and flattened files agree.");
85
+ add("WARN", "db-objects", "SKIP live database checks are not available yet.");
86
+ return finish(checks, argvOptions, appManifest, targetDir);
87
+ }
88
+
89
+ async function finish(checks, options, appManifest, targetDir) {
90
+ for (const check of checks) output.write(`${check.status} ${check.id}: ${check.message}\n`);
91
+ const hasFailure = checks.some((check) => check.status === "FAIL") || (options.strict && checks.some((check) => check.status === "WARN"));
92
+ if (options.report && appManifest) {
93
+ appManifest.lastDoctor = { at: new Date().toISOString(), ok: !hasFailure };
94
+ await writeAppManifest(targetDir, appManifest);
95
+ }
96
+ return { ok: !hasFailure, checks };
97
+ }
98
+
99
+ export { HELP as DOCTOR_HELP };
package/src/generator.mjs CHANGED
@@ -10,11 +10,13 @@ import {
10
10
  CLI_DISPLAY_NAME,
11
11
  CORE_PACKAGES,
12
12
  DEFAULTS,
13
+ ORGS_PACKAGE_NAME,
13
14
  SELECTABLE_MODULES,
14
15
  SITE_DEPENDENCY_DEFAULTS,
15
16
  SITE_DEV_DEPENDENCY_DEFAULTS,
16
17
  TEMPLATE_OPTIONS,
17
18
  } from "./constants.mjs";
19
+ import { createInitialAppManifest, writeAppManifest } from "./app-manifest.mjs";
18
20
 
19
21
  export const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
20
22
  export const TEMPLATE_ROOT = path.join(PACKAGE_ROOT, "template");
@@ -24,8 +26,9 @@ const DEFAULT_DB_MODULE_REGISTRY = {
24
26
  modules: {
25
27
  core: { label: "Core", dependsOn: [] },
26
28
  admin: { label: "Admin", dependsOn: ["core"] },
27
- crm: { label: "CRM", dependsOn: ["core", "admin"] },
28
- projects: { label: "Projects", dependsOn: ["core", "admin", "crm"] },
29
+ orgs: { label: "Organizations", dependsOn: ["core", "admin"] },
30
+ crm: { label: "CRM", dependsOn: ["core", "admin", "orgs"] },
31
+ projects: { label: "Projects", dependsOn: ["core", "admin", "orgs"] },
29
32
  },
30
33
  };
31
34
 
@@ -131,7 +134,8 @@ export async function getDbModuleRegistry(workspaceRoot) {
131
134
  return DEFAULT_DB_MODULE_REGISTRY;
132
135
  }
133
136
 
134
- function resolveModuleOrder(registry, enabledModules) {
137
+ // Duplicated by design: scripts/_db-modules.mjs — keep in sync.
138
+ export function resolveModuleOrder(registry, enabledModules) {
135
139
  const resolved = [];
136
140
  const visited = new Set();
137
141
  const visiting = new Set();
@@ -247,7 +251,9 @@ export async function getVersionMap(workspaceRoot) {
247
251
  "@brightweblabs/infra",
248
252
  "@brightweblabs/module-admin",
249
253
  "@brightweblabs/module-crm",
254
+ "@brightweblabs/module-orgs",
250
255
  "@brightweblabs/module-projects",
256
+ "@brightweblabs/theme",
251
257
  "@brightweblabs/ui",
252
258
  ]) {
253
259
  const folderName = packageName.replace("@brightweblabs/", "");
@@ -270,7 +276,6 @@ function createDerivedBrandValues(slug) {
270
276
  tagline: DEFAULTS.tagline,
271
277
  contactEmail: DEFAULTS.contactEmail,
272
278
  supportEmail: DEFAULTS.supportEmail,
273
- primaryHex: DEFAULTS.primaryHex,
274
279
  };
275
280
  }
276
281
 
@@ -283,7 +288,6 @@ function createPlatformBrandConfigFile({ slug, brandValues }) {
283
288
  " tagline: string;",
284
289
  " contactEmail: string;",
285
290
  " supportEmail: string;",
286
- " primaryHex: string;",
287
291
  "};",
288
292
  "",
289
293
  "export const starterBrandConfig: StarterBrandConfig = {",
@@ -293,7 +297,6 @@ function createPlatformBrandConfigFile({ slug, brandValues }) {
293
297
  ` tagline: ${JSON.stringify(brandValues.tagline)},`,
294
298
  ` contactEmail: ${JSON.stringify(brandValues.contactEmail)},`,
295
299
  ` supportEmail: ${JSON.stringify(brandValues.supportEmail)},`,
296
- ` primaryHex: ${JSON.stringify(brandValues.primaryHex)},`,
297
300
  "};",
298
301
  "",
299
302
  ].join("\n");
@@ -301,9 +304,10 @@ function createPlatformBrandConfigFile({ slug, brandValues }) {
301
304
 
302
305
  export function createPlatformModulesConfigFile(selectedModules) {
303
306
  const selected = new Set(selectedModules);
307
+ const orgsEnabled = selected.has("crm") || selected.has("projects");
304
308
 
305
309
  return [
306
- 'export type StarterModuleKey = "core-auth" | "crm" | "projects" | "admin";',
310
+ 'export type StarterModuleKey = "core-auth" | "orgs" | "crm" | "projects" | "admin";',
307
311
  "",
308
312
  "export type StarterModuleConfig = {",
309
313
  " key: StarterModuleKey;",
@@ -312,7 +316,7 @@ export function createPlatformModulesConfigFile(selectedModules) {
312
316
  " enabled: boolean;",
313
317
  " packageName: string;",
314
318
  " playgroundHref?: string;",
315
- ' placement: "core" | "primary" | "admin";',
319
+ ' placement: "core" | "primary" | "admin" | "hidden";',
316
320
  "};",
317
321
  "",
318
322
  "export const starterModuleConfig: StarterModuleConfig[] = [",
@@ -326,12 +330,20 @@ export function createPlatformModulesConfigFile(selectedModules) {
326
330
  ' placement: "core",',
327
331
  " },",
328
332
  " {",
333
+ ' key: "orgs",',
334
+ ' label: "Organizations",',
335
+ ' description: "Shared organizations, membership, and invitation foundation for CRM and Projects.",',
336
+ ` enabled: ${String(orgsEnabled)},`,
337
+ ' packageName: "@brightweblabs/module-orgs",',
338
+ ' placement: "hidden",',
339
+ " },",
340
+ " {",
329
341
  ' key: "crm",',
330
342
  ' label: "CRM",',
331
- ' description: "Contacts, marketing audience, and CRM server/data layer.",',
343
+ ' description: "Contacts and CRM server/data layer, with marketing-adjacent operational data stored in Supabase.",',
332
344
  ` enabled: ${String(selected.has("crm"))},`,
333
345
  ' packageName: "@brightweblabs/module-crm",',
334
- ' playgroundHref: "/playground/crm",',
346
+ ' playgroundHref: "/crm",',
335
347
  ' placement: "primary",',
336
348
  " },",
337
349
  " {",
@@ -431,7 +443,7 @@ function getPlatformStarterRoutes(selectedModules) {
431
443
  "/bootstrap",
432
444
  "/preview/app-shell",
433
445
  "/playground/auth",
434
- ...selectedModules.map((moduleKey) => `/playground/${moduleKey}`),
446
+ ...selectedModules.map((moduleKey) => moduleKey === "crm" ? "/crm" : `/playground/${moduleKey}`),
435
447
  ];
436
448
  }
437
449
 
@@ -636,11 +648,13 @@ export function createAppContextFile({
636
648
  "AGENTS.md",
637
649
  "docs/ai/README.md",
638
650
  "README.md",
651
+ "app/globals.css",
639
652
  "config/brand.ts",
640
653
  "config/modules.ts",
641
654
  "config/client.ts",
642
655
  "config/bootstrap.ts",
643
656
  "config/shell.ts",
657
+ "config/shell.overrides.ts",
644
658
  ".env.local",
645
659
  ],
646
660
  appRoutesRoot: "app",
@@ -661,6 +675,7 @@ export function createAppContextFile({
661
675
  ],
662
676
  packageOwned: [
663
677
  ...CORE_PACKAGES,
678
+ ...(selectedModules.includes("crm") || selectedModules.includes("projects") ? [ORGS_PACKAGE_NAME] : []),
664
679
  ...SELECTABLE_MODULES
665
680
  .filter((moduleDefinition) => selectedModules.includes(moduleDefinition.key))
666
681
  .map((moduleDefinition) => moduleDefinition.packageName),
@@ -722,6 +737,7 @@ export function createPackageJson({
722
737
  "@brightweblabs/app-shell": internalDependencyVersion("@brightweblabs/app-shell"),
723
738
  "@brightweblabs/core-auth": internalDependencyVersion("@brightweblabs/core-auth"),
724
739
  "@brightweblabs/infra": internalDependencyVersion("@brightweblabs/infra"),
740
+ "@brightweblabs/theme": internalDependencyVersion("@brightweblabs/theme"),
725
741
  "@brightweblabs/ui": internalDependencyVersion("@brightweblabs/ui"),
726
742
  "lucide-react": versionMap["lucide-react"],
727
743
  "next": versionMap.next,
@@ -734,6 +750,9 @@ export function createPackageJson({
734
750
  dependencies[moduleDefinition.packageName] = internalDependencyVersion(moduleDefinition.packageName);
735
751
  }
736
752
  }
753
+ if (selectedModules.includes("crm") || selectedModules.includes("projects")) {
754
+ dependencies[ORGS_PACKAGE_NAME] = internalDependencyVersion(ORGS_PACKAGE_NAME);
755
+ }
737
756
 
738
757
  return {
739
758
  name: slug,
@@ -770,6 +789,9 @@ export function createNextConfig({ template, selectedModules }) {
770
789
  }
771
790
 
772
791
  const transpilePackages = [...CORE_PACKAGES];
792
+ if (selectedModules.includes("crm") || selectedModules.includes("projects")) {
793
+ transpilePackages.push(ORGS_PACKAGE_NAME);
794
+ }
773
795
 
774
796
  for (const moduleDefinition of SELECTABLE_MODULES) {
775
797
  if (selectedModules.includes(moduleDefinition.key)) {
@@ -795,6 +817,11 @@ export function createShellConfig(selectedModules) {
795
817
  const importLines = [];
796
818
  const registrationLines = [];
797
819
 
820
+ if (selectedModules.includes("crm") || selectedModules.includes("projects")) {
821
+ importLines.push('import { orgsModuleRegistration } from "@brightweblabs/module-orgs/registration";');
822
+ registrationLines.push(' if (enabled.has("orgs")) registrations.push(orgsModuleRegistration);');
823
+ }
824
+
798
825
  if (selectedModules.includes("admin")) {
799
826
  importLines.push('import { adminModuleRegistration } from "@brightweblabs/module-admin/registration";');
800
827
  registrationLines.push(' if (enabled.has("admin")) registrations.push(adminModuleRegistration);');
@@ -811,8 +838,10 @@ export function createShellConfig(selectedModules) {
811
838
  }
812
839
 
813
840
  return [
841
+ "// MANAGED BY BRIGHTWEB — regenerated by create-bw-app update; put customizations in config/shell.overrides.ts",
814
842
  'import { LayoutDashboard, Wrench } from "lucide-react";',
815
843
  "import {",
844
+ " applyShellRegistrationOverrides,",
816
845
  " buildClientAppShellRegistration,",
817
846
  " resolveClientAppShellConfig,",
818
847
  " type ClientAppShellRegistration,",
@@ -822,6 +851,7 @@ export function createShellConfig(selectedModules) {
822
851
  ...importLines,
823
852
  'import { starterBrandConfig } from "./brand";',
824
853
  'import { getEnabledStarterModules } from "./modules";',
854
+ 'import { shellRegistrationOverrides } from "./shell.overrides";',
825
855
  "",
826
856
  "const dashboardModuleRegistration: ShellModuleRegistration<ShellContextualAction> = {",
827
857
  ' key: "dashboard",',
@@ -841,6 +871,10 @@ export function createShellConfig(selectedModules) {
841
871
  "",
842
872
  "export function getStarterShellConfig() {",
843
873
  " const enabledModules = getEnabledStarterModules();",
874
+ " const modules = applyShellRegistrationOverrides(",
875
+ " getStarterModuleRegistrations(),",
876
+ " shellRegistrationOverrides,",
877
+ " );",
844
878
  " const shellRegistration: ClientAppShellRegistration<ShellContextualAction> = {",
845
879
  " brand: {",
846
880
  ' href: "/",',
@@ -868,7 +902,7 @@ export function createShellConfig(selectedModules) {
868
902
  " icon: Wrench,",
869
903
  ' collapsedHref: enabledModules.find((moduleConfig) => moduleConfig.playgroundHref)?.playgroundHref || "/",',
870
904
  " },",
871
- " modules: getStarterModuleRegistrations(),",
905
+ " modules,",
872
906
  " };",
873
907
  "",
874
908
  " const builtRegistration = buildClientAppShellRegistration(shellRegistration);",
@@ -1257,6 +1291,7 @@ async function scaffoldPlatformProject({
1257
1291
 
1258
1292
  if (workspaceMode) {
1259
1293
  await writeClientStack(workspaceRoot, answers.slug, dbInstallPlan, { workspaceMode: true });
1294
+ await writeSupabaseCliMigrations({ targetDir, dbInstallPlan });
1260
1295
  } else {
1261
1296
  await writeBundledSupabaseBaseline({
1262
1297
  targetDir,
@@ -1265,6 +1300,17 @@ async function scaffoldPlatformProject({
1265
1300
  registry: dbRegistry,
1266
1301
  });
1267
1302
  }
1303
+
1304
+ const cliPackage = await readJsonIfPresent(path.join(PACKAGE_ROOT, "package.json"));
1305
+ await writeAppManifest(targetDir, await createInitialAppManifest({
1306
+ targetDir,
1307
+ slug: answers.slug,
1308
+ template: "platform",
1309
+ selectedModules,
1310
+ versionMap,
1311
+ dbInstallPlan,
1312
+ cliVersion: cliPackage?.version || "0.0.0",
1313
+ }));
1268
1314
  }
1269
1315
 
1270
1316
  async function scaffoldSiteProject({
@@ -1316,6 +1362,16 @@ async function scaffoldSiteProject({
1316
1362
  packageManager,
1317
1363
  }),
1318
1364
  );
1365
+ const cliPackage = await readJsonIfPresent(path.join(PACKAGE_ROOT, "package.json"));
1366
+ await writeAppManifest(targetDir, await createInitialAppManifest({
1367
+ targetDir,
1368
+ slug: answers.slug,
1369
+ template: "site",
1370
+ selectedModules: [],
1371
+ versionMap,
1372
+ dbInstallPlan: { resolvedOrder: [] },
1373
+ cliVersion: cliPackage?.version || "0.0.0",
1374
+ }));
1319
1375
  }
1320
1376
 
1321
1377
  function printCompletionMessage({ targetDir, workspaceMode, slug, packageManager, install }) {
@@ -0,0 +1,92 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { TEMPLATE_ROOT, pathExists } from "./generator.mjs";
4
+
5
+ export async function findAppMigrationsDirectory(targetDir) {
6
+ let current = path.resolve(targetDir);
7
+ while (true) {
8
+ const candidate = path.join(current, "supabase", "migrations");
9
+ if (await pathExists(candidate)) return candidate;
10
+ const parent = path.dirname(current);
11
+ if (parent === current) return path.join(path.resolve(targetDir), "supabase", "migrations");
12
+ current = parent;
13
+ }
14
+ }
15
+
16
+ export async function getModuleMigrations(moduleKey, catalogEntry = {}) {
17
+ const candidates = [];
18
+ const configuredPath = catalogEntry.manifest?.database?.migrations;
19
+ if (catalogEntry.packageRoot && configuredPath) candidates.push(path.resolve(catalogEntry.packageRoot, configuredPath));
20
+ if (catalogEntry.packageRoot) candidates.push(path.join(catalogEntry.packageRoot, "migrations"));
21
+ candidates.push(path.join(TEMPLATE_ROOT, "supabase", "modules", moduleKey, "migrations"));
22
+ for (const directory of candidates) {
23
+ if (!(await pathExists(directory))) continue;
24
+ const fileNames = (await fs.readdir(directory)).filter((fileName) => fileName.endsWith(".sql")).sort();
25
+ if (fileNames.length > 0) return fileNames.map((fileName) => ({ fileName, sourcePath: path.join(directory, fileName) }));
26
+ }
27
+ return [];
28
+ }
29
+
30
+ export async function planMigrationAppends({ targetDir, moduleKeys, catalog, migrationCursor = {} }) {
31
+ const migrationsDir = await findAppMigrationsDirectory(targetDir);
32
+ const existing = (await pathExists(migrationsDir))
33
+ ? (await fs.readdir(migrationsDir)).filter((fileName) => fileName.endsWith(".sql")).sort()
34
+ : [];
35
+ let sequence = existing.reduce((maximum, fileName) => {
36
+ const match = fileName.match(/^(\d+)_/);
37
+ return Math.max(maximum, Number(match?.[1] || 0));
38
+ }, 0);
39
+ const writes = [];
40
+ const nextCursor = { ...migrationCursor };
41
+ for (const moduleKey of moduleKeys) {
42
+ const migrations = await getModuleMigrations(moduleKey, catalog[moduleKey]);
43
+ const cursor = migrationCursor[moduleKey];
44
+ const pending = cursor ? migrations.filter((entry) => entry.fileName > cursor) : migrations;
45
+ for (const entry of pending) {
46
+ sequence += 1;
47
+ const targetFileName = `${String(sequence).padStart(4, "0")}_${moduleKey}__${entry.fileName}`;
48
+ const source = await fs.readFile(entry.sourcePath, "utf8");
49
+ const version = catalog[moduleKey]?.version || "unknown";
50
+ writes.push({
51
+ moduleKey,
52
+ originalFileName: entry.fileName,
53
+ targetFileName,
54
+ targetPath: path.join(migrationsDir, targetFileName),
55
+ content: `-- bw-module: ${moduleKey}@${version} ${entry.fileName}\n${source}`,
56
+ });
57
+ }
58
+ if (migrations.length > 0) nextCursor[moduleKey] = migrations.at(-1).fileName;
59
+ }
60
+ return { writes, nextCursor };
61
+ }
62
+
63
+ export async function applyMigrationWrites(writes) {
64
+ for (const write of writes) {
65
+ await fs.mkdir(path.dirname(write.targetPath), { recursive: true });
66
+ await fs.writeFile(write.targetPath, write.content, "utf8");
67
+ }
68
+ }
69
+
70
+ export async function cursorMigrationStatus({ targetDir, moduleKey, cursor, catalogEntry }) {
71
+ const migrations = await getModuleMigrations(moduleKey, catalogEntry);
72
+ if (migrations.length === 0) return { shipsMigrations: false, missing: [] };
73
+ if (!cursor) return { shipsMigrations: true, missing: ["migration cursor"] };
74
+ const expected = migrations.filter((entry) => entry.fileName <= cursor);
75
+ const migrationsDir = await findAppMigrationsDirectory(targetDir);
76
+ const installed = [];
77
+ if (await pathExists(migrationsDir)) {
78
+ for (const fileName of await fs.readdir(migrationsDir)) {
79
+ if (!fileName.endsWith(".sql")) continue;
80
+ const content = await fs.readFile(path.join(migrationsDir, fileName), "utf8");
81
+ installed.push({ fileName, content });
82
+ }
83
+ }
84
+ return {
85
+ shipsMigrations: true,
86
+ missing: expected.filter((entry) => !installed.some(({ fileName, content }) => {
87
+ if (fileName === entry.fileName || fileName.endsWith(`_${moduleKey}__${entry.fileName}`)) return true;
88
+ const header = content.match(/^\s*--\s*bw-module:\s*([^@\s]+)@[^\s]+\s+([^\s]+)/im);
89
+ return header?.[1] === moduleKey && header?.[2] === entry.fileName;
90
+ })).map((entry) => entry.fileName),
91
+ };
92
+ }