create-bw-app 0.10.1 → 0.12.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 (41) 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 +101 -0
  5. package/src/adopt.mjs +190 -0
  6. package/src/app-manifest.mjs +257 -0
  7. package/src/bw.mjs +55 -0
  8. package/src/constants.mjs +22 -10
  9. package/src/diff.mjs +85 -0
  10. package/src/doctor.mjs +113 -0
  11. package/src/generator.mjs +66 -8
  12. package/src/migrations.mjs +92 -0
  13. package/src/remove.mjs +100 -0
  14. package/src/scaffold-cmd.mjs +74 -0
  15. package/src/scaffold.mjs +90 -0
  16. package/src/update.mjs +51 -3
  17. package/src/upgrade.mjs +78 -0
  18. package/template/base/app/globals.css +1 -0
  19. package/template/base/config/bootstrap.ts +1 -1
  20. package/template/base/config/modules.ts +1 -1
  21. package/template/base/config/shell.overrides.ts +16 -0
  22. package/template/base/docs/ai/README.md +3 -2
  23. package/template/base/docs/ai/examples.md +2 -2
  24. package/template/base/public/brand/logo-dark.svg +2 -2
  25. package/template/base/public/brand/logo-light.svg +2 -2
  26. package/template/base/public/brand/logo-mark.svg +2 -2
  27. package/template/module-manifests/admin/brightweb.module.json +6 -0
  28. package/template/module-manifests/crm/brightweb.module.json +7 -0
  29. package/template/module-manifests/orgs/brightweb.module.json +6 -0
  30. package/template/module-manifests/projects/brightweb.module.json +6 -0
  31. package/template/modules/crm/app/api/crm/contacts/route.ts +10 -0
  32. package/template/modules/crm/app/api/crm/timeline/route.ts +8 -0
  33. package/template/modules/crm/app/crm/layout.tsx +5 -0
  34. package/template/modules/crm/app/crm/page.tsx +5 -0
  35. package/template/supabase/module-registry.json +9 -3
  36. package/template/supabase/modules/crm/migrations/20260316092000_crm_v1.sql +3 -253
  37. package/template/supabase/modules/crm/migrations/20260316092010_crm_org_integration.sql +66 -0
  38. package/template/supabase/modules/crm/migrations/20260421201523_portal_read_indexes.sql +0 -3
  39. package/template/supabase/modules/orgs/README.md +4 -0
  40. package/template/supabase/modules/orgs/migrations/20260316091500_orgs_v1.sql +216 -0
  41. package/template/modules/crm/app/playground/crm/page.tsx +0 -103
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Scaffold a new BrightWeb app from either the `platform` or `site` starter.
4
4
 
5
- The CLI can also update an existing generated platform app in place.
5
+ The package also installs the `bw` lifecycle CLI for generated apps.
6
6
 
7
7
  ## Workspace usage
8
8
 
@@ -27,6 +27,19 @@ pnpm dlx create-bw-app update
27
27
  npm create bw-app@latest
