create-bw-app 0.27.1 → 0.27.3

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 CHANGED
@@ -40,7 +40,7 @@ bw admin create --email owner@example.com
40
40
  - `bw add <moduleKey>` resolves requirements, installs thin package mounts and module wiring, and appends migrations.
41
41
  - `bw upgrade [moduleKey]` includes the existing managed update flow plus forward-only module migrations. For a staged database rollout, `bw upgrade <moduleKey> --through-migration <filename>` still updates packages and managed files but appends and records that module only through the named migration.
42
42
  - Destructive migrations are held at the preceding safe boundary. After compatible code is deployed and affected data is backed up, apply a held migration with an explicit module-scoped `bw upgrade <moduleKey> --include-destructive-migrations`. A destructive `--through-migration` target also requires that opt-in.
43
- - `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.
43
+ - `bw doctor` checks package, config, scaffold, environment-name, migration, configured function-region, and deployed function-region consistency. App-owned scaffold files are preserved on upgrade; doctor warns when their recorded template baseline is outdated and their contents differ from the current template. Use `bw diff`, reconcile relevant changes, and verify behavior before recording the reviewed baseline with `bw scaffold manage <path>` followed by `bw scaffold own <path>` (neither replaces file contents). Live database objects, applied migrations, and authenticated permissions are explicitly **NOT VERIFIED** by doctor; a successful local result does not certify deployed behavior. Pass `--deployment-url` to inspect the deployed `x-vercel-id`; add `--report` to stamp the result in the app manifest.
44
44
  - `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.
45
45
  - All mutating commands support `--dry-run`.
46
46
 
@@ -62,7 +62,7 @@ Current updater behavior:
62
62
  - fails the update if npm resolution fails unless you pass `--allow-stale-fallback`
63
63
  - re-syncs managed BrightWeb config files such as `next.config.ts`, `config/modules.ts`, and `config/shell.ts`
64
64
  - preserves app-owned shell customizations in the scaffolded `config/shell.overrides.ts`
65
- - reports missing or drifted scaffold mounts and only rewrites them with `--refresh-starters`
65
+ - reports missing or drifted scaffold mounts and only rewrites them with `--refresh-starters`; tracked `owned` and `skipped` files remain protected, including managed config routes
66
66
  - prints the follow-up install command unless `--install` is passed
67
67
  - preserves unrelated third-party dependencies and app-owned product pages
68
68
 
@@ -139,3 +139,11 @@ on generated paths. Existing JSON and page files are never replaced, even when
139
139
  empty or customized; reruns preserve them. These files are not managed templates
140
140
  or refreshable starters, and `bw update --refresh-starters` preserves them and
141
141
  shell overrides. New marketing apps do not receive this feature until opted in.
142
+
143
+ ## File and compatibility safety
144
+
145
+ Lifecycle commands resolve the selected app root and reject symlinks or invalid file types beneath it before writing planned outputs. Root aliases are supported; concurrent filesystem edits are outside this preflight guarantee. App-owned brand, theme, shell override and social content seeds are preserved. The generated app context lists exact generated/scaffold paths, defaults unknown paths to app ownership, and shares its generated inventory with the CLI.
146
+
147
+ Upgrade refuses unknown migration cursors and uses standard semantic-version ranges. Module removal refuses surviving literal imports of the removed package before changing files; reconcile the app-owned dependents explicitly. This conservative scan is not a complete bundler analysis.
148
+
149
+ Forced re-adoption and module removal preserve exact-path `owned` and `skipped` decisions. Re-adding a module respects retained decisions; use `bw scaffold manage` after re-adding to reset a path deliberately. Every pending destructive migration requires opt-in, even after a previous destructive step was approved. Managed platform outputs share one renderer across scaffolding, add, remove, and update, with enforced ownership-inventory parity.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "create-bw-app",
3
3
  "private": false,
4
- "version": "0.27.1",
4
+ "version": "0.27.3",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "create-bw-app": "bin/create-bw-app.mjs",
@@ -26,6 +26,7 @@
26
26
  },
27
27
  "dependencies": {
28
28
  "@inquirer/prompts": "^7.10.1",
29
- "@supabase/supabase-js": "^2.110.8"
29
+ "@supabase/supabase-js": "^2.110.8",
30
+ "semver": "^7.8.5"
30
31
  }
31
32
  }
package/src/add.mjs CHANGED
@@ -1,9 +1,11 @@
1
+ import { assertMutationTargets } from "./mutation-paths.mjs";
1
2
  import fs from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import { stdout as output } from "node:process";
4
5
  import { SELECTABLE_MODULES } from "./constants.mjs";
5
- import { TEMPLATE_ROOT, createAppContextFile, createDbInstallPlan, createModuleToolbarControlsConfig, createNextConfig, createOptionalModuleRouteFiles, createPlatformGlobalsCss, createPlatformModulesConfigFile, createShellConfig, getDbModuleRegistry, getVersionMap, pathExists, readJsonIfPresent } from "./generator.mjs";
6
+ import { TEMPLATE_ROOT, createDbInstallPlan, createManagedPlatformFiles, getDbModuleRegistry, getVersionMap, pathExists, readJsonIfPresent } from "./generator.mjs";
6
7
  import { collectScaffoldFiles, findWorkspaceRoot, loadModuleCatalog, MODULE_PACKAGES, readAppManifest, resolveModuleClosure, satisfiesVersion, writeAppManifest } from "./app-manifest.mjs";
