create-bw-app 0.27.1 → 0.27.2

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
@@ -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.2",
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
@@ -186,16 +186,16 @@ export const PLATFORM_STARTER_FILES = [
186
186
  ];
187
187
 
188
188
  export const APP_DEPENDENCY_DEFAULTS = {
189
- "@brightweblabs/app-shell": "^0.16.2",
190
- "@brightweblabs/core-auth": "^0.12.1",
189
+ "@brightweblabs/app-shell": "^0.16.3",
190
+ "@brightweblabs/core-auth": "^0.12.2",
191
191
  "@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",
192
+ "@brightweblabs/module-admin": "^0.9.7",
193
+ "@brightweblabs/module-crm": "^0.18.4",
194
+ "@brightweblabs/module-marketing": "^0.5.2",
195
+ "@brightweblabs/module-orgs": "^0.7.3",
196
+ "@brightweblabs/module-projects": "^0.19.3",
197
197
  "@brightweblabs/theme": "^0.8.4",
198
- "@brightweblabs/ui": "^1.5.5",
198
+ "@brightweblabs/ui": "^1.5.6",
199
199
  "geist": "1.7.2",
200
200
  "lucide-react": "^1.8.0",
201
201
  "next": "^16.0.0",
@@ -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
 
@@ -1,3 +1,4 @@
1
+ import semver from "semver";
1
2
  import fs from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import { createHash } from "node:crypto";
@@ -66,6 +67,9 @@ export async function planMigrationAppends({
66
67
  for (const moduleKey of moduleKeys) {
67
68
  const migrations = await getModuleMigrations(moduleKey, catalog[moduleKey]);
68
69
  const cursor = migrationCursor[moduleKey];
70
+ if (cursor != null && !migrations.some((entry) => entry.fileName === cursor)) {
71
+ throw new Error(`Migration cursor ${cursor} does not exist in the shipped ${moduleKey} history; reconcile it explicitly before upgrading.`);
72
+ }
69
73
  const upperBound = migrationUpperBounds[moduleKey];
70
74
  let migrationsInScope = migrations;
71
75
  if (upperBound) {
@@ -75,9 +79,6 @@ export async function planMigrationAppends({
75
79
  }
76
80
  if (cursor) {
77
81
  const cursorIndex = migrations.findIndex((entry) => entry.fileName === cursor);
78
- if (cursorIndex === -1) {
79
- throw new Error(`Migration cutoff blocked: current cursor ${cursor} does not exist in the shipped ${moduleKey} migration history.`);
80
- }
81
82
  if (upperBoundIndex < cursorIndex) {
82
83
  throw new Error(`Migration cutoff ${upperBound} is before the current ${moduleKey} cursor ${cursor}.`);
83
84
  }
@@ -117,6 +118,9 @@ export async function cursorMigrationStatus({ targetDir, moduleKey, cursor, cata
117
118
  const migrations = await getModuleMigrations(moduleKey, catalogEntry);
118
119
  if (migrations.length === 0) return { shipsMigrations: false, missing: [] };
119
120
  if (!cursor) return { shipsMigrations: true, missing: ["migration cursor"] };
121
+ if (!migrations.some((entry) => entry.fileName === cursor)) {
122
+ return { shipsMigrations: true, missing: [`cursor ${cursor} does not exist in the shipped migration history`] };
123
+ }
120
124
  const expected = migrations.filter((entry) => entry.fileName <= cursor);
121
125
  const migrationsDir = await findAppMigrationsDirectory(targetDir);
122
126
  const installed = [];
@@ -149,13 +153,7 @@ const REVIEWED_LEGACY_MIGRATION_HASHES = new Map([
149
153
  ]);
150
154
 
151
155
  function compareSemver(left, right) {
152
- const leftParts = String(left).split(".").map((value) => Number.parseInt(value, 10));
153
- const rightParts = String(right).split(".").map((value) => Number.parseInt(value, 10));
154
- if (leftParts.length !== 3 || rightParts.length !== 3 || [...leftParts, ...rightParts].some(Number.isNaN)) return null;
155
- for (let index = 0; index < 3; index += 1) {
156
- if (leftParts[index] !== rightParts[index]) return leftParts[index] < rightParts[index] ? -1 : 1;
157
- }
158
- return 0;
156
+ return semver.valid(left) && semver.valid(right) ? semver.compare(left, right) : null;
159
157
  }
160
158
 
161
159
  export async function exactMigrationCompatibilityStatus({ targetDir, moduleKey, cursor, catalogEntry, allowDeferred = false }) {
@@ -0,0 +1,28 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { normalizeSafeRelativePath } from "./safe-path.mjs";
4
+
5
+ // The caller-selected app root may be an alias (including macOS /var). Resolve
6
+ // that boundary once; links beneath it are never writable lifecycle targets.
7
+ // This preflight assumes no concurrent filesystem edits; it is not a sandbox.
8
+ export async function assertMutationTargets(targetDir, relativePaths) {
9
+ const root = await fs.realpath(path.resolve(targetDir));
10
+ if (!(await fs.stat(root)).isDirectory()) throw new Error("App root must be a directory.");
11
+ for (const input of new Set(relativePaths)) {
12
+ const relativePath = normalizeSafeRelativePath(input, "Mutation target");
13
+ let current = root;
14
+ const parts = relativePath.split("/");
15
+ for (const [index, part] of parts.entries()) {
16
+ current = path.join(current, part);
17
+ const stat = await fs.lstat(current).catch((error) => {
18
+ if (error.code === "ENOENT") return null;
19
+ throw error;
20
+ });
21
+ if (!stat) break;
22
+ if (stat.isSymbolicLink()) throw new Error(`Lifecycle writes do not follow symlinks: ${relativePath}`);
23
+ if (index < parts.length - 1 ? !stat.isDirectory() : !stat.isFile()) {
24
+ throw new Error(`Unexpected filesystem type at mutation target: ${relativePath}`);
25
+ }
26
+ }
27
+ }
28
+ }