28
28
  ```
29
29
 
30
+ From a generated app, use `bw` to manage the machine-readable `.brightweb/app-manifest.json` contract:
31
+
32
+ ```bash
33
+ bw add projects
34
+ bw upgrade
35
+ bw doctor
36
+ ```
37
+
38
+ - `bw add <moduleKey>` resolves requirements, installs module wiring and starter overlays, and appends migrations.
39
+ - `bw upgrade [moduleKey]` includes the existing managed update flow plus forward-only module migrations.
40
+ - `bw doctor` checks package, config, scaffold, environment-name, and migration consistency. Add `--report` to stamp the result in the app manifest.
41
+ - All mutating commands support `--dry-run`.
42
+
30
43
  ## Update existing apps
31
44
 
32
45
  Run the updater from an existing generated app directory, or point it at one with `--target-dir`:
@@ -44,6 +57,7 @@ Current updater behavior:
44
57
  - in published mode, resolves those `@brightweblabs/*` target versions from npm at update time
45
58
  - fails the update if npm resolution fails unless you pass `--allow-stale-fallback`
46
59
  - re-syncs managed BrightWeb config files such as `next.config.ts`, `config/modules.ts`, and `config/shell.ts`
60
+ - preserves app-owned shell customizations in the scaffolded `config/shell.overrides.ts`
47
61
  - reports missing or drifted starter files and only rewrites them with `--refresh-starters`
48
62
  - prints the follow-up install command unless `--install` is passed
49
63
  - preserves unrelated third-party dependencies and app-owned product pages
@@ -63,6 +77,7 @@ Current updater behavior:
63
77
  - platform apps also write `.env.local`, `AGENTS.md`, `docs/ai/README.md`, `docs/ai/examples.md`, `docs/ai/app-context.json`, and generated config files for brand and module state
64
78
  - site apps also write `AGENTS.md`, `docs/ai/README.md`, `docs/ai/examples.md`, and `docs/ai/app-context.json` for app-local AI handoff
65
79
  - supports repo-local `workspace:*` wiring and future published dependency wiring
80
+ - writes `.brightweb/app-manifest.json` as the machine-authoritative scaffold and module record
66
81
 
67
82
  ## Workspace mode extras
68
83
 
package/bin/bw.mjs ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { runBwCli } from "../src/bw.mjs";
4
+
5
+ runBwCli(process.argv.slice(2)).catch((error) => {
6
+ console.error(`\nbw failed: ${error instanceof Error ? error.message : "Unknown error"}`);
7
+ process.exitCode = 1;
8
+ });
package/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "create-bw-app",
3
3
  "private": false,
4
- "version": "0.10.1",
4
+ "version": "0.12.0",
5
5
  "type": "module",
6
- "bin": "bin/create-bw-app.mjs",
6
+ "bin": {
7
+ "create-bw-app": "bin/create-bw-app.mjs",
8
+ "bw": "bin/bw.mjs"
9
+ },
7
10
  "files": [
8
11
  "bin",
9
12
  "src",
package/src/add.mjs ADDED
@@ -0,0 +1,101 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { stdout as output } from "node:process";
4
+ import { SELECTABLE_MODULES } from "./constants.mjs";
5
+ import { TEMPLATE_ROOT, createAppContextFile, createDbInstallPlan, createNextConfig, createPlatformModulesConfigFile, createShellConfig, getDbModuleRegistry, getVersionMap, pathExists, readJsonIfPresent } from "./generator.mjs";
6
+ import { collectScaffoldFiles, findWorkspaceRoot, loadModuleCatalog, MODULE_PACKAGES, readAppManifest, resolveModuleClosure, satisfiesVersion, writeAppManifest } from "./app-manifest.mjs";
7
+ import { applyMigrationWrites, planMigrationAppends } from "./migrations.mjs";
8
+
9
+ const HELP = `Usage: bw add <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 install plan without writing\n --help Show this help`;
10
+
11
+ export async function addBrightwebModule(moduleKey, argvOptions = {}, runtimeOptions = {}) {
12
+ if (!moduleKey || argvOptions.help) {
13
+ output.write(`${HELP}\n`);
14
+ return { help: true };
15
+ }
16
+ const targetDir = path.resolve(runtimeOptions.targetDir || argvOptions.targetDir || process.cwd());
17
+ const appManifest = await readAppManifest(targetDir);
18
+ if (appManifest.app.template !== "platform") throw new Error("bw add is only available for platform apps.");
19
+ const packageJsonPath = path.join(targetDir, "package.json");
20
+ const packageJson = await readJsonIfPresent(packageJsonPath);
21
+ if (!packageJson) throw new Error(`Target directory does not contain package.json: ${targetDir}`);
22
+ const workspaceRootCandidate = runtimeOptions.workspaceRoot || argvOptions.workspaceRoot || await findWorkspaceRoot(targetDir);
23
+ const workspaceRoot = workspaceRootCandidate ? path.resolve(workspaceRootCandidate) : null;
24
+ const catalog = await loadModuleCatalog({ targetDir, workspaceRoot });
25
+ if (!catalog[moduleKey] || moduleKey === "core") throw new Error(`Unknown installable module key: ${moduleKey}`);
26
+
27
+ const resolved = resolveModuleClosure(catalog, [moduleKey]);
28
+ const installedVersions = { core: catalog.core.version, admin: catalog.admin.version };
29
+ for (const [key, entry] of Object.entries(appManifest.modules)) installedVersions[key] = entry.version;
30
+ const conflicts = [];
31
+ for (const key of resolved) {
32
+ for (const [requiredKey, range] of Object.entries(catalog[key].requires || {})) {
33
+ if (installedVersions[requiredKey] && !satisfiesVersion(installedVersions[requiredKey], range)) conflicts.push(`${key} requires ${requiredKey}@${range}, but ${requiredKey}@${installedVersions[requiredKey]} is installed`);
34
+ }
35
+ }
36
+ if (conflicts.length > 0) {
37
+ let compatibility = "Compatibility-set check skipped (published mode).";
38
+ if (workspaceRoot) {
39
+ const release = await readJsonIfPresent(path.join(workspaceRoot, "brightweb-release.json"));
40
+ compatibility = `Compatibility set: ${JSON.stringify(release?.packages || {})}`;
41
+ }
42
+ throw new Error(`Module version conflict:\n- ${conflicts.join("\n- ")}\n${compatibility}`);
43
+ }
44
+
45
+ const newModules = resolved.filter((key) => !appManifest.modules[key]);
46
+ const versionMap = await getVersionMap(workspaceRoot);
47
+ const dependencyMode = Object.values(packageJson.dependencies || {}).some((value) => String(value).startsWith("workspace:")) ? "workspace" : "published";
48
+ const nextPackageJson = structuredClone(packageJson);
49
+ nextPackageJson.dependencies ||= {};
50
+ for (const key of newModules) {
51
+ const packageName = MODULE_PACKAGES[key];
52
+ if (!packageName) continue;
53
+ nextPackageJson.dependencies[packageName] = dependencyMode === "workspace" ? "workspace:*" : versionMap[packageName];
54
+ }
55
+ nextPackageJson.dependencies = Object.fromEntries(Object.entries(nextPackageJson.dependencies).sort(([a], [b]) => a.localeCompare(b)));
56
+
57
+ const installedModuleKeys = Array.from(new Set([...Object.keys(appManifest.modules), ...newModules]));
58
+ const dbRegistry = await getDbModuleRegistry(workspaceRoot);
59
+ const dbInstallPlan = createDbInstallPlan({ selectedModules: installedModuleKeys.filter((key) => key !== "orgs"), workspaceMode: dependencyMode === "workspace", registry: dbRegistry });
60
+ const migrationPlan = await planMigrationAppends({ targetDir, moduleKeys: newModules, catalog, migrationCursor: appManifest.migrationCursor });
61
+ const overlays = [];
62
+ for (const key of newModules) {
63
+ const definition = SELECTABLE_MODULES.find((entry) => entry.key === key);
64
+ if (definition && await pathExists(path.join(TEMPLATE_ROOT, "modules", definition.templateFolder))) overlays.push({ key, source: path.join(TEMPLATE_ROOT, "modules", definition.templateFolder) });
65
+ }
66
+ const managedWrites = {
67
+ "next.config.ts": createNextConfig({ template: "platform", selectedModules: installedModuleKeys }),
68
+ "config/modules.ts": createPlatformModulesConfigFile(installedModuleKeys),
69
+ "config/shell.ts": createShellConfig(installedModuleKeys),
70
+ "docs/ai/app-context.json": createAppContextFile({ slug: appManifest.app.slug, template: "platform", selectedModules: installedModuleKeys.filter((key) => key !== "orgs"), dbInstallPlan }),
71
+ };
72
+
73
+ const summary = [
74
+ "bw add",
75
+ `Modules to install: ${newModules.join(" -> ") || "none"}`,
76
+ `Migrations to append: ${migrationPlan.writes.map((entry) => entry.targetFileName).join(", ") || "none"}`,
77
+ `Files to write: ${[...(newModules.length ? ["package.json"] : []), ...Object.keys(managedWrites), ...overlays.map((entry) => `template/modules/${entry.key}`)].join(", ") || "none"}`,
78
+ ];
79
+ if (!workspaceRoot) summary.push("WARN Compatibility-set check skipped in published mode.");
80
+ output.write(`${summary.join("\n")}\n`);
81
+ if (argvOptions.dryRun) return { dryRun: true, newModules, migrationPlan };
82
+
83
+ if (newModules.length > 0) await fs.writeFile(packageJsonPath, `${JSON.stringify(nextPackageJson, null, 2)}\n`, "utf8");
84
+ for (const overlay of overlays) await fs.cp(overlay.source, targetDir, { recursive: true });
85
+ for (const [relativePath, content] of Object.entries(managedWrites)) {
86
+ const targetPath = path.join(targetDir, relativePath);
87
+ await fs.mkdir(path.dirname(targetPath), { recursive: true });
88
+ await fs.writeFile(targetPath, content, "utf8");
89
+ }
90
+ await applyMigrationWrites(migrationPlan.writes);
91
+ const now = new Date().toISOString();
92
+ for (const key of newModules) appManifest.modules[key] = { version: catalog[key].version, installedAt: now, exposed: true };
93
+ appManifest.migrationCursor = migrationPlan.nextCursor;
94
+ const collectedScaffoldFiles = await collectScaffoldFiles(targetDir, installedModuleKeys);
95
+ appManifest.scaffoldFiles = { ...collectedScaffoldFiles, ...appManifest.scaffoldFiles };
96
+ await writeAppManifest(targetDir, appManifest);
97
+ 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`);
98
+ return { dryRun: false, newModules, migrationPlan };
99
+ }
100
+
101
+ export { HELP as ADD_HELP };
package/src/adopt.mjs ADDED
@@ -0,0 +1,190 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { stdout as output } from "node:process";
4
+ import { fileURLToPath } from "node:url";
5
+ import {
6
+ APP_MANIFEST_PATH,
7
+ MANAGED_APP_FILES,
8
+ MODULE_PACKAGES,
9
+ cleanVersion,
10
+ findWorkspaceRoot,
11
+ loadModuleCatalog,
12
+ readConfiguredModuleFlags,
13
+ writeAppManifest,
14
+ } from "./app-manifest.mjs";
15
+ import { pathExists, readJsonIfPresent } from "./generator.mjs";
16
+ import { findAppMigrationsDirectory, getModuleMigrations } from "./migrations.mjs";
17
+ import { inventoryScaffoldFiles, resolveTemplateRoot } from "./scaffold.mjs";
18
+ import { detectDependencyMode, detectTemplate } from "./update.mjs";
19
+
20
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
21
+ const HELP = `Usage: bw adopt [options]\n\nOptions:\n --target-dir <path> App directory (defaults to cwd)\n --workspace-root <path> BrightWeb workspace root\n --cursor <key>=<migrationFilename> Override a migration cursor (repeatable)\n --owned-surface <name> Record an app-owned surface (repeatable)\n --own <path> Mark an existing scaffold file app-owned (repeatable)\n --skip <path> Mark a missing scaffold file intentionally absent (repeatable)\n --allow-uncursored Allow doctor to warn instead of fail on null cursors\n --force Replace an existing app manifest\n --dry-run Print the manifest and warnings without writing\n --help Show this help`;
22
+
23
+ function asList(value) {
24
+ if (value == null) return [];
25
+ return Array.isArray(value) ? value : [value];
26
+ }
27
+
28
+ function parseCursorOverrides(values) {
29
+ const overrides = {};
30
+ for (const value of asList(values)) {
31
+ const separator = String(value).indexOf("=");
32
+ if (separator < 1 || separator === String(value).length - 1) throw new Error(`Invalid --cursor value: ${value}. Expected <key>=<migrationFilename>.`);
33
+ overrides[String(value).slice(0, separator)] = String(value).slice(separator + 1);
34
+ }
35
+ return overrides;
36
+ }
37
+
38
+ async function migrationFiles(targetDir) {
39
+ const directory = await findAppMigrationsDirectory(targetDir);
40
+ if (!(await pathExists(directory))) return [];
41
+ const files = [];
42
+ for (const fileName of (await fs.readdir(directory)).filter((entry) => entry.endsWith(".sql")).sort()) {
43
+ files.push({ fileName, content: await fs.readFile(path.join(directory, fileName), "utf8") });
44
+ }
45
+ return files;
46
+ }
47
+
48
+ function latestShippedMatch(shipped, predicate) {
49
+ return shipped.filter((entry) => predicate(entry.fileName)).at(-1)?.fileName || null;
50
+ }
51
+
52
+ function leadingBaselineDomain(content) {
53
+ const line = content.split(/\r?\n/).find((entry) => entry.trim().length > 0);
54
+ return line?.match(/^\s*--\s*Brightweb\s+(.+?)\s+v1\s+baseline\.?\s*$/i)?.[1]?.trim().toLowerCase() || null;
55
+ }
56
+
57
+ async function bootstrapCursor({ moduleKey, catalogEntry, appMigrations, override, warnings }) {
58
+ const shipped = await getModuleMigrations(moduleKey, catalogEntry);
59
+ if (shipped.length === 0) return { shipsMigrations: false, cursor: undefined, strategy: "none" };
60
+ if (override) {
61
+ if (!shipped.some((entry) => entry.fileName === override)) throw new Error(`Cursor override for ${moduleKey} does not name a shipped migration: ${override}`);
62
+ return { shipsMigrations: true, cursor: override, strategy: "override" };
63
+ }
64
+
65
+ const escapedKey = moduleKey.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
66
+ const provenance = latestShippedMatch(shipped, (shippedName) => appMigrations.some(({ content }) => {
67
+ const match = content.match(new RegExp(`^\\s*--\\s*bw-module:\\s*${escapedKey}@[^\\s]+\\s+([^\\s]+)`, "im"));
68
+ return match?.[1] === shippedName;
69
+ }));
70
+ if (provenance) return { shipsMigrations: true, cursor: provenance, strategy: "provenance" };
71
+
72
+ const filename = latestShippedMatch(shipped, (shippedName) => appMigrations.some(({ fileName }) =>
73
+ fileName.endsWith(`_${moduleKey}__${shippedName}`)));
74
+ if (filename) return { shipsMigrations: true, cursor: filename, strategy: "filename" };
75
+
76
+ const domains = new Set([moduleKey.toLowerCase(), catalogEntry?.manifest?.title?.toLowerCase()].filter(Boolean));
77
+ const baselineFound = appMigrations.some(({ content }) => domains.has(leadingBaselineDomain(content)));
78
+ if (baselineFound) {
79
+ const v1 = shipped.filter((entry) => /(?:^|_)v1(?:_|\.sql$)/i.test(entry.fileName));
80
+ const cursor = (v1.length > 0 ? v1 : shipped.slice(0, 1)).at(-1).fileName;
81
+ const later = shipped.filter((entry) => entry.fileName > cursor).map((entry) => entry.fileName);
82
+ warnings.push(`WARN ${moduleKey}: baseline header matched; cursor set to ${cursor}.${later.length ? ` Later package migrations are UNAPPLIED and the next bw upgrade will plan: ${later.join(", ")}.` : ""}`);
83
+ return { shipsMigrations: true, cursor, strategy: "baseline-header" };
84
+ }
85
+
86
+ warnings.push(`WARN BLOCKED ${moduleKey}: no migration provenance, filename, or baseline-header match. Cursor is null; module migration upgrades are blocked until a cursor is set.`);
87
+ return { shipsMigrations: true, cursor: null, strategy: "uncursored" };
88
+ }
89
+
90
+ export async function adoptBrightwebApp(argvOptions = {}, runtimeOptions = {}) {
91
+ if (argvOptions.help) { output.write(`${HELP}\n`); return { help: true }; }
92
+ const targetDir = path.resolve(runtimeOptions.targetDir || argvOptions.targetDir || process.cwd());
93
+ const existingManifestPath = path.join(targetDir, APP_MANIFEST_PATH);
94
+ if (await pathExists(existingManifestPath) && !argvOptions.force) throw new Error(`Refusing to overwrite existing ${APP_MANIFEST_PATH}; pass --force to replace it.`);
95
+ const packageJson = await readJsonIfPresent(path.join(targetDir, "package.json"));
96
+ if (!packageJson) throw new Error(`Target directory does not contain package.json: ${targetDir}`);
97
+
98
+ const dependencies = { ...(packageJson.dependencies || {}), ...(packageJson.devDependencies || {}) };
99
+ const installedBrightwebPackages = new Map(Object.entries(dependencies)
100
+ .filter(([name]) => name.startsWith("@brightweblabs/"))
101
+ .map(([name, version]) => [name, { version }]));
102
+ const template = await detectTemplate(targetDir, installedBrightwebPackages);
103
+ const dependencyMode = detectDependencyMode(installedBrightwebPackages);
104
+ const workspaceRootCandidate = runtimeOptions.workspaceRoot || argvOptions.workspaceRoot || await findWorkspaceRoot(targetDir);
105
+ const workspaceRoot = workspaceRootCandidate ? path.resolve(workspaceRootCandidate) : null;
106
+ const catalog = await loadModuleCatalog({ targetDir, workspaceRoot });
107
+ const warnings = [];
108
+ const now = (runtimeOptions.now ? new Date(runtimeOptions.now) : new Date()).toISOString();
109
+ const flags = await readConfiguredModuleFlags(targetDir);
110
+ const modules = {};
111
+ for (const [moduleKey, packageName] of Object.entries(MODULE_PACKAGES)) {
112
+ if (!dependencies[packageName]) continue;
113
+ const exposed = typeof flags[moduleKey] === "boolean" ? flags[moduleKey] : true;
114
+ if (typeof flags[moduleKey] !== "boolean") warnings.push(`WARN ${moduleKey}: config/modules.ts exposure was not parseable; defaulting exposed=true.`);
115
+ modules[moduleKey] = {
116
+ version: cleanVersion(dependencies[packageName]) || catalog[moduleKey]?.version,
117
+ installedAt: now,
118
+ exposed,
119
+ };
120
+ }
121
+
122
+ const templateRoot = await resolveTemplateRoot({ targetDir, workspaceRoot });
123
+ const scaffold = template === "platform"
124
+ ? await inventoryScaffoldFiles({ targetDir, moduleKeys: Object.keys(modules), templateRoot })
125
+ : { records: {}, unsupported: [] };
126
+ const ownedPaths = new Set(asList(argvOptions.own).map(String));
127
+ const skippedPaths = new Set(asList(argvOptions.skip).map(String));
128
+ for (const relativePath of [...ownedPaths, ...skippedPaths]) {
129
+ if (!scaffold.records[relativePath]) throw new Error(`${relativePath} is not a tracked scaffold file.`);
130
+ }
131
+ for (const relativePath of ownedPaths) {
132
+ if (skippedPaths.has(relativePath)) throw new Error(`${relativePath} cannot be both --own and --skip.`);
133
+ if (scaffold.records[relativePath].status === "missing") throw new Error(`Cannot own missing scaffold file: ${relativePath}`);
134
+ scaffold.records[relativePath].intent = "owned";
135
+ }
136
+ for (const relativePath of skippedPaths) {
137
+ if (scaffold.records[relativePath].status !== "missing") throw new Error(`Cannot skip existing scaffold file: ${relativePath}`);
138
+ scaffold.records[relativePath].intent = "skipped";
139
+ }
140
+ for (const relativePath of scaffold.unsupported) warnings.push(`WARN ${relativePath}: installed-version template is unavailable; scaffold comparison is unsupported.`);
141
+ for (const [relativePath, record] of Object.entries(scaffold.records)) {
142
+ if (record.status === "drifted" && record.intent !== "owned") warnings.push(`WARN drifted scaffold file: ${relativePath}`);
143
+ if (record.status === "missing" && record.intent !== "skipped") warnings.push(`WARN missing scaffold file: ${relativePath} (not created; use bw diff and the current template as guidance).`);
144
+ }
145
+
146
+ const overrides = parseCursorOverrides(argvOptions.cursor);
147
+ const unknownOverride = Object.keys(overrides).find((key) => !catalog[key]);
148
+ if (unknownOverride) throw new Error(`Unknown module key in --cursor: ${unknownOverride}`);
149
+ const appMigrations = template === "platform" ? await migrationFiles(targetDir) : [];
150
+ const migrationCursor = {};
151
+ const cursorStrategies = {};
152
+ const migrationKeys = template === "platform" ? Array.from(new Set(["core", "admin", ...Object.keys(modules)])) : [];
153
+ for (const moduleKey of migrationKeys) {
154
+ const result = await bootstrapCursor({ moduleKey, catalogEntry: catalog[moduleKey], appMigrations, override: overrides[moduleKey], warnings });
155
+ if (result.shipsMigrations) {
156
+ migrationCursor[moduleKey] = result.cursor;
157
+ cursorStrategies[moduleKey] = result.strategy;
158
+ }
159
+ }
160
+
161
+ const cliPackage = await readJsonIfPresent(path.join(PACKAGE_ROOT, "package.json"));
162
+ const manifest = {
163
+ contractVersion: 1,
164
+ app: {
165
+ slug: packageJson.name || path.basename(targetDir),
166
+ template,
167
+ scaffoldedWith: `create-bw-app@${cliPackage?.version || "unknown"}`,
168
+ dependencyMode,
169
+ adoptedAt: now,
170
+ },
171
+ modules,
172
+ scaffoldFiles: scaffold.records,
173
+ managedFiles: template === "platform" ? MANAGED_APP_FILES : ["docs/ai/app-context.json"],
174
+ migrationCursor,
175
+ ownedSurfaces: Array.from(new Set(asList(argvOptions.ownedSurface).map(String))),
176
+ adoptionNotes: {
177
+ allowUncursored: argvOptions.allowUncursored === true,
178
+ cursorStrategies,
179
+ },
180
+ };
181
+
182
+ output.write(`bw adopt${argvOptions.dryRun ? " --dry-run" : ""}\n${JSON.stringify(manifest, null, 2)}\n`);
183
+ for (const warning of warnings) output.write(`${warning}\n`);
184
+ if (argvOptions.dryRun) return { dryRun: true, manifest, warnings };
185
+ await writeAppManifest(targetDir, manifest);
186
+ output.write(`Adopted ${manifest.app.slug}; wrote ${APP_MANIFEST_PATH}. No migration files or database objects were changed.\n`);
187
+ return { dryRun: false, manifest, warnings };
188
+ }
189
+
190
+ export { HELP as ADOPT_HELP };
@@ -0,0 +1,257 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { APP_DEPENDENCY_DEFAULTS, MODULE_STARTER_FILES, PLATFORM_STARTER_FILES, SELECTABLE_MODULES } from "./constants.mjs";
6
+
7
+ const TEMPLATE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "template");
8
+
9
+ async function pathExists(targetPath) {
10
+ try { await fs.access(targetPath); return true; } catch { return false; }
11
+ }
12
+
13
+ async function readJsonIfPresent(filePath) {
14
+ if (!(await pathExists(filePath))) return null;
15
+ return JSON.parse(await fs.readFile(filePath, "utf8"));
16
+ }
17
+
18
+ export const APP_MANIFEST_PATH = path.join(".brightweb", "app-manifest.json");
19
+ export const MANAGED_APP_FILES = [
20
+ "next.config.ts",
21
+ "config/modules.ts",
22
+ "config/shell.ts",
23
+ "docs/ai/app-context.json",
24
+ ];
25
+
26
+ export const MODULE_PACKAGES = {
27
+ admin: "@brightweblabs/module-admin",
28
+ crm: "@brightweblabs/module-crm",
29
+ orgs: "@brightweblabs/module-orgs",
30
+ projects: "@brightweblabs/module-projects",
31
+ };
32
+
33
+ const FALLBACK_REQUIRES = {
34
+ core: {},
35
+ admin: { core: ">=0.3" },
36
+ orgs: { core: ">=0.3", admin: ">=0.3" },
37
+ crm: { core: ">=0.3", admin: ">=0.3", orgs: ">=0.1" },
38
+ projects: { core: ">=0.3", admin: ">=0.3", orgs: ">=0.1" },
39
+ };
40
+
41
+ export function cleanVersion(version) {
42
+ if (typeof version !== "string") return null;
43
+ const match = version.match(/(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/);
44
+ return match?.[1] || null;
45
+ }
46
+
47
+ function compareVersions(left, right) {
48
+ const a = cleanVersion(left)?.split("-")[0].split(".").map(Number) || [];
49
+ const b = cleanVersion(right)?.split("-")[0].split(".").map(Number) || [];
50
+ for (let index = 0; index < 3; index += 1) {
51
+ if ((a[index] || 0) !== (b[index] || 0)) return (a[index] || 0) - (b[index] || 0);
52
+ }
53
+ return 0;
54
+ }
55
+
56
+ export function satisfiesVersion(version, range) {
57
+ const normalized = cleanVersion(version);
58
+ if (!normalized || typeof range !== "string") return false;
59
+ if (range === "*" || range === "workspace:*") return true;
60
+ const expectedMatch = range.match(/(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
61
+ const expected = expectedMatch ? `${expectedMatch[1]}.${expectedMatch[2] || "0"}.${expectedMatch[3] || "0"}` : null;
62
+ if (!expected) return false;
63
+ if (range.trim().startsWith(">=")) return compareVersions(normalized, expected) >= 0;
64
+ if (range.trim().startsWith("^")) {
65
+ return normalized.split(".")[0] === expected.split(".")[0] && compareVersions(normalized, expected) >= 0;
66
+ }
67
+ if (range.trim().startsWith("~")) {
68
+ const actualParts = normalized.split(".");
69
+ const expectedParts = expected.split(".");
70
+ return actualParts[0] === expectedParts[0] && actualParts[1] === expectedParts[1] && compareVersions(normalized, expected) >= 0;
71
+ }
72
+ return normalized === expected;
73
+ }
74
+
75
+ export async function hashFile(filePath) {
76
+ const content = await fs.readFile(filePath);
77
+ return `sha256:${crypto.createHash("sha256").update(content).digest("hex")}`;
78
+ }
79
+
80
+ export async function readAppManifest(targetDir, { required = true } = {}) {
81
+ const manifestPath = path.join(targetDir, APP_MANIFEST_PATH);
82
+ const manifest = await readJsonIfPresent(manifestPath);
83
+ if (!manifest && required) {
84
+ throw new Error(`No BrightWeb app manifest found at ${APP_MANIFEST_PATH}. Pre-manifest apps must be adopted before using bw.`);
85
+ }
86
+ return manifest;
87
+ }
88
+
89
+ export async function writeAppManifest(targetDir, manifest) {
90
+ const manifestPath = path.join(targetDir, APP_MANIFEST_PATH);
91
+ await fs.mkdir(path.dirname(manifestPath), { recursive: true });
92
+ const temporaryPath = `${manifestPath}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.tmp`;
93
+ try {
94
+ await fs.writeFile(temporaryPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
95
+ await fs.rename(temporaryPath, manifestPath);
96
+ } catch (error) {
97
+ await fs.rm(temporaryPath, { force: true }).catch(() => {});
98
+ throw error;
99
+ }
100
+ }
101
+
102
+ export function validateAppManifest(manifest) {
103
+ const errors = [];
104
+ if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) return ["manifest must be an object"];
105
+ if (manifest.contractVersion !== 1) errors.push("contractVersion must equal 1");
106
+ if (!manifest.app || typeof manifest.app.slug !== "string" || !["platform", "site"].includes(manifest.app.template) || typeof manifest.app.scaffoldedWith !== "string") {
107
+ errors.push("app must contain slug, template, and scaffoldedWith");
108
+ }
109
+ for (const key of ["modules", "scaffoldFiles", "migrationCursor"]) {
110
+ if (!manifest[key] || typeof manifest[key] !== "object" || Array.isArray(manifest[key])) errors.push(`${key} must be an object`);
111
+ }
112
+ if (!Array.isArray(manifest.managedFiles) || manifest.managedFiles.some((entry) => typeof entry !== "string")) errors.push("managedFiles must be an array of paths");
113
+ for (const [key, entry] of Object.entries(manifest.modules || {})) {
114
+ if (!entry || !cleanVersion(entry.version) || typeof entry.installedAt !== "string" || typeof entry.exposed !== "boolean") errors.push(`modules.${key} is invalid`);
115
+ }
116
+ for (const [relativePath, entry] of Object.entries(manifest.scaffoldFiles || {})) {
117
+ 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`);
118
+ }
119
+ if (manifest.lastDoctor != null && (typeof manifest.lastDoctor.at !== "string" || typeof manifest.lastDoctor.ok !== "boolean")) errors.push("lastDoctor is invalid");
120
+ return errors;
121
+ }
122
+
123
+ export function moduleVersion(moduleKey, versionMap = {}) {
124
+ if (moduleKey === "core") return cleanVersion(versionMap["@brightweblabs/core-auth"] || APP_DEPENDENCY_DEFAULTS["@brightweblabs/core-auth"]);
125
+ const packageName = MODULE_PACKAGES[moduleKey];
126
+ return cleanVersion(versionMap[packageName] || APP_DEPENDENCY_DEFAULTS[packageName]);
127
+ }
128
+
129
+ export async function collectScaffoldFiles(targetDir, selectedModules) {
130
+ const files = new Map();
131
+ for (const relativePath of PLATFORM_STARTER_FILES) files.set(relativePath, "platform-base");
132
+ for (const moduleKey of selectedModules) {
133
+ const definition = SELECTABLE_MODULES.find((candidate) => candidate.key === moduleKey);
134
+ if (!definition) continue;
135
+ const root = path.join(TEMPLATE_ROOT, "modules", definition.templateFolder);
136
+ if (!(await pathExists(root))) continue;
137
+ const pending = [root];
138
+ while (pending.length > 0) {
139
+ const current = pending.pop();
140
+ for (const entry of await fs.readdir(current, { withFileTypes: true })) {
141
+ const fullPath = path.join(current, entry.name);
142
+ if (entry.isDirectory()) pending.push(fullPath);
143
+ else files.set(path.relative(root, fullPath), moduleKey);
144
+ }
145
+ }
146
+ for (const relativePath of MODULE_STARTER_FILES[moduleKey] || []) files.set(relativePath, moduleKey);
147
+ }
148
+ const result = {};
149
+ for (const [relativePath, moduleKey] of Array.from(files.entries()).sort()) {
150
+ const targetPath = path.join(targetDir, relativePath);
151
+ if (await pathExists(targetPath)) result[relativePath] = { module: moduleKey, hash: await hashFile(targetPath), status: "current" };
152
+ }
153
+ return result;
154
+ }
155
+
156
+ export async function createInitialAppManifest({ targetDir, slug, template, selectedModules, versionMap, dbInstallPlan, cliVersion }) {
157
+ const now = new Date().toISOString();
158
+ const modules = {};
159
+ if (template === "platform") {
160
+ for (const moduleKey of selectedModules) modules[moduleKey] = { version: moduleVersion(moduleKey, versionMap), installedAt: now, exposed: true };
161
+ if ((selectedModules.includes("crm") || selectedModules.includes("projects")) && !modules.orgs) {
162
+ modules.orgs = { version: moduleVersion("orgs", versionMap), installedAt: now, exposed: true };
163
+ }
164
+ }
165
+ const migrationCursor = {};
166
+ for (const moduleKey of dbInstallPlan?.resolvedOrder || []) {
167
+ const directory = path.join(TEMPLATE_ROOT, "supabase", "modules", moduleKey, "migrations");
168
+ if (!(await pathExists(directory))) continue;
169
+ const migrations = (await fs.readdir(directory)).filter((name) => name.endsWith(".sql")).sort();
170
+ if (migrations.length > 0) migrationCursor[moduleKey] = migrations.at(-1);
171
+ }
172
+ return {
173
+ contractVersion: 1,
174
+ app: { slug, template, scaffoldedWith: `create-bw-app@${cliVersion}` },
175
+ modules,
176
+ scaffoldFiles: template === "platform" ? await collectScaffoldFiles(targetDir, selectedModules) : {},
177
+ managedFiles: template === "platform" ? MANAGED_APP_FILES : ["docs/ai/app-context.json"],
178
+ migrationCursor,
179
+ };
180
+ }
181
+
182
+ export async function findWorkspaceRoot(startDir) {
183
+ let current = path.resolve(startDir);
184
+ while (true) {
185
+ if ((await pathExists(path.join(current, "brightweb-release.json"))) && (await pathExists(path.join(current, "packages")))) return current;
186
+ const parent = path.dirname(current);
187
+ if (parent === current) return null;
188
+ current = parent;
189
+ }
190
+ }
191
+
192
+ export async function loadModuleCatalog({ targetDir, workspaceRoot }) {
193
+ const release = workspaceRoot
194
+ ? await readJsonIfPresent(path.join(workspaceRoot, "brightweb-release.json"))
195
+ : null;
196
+ const coreVersion = cleanVersion(release?.packages?.["@brightweblabs/core-auth"])
197
+ || moduleVersion("core");
198
+ const catalog = { core: { key: "core", requires: {}, version: coreVersion, packageName: "@brightweblabs/core-auth" } };
199
+ for (const [moduleKey, packageName] of Object.entries(MODULE_PACKAGES)) {
200
+ const folderName = packageName.replace("@brightweblabs/", "");
201
+ const candidates = [
202
+ workspaceRoot && path.join(workspaceRoot, "packages", folderName),
203
+ path.join(targetDir, "node_modules", ...packageName.split("/")),
204
+ ].filter(Boolean);
205
+ let packageRoot = null;
206
+ let moduleManifest = null;
207
+ let packageManifest = null;
208
+ for (const candidate of candidates) {
209
+ moduleManifest = await readJsonIfPresent(path.join(candidate, "brightweb.module.json"));
210
+ packageManifest = await readJsonIfPresent(path.join(candidate, "package.json"));
211
+ if (moduleManifest) { packageRoot = candidate; break; }
212
+ }
213
+ moduleManifest ||= await readJsonIfPresent(path.join(TEMPLATE_ROOT, "module-manifests", moduleKey, "brightweb.module.json"));
214
+ catalog[moduleKey] = {
215
+ key: moduleKey,
216
+ packageName,
217
+ packageRoot,
218
+ manifest: moduleManifest || { key: moduleKey, requires: FALLBACK_REQUIRES[moduleKey], env: [] },
219
+ requires: moduleManifest?.requires || FALLBACK_REQUIRES[moduleKey],
220
+ version: cleanVersion(packageManifest?.version) || moduleVersion(moduleKey),
221
+ };
222
+ }
223
+ return catalog;
224
+ }
225
+
226
+ export function resolveModuleClosure(catalog, requestedKeys) {
227
+ const order = [];
228
+ const visited = new Set(["core", "admin"]);
229
+ const visiting = new Set();
230
+ function visit(key) {
231
+ if (visited.has(key)) return;
232
+ if (!catalog[key]) throw new Error(`Unknown module key: ${key}`);
233
+ if (visiting.has(key)) throw new Error(`Circular module dependency detected at ${key}`);
234
+ visiting.add(key);
235
+ for (const dependency of Object.keys(catalog[key].requires || {})) visit(dependency);
236
+ visiting.delete(key);
237
+ visited.add(key);
238
+ order.push(key);
239
+ }
240
+ for (const key of requestedKeys) {
241
+ if (key === "admin") order.push("admin");
242
+ else visit(key);
243
+ }
244
+ return order;
245
+ }
246
+
247
+ export async function readConfiguredModuleFlags(targetDir) {
248
+ const filePath = path.join(targetDir, "config", "modules.ts");
249
+ if (!(await pathExists(filePath))) return {};
250
+ const content = await fs.readFile(filePath, "utf8");
251
+ const flags = {};
252
+ for (const key of ["core-auth", "orgs", "crm", "projects", "admin"]) {
253
+ const match = content.match(new RegExp(`key:\\s*"${key}"[\\s\\S]*?enabled:\\s*(true|false)`));
254
+ if (match) flags[key === "core-auth" ? "core" : key] = match[1] === "true";
255
+ }
256
+ return flags;
257
+ }