8
+ import { assertNoUntrackedScaffoldWrites, scaffoldDrift } from "./scaffold.mjs";
7
9
  import { applyMigrationWrites, planMigrationAppends } from "./migrations.mjs";
8
10
 
9
11
  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`;
@@ -13,7 +15,7 @@ export async function addBrightwebModule(moduleKey, argvOptions = {}, runtimeOpt
13
15
  output.write(`${HELP}\n`);
14
16
  return { help: true };
15
17
  }
16
- const targetDir = path.resolve(runtimeOptions.targetDir || argvOptions.targetDir || process.cwd());
18
+ const targetDir = await fs.realpath(path.resolve(runtimeOptions.targetDir || argvOptions.targetDir || process.cwd()));
17
19
  const appManifest = await readAppManifest(targetDir);
18
20
  if (appManifest.app.template !== "platform") throw new Error("bw add is only available for platform apps.");
19
21
  const packageJsonPath = path.join(targetDir, "package.json");
@@ -63,15 +65,51 @@ export async function addBrightwebModule(moduleKey, argvOptions = {}, runtimeOpt
63
65
  const definition = SELECTABLE_MODULES.find((entry) => entry.key === key);
64
66
  if (definition && await pathExists(path.join(TEMPLATE_ROOT, "modules", definition.templateFolder))) overlays.push({ key, source: path.join(TEMPLATE_ROOT, "modules", definition.templateFolder) });
65
67
  }
66
- const managedWrites = {
67
- "next.config.ts": createNextConfig({ template: "platform", selectedModules: installedModuleKeys }),
68
- "app/globals.css": await createPlatformGlobalsCss(installedModuleKeys),
69
- "config/module-toolbar-controls.tsx": createModuleToolbarControlsConfig(installedModuleKeys),
70
- "config/modules.ts": createPlatformModulesConfigFile(installedModuleKeys),
71
- "config/shell.ts": createShellConfig(installedModuleKeys),
72
- "docs/ai/app-context.json": createAppContextFile({ slug: appManifest.app.slug, template: "platform", selectedModules: installedModuleKeys.filter((key) => key !== "orgs"), dbInstallPlan }),
73
- ...createOptionalModuleRouteFiles(installedModuleKeys),
74
- };
68
+ const managedWrites = await createManagedPlatformFiles({ slug: appManifest.app.slug, selectedModules: installedModuleKeys, dbInstallPlan });
69
+
70
+ const live = await scaffoldDrift(targetDir, appManifest.scaffoldFiles);
71
+ const { protectedPaths } = live;
72
+ for (const relativePath of protectedPaths) delete managedWrites[relativePath];
73
+
74
+ if (newModules.length === 0) for (const relativePath of Object.keys(managedWrites)) delete managedWrites[relativePath];
75
+
76
+ await assertNoUntrackedScaffoldWrites({
77
+ targetDir,
78
+ scaffoldFiles: appManifest.scaffoldFiles,
79
+ moduleKeys: installedModuleKeys,
80
+ relativePaths: Object.keys(managedWrites),
81
+ });
82
+
83
+ const overlayFiles = [];
84
+ // Refuse unknown overlay collisions before package, configuration or migration writes.
85
+ for (const overlay of overlays) {
86
+ const pending = [overlay.source];
87
+ while (pending.length) {
88
+ const directory = pending.pop();
89
+ for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
90
+ const source = path.join(directory, entry.name);
91
+ const relativePath = path.relative(overlay.source, source);
92
+ const destination = path.join(targetDir, relativePath);
93
+ const existing = await fs.lstat(destination).catch((error) => {
94
+ if (error.code === "ENOENT") return null;
95
+ throw error;
96
+ });
97
+ if (entry.isDirectory()) {
98
+ if (existing && !existing.isDirectory()) {
99
+ throw new Error(`Cannot add ${moduleKey}: module scaffold requires a directory at ${relativePath}, but an app file or link exists. Reconcile it explicitly before adding the module.`);
100
+ }
101
+ pending.push(source);
102
+ continue;
103
+ }
104
+ overlayFiles.push(relativePath);
105
+ if (!appManifest.scaffoldFiles[relativePath] && existing) {
106
+ throw new Error(`Cannot add ${moduleKey}: untracked app file conflicts with module scaffold: ${relativePath}. Move or reconcile it explicitly before adding the module.`);
107
+ }
108
+ }
109
+ }
110
+ }
111
+
112
+ await assertMutationTargets(targetDir, ["package.json", ".brightweb/app-manifest.json", ...Object.keys(managedWrites), ...overlayFiles, ...migrationPlan.writes.map((write) => path.relative(targetDir, write.targetPath))]);
75
113
 
76
114
  const summary = [
77
115
  "bw add",
@@ -83,8 +121,19 @@ export async function addBrightwebModule(moduleKey, argvOptions = {}, runtimeOpt
83
121
  output.write(`${summary.join("\n")}\n`);
84
122
  if (argvOptions.dryRun) return { dryRun: true, newModules, migrationPlan };
85
123
 
124
+ if (newModules.length === 0) return { dryRun: false, newModules, migrationPlan };
125
+
86
126
  if (newModules.length > 0) await fs.writeFile(packageJsonPath, `${JSON.stringify(nextPackageJson, null, 2)}\n`, "utf8");
87
- for (const overlay of overlays) await fs.cp(overlay.source, targetDir, { recursive: true });
127
+ const copiedPaths = new Set();
128
+ for (const overlay of overlays) await fs.cp(overlay.source, targetDir, {
129
+ recursive: true,
130
+ filter: async (source, destination) => {
131
+ const relativePath = path.relative(targetDir, destination);
132
+ if (protectedPaths.has(relativePath)) return false;
133
+ if ((await fs.stat(source)).isFile()) copiedPaths.add(relativePath);
134
+ return true;
135
+ },
136
+ });
88
137
  for (const [relativePath, content] of Object.entries(managedWrites)) {
89
138
  const targetPath = path.join(targetDir, relativePath);
90
139
  await fs.mkdir(path.dirname(targetPath), { recursive: true });
@@ -95,13 +144,13 @@ export async function addBrightwebModule(moduleKey, argvOptions = {}, runtimeOpt
95
144
  for (const key of newModules) appManifest.modules[key] = { version: catalog[key].version, installedAt: now, exposed: true };
96
145
  appManifest.migrationCursor = migrationPlan.nextCursor;
97
146
  const collectedScaffoldFiles = await collectScaffoldFiles(targetDir, installedModuleKeys);
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 };
147
+ for (const [relativePath, record] of Object.entries(collectedScaffoldFiles)) {
148
+ // Existing baselines describe generated bytes, never arbitrary app edits.
149
+ // Refresh only tracked outputs actually rendered by this command.
150
+ if (Object.hasOwn(managedWrites, relativePath) || copiedPaths.has(relativePath)) {
151
+ appManifest.scaffoldFiles[relativePath] = { ...appManifest.scaffoldFiles[relativePath], ...record };
152
+ }
153
+ }
105
154
  await writeAppManifest(targetDir, appManifest);
106
155
  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`);
