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.
@@ -0,0 +1,32 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ // Conservative literal-import diagnostic, not a complete application bundler.
5
+ // Generated replacements and deliberate clean deletions are outside the surviving set.
6
+ export async function findSurvivingPackageImports(targetDir, packageName, excludedPaths) {
7
+ const escaped = packageName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
8
+ const imports = new RegExp(`(?:\\bfrom\\s*|\\bimport\\s*(?:\\(\\s*)?|\\brequire\\s*\\(\\s*)["']${escaped}(?:/[^"']*)?["']`);
9
+ const stylesheetImports = new RegExp(`@(?:import|reference)\\s*(?:url\\(\\s*)?["']?${escaped}(?:/[^"'\\s);]+)?(?=["'\\s);]|$)`, "i");
10
+ const sourceExtension = /\.(?:[cm]?[jt]sx?|mdx|css)$/i;
11
+ const findings = [];
12
+ const ignored = new Set(["node_modules", ".git", ".next", ".brightweb", "supabase", "dist", "build"]);
13
+ async function visit(directory) {
14
+ for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
15
+ if (ignored.has(entry.name)) continue;
16
+ const fullPath = path.join(directory, entry.name);
17
+ const relativePath = path.relative(targetDir, fullPath);
18
+ if (excludedPaths.has(relativePath)) continue;
19
+ if (entry.isSymbolicLink()) {
20
+ // Do not read outside the app or silently declare linked source safe.
21
+ if (entry.name.includes(".") && !sourceExtension.test(entry.name)) continue;
22
+ findings.push(`${relativePath} (linked source; inspect dependencies explicitly)`);
23
+ } else if (entry.isDirectory()) await visit(fullPath);
24
+ else if (sourceExtension.test(entry.name)) {
25
+ const source = await fs.readFile(fullPath, "utf8");
26
+ if ((/\.css$/i.test(entry.name) ? stylesheetImports : imports).test(source)) findings.push(relativePath);
27
+ }
28
+ }
29
+ }
30
+ await visit(targetDir);
31
+ return findings.sort();
32
+ }
package/src/remove.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import { findSurvivingPackageImports } from "./removal-dependents.mjs";
2
+ import { assertMutationTargets } from "./mutation-paths.mjs";
1
3
  import fs from "node:fs/promises";
2
4
  import path from "node:path";
3
5
  import { stdout as output } from "node:process";
@@ -10,18 +12,13 @@ import {
10
12
  writeAppManifest,
11
13
  } from "./app-manifest.mjs";
12
14
  import {
13
- createAppContextFile,
14
15
  createDbInstallPlan,
15
- createModuleToolbarControlsConfig,
16
- createNextConfig,
17
- createOptionalModuleRouteFiles,
18
- createPlatformGlobalsCss,
19
- createPlatformModulesConfigFile,
20
- createShellConfig,
16
+ createManagedPlatformFiles,
21
17
  getDbModuleRegistry,
22
18
  pathExists,
23
19
  readJsonIfPresent,
24
20
  } from "./generator.mjs";
21
+ import { assertNoUntrackedScaffoldWrites, preserveScaffoldDecisions, scaffoldDrift } from "./scaffold.mjs";
25
22
  import { resolveSafeRelativePath } from "./safe-path.mjs";
26
23
 
27
24
  const HELP = `Usage: bw remove <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 removal plan without writing\n --yes Apply the removal plan\n --help Show this help`;
@@ -38,7 +35,7 @@ function databaseNotice(moduleKey, ownedObjects) {
38
35
 
39
36
  export async function removeBrightwebModule(moduleKey, argvOptions = {}, runtimeOptions = {}) {
40
37
  if (!moduleKey || argvOptions.help) { output.write(`${HELP}\n`); return { help: true }; }
41
- const targetDir = path.resolve(runtimeOptions.targetDir || argvOptions.targetDir || process.cwd());
38
+ const targetDir = await fs.realpath(path.resolve(runtimeOptions.targetDir || argvOptions.targetDir || process.cwd()));
42
39
  const appManifest = await readAppManifest(targetDir);
43
40
  if (!appManifest.modules[moduleKey]) throw new Error(`Module ${moduleKey} is not installed according to .brightweb/app-manifest.json.`);
44
41
  const workspaceRoot = runtimeOptions.workspaceRoot || argvOptions.workspaceRoot || await findWorkspaceRoot(targetDir);
@@ -61,34 +58,34 @@ export async function removeBrightwebModule(moduleKey, argvOptions = {}, runtime
61
58
  workspaceMode: Object.values(nextPackageJson.dependencies || {}).some((value) => String(value).startsWith("workspace:")),
62
59
  registry: dbRegistry,
63
60
  });
64
- const managedWrites = {
65
- "next.config.ts": createNextConfig({ template: "platform", selectedModules: remainingModules }),
66
- "app/globals.css": await createPlatformGlobalsCss(remainingModules),
67
- "config/module-toolbar-controls.tsx": createModuleToolbarControlsConfig(remainingModules),
68
- "config/modules.ts": createPlatformModulesConfigFile(remainingModules),
69
- "config/shell.ts": createShellConfig(remainingModules),
70
- "docs/ai/app-context.json": createAppContextFile({ slug: appManifest.app.slug, template: "platform", selectedModules: remainingModules.filter((key) => key !== "orgs"), dbInstallPlan }),
71
- ...createOptionalModuleRouteFiles(remainingModules),
72
- };
61
+ const managedWrites = await createManagedPlatformFiles({ slug: appManifest.app.slug, selectedModules: remainingModules, dbInstallPlan });
73
62
 
74
- const cleanFiles = [];
75
- const driftedFiles = [];
76
- for (const [relativePath, record] of Object.entries(appManifest.scaffoldFiles || {})) {
77
- if (record.module !== moduleKey) continue;
78
- const filePath = resolveSafeRelativePath(targetDir, relativePath, "Manifest scaffold file path");
79
- if (!(await pathExists(filePath))) continue;
80
- if ((record.intent || "managed") === "managed" && await hashFile(filePath) === record.hash) cleanFiles.push(relativePath);
81
- else driftedFiles.push(relativePath);
82
- }
63
+ const live = await scaffoldDrift(targetDir, appManifest.scaffoldFiles);
64
+ const { protectedPaths } = live;
65
+ for (const relativePath of protectedPaths) delete managedWrites[relativePath];
66
+ await assertNoUntrackedScaffoldWrites({
67
+ targetDir,
68
+ scaffoldFiles: appManifest.scaffoldFiles,
69
+ moduleKeys: remainingModules,
70
+ relativePaths: Object.keys(managedWrites),
71
+ });
72
+ const retainedFiles = live.entries.filter((entry) => entry.module === moduleKey && protectedPaths.has(entry.relativePath)).map((entry) => entry.relativePath);
73
+ const moduleFiles = live.entries.filter((entry) => entry.module === moduleKey && entry.status !== "missing");
74
+ const cleanFiles = moduleFiles.filter((entry) => !protectedPaths.has(entry.relativePath)).map((entry) => entry.relativePath);
75
+ const driftedFiles = moduleFiles.filter((entry) => protectedPaths.has(entry.relativePath)).map((entry) => entry.relativePath);
76
+ await assertMutationTargets(targetDir, ["package.json", ".brightweb/app-manifest.json", ...cleanFiles, ...Object.keys(managedWrites)]);
77
+ const dependentsOnDisk = await findSurvivingPackageImports(targetDir, packageName, new Set([...cleanFiles, ...Object.keys(managedWrites)]));
78
+ if (dependentsOnDisk.length) throw new Error(`Cannot remove ${moduleKey}: surviving app files depend on ${packageName}: ${dependentsOnDisk.join(", ")}. Reconcile those imports before removal; app-owned content was not changed.`);
83
79
  const notice = databaseNotice(moduleKey, catalog[moduleKey]?.manifest?.database?.ownedObjects || []);
84
80
  const apply = argvOptions.yes === true && argvOptions.dryRun !== true;
85
81
  output.write(`bw remove ${moduleKey}${apply ? "" : " (plan only; pass --yes to apply)"}\n`);
86
82
  output.write(`Dependency to remove: ${packageName}\n`);
87
83
  output.write(`Clean scaffold files to remove: ${cleanFiles.join(", ") || "none"}\n`);
84
+ output.write(`Scaffold decisions retained for re-add: ${retainedFiles.join(", ") || "none"}\n`);
88
85
  output.write(`Drifted scaffold files left in place: ${driftedFiles.join(", ") || "none"}\n`);
89
86
  for (const relativePath of driftedFiles) output.write(`WARN ${relativePath} is drifted and will be left in place.\n`);
90
87
  for (const line of notice) output.write(`${line}\n`);
91
- if (!apply) return { dryRun: true, moduleKey, cleanFiles, driftedFiles, notice };
88
+ if (!apply) return { dryRun: true, moduleKey, cleanFiles, driftedFiles, retainedFiles, notice };
92
89
 
93
90
  await fs.writeFile(packagePath, `${JSON.stringify(nextPackageJson, null, 2)}\n`, "utf8");
94
91
  for (const relativePath of cleanFiles) await fs.rm(resolveSafeRelativePath(targetDir, relativePath, "Manifest scaffold file path"));
@@ -101,7 +98,11 @@ export async function removeBrightwebModule(moduleKey, argvOptions = {}, runtime
101
98
  if (appManifest.modules.orgs) {
102
99
  appManifest.modules.orgs.exposed = remainingModules.some((key) => ["crm", "marketing", "projects"].includes(key));
103
100
  }
104
- for (const [relativePath, record] of Object.entries(appManifest.scaffoldFiles || {})) if (record.module === moduleKey) delete appManifest.scaffoldFiles[relativePath];
101
+ appManifest.scaffoldFiles = preserveScaffoldDecisions(
102
+ Object.fromEntries(Object.entries(appManifest.scaffoldFiles).filter(([, record]) => record.module !== moduleKey)),
103
+ appManifest.scaffoldFiles,
104
+ protectedPaths,
105
+ );
105
106
  for (const relativePath of Object.keys(managedWrites)) {
106
107
  const record = appManifest.scaffoldFiles[relativePath];
107
108
  if (!record) continue;
@@ -112,7 +113,7 @@ export async function removeBrightwebModule(moduleKey, argvOptions = {}, runtime
112
113
  }
113
114
  await writeAppManifest(targetDir, appManifest);
114
115
  output.write(`Removed ${moduleKey} package wiring and ${cleanFiles.length} clean scaffold file${cleanFiles.length === 1 ? "" : "s"}. Install dependencies next.\n`);
115
- return { dryRun: false, moduleKey, cleanFiles, driftedFiles, notice };
116
+ return { dryRun: false, moduleKey, cleanFiles, driftedFiles, retainedFiles, notice };
116
117
  }
117
118
 
118
119
  export { HELP as REMOVE_HELP };
@@ -1,8 +1,9 @@
1
+ import fs from "node:fs/promises";
1
2
  import path from "node:path";
2
3
  import { stdout as output } from "node:process";
3
4
  import { findWorkspaceRoot, hashFile, readAppManifest, writeAppManifest } from "./app-manifest.mjs";
4
5
  import { pathExists } from "./generator.mjs";
5
- import { findTrackedTemplate, scaffoldDrift } from "./scaffold.mjs";
6
+ import { canonicalScaffoldHash, scaffoldDrift } from "./scaffold.mjs";
6
7
  import { normalizeSafeRelativePath, resolveSafeRelativePath } from "./safe-path.mjs";
7
8
 
8
9
  const HELP = `Usage: bw scaffold <action> [paths...] [options]\n\nActions:\n list List tracked files, live status, and intent\n own <path>... Mark existing tracked files as app-owned\n skip <path>... Mark missing tracked files as intentionally absent\n manage <path>... Return tracked files to BrightWeb management\n\nOptions:\n --target-dir <path> App directory (defaults to cwd)\n --workspace-root <path> BrightWeb workspace root\n --help Show this help`;
@@ -14,7 +15,7 @@ export async function scaffoldBrightwebApp(action, paths = [], argvOptions = {},
14
15
  if (action !== "list" && paths.length === 0) throw new Error(`bw scaffold ${action} requires at least one tracked <path>.`);
15
16
  if (action === "list" && paths.length > 0) throw new Error("bw scaffold list does not accept file paths.");
16
17
 
17
- const targetDir = path.resolve(runtimeOptions.targetDir || argvOptions.targetDir || process.cwd());
18
+ const targetDir = await fs.realpath(path.resolve(runtimeOptions.targetDir || argvOptions.targetDir || process.cwd()));
18
19
  const manifest = await readAppManifest(targetDir);
19
20
  const live = await scaffoldDrift(targetDir, manifest.scaffoldFiles);
20
21
  if (action === "list") {
@@ -43,15 +44,14 @@ export async function scaffoldBrightwebApp(action, paths = [], argvOptions = {},
43
44
  const appPath = resolveSafeRelativePath(targetDir, relativePath, "Manifest scaffold file path");
44
45
  const exists = await pathExists(appPath);
45
46
  if (action === "manage") {
46
- const located = await findTrackedTemplate({ relativePath, manifest, targetDir, workspaceRoot });
47
- if (!located.templatePath) throw new Error(`Installed-package template unavailable for ${relativePath}; cannot manage it safely.`);
48
- const templateHash = await hashFile(located.templatePath);
47
+ const templateHash = await canonicalScaffoldHash({ relativePath, manifest, targetDir, workspaceRoot });
48
+ if (!templateHash) throw new Error(`Installed-package template unavailable for ${relativePath}; cannot manage it safely.`);
49
49
  if (exists) {
50
- record.hash = await hashFile(appPath);
51
- record.status = record.hash === templateHash ? "current" : "drifted";
50
+ record.status = await hashFile(appPath) === templateHash ? "current" : "drifted";
52
51
  } else {
53
52
  record.status = "missing";
54
53
  }
54
+ record.hash = templateHash;
55
55
  } else {
56
56
  record.status = liveByPath.get(relativePath).status;
57
57
  }
package/src/scaffold.mjs CHANGED
@@ -1,3 +1,6 @@
1
+ import { isAppOwnedSeed } from "./file-policy.mjs";
2
+ import { assertMutationTargets } from "./mutation-paths.mjs";
3
+ import { createHash } from "node:crypto";
1
4
  import fs from "node:fs/promises";
2
5
  import path from "node:path";
3
6
  import { fileURLToPath } from "node:url";
@@ -34,7 +37,22 @@ export function trackedScaffoldDefinitions(moduleKeys = []) {
34
37
  return definitions.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
35
38
  }
36
39
 
40
+ /** Missing ownership history is not permission to replace an existing scaffold. */
41
+ export async function assertNoUntrackedScaffoldWrites({ targetDir, scaffoldFiles, moduleKeys, relativePaths }) {
42
+ const tracked = new Set(trackedScaffoldDefinitions(moduleKeys).map((entry) => entry.relativePath));
43
+ for (const relativePath of relativePaths) {
44
+ if (!tracked.has(relativePath) || scaffoldFiles[relativePath]) continue;
45
+ const targetPath = resolveSafeRelativePath(targetDir, relativePath, "Scaffold output path");
46
+ const exists = await fs.lstat(targetPath).then(() => true, (error) => {
47
+ if (error.code === "ENOENT") return false;
48
+ throw error;
49
+ });
50
+ if (exists) throw new Error(`Untracked app file conflicts with scaffold output: ${relativePath}. Move or reconcile it explicitly before continuing.`);
51
+ }
52
+ }
53
+
37
54
  export async function inventoryScaffoldFiles({ targetDir, moduleKeys, templateRoot }) {
55
+ await assertMutationTargets(targetDir, trackedScaffoldDefinitions(moduleKeys).map((entry) => entry.relativePath));
38
56
  const records = {};
39
57
  const unsupported = [];
40
58
  for (const definition of trackedScaffoldDefinitions(moduleKeys)) {
@@ -47,6 +65,7 @@ export async function inventoryScaffoldFiles({ targetDir, moduleKeys, templateRo
47
65
  const templateHash = await hashFile(templatePath);
48
66
  const exists = await pathExists(appPath);
49
67
  records[definition.relativePath] = {
68
+ ...(isAppOwnedSeed(definition.relativePath) ? { intent: "owned" } : {}),
50
69
  module: definition.moduleKey,
51
70
  hash: templateHash,
52
71
  status: !exists ? "missing" : await hashFile(appPath) === templateHash ? "current" : "drifted",
@@ -55,14 +74,26 @@ export async function inventoryScaffoldFiles({ targetDir, moduleKeys, templateRo
55
74
  return { records, unsupported };
56
75
  }
57
76
 
77
+ /** Ownership belongs to an exact app path, independently of installed modules. */
78
+ export function preserveScaffoldDecisions(records, previousRecords = {}, protectedPaths = new Set()) {
79
+ const reconciled = { ...records };
80
+ for (const [relativePath, record] of Object.entries(previousRecords)) {
81
+ if (["owned", "skipped"].includes(record.intent) || protectedPaths.has(relativePath)) {
82
+ reconciled[relativePath] = { ...record };
83
+ }
84
+ }
85
+ return reconciled;
86
+ }
87
+
58
88
  export async function scaffoldDrift(targetDir, scaffoldFiles = {}) {
89
+ await assertMutationTargets(targetDir, Object.keys(scaffoldFiles));
59
90
  const current = [];
60
91
  const drifted = [];
61
92
  const missing = [];
62
93
  const entries = [];
63
94
  for (const [relativePath, record] of Object.entries(scaffoldFiles)) {
64
95
  const appPath = resolveSafeRelativePath(targetDir, relativePath, "Manifest scaffold file path");
65
- const intent = record.intent || "managed";
96
+ const intent = record.intent === "skipped" ? "skipped" : isAppOwnedSeed(relativePath) ? "owned" : record.intent || "managed";
66
97
  let status = "missing";
67
98
  if (await pathExists(appPath)) {
68
99
  const matchesRecordedHash = await hashFile(appPath) === record.hash;
@@ -73,7 +104,9 @@ export async function scaffoldDrift(targetDir, scaffoldFiles = {}) {
73
104
  else if (status === "current") current.push(relativePath);
74
105
  else drifted.push(relativePath);
75
106
  }
76
- return { current, drifted, missing, entries };
107
+ const intentional = entries.filter((entry) => entry.intent !== "managed").map((entry) => entry.relativePath);
108
+ const protectedPaths = new Set([...drifted, ...intentional]);
109
+ return { current, drifted, missing, entries, intentional, protectedPaths };
77
110
  }
78
111
 
79
112
  export async function findTrackedTemplate({ relativePath, manifest, targetDir, workspaceRoot }) {
@@ -89,3 +122,11 @@ export async function findTrackedTemplate({ relativePath, manifest, targetDir, w
89
122
  export async function readTextFile(filePath) {
90
123
  return fs.readFile(filePath, "utf8");
91
124
  }
125
+
126
+ export async function canonicalScaffoldHash({ relativePath, manifest, targetDir, workspaceRoot }) {
127
+ const { createOptionalModuleRouteFiles } = await import("./generator.mjs");
128
+ const generated = createOptionalModuleRouteFiles(Object.keys(manifest.modules || {}))[relativePath];
129
+ if (generated != null) return `sha256:${createHash("sha256").update(generated).digest("hex")}`;
130
+ const located = await findTrackedTemplate({ relativePath, manifest, targetDir, workspaceRoot });
131
+ return located.templatePath ? hashFile(located.templatePath) : null;
132
+ }
package/src/setup.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import fs from "node:fs/promises";
2
+ import { assertMutationTargets } from "./mutation-paths.mjs";
2
3
  import path from "node:path";
3
4
  import { createRequire } from "node:module";
4
5
  import { readAppManifest, readConfiguredModuleFlags } from "./app-manifest.mjs";
@@ -26,23 +27,12 @@ export default function Page() {
26
27
  }
27
28
  `;
28
29
 
29
- // Generated paths must remain real directories/files inside the app, even when
30
- // an intermediate directory or an existing target is a symlink.
31
30
  async function inspectTarget(root, relativePath) {
32
- let current = root;
33
- const parts = relativePath.split("/");
34
- for (const [index, part] of parts.entries()) {
35
- current = path.join(current, part);
36
- let stat;
37
- try { stat = await fs.lstat(current); } catch (error) {
38
- if (error.code === "ENOENT") return null;
39
- throw error;
40
- }
41
- if (stat.isSymbolicLink()) throw new Error(`Setup does not follow symlinks: ${relativePath}`);
42
- if (index < parts.length - 1 && !stat.isDirectory()) throw new Error(`Expected a directory at ${current}`);
43
- if (index === parts.length - 1 && !stat.isFile()) throw new Error(`Expected a file at ${relativePath}`);
44
- }
45
- return fs.readFile(current, "utf8");
31
+ await assertMutationTargets(root, [relativePath]);
32
+ return fs.readFile(path.join(root, relativePath), "utf8").catch((error) => {
33
+ if (error.code === "ENOENT") return null;
34
+ throw error;
35
+ });
46
36
  }
47
37
 
48
38
  function navigationSource(source) {
package/src/update.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import { assertMutationTargets } from "./mutation-paths.mjs";
2
+ import { MANAGED_PLATFORM_FILES, MANAGED_SITE_FILES, MODULE_SELECTED_FILES, isAppOwnedSeed } from "./file-policy.mjs";
1
3
  import fs from "node:fs/promises";
2
4
  import path from "node:path";
3
5
  import { stdout as output } from "node:process";
@@ -13,13 +15,8 @@ import {
13
15
  TEMPLATE_ROOT,
14
16
  createAppContextFile,
15
17
  createDbInstallPlan,
16
- createModuleToolbarControlsConfig,
17
- createNextConfig,
18
- createOptionalModuleRouteFiles,
18
+ createManagedPlatformFiles,
19
19
  createPackageJson,
20
- createPlatformGlobalsCss,
21
- createPlatformModulesConfigFile,
22
- createShellConfig,
23
20
  detectPackageManager,
24
21
  getDbModuleRegistry,
25
22
  getVersionMap,
@@ -27,33 +24,10 @@ import {
27
24
  readJsonIfPresent,
28
25
  runInstall,
29
26
  } from "./generator.mjs";
30
- import { readAppManifest } from "./app-manifest.mjs";
31
-
32
- const MANAGED_PLATFORM_FILES = [
33
- "next.config.ts",
34
- path.join("app", "globals.css"),
35
- path.join("config", "module-toolbar-controls.tsx"),
36
- path.join("config", "modules.ts"),
37
- path.join("config", "shell.ts"),
38
- path.join("app", "api", "invitations", "_dependencies.ts"),
39
- path.join("app", "api", "organizations", "route.ts"),
40
- path.join("app", "api", "organizations", "[id]", "route.ts"),
41
- path.join("app", "api", "organizations", "[id]", "invitations", "route.ts"),
42
- path.join("app", "api", "organizations", "[id]", "invitations", "[invitationId]", "route.ts"),
43
- path.join("docs", "ai", "app-context.json"),
44
- ];
45
-
46
- const MANAGED_SITE_FILES = [
47
- path.join("docs", "ai", "app-context.json"),
48
- ];
49
-
50
- const MODULE_SELECTED_PLATFORM_FILES = new Set([
51
- path.join("app", "api", "invitations", "_dependencies.ts"),
52
- path.join("app", "api", "organizations", "route.ts"),
53
- path.join("app", "api", "organizations", "[id]", "route.ts"),
54
- path.join("app", "api", "organizations", "[id]", "invitations", "route.ts"),
55
- path.join("app", "api", "organizations", "[id]", "invitations", "[invitationId]", "route.ts"),
56
- ]);
27
+ import { assertNoUntrackedScaffoldWrites, scaffoldDrift } from "./scaffold.mjs";
28
+ import { hashFile, writeAppManifest, readAppManifest } from "./app-manifest.mjs";
29
+
30
+ const MODULE_SELECTED_PLATFORM_FILES = new Set(MODULE_SELECTED_FILES);
57
31
 
58
32
  const REFRESHABLE_PLATFORM_STARTER_FILES = new Set([
59
33
  path.join("app", "api", "notifications", "route.ts"),
@@ -459,8 +433,9 @@ function renderPlanSummary(plan, options = {}) {
459
433
  }
460
434
 
461
435
  export async function buildBrightwebAppUpdatePlan(argvOptions = {}, runtimeOptions = {}) {
462
- const targetDir = resolveUpdateTargetDirectory(runtimeOptions, argvOptions);
463
- await readAppManifest(targetDir, { required: false });
436
+ const targetDir = await fs.realpath(resolveUpdateTargetDirectory(runtimeOptions, argvOptions));
437
+ const appManifest = await readAppManifest(targetDir, { required: false });
438
+ await assertMutationTargets(targetDir, ["package.json", ...Object.keys(appManifest?.scaffoldFiles || {})]);
464
439
  const packageJsonPath = path.join(targetDir, "package.json");
465
440
  const manifest = await readJsonIfPresent(packageJsonPath);
466
441
 
@@ -527,20 +502,7 @@ export async function buildBrightwebAppUpdatePlan(argvOptions = {}, runtimeOptio
527
502
  }
528
503
 
529
504
  if (template === "platform") {
530
- const canonicalConfigFiles = {
531
- "next.config.ts": createNextConfig({ template: "platform", selectedModules: installedModules }),
532
- [path.join("app", "globals.css")]: await createPlatformGlobalsCss(installedModules),
533
- [path.join("config", "module-toolbar-controls.tsx")]: createModuleToolbarControlsConfig(installedModules),
534
- [path.join("config", "modules.ts")]: createPlatformModulesConfigFile(installedModules),
535
- [path.join("config", "shell.ts")]: createShellConfig(installedModules),
536
- ...createOptionalModuleRouteFiles(installedModules),
537
- [path.join("docs", "ai", "app-context.json")]: createAppContextFile({
538
- slug: manifest.name || path.basename(targetDir),
539
- template: "platform",
540
- selectedModules: installedModules,
541
- dbInstallPlan,
542
- }),
543
- };
505
+ const canonicalConfigFiles = await createManagedPlatformFiles({ slug: manifest.name || path.basename(targetDir), selectedModules: installedModules, dbInstallPlan });
544
506
 
545
507
  for (const relativePath of MANAGED_PLATFORM_FILES) {
546
508
  const targetPath = path.join(targetDir, relativePath);
@@ -588,7 +550,8 @@ export async function buildBrightwebAppUpdatePlan(argvOptions = {}, runtimeOptio
588
550
 
589
551
  if (argvOptions.refreshStarters) {
590
552
  for (const entry of starterFiles.filter((candidate) =>
591
- candidate.status !== "current" && (candidate.status === "missing" || candidate.refreshable !== false))) {
553
+ !MANAGED_PLATFORM_FILES.includes(candidate.relativePath)
554
+ && candidate.status !== "current" && (candidate.status === "missing" || candidate.refreshable !== false))) {
592
555
  fileWrites.push({
593
556
  moduleKey: entry.moduleKey,
594
557
  relativePath: entry.relativePath,
@@ -599,6 +562,22 @@ export async function buildBrightwebAppUpdatePlan(argvOptions = {}, runtimeOptio
599
562
  }
600
563
  }
601
564
 
565
+ // Legacy update may explicitly refresh drift, but never overrides ownership intent.
566
+ const liveScaffold = await scaffoldDrift(targetDir, appManifest?.scaffoldFiles);
567
+ const intentionalPaths = new Set(liveScaffold.intentional);
568
+ for (let index = fileWrites.length - 1; index >= 0; index -= 1) {
569
+ if (intentionalPaths.has(fileWrites[index].relativePath) || isAppOwnedSeed(fileWrites[index].relativePath)) fileWrites.splice(index, 1);
570
+ }
571
+
572
+ if (appManifest) await assertNoUntrackedScaffoldWrites({
573
+ targetDir,
574
+ scaffoldFiles: appManifest.scaffoldFiles,
575
+ moduleKeys: installedModules,
576
+ relativePaths: fileWrites.map((entry) => entry.relativePath),
577
+ });
578
+
579
+ await assertMutationTargets(targetDir, [".brightweb/app-manifest.json", ...fileWrites.map((write) => write.relativePath)]);
580
+
602
581
  const modulesConfigMismatch = template === "platform"
603
582
  ? await detectModulesConfigMismatch(targetDir, installedModules)
604
583
  : null;
@@ -613,6 +592,7 @@ export async function buildBrightwebAppUpdatePlan(argvOptions = {}, runtimeOptio
613
592
  installedModules,
614
593
  installedBrightwebPackages: Array.from(installedBrightwebPackagesMap.keys()).sort(),
615
594
  targetVersions: canonicalVersions,
595
+ appManifest,
616
596
  packageUpdates: packageJsonUpdate.packageUpdates,
617
597
  configFilesToWrite: fileWrites.filter((entry) => entry.type === "config").map((entry) => entry.relativePath),
618
598
  starterFilesMissing: starterFilesMissing.map((entry) => entry.relativePath),
@@ -648,6 +628,18 @@ export async function updateBrightwebApp(argvOptions = {}, runtimeOptions = {})
648
628
  await fs.writeFile(fileWrite.targetPath, fileWrite.content, "utf8");
649
629
  }
650
630
 
631
+ if (plan.appManifest) {
632
+ let scaffoldChanged = false;
633
+ for (const write of plan.fileWrites) {
634
+ const record = plan.appManifest.scaffoldFiles[write.relativePath];
635
+ if (!record) continue;
636
+ record.hash = await hashFile(write.targetPath);
637
+ record.status = "current";
638
+ scaffoldChanged = true;
639
+ }
640
+ if (scaffoldChanged) await writeAppManifest(plan.targetDir, plan.appManifest);
641
+ }
642
+
651
643
  const packageJsonChanged = plan.fileWrites.some((entry) => entry.relativePath === "package.json");
652
644
  if (argvOptions.install && packageJsonChanged) {
653
645
  const installRunner = runtimeOptions.installRunner || runInstall;
package/src/upgrade.mjs CHANGED
@@ -1,3 +1,4 @@
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";
@@ -6,6 +7,7 @@ import { pathExists, runInstall } from "./generator.mjs";
6
7
  import { applyMigrationWrites, getModuleMigrations, planMigrationAppends } from "./migrations.mjs";
7
8
  import { buildBrightwebAppUpdatePlan } from "./update.mjs";
8
9
  import { resolveSafeRelativePath } from "./safe-path.mjs";
10
+ import { scaffoldDrift } from "./scaffold.mjs";
9
11
  import { RETIRED_MODULE_STARTER_FILES } from "./constants.mjs";
10
12
 
11
13
  const HELP = `Usage: bw upgrade [moduleKey] [options]\n\nOptions:\n --target-dir <path> App directory (defaults to cwd)\n --workspace-root <path> BrightWeb workspace root\n --through-migration <file> Advance the named module only through this migration\n --include-destructive-migrations Explicitly include held destructive migrations\n --allow-stale-fallback Use baked-in versions if npm lookup fails\n --install Install changed dependencies\n --refresh-starters Refresh unchanged starter files\n --dry-run Print the upgrade plan without writing\n --help Show this help`;
@@ -25,36 +27,30 @@ export async function upgradeBrightwebApp(moduleKey, argvOptions = {}, runtimeOp
25
27
  if (includeDestructiveMigrations && !moduleKey) {
26
28
  throw new Error("--include-destructive-migrations requires an explicit module key so destructive scope cannot expand implicitly.");
27
29
  }
28
- const targetDir = path.resolve(runtimeOptions.targetDir || argvOptions.targetDir || process.cwd());
30
+ const targetDir = await fs.realpath(path.resolve(runtimeOptions.targetDir || argvOptions.targetDir || process.cwd()));
29
31
  const appManifest = await readAppManifest(targetDir);
30
32
  if (moduleKey && !appManifest.modules[moduleKey]) throw new Error(`Module ${moduleKey} is not installed according to ${path.join(".brightweb", "app-manifest.json")}.`);
31
33
  const workspaceRoot = runtimeOptions.workspaceRoot || argvOptions.workspaceRoot || await findWorkspaceRoot(targetDir);
32
34
  const updateOptions = { ...argvOptions, targetDir, ...(workspaceRoot ? { workspaceRoot } : {}) };
33
35
  const plan = await buildBrightwebAppUpdatePlan(updateOptions, runtimeOptions);
34
- const drifted = [];
35
- const missing = [];
36
- const intentional = [];
37
- for (const [relativePath, record] of Object.entries(appManifest.scaffoldFiles)) {
38
- if (["owned", "skipped"].includes(record.intent)) intentional.push(relativePath);
39
- const filePath = resolveSafeRelativePath(targetDir, relativePath, "Manifest scaffold file path");
40
- if (!(await pathExists(filePath))) { missing.push(relativePath); continue; }
41
- if (await hashFile(filePath) !== record.hash) drifted.push(relativePath);
42
- }
43
- const protectedPaths = new Set([...drifted, ...intentional]);
36
+ const live = await scaffoldDrift(targetDir, appManifest.scaffoldFiles);
37
+ const { drifted, missing, intentional, protectedPaths } = live;
44
38
  const obsoleteScaffoldFiles = [];
45
39
  if (argvOptions.refreshStarters) {
40
+ const currentPaths = new Set(live.current);
46
41
  for (const moduleKey of Object.keys(appManifest.modules)) {
47
42
  for (const relativePath of RETIRED_MODULE_STARTER_FILES[moduleKey] ?? []) {
48
43
  const record = appManifest.scaffoldFiles[relativePath];
49
44
  if (!record || protectedPaths.has(relativePath)) continue;
50
45
  const targetPath = resolveSafeRelativePath(targetDir, relativePath, "Obsolete scaffold file path");
51
- if (!(await pathExists(targetPath)) || await hashFile(targetPath) !== record.hash) continue;
46
+ if (!currentPaths.has(relativePath)) continue;
52
47
  obsoleteScaffoldFiles.push({ relativePath, targetPath });
53
48
  }
54
49
  }
55
50
  }
56
51
  plan.fileDeletes = obsoleteScaffoldFiles;
57
- plan.fileWrites = plan.fileWrites.filter((entry) => entry.type !== "starter" || !protectedPaths.has(entry.relativePath));
52
+ plan.fileWrites = plan.fileWrites.filter((entry) => !protectedPaths.has(entry.relativePath));
53
+ plan.configFilesToWrite = plan.fileWrites.filter((entry) => entry.type === "config").map((entry) => entry.relativePath);
58
54
  plan.starterFilesToRefresh = plan.fileWrites.filter((entry) => entry.type === "starter").map((entry) => entry.relativePath);
59
55
  plan.starterFilesDrifted = Array.from(new Set([...plan.starterFilesDrifted, ...drifted]));
60
56
  plan.starterFilesMissing = Array.from(new Set([...plan.starterFilesMissing, ...missing]));
@@ -81,7 +77,8 @@ export async function upgradeBrightwebApp(moduleKey, argvOptions = {}, runtimeOp
81
77
  const safetyBoundaries = [];
82
78
  for (const key of moduleKeys) {
83
79
  const migrations = await getModuleMigrations(key, catalog[key]);
84
- const destructiveIndex = migrations.findIndex((entry) => entry.destructive);
80
+ const cursorIndex = migrations.findIndex((entry) => entry.fileName === appManifest.migrationCursor?.[key]);
81
+ const destructiveIndex = migrations.findIndex((entry, index) => index > cursorIndex && entry.destructive);
85
82
  if (throughMigration && key === moduleKey) {
86
83
  const cutoffIndex = migrations.findIndex((entry) => entry.fileName === throughMigration);
87
84
  if (cutoffIndex >= destructiveIndex && destructiveIndex >= 0 && !includeDestructiveMigrations) {
@@ -91,8 +88,6 @@ export async function upgradeBrightwebApp(moduleKey, argvOptions = {}, runtimeOp
91
88
  continue;
92
89
  }
93
90
  if (includeDestructiveMigrations || destructiveIndex < 0) continue;
94
- const cursorIndex = migrations.findIndex((entry) => entry.fileName === appManifest.migrationCursor?.[key]);
95
- if (cursorIndex >= destructiveIndex) continue;
96
91
  if (destructiveIndex === 0) {
97
92
  throw new Error(`Migration upgrade blocked: ${key} begins with destructive migration ${migrations[0].fileName}; use --include-destructive-migrations after review.`);
98
93
  }
@@ -107,6 +102,7 @@ export async function upgradeBrightwebApp(moduleKey, argvOptions = {}, runtimeOp
107
102
  migrationCursor: appManifest.migrationCursor,
108
103
  migrationUpperBounds,
109
104
  });
105
+ await assertMutationTargets(targetDir, [".brightweb/app-manifest.json", ...plan.fileWrites.map((write) => write.relativePath), ...plan.fileDeletes.map((entry) => entry.relativePath), ...migrationPlan.writes.map((write) => path.relative(targetDir, write.targetPath))]);
110
106
  output.write(`bw upgrade\nPackages to update: ${plan.packageUpdates.length}\nManaged files to write: ${plan.fileWrites.length}\nObsolete managed files to remove: ${plan.fileDeletes.length}\nMigrations to append: ${migrationPlan.appends.length}\n`);
111
107
  if (throughMigration) output.write(`Migration cutoff: ${moduleKey} through ${throughMigration}\n`);
112
108
  for (const boundary of safetyBoundaries) {
@@ -157,7 +153,7 @@ export async function upgradeBrightwebApp(moduleKey, argvOptions = {}, runtimeOp
157
153
  const existingRecord = appManifest.scaffoldFiles[relativePath];
158
154
  if (!existingRecord && write.type !== "starter") continue;
159
155
  const targetPath = resolveSafeRelativePath(targetDir, relativePath, "Manifest scaffold file path");
160
- if (write.type === "starter" && protectedPaths.has(relativePath)) continue;
156
+ if (protectedPaths.has(relativePath)) continue;
161
157
  if (!(await pathExists(targetPath))) continue;
162
158
  const hash = await hashFile(targetPath);
163
159
  if (existingRecord) {
@@ -0,0 +1,50 @@
1
+ -- atomic_admin_invitation_acceptance
2
+ -- target: admin
3
+ -- created_at: 2026-09-06T10:16:16.616Z
4
+
5
+ CREATE OR REPLACE FUNCTION public.accept_admin_user_invitation(p_invitation_id uuid, p_profile_id uuid, p_user_email text)
6
+ RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = '' AS $$
7
+ DECLARE
8
+ v_invitation public.admin_user_invitations%ROWTYPE;
9
+ v_old_role text;
10
+ BEGIN
11
+ SELECT * INTO v_invitation FROM public.admin_user_invitations WHERE id = p_invitation_id FOR UPDATE;
12
+ IF NOT FOUND THEN RAISE EXCEPTION 'Convite não encontrado.'; END IF;
13
+ IF lower(btrim(p_user_email)) IS DISTINCT FROM lower(btrim(v_invitation.invited_email)) THEN
14
+ RAISE EXCEPTION 'Este convite pertence a outro email.';
15
+ END IF;
16
+ -- Only the service role can call this RPC; the server supplies the authenticated
17
+ -- user's email. Require the matching auth-linked profile without granting auth-table access.
18
+ PERFORM p.id FROM public.profiles p
19
+ WHERE p.id = p_profile_id AND p.user_id IS NOT NULL
20
+ AND lower(btrim(p.email)) = lower(btrim(v_invitation.invited_email))
21
+ FOR UPDATE OF p;
22
+ IF NOT FOUND THEN RAISE EXCEPTION 'Este convite pertence a outro email.'; END IF;
23
+ IF v_invitation.status = 'accepted' AND v_invitation.accepted_by_profile_id = p_profile_id THEN
24
+ RETURN jsonb_build_object('status', 'accepted', 'role', v_invitation.role_code);
25
+ END IF;
26
+ IF v_invitation.status <> 'pending' THEN RAISE EXCEPTION 'Este convite já não está disponível.'; END IF;
27
+ IF v_invitation.expires_at <= clock_timestamp() THEN
28
+ UPDATE public.admin_user_invitations SET status = 'expired' WHERE id = p_invitation_id;
29
+ RETURN jsonb_build_object('status', 'expired');
30
+ END IF;
31
+
32
+ SELECT role_code INTO v_old_role FROM public.user_role_assignments WHERE profile_id = p_profile_id FOR UPDATE;
33
+ INSERT INTO public.user_role_assignments(profile_id, role_code, assigned_by_profile_id, assigned_at, reason)
34
+ VALUES(p_profile_id, v_invitation.role_code, v_invitation.invited_by_profile_id, now(), 'Convite de utilizador do portal aceite.')
35
+ ON CONFLICT(profile_id) DO UPDATE SET role_code = EXCLUDED.role_code,
36
+ assigned_by_profile_id = EXCLUDED.assigned_by_profile_id, assigned_at = EXCLUDED.assigned_at, reason = EXCLUDED.reason;
37
+ IF v_invitation.invited_by_profile_id IS NOT NULL THEN
38
+ INSERT INTO public.role_change_audit(target_profile_id, changed_by_profile_id, old_role_code, new_role_code, reason)
39
+ VALUES(p_profile_id, v_invitation.invited_by_profile_id, v_old_role, v_invitation.role_code, 'Convite de utilizador do portal aceite.');
40
+ END IF;
41
+ UPDATE public.admin_user_invitations SET status = 'accepted', accepted_at = now(), accepted_by_profile_id = p_profile_id
42
+ WHERE id = p_invitation_id;
43
+ PERFORM public.log_app_activity_event('admin', 'admin_user_invitation_accepted', 'admin_user_invitations', p_invitation_id,
44
+ 'Convite de utilizador aceite.', jsonb_build_object('email', v_invitation.invited_email, 'role', v_invitation.role_code,
45
+ 'status', 'accepted', 'accepted_by_profile_id', p_profile_id), p_profile_id);
46
+ RETURN jsonb_build_object('status', 'accepted', 'role', v_invitation.role_code);
47
+ END;
48
+ $$;
49
+ REVOKE ALL ON FUNCTION public.accept_admin_user_invitation(uuid, uuid, text) FROM PUBLIC, anon, authenticated;
50
+ GRANT EXECUTE ON FUNCTION public.accept_admin_user_invitation(uuid, uuid, text) TO service_role;
@@ -0,0 +1,4 @@
1
+ -- Profile synchronization is an internal auth bridge, not a caller-supplied identity API.
2
+ -- Auth triggers execute as their owner; server registration uses service_role.
3
+ REVOKE ALL ON FUNCTION public.sync_profile_from_auth_identity(uuid, text, jsonb) FROM PUBLIC, anon, authenticated;
4
+ GRANT EXECUTE ON FUNCTION public.sync_profile_from_auth_identity(uuid, text, jsonb) TO service_role;
@@ -0,0 +1,31 @@
1
+ -- atomic_invitation_contact_link
2
+ -- target: crm
3
+ -- created_at: 2026-09-06T10:16:16.695Z
4
+
5
+ -- This integration is installed after orgs and is never required by an orgs-only app.
6
+ CREATE OR REPLACE FUNCTION public.link_organization_invitation_contact(p_profile_id uuid, p_organization_id uuid)
7
+ RETURNS uuid LANGUAGE plpgsql SECURITY INVOKER SET search_path = '' AS $$
8
+ DECLARE
9
+ v_profile public.profiles%ROWTYPE;
10
+ v_contact public.crm_contacts%ROWTYPE;
11
+ BEGIN
12
+ SELECT * INTO STRICT v_profile FROM public.profiles WHERE id = p_profile_id FOR UPDATE;
13
+ SELECT * INTO v_contact FROM public.crm_contacts
14
+ WHERE profile_id = p_profile_id OR lower(btrim(email)) = lower(btrim(v_profile.email))
15
+ ORDER BY (profile_id = p_profile_id) DESC NULLS LAST, id LIMIT 1 FOR UPDATE;
16
+ IF FOUND THEN
17
+ IF v_contact.profile_id IS NOT NULL AND v_contact.profile_id <> p_profile_id THEN
18
+ RAISE EXCEPTION 'O contacto CRM já está ligado a outro perfil.';
19
+ END IF;
20
+ UPDATE public.crm_contacts SET profile_id = p_profile_id WHERE id = v_contact.id;
21
+ ELSE
22
+ INSERT INTO public.crm_contacts(profile_id, first_name, last_name, email, status, source, organization_id)
23
+ VALUES(p_profile_id, v_profile.first_name, v_profile.last_name, lower(btrim(v_profile.email)), 'lead',
24
+ 'organization_invitation_accept', p_organization_id) RETURNING * INTO v_contact;
25
+ END IF;
26
+ PERFORM public.link_crm_contact_organization(v_contact.id, p_organization_id);
27
+ RETURN v_contact.id;
28
+ END;
29
+ $$;
30
+ REVOKE ALL ON FUNCTION public.link_organization_invitation_contact(uuid, uuid) FROM PUBLIC, anon, authenticated;
31
+ GRANT EXECUTE ON FUNCTION public.link_organization_invitation_contact(uuid, uuid) TO service_role;