107
156
  return { dryRun: false, newModules, migrationPlan };
package/src/adopt.mjs CHANGED
@@ -10,15 +10,16 @@ import {
10
10
  findWorkspaceRoot,
11
11
  loadModuleCatalog,
12
12
  readConfiguredModuleFlags,
13
+ readAppManifest,
13
14
  writeAppManifest,
14
15
  } from "./app-manifest.mjs";
15
16
  import { pathExists, readJsonIfPresent } from "./generator.mjs";
16
17
  import { findAppMigrationsDirectory, getModuleMigrations } from "./migrations.mjs";
17
- import { inventoryScaffoldFiles, resolveTemplateRoot } from "./scaffold.mjs";
18
+ import { inventoryScaffoldFiles, preserveScaffoldDecisions, resolveTemplateRoot, scaffoldDrift } from "./scaffold.mjs";
18
19
  import { detectDependencyMode, detectTemplate } from "./update.mjs";
19
20
 
20
21
  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
+ 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 Reconcile an existing manifest; preserve ownership decisions\n --dry-run Print the manifest and warnings without writing\n --help Show this help`;
22
23
 
23
24
  function asList(value) {
24
25
  if (value == null) return [];
@@ -54,13 +55,19 @@ function leadingBaselineDomain(content) {
54
55
  return line?.match(/^\s*--\s*Brightweb\s+(.+?)\s+v1\s+baseline\.?\s*$/i)?.[1]?.trim().toLowerCase() || null;
55
56
  }
56
57
 
57
- async function bootstrapCursor({ moduleKey, catalogEntry, appMigrations, override, warnings }) {
58
+ async function bootstrapCursor({ moduleKey, catalogEntry, appMigrations, override, retainedCursor, warnings }) {
58
59
  const shipped = await getModuleMigrations(moduleKey, catalogEntry);
59
- if (shipped.length === 0) return { shipsMigrations: false, cursor: undefined, strategy: "none" };
60
60
  if (override) {
61
61
  if (!shipped.some((entry) => entry.fileName === override)) throw new Error(`Cursor override for ${moduleKey} does not name a shipped migration: ${override}`);
62
62
  return { shipsMigrations: true, cursor: override, strategy: "override" };
63
63
  }
64
+ if (retainedCursor !== undefined) {
65
+ if (!shipped.some((entry) => entry.fileName === retainedCursor)) {
66
+ throw new Error(`Retained migration cursor for removed module ${moduleKey} does not name a shipped migration: ${retainedCursor}. Reconcile it with an explicit --cursor ${moduleKey}=<migrationFilename>.`);
67
+ }
68
+ return { shipsMigrations: true, cursor: retainedCursor, strategy: "retained" };
69
+ }
70
+ if (shipped.length === 0) return { shipsMigrations: false, cursor: undefined, strategy: "none" };
64
71
 
65
72
  const escapedKey = moduleKey.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
66
73
  const provenance = latestShippedMatch(shipped, (shippedName) => appMigrations.some(({ content }) => {
@@ -89,9 +96,10 @@ async function bootstrapCursor({ moduleKey, catalogEntry, appMigrations, overrid
89
96
 
90
97
  export async function adoptBrightwebApp(argvOptions = {}, runtimeOptions = {}) {
91
98
  if (argvOptions.help) { output.write(`${HELP}\n`); return { help: true }; }
92
- const targetDir = path.resolve(runtimeOptions.targetDir || argvOptions.targetDir || process.cwd());
99
+ const targetDir = await fs.realpath(path.resolve(runtimeOptions.targetDir || argvOptions.targetDir || process.cwd()));
93
100
  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.`);
101
+ if (await pathExists(existingManifestPath) && !argvOptions.force) throw new Error(`Refusing to overwrite existing ${APP_MANIFEST_PATH}; pass --force to reconcile it while preserving ownership decisions.`);
102
+ const previousManifest = await readAppManifest(targetDir, { required: false });
95
103
  const packageJson = await readJsonIfPresent(path.join(targetDir, "package.json"));
96
104
  if (!packageJson) throw new Error(`Target directory does not contain package.json: ${targetDir}`);
97
105
 
@@ -123,6 +131,11 @@ export async function adoptBrightwebApp(argvOptions = {}, runtimeOptions = {}) {
123
131
  const scaffold = template === "platform"
124
132
  ? await inventoryScaffoldFiles({ targetDir, moduleKeys: Object.keys(modules), templateRoot })
125
133
  : { records: {}, unsupported: [] };
134
+ const previousScaffold = await scaffoldDrift(targetDir, previousManifest?.scaffoldFiles);
135
+ scaffold.records = preserveScaffoldDecisions(scaffold.records, previousManifest?.scaffoldFiles, previousScaffold.protectedPaths);
136
+ for (const entry of (await scaffoldDrift(targetDir, scaffold.records)).entries) {
137
+ scaffold.records[entry.relativePath].status = entry.status;
138
+ }
126
139
  const ownedPaths = new Set(asList(argvOptions.own).map(String));
127
140
  const skippedPaths = new Set(asList(argvOptions.skip).map(String));
128
141
  for (const relativePath of [...ownedPaths, ...skippedPaths]) {
@@ -144,14 +157,23 @@ export async function adoptBrightwebApp(argvOptions = {}, runtimeOptions = {}) {
144
157
  }
145
158
 
146
159
  const overrides = parseCursorOverrides(argvOptions.cursor);
147
- const unknownOverride = Object.keys(overrides).find((key) => !catalog[key]);
160
+ const unknownOverride = Object.keys(overrides).find((key) => !Object.hasOwn(catalog, key));
148
161
  if (unknownOverride) throw new Error(`Unknown module key in --cursor: ${unknownOverride}`);
149
162
  const appMigrations = template === "platform" ? await migrationFiles(targetDir) : [];
150
163
  const migrationCursor = {};
151
164
  const cursorStrategies = {};
152
- const migrationKeys = template === "platform" ? Array.from(new Set(["core", "admin", ...Object.keys(modules)])) : [];
165
+ const installedMigrationKeys = new Set(template === "platform" ? ["core", "admin", ...Object.keys(modules)] : []);
166
+ const migrationKeys = template === "platform"
167
+ ? Array.from(new Set([...installedMigrationKeys, ...Object.keys(previousManifest?.migrationCursor || {}), ...Object.keys(overrides)]))
168
+ : [];
153
169
  for (const moduleKey of migrationKeys) {
154
- const result = await bootstrapCursor({ moduleKey, catalogEntry: catalog[moduleKey], appMigrations, override: overrides[moduleKey], warnings });
170
+ if (!Object.hasOwn(catalog, moduleKey)) throw new Error(`Unknown retained migration module: ${moduleKey}. Reconcile its history before re-adopting.`);
171
+ // Removing package wiring never removes applied history. Preserve an exact
172
+ // retained cursor rather than advancing it from files copied by older tools.
173
+ const retainedCursor = !installedMigrationKeys.has(moduleKey)
174
+ ? previousManifest?.migrationCursor?.[moduleKey]
175
+ : undefined;
176
+ const result = await bootstrapCursor({ moduleKey, catalogEntry: catalog[moduleKey], appMigrations, override: overrides[moduleKey], retainedCursor, warnings });
155
177
  if (result.shipsMigrations) {
156
178
  migrationCursor[moduleKey] = result.cursor;
157
179
  cursorStrategies[moduleKey] = result.strategy;
@@ -172,7 +194,7 @@ export async function adoptBrightwebApp(argvOptions = {}, runtimeOptions = {}) {
172
194
  scaffoldFiles: scaffold.records,
173
195
  managedFiles: template === "platform" ? MANAGED_APP_FILES : ["docs/ai/app-context.json"],
174
196
  migrationCursor,
175
- ownedSurfaces: Array.from(new Set(asList(argvOptions.ownedSurface).map(String))),
197
+ ownedSurfaces: Array.from(new Set([...(previousManifest?.ownedSurfaces || []), ...asList(argvOptions.ownedSurface).map(String)])),
176
198
  adoptionNotes: {
177
199
  allowUncursored: argvOptions.allowUncursored === true,
178
200
  cursorStrategies,
@@ -1,3 +1,6 @@
1
+ import { MANAGED_PLATFORM_FILES, isAppOwnedSeed } from "./file-policy.mjs";
2
+ import semver from "semver";
3
+ import { assertMutationTargets } from "./mutation-paths.mjs";
1
4
  import crypto from "node:crypto";
2
5
  import fs from "node:fs/promises";
3
6
  import path from "node:path";
@@ -17,13 +20,7 @@ async function readJsonIfPresent(filePath) {
17
20
  }
18
21
 
19
22
  export const APP_MANIFEST_PATH = path.join(".brightweb", "app-manifest.json");
20
- export const MANAGED_APP_FILES = [
21
- "next.config.ts",
22
- "app/globals.css",
23
- "config/modules.ts",
24
- "config/shell.ts",
25
- "docs/ai/app-context.json",
26
- ];
23
+ export const MANAGED_APP_FILES = MANAGED_PLATFORM_FILES;
27
24
 
28
25
  export const MODULE_PACKAGES = {
29
26
  admin: "@brightweblabs/module-admin",
@@ -44,36 +41,13 @@ const FALLBACK_REQUIRES = {
44
41
 
45
42
  export function cleanVersion(version) {
46
43
  if (typeof version !== "string") return null;
47
- const match = version.match(/(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/);
48
- return match?.[1] || null;
49
- }
50
-
51
- function compareVersions(left, right) {
52
- const a = cleanVersion(left)?.split("-")[0].split(".").map(Number) || [];
53
- const b = cleanVersion(right)?.split("-")[0].split(".").map(Number) || [];
54
- for (let index = 0; index < 3; index += 1) {
55
- if ((a[index] || 0) !== (b[index] || 0)) return (a[index] || 0) - (b[index] || 0);
56
- }
57
- return 0;
44
+ return semver.valid(version.trim().replace(/^[~^]\s*/, ""));
58
45
  }
59
46
 
60
47
  export function satisfiesVersion(version, range) {
61
- const normalized = cleanVersion(version);
62
- if (!normalized || typeof range !== "string") return false;
63
- if (range === "*" || range === "workspace:*") return true;
64
- const expectedMatch = range.match(/(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
65
- const expected = expectedMatch ? `${expectedMatch[1]}.${expectedMatch[2] || "0"}.${expectedMatch[3] || "0"}` : null;
66
- if (!expected) return false;
67
- if (range.trim().startsWith(">=")) return compareVersions(normalized, expected) >= 0;
68
- if (range.trim().startsWith("^")) {
69
- return normalized.split(".")[0] === expected.split(".")[0] && compareVersions(normalized, expected) >= 0;
70
- }
71
- if (range.trim().startsWith("~")) {
72
- const actualParts = normalized.split(".");
73
- const expectedParts = expected.split(".");
74
- return actualParts[0] === expectedParts[0] && actualParts[1] === expectedParts[1] && compareVersions(normalized, expected) >= 0;
75
- }
76
- return normalized === expected;
48
+ if (typeof version !== "string" || typeof range !== "string" || !semver.valid(version)) return false;
49
+ if (range === "workspace:*") return true;
50
+ return semver.satisfies(version, range);
77
51
  }
78
52
 
79
53
  export async function hashFile(filePath) {
@@ -82,6 +56,7 @@ export async function hashFile(filePath) {
82
56
  }
83
57
 
84
58
  export async function readAppManifest(targetDir, { required = true } = {}) {
59
+ await assertMutationTargets(targetDir, [APP_MANIFEST_PATH]);
85
60
  const manifestPath = path.join(targetDir, APP_MANIFEST_PATH);
86
61
  const manifest = await readJsonIfPresent(manifestPath);
87
62
  if (!manifest && required) {
@@ -95,6 +70,7 @@ export async function readAppManifest(targetDir, { required = true } = {}) {
95
70
  }
96
71
 
97
72
  export async function writeAppManifest(targetDir, manifest) {
73
+ await assertMutationTargets(targetDir, [APP_MANIFEST_PATH]);
98
74
  const manifestPath = path.join(targetDir, APP_MANIFEST_PATH);
99
75
  await fs.mkdir(path.dirname(manifestPath), { recursive: true });
100
76
  const temporaryPath = `${manifestPath}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.tmp`;
@@ -178,7 +154,7 @@ export async function collectScaffoldFiles(targetDir, selectedModules) {
178
154
  const result = {};
179
155
  for (const [relativePath, moduleKey] of Array.from(files.entries()).sort()) {
180
156
  const targetPath = resolveSafeRelativePath(targetDir, relativePath, "Scaffold file path");
181
- if (await pathExists(targetPath)) result[relativePath] = { module: moduleKey, hash: await hashFile(targetPath), status: "current" };
157
+ if (await pathExists(targetPath)) result[relativePath] = { module: moduleKey, hash: await hashFile(targetPath), status: "current", ...(isAppOwnedSeed(relativePath) ? { intent: "owned" } : {}) };
182
158
  }
183
159
  return result;
184
160
  }
package/src/constants.mjs CHANGED
@@ -116,6 +116,7 @@ export const MODULE_STARTER_FILES = {
116
116
  "app/(shell)/projetos/[projectId]/page.tsx",
117
117
  "app/(shell)/projetos/[projectId]/quadro/page.tsx",
118
118
  "app/(shell)/projetos/[projectId]/tarefas/page.tsx",
119
+ "app/(shell)/account/perfil/page.tsx",
119
120
  "app/(shell)/account/projetos/page.tsx",
120
121
  "app/(shell)/account/projetos/loading.tsx",
121
122
  "app/(shell)/account/projetos/[projectId]/page.tsx",
@@ -186,16 +187,16 @@ export const PLATFORM_STARTER_FILES = [
186
187
  ];
187
188
 
188
189
  export const APP_DEPENDENCY_DEFAULTS = {
189
- "@brightweblabs/app-shell": "^0.16.2",
190
- "@brightweblabs/core-auth": "^0.12.1",
190
+ "@brightweblabs/app-shell": "^0.16.3",
191
+ "@brightweblabs/core-auth": "^0.12.2",
191
192
  "@brightweblabs/infra": "^0.7.0",
192
- "@brightweblabs/module-admin": "^0.9.6",
193
- "@brightweblabs/module-crm": "^0.18.3",
194
- "@brightweblabs/module-marketing": "^0.5.1",
195
- "@brightweblabs/module-orgs": "^0.7.2",
196
- "@brightweblabs/module-projects": "^0.19.2",
193
+ "@brightweblabs/module-admin": "^0.9.7",
194
+ "@brightweblabs/module-crm": "^0.18.4",
195
+ "@brightweblabs/module-marketing": "^0.5.2",
196
+ "@brightweblabs/module-orgs": "^0.7.3",
197
+ "@brightweblabs/module-projects": "^0.19.3",
197
198
  "@brightweblabs/theme": "^0.8.4",
198
- "@brightweblabs/ui": "^1.5.5",
199
+ "@brightweblabs/ui": "^1.5.6",
199
200
  "geist": "1.7.2",
200
201
  "lucide-react": "^1.8.0",
201
202
  "next": "^16.0.0",
package/src/doctor.mjs CHANGED
@@ -3,11 +3,11 @@ import path from "node:path";
3
3
  import { stdout as output } from "node:process";
4
4
  import { APP_DEPENDENCY_DEFAULTS, BRIGHTWEB_PACKAGE_NAMES } from "./constants.mjs";
5
5
  import { cursorMigrationStatus, exactMigrationCompatibilityStatus } from "./migrations.mjs";
6
- import { findWorkspaceRoot, loadModuleCatalog, MODULE_PACKAGES, readAppManifest, readConfiguredModuleFlags, satisfiesVersion, validateAppManifest, writeAppManifest } from "./app-manifest.mjs";
6
+ import { findWorkspaceRoot, hashFile, loadModuleCatalog, MODULE_PACKAGES, readAppManifest, readConfiguredModuleFlags, satisfiesVersion, validateAppManifest, writeAppManifest } from "./app-manifest.mjs";
7
7
  import { loadAppEnvironment, readFirstEnvironmentValue } from "./env.mjs";
8
8
  import { pathExists, readJsonIfPresent } from "./generator.mjs";
9
9
  import { nearestVercelRegion, normalizeSupabaseRegion } from "./regions.mjs";
10
- import { scaffoldDrift } from "./scaffold.mjs";
10
+ import { canonicalScaffoldHash, scaffoldDrift } from "./scaffold.mjs";
11
11
 
12
12
  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`;
13
13
  const RUNTIME_PACKAGE_NAMES = ["react", "react-dom", "next"];
@@ -378,6 +378,18 @@ export async function doctorBrightwebApp(argvOptions = {}, runtimeOptions = {})
378
378
  add(topologyProblems.length ? "FAIL" : "PASS", "topology", topologyProblems.join("; ") || "Module requirements are satisfied.");
379
379
 
380
380
  const scaffold = await scaffoldDrift(targetDir, appManifest.scaffoldFiles);
381
+ const ownedTemplateUpdates = [];
382
+ for (const entry of scaffold.entries) {
383
+ if (entry.intent !== "owned" || entry.status === "missing") continue;
384
+ const templateHash = await canonicalScaffoldHash({ relativePath: entry.relativePath, manifest: appManifest, targetDir, workspaceRoot });
385
+ // Ownership preserves the recorded baseline during upgrades; it does not prove
386
+ // that later starter fixes have been reconciled into the app's custom file.
387
+ if (templateHash && templateHash !== appManifest.scaffoldFiles[entry.relativePath].hash
388
+ && templateHash !== await hashFile(path.join(targetDir, entry.relativePath))) {
389
+ ownedTemplateUpdates.push(entry.relativePath);
390
+ }
391
+ }
392
+ if (ownedTemplateUpdates.length) add("WARN", "scaffold-owned-template-updates", `Current templates changed for preserved app-owned files: ${ownedTemplateUpdates.join(", ")}. Compatibility is NOT VERIFIED. Run bw diff, reconcile relevant fixes and verify behavior. After review, use bw scaffold manage followed by bw scaffold own for each custom file to record the current template baseline; these commands do not replace file contents.`);
381
393
  const scaffoldGroups = {
382
394
  current: scaffold.entries.filter((entry) => entry.status === "current" && entry.intent !== "skipped"),
383
395
  owned: scaffold.entries.filter((entry) => entry.intent === "owned" && entry.status === "drifted"),
@@ -391,7 +403,7 @@ export async function doctorBrightwebApp(argvOptions = {}, runtimeOptions = {})
391
403
  if (scaffoldGroups.undecidedDrift.length) add("WARN", "scaffold-undecided-drift", `Unacknowledged drift: ${scaffoldGroups.undecidedDrift.map((entry) => entry.relativePath).join(", ")} (use bw scaffold own or bw diff).`);
392
404
  if (scaffoldGroups.undecidedMissing.length) add("WARN", "scaffold-undecided-missing", `Unacknowledged missing files: ${scaffoldGroups.undecidedMissing.map((entry) => entry.relativePath).join(", ")} (use bw scaffold skip after review).`);
393
405
  if (scaffoldGroups.mismatched.length) add("FAIL", "scaffold-intent-mismatch", `Recorded scaffold intent no longer matches reality: ${scaffoldGroups.mismatched.map((entry) => `${entry.relativePath} (${entry.intent}, ${entry.status})`).join(", ")}.`);
394
- const scaffoldStatus = scaffoldGroups.mismatched.length ? "FAIL" : scaffoldGroups.undecidedDrift.length || scaffoldGroups.undecidedMissing.length ? "WARN" : "PASS";
406
+ const scaffoldStatus = scaffoldGroups.mismatched.length ? "FAIL" : ownedTemplateUpdates.length || scaffoldGroups.undecidedDrift.length || scaffoldGroups.undecidedMissing.length ? "WARN" : "PASS";
395
407
  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.`);
396
408
  add("INFO", "owned-surfaces", `Owned surfaces: ${(appManifest.ownedSurfaces || []).join(", ") || "none"}.`);
397
409
 
@@ -456,7 +468,7 @@ export async function doctorBrightwebApp(argvOptions = {}, runtimeOptions = {})
456
468
  add("INFO", `migration-legacy-equivalent-${key}`, `${key}: ${status.legacyEquivalent.length} immutable historical migration file${status.legacyEquivalent.length === 1 ? " matches" : "s match"} a reviewed SQL-equivalent legacy hash.`);
457
469
  }
458
470
  }
459
- add("WARN", "db-objects", "SKIP live database checks are not available yet.");
471
+ add("WARN", "db-objects", "NOT VERIFIED: live database objects, applied migrations, and authenticated row-level permissions. Local migration provenance does not establish deployed behavior; apply pending migrations and run authenticated admin/client permission checks against the target database.");
460
472
  return finish(checks, argvOptions, appManifest, targetDir);
461
473
  }
462
474
 
@@ -0,0 +1,40 @@
1
+ import { MODULE_STARTER_FILES, PLATFORM_STARTER_FILES } from "./constants.mjs";
2
+
3
+ export const MODULE_SELECTED_FILES = [
4
+ "app/api/invitations/_dependencies.ts",
5
+ "app/api/organizations/route.ts",
6
+ "app/api/organizations/[id]/route.ts",
7
+ "app/api/organizations/[id]/invitations/route.ts",
8
+ "app/api/organizations/[id]/invitations/[invitationId]/route.ts",
9
+ "app/api/organizations/[id]/members/[profileId]/route.ts",
10
+ ];
11
+ export const MANAGED_PLATFORM_FILES = [
12
+ "next.config.ts", "app/globals.css", "config/module-toolbar-controls.tsx",
13
+ "config/modules.ts", "config/shell.ts", ...MODULE_SELECTED_FILES,
14
+ "docs/ai/app-context.json",
15
+ ];
16
+ export const MANAGED_SITE_FILES = ["docs/ai/app-context.json"];
17
+ export const APP_OWNED_FILES = [
18
+ "config/brand.ts", "config/shell.overrides.ts", "app/theme.css", "app/fonts.ts",
19
+ "config/social-media-plan.json", "app/(shell)/marketing/social-media/page.tsx",
20
+ "public/brand/logo-mark.svg", "public/brand/logo-light.svg", "public/brand/logo-dark.svg",
21
+ "docs/ai/README.md", "docs/ai/examples.md", "AGENTS.md", "README.md",
22
+ ];
23
+ export function fileOwnership({ template = "platform", modules = [] } = {}) {
24
+ const generated = template === "platform" ? MANAGED_PLATFORM_FILES : MANAGED_SITE_FILES;
25
+ const appOwned = template === "platform" ? APP_OWNED_FILES : ["config/site.ts", "app/fonts.ts", "app/globals.css", "app/layout.tsx", "next.config.ts", "postcss.config.mjs", "tsconfig.json", "docs/ai/README.md", "docs/ai/examples.md", "AGENTS.md", "README.md"];
26
+ const scaffold = template === "platform" ? [...new Set([...PLATFORM_STARTER_FILES, ...modules.flatMap((key) => MODULE_STARTER_FILES[key] || [])])].filter((p) => !generated.includes(p) && !appOwned.includes(p)) : [];
27
+ return { default: "app-owned", appOwned: [...appOwned], scaffoldManaged: scaffold, generated: [...generated], precedence: ["explicit-intent-or-drift", "exact-path-policy", "recorded-scaffold", "app-owned-default"] };
28
+ }
29
+ export function isAppOwnedSeed(relativePath) { return APP_OWNED_FILES.includes(relativePath); }
30
+
31
+ /** Fail before writing when a renderer and its ownership policy disagree. */
32
+ export function assertGeneratedFileInventory(files, expectedPaths, label) {
33
+ const actualPaths = Object.keys(files);
34
+ const missing = expectedPaths.filter((relativePath) => !Object.hasOwn(files, relativePath));
35
+ const unexpected = actualPaths.filter((relativePath) => !expectedPaths.includes(relativePath));
36
+ if (missing.length || unexpected.length) {
37
+ throw new Error(`${label} inventory mismatch: missing ${missing.join(", ") || "none"}; unexpected ${unexpected.join(", ") || "none"}.`);
38
+ }
39
+ return files;
40
+ }
package/src/generator.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { fileOwnership, assertGeneratedFileInventory, MANAGED_PLATFORM_FILES, MODULE_SELECTED_FILES } from "./file-policy.mjs";
1
2
  import fs from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import { fileURLToPath } from "node:url";
@@ -690,20 +691,7 @@ export function createAppContextFile({
690
691
  },
691
692
  starterRoutes: getSiteStarterRoutes(),
692
693
  ownership: {
693
- appOwned: [
694
- "config/**",
695
- "docs/ai/**",
696
- "public/**",
697
- "AGENTS.md",
698
- "README.md",
699
- ],
700
- scaffoldManaged: [
701
- "app/layout.tsx",
702
- "app/globals.css",
703
- "next.config.ts",
704
- "postcss.config.mjs",
705
- "tsconfig.json",
706
- ],
694
+ ...fileOwnership({ template: "site" }),
707
695
  packageOwned: [],
708
696
  },
709
697
  agentRules: {
@@ -753,22 +741,7 @@ export function createAppContextFile({
753
741
  },
754
742
  starterRoutes: getPlatformStarterRoutes(selectedModules),
755
743
  ownership: {
756
- appOwned: [
757
- "config/**",
758
- "docs/ai/**",
759
- "public/brand/**",
760
- "AGENTS.md",
761
- "README.md",
762
- ],
763
- scaffoldManaged: [
764
- "app/layout.tsx",
765
- "app/globals.css",
766
- "app/**/page.tsx",
767
- "app/**/route.ts",
768
- "next.config.ts",
769
- "postcss.config.mjs",
770
- "tsconfig.json",
771
- ],
744
+ ...fileOwnership({ template: "platform", modules: selectedModules }),
772
745
  packageOwned: [
773
746
  ...CORE_PACKAGES,
774
747
  ...(selectedModules.includes("crm") || selectedModules.includes("marketing") || selectedModules.includes("projects") ? [ORGS_PACKAGE_NAME] : []),
@@ -1182,7 +1155,7 @@ export function createOptionalModuleRouteFiles(selectedModules) {
1182
1155
  'const acceptOrganizationInvitation = async (_client: never, _input: unknown): Promise<never> => { throw new Error("Convite não encontrado."); };',
1183
1156
  ] : []),
1184
1157
  ...(!crmEnabled ? [
1185
- "const ensureCrmContactForProfile = async () => ({ success: true as const });",
1158
+ "const ensureCrmContactForProfile = undefined;",
1186
1159
  ] : []),
1187
1160
  (!adminEnabled || !orgsEnabled || !crmEnabled) ? "" : null,
1188
1161
  "export const invitationHttpDependencies = {",
@@ -1213,7 +1186,7 @@ export function createOptionalModuleRouteFiles(selectedModules) {
1213
1186
  "",
1214
1187
  ].filter((line) => line !== null).join("\n");
1215
1188
 
1216
- return {
1189
+ return assertGeneratedFileInventory({
1217
1190
  "app/api/invitations/_dependencies.ts": invitationDependencies,
1218
1191
  "app/api/organizations/route.ts": createOrganizationRoute([
1219
1192
  crmEnabled
@@ -1287,7 +1260,20 @@ export function createOptionalModuleRouteFiles(selectedModules) {
1287
1260
  "}",
1288
1261
  "",
1289
1262
  ].join("\n"),
1290
- };
1263
+ }, MODULE_SELECTED_FILES, "Module-selected routes");
1264
+ }
1265
+
1266
+ /** The same module-aware outputs drive initial scaffolding and every topology refresh. */
1267
+ export async function createManagedPlatformFiles({ slug, selectedModules, dbInstallPlan }) {
1268
+ return assertGeneratedFileInventory({
1269
+ "next.config.ts": createNextConfig({ template: "platform", selectedModules }),
1270
+ "app/globals.css": await createPlatformGlobalsCss(selectedModules),
1271
+ "config/module-toolbar-controls.tsx": createModuleToolbarControlsConfig(selectedModules),
1272
+ "config/modules.ts": createPlatformModulesConfigFile(selectedModules),
1273
+ "config/shell.ts": createShellConfig(selectedModules),
1274
+ ...createOptionalModuleRouteFiles(selectedModules),
1275
+ "docs/ai/app-context.json": createAppContextFile({ slug, template: "platform", selectedModules, dbInstallPlan }),
1276
+ }, MANAGED_PLATFORM_FILES, "Managed platform files");
1291
1277
  }
1292
1278
 
1293
1279
  function createSiteConfigFile(slug) {
@@ -1628,6 +1614,7 @@ async function scaffoldPlatformProject({
1628
1614
  dbRegistry,
1629
1615
  }) {
1630
1616
  const brandValues = createDerivedBrandValues(answers.slug);
1617
+ const managedFiles = await createManagedPlatformFiles({ slug: answers.slug, selectedModules, dbInstallPlan });
1631
1618
  const baseTemplateDir = path.join(TEMPLATE_ROOT, "base");
1632
1619
 
1633
1620
  await ensureDirectory(path.dirname(targetDir));
@@ -1659,30 +1646,14 @@ async function scaffoldPlatformProject({
1659
1646
  2,
1660
1647
  )}\n`,
1661
1648
  );
1662
- await fs.writeFile(path.join(targetDir, "next.config.ts"), createNextConfig({ template: "platform", selectedModules }));
1663
- await fs.writeFile(path.join(targetDir, "app", "globals.css"), await createPlatformGlobalsCss(selectedModules));
1664
1649
  await fs.writeFile(
1665
1650
  path.join(targetDir, "config", "brand.ts"),
1666
1651
  createPlatformBrandConfigFile({ slug: answers.slug, brandValues }),
1667
1652
  );
1668
- await fs.writeFile(path.join(targetDir, "config", "modules.ts"), createPlatformModulesConfigFile(selectedModules));
1669
- await fs.writeFile(
1670
- path.join(targetDir, "config", "module-toolbar-controls.tsx"),
1671
- createModuleToolbarControlsConfig(selectedModules),
1672
- );
1673
- await fs.writeFile(path.join(targetDir, "config", "shell.ts"), createShellConfig(selectedModules));
1674
- for (const [relativePath, content] of Object.entries(createOptionalModuleRouteFiles(selectedModules))) {
1653
+ for (const [relativePath, content] of Object.entries(managedFiles)) {
1654
+ await ensureDirectory(path.dirname(path.join(targetDir, relativePath)));
1675
1655
  await fs.writeFile(path.join(targetDir, relativePath), content);
1676
1656
  }
1677
- await fs.writeFile(
1678
- path.join(targetDir, "docs", "ai", "app-context.json"),
1679
- createAppContextFile({
1680
- slug: answers.slug,
1681
- template: "platform",
1682
- selectedModules,
1683
- dbInstallPlan,
1684
- }),
1685
- );
1686
1657
 
1687
1658
  const vercelConfig = createVercelConfig(answers.supabaseRegion);
1688
1659