create-bw-app 0.25.1 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/package.json +1 -1
  2. package/src/app-manifest.mjs +8 -0
  3. package/src/constants.mjs +14 -9
  4. package/src/doctor.mjs +181 -1
  5. package/src/generator.mjs +29 -9
  6. package/src/migrations.mjs +116 -6
  7. package/src/upgrade.mjs +35 -2
  8. package/template/base/app/(shell)/shell-layout-client.tsx +1 -1
  9. package/template/base/app/api/organizations/[id]/invitations/[invitationId]/route.ts +8 -0
  10. package/template/base/app/api/organizations/[id]/invitations/route.ts +2 -2
  11. package/template/base/app/api/organizations/[id]/route.ts +2 -2
  12. package/template/base/app/api/organizations/route.ts +2 -2
  13. package/template/base/config/module-toolbar-controls.tsx +6 -4
  14. package/template/modules/projects/app/(shell)/projetos/[projectId]/page.tsx +1 -1
  15. package/template/modules/projects/app/(shell)/projetos/[projectId]/quadro/page.tsx +1 -1
  16. package/template/modules/projects/app/(shell)/projetos/[projectId]/tarefas/page.tsx +1 -1
  17. package/template/modules/projects/app/(shell)/projetos/page.tsx +1 -1
  18. package/template/modules/projects/app/api/account/projects/[id]/route.ts +1 -1
  19. package/template/modules/projects/app/api/account/projects/route.ts +1 -1
  20. package/template/modules/projects/app/api/projects/[id]/activity/route.ts +1 -1
  21. package/template/modules/projects/app/api/projects/[id]/client-access/route.ts +1 -1
  22. package/template/modules/projects/app/api/projects/[id]/links/[itemId]/route.ts +1 -1
  23. package/template/modules/projects/app/api/projects/[id]/links/route.ts +1 -1
  24. package/template/modules/projects/app/api/projects/[id]/members/route.ts +1 -1
  25. package/template/modules/projects/app/api/projects/[id]/milestones/[itemId]/route.ts +1 -1
  26. package/template/modules/projects/app/api/projects/[id]/milestones/route.ts +1 -1
  27. package/template/modules/projects/app/api/projects/[id]/organizations/route.ts +1 -1
  28. package/template/modules/projects/app/api/projects/[id]/route.ts +1 -1
  29. package/template/modules/projects/app/api/projects/[id]/tasks/[itemId]/route.ts +1 -1
  30. package/template/modules/projects/app/api/projects/[id]/tasks/route.ts +1 -1
  31. package/template/modules/projects/app/api/projects/organizations/route.ts +1 -1
  32. package/template/modules/projects/app/api/projects/route.ts +1 -1
  33. package/template/modules/projects/app/api/projects/setup-options/route.ts +1 -1
  34. package/template/modules/projects/app/api/projects/stats/route.ts +1 -1
  35. package/template/supabase/modules/crm/migrations/20260816120000_crm_contact_organizations.sql +102 -0
  36. package/template/supabase/modules/projects/migrations/20260815120000_project_client_access_member_roles.sql +77 -0
  37. package/template/supabase/modules/projects/migrations/20260815133000_project_admin_creation_and_task_permissions.sql +376 -0
  38. package/template/modules/projects/app/(shell)/projetos/projetos-live-mounts.tsx +0 -27
  39. package/template/modules/projects/app/(shell)/projetos/projetos-server-mounts.tsx +0 -63
  40. package/template/modules/projects/app/api/projects/_handlers.ts +0 -110
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "create-bw-app",
3
3
  "private": false,
4
- "version": "0.25.1",
4
+ "version": "0.26.0",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "create-bw-app": "bin/create-bw-app.mjs",
@@ -129,6 +129,14 @@ export function validateAppManifest(manifest) {
129
129
  try { normalizeSafeRelativePath(relativePath, `scaffoldFiles path`); } catch (error) { errors.push(error.message); }
130
130
  if (!entry || typeof entry.module !== "string" || !/^sha256:[a-f0-9]{64}$/.test(entry.hash || "") || !["current", "drifted", "missing"].includes(entry.status) || (entry.intent != null && !["managed", "owned", "skipped"].includes(entry.intent))) errors.push(`scaffoldFiles.${relativePath} is invalid`);
131
131
  }
132
+ if (manifest.migrationDeferrals != null) {
133
+ if (typeof manifest.migrationDeferrals !== "object" || Array.isArray(manifest.migrationDeferrals)) errors.push("migrationDeferrals must be an object");
134
+ else for (const [key, entry] of Object.entries(manifest.migrationDeferrals)) {
135
+ if (!entry || entry.reason !== "destructive" || typeof entry.cursor !== "string" || typeof entry.nextMigration !== "string") {
136
+ errors.push(`migrationDeferrals.${key} is invalid`);
137
+ }
138
+ }
139
+ }
132
140
  if (manifest.lastDoctor != null && (typeof manifest.lastDoctor.at !== "string" || typeof manifest.lastDoctor.ok !== "boolean")) errors.push("lastDoctor is invalid");
133
141
  if (
134
142
  manifest.infrastructure != null
package/src/constants.mjs CHANGED
@@ -113,8 +113,6 @@ export const MODULE_STARTER_FILES = {
113
113
  projects: [
114
114
  "app/(shell)/projetos/layout.tsx",
115
115
  "app/(shell)/projetos/page.tsx",
116
- "app/(shell)/projetos/projetos-live-mounts.tsx",
117
- "app/(shell)/projetos/projetos-server-mounts.tsx",
118
116
  "app/(shell)/projetos/[projectId]/page.tsx",
119
117
  "app/(shell)/projetos/[projectId]/quadro/page.tsx",
120
118
  "app/(shell)/projetos/[projectId]/tarefas/page.tsx",
@@ -122,7 +120,6 @@ export const MODULE_STARTER_FILES = {
122
120
  "app/(shell)/account/projetos/loading.tsx",
123
121
  "app/(shell)/account/projetos/[projectId]/page.tsx",
124
122
  "app/(shell)/account/projetos/[projectId]/loading.tsx",
125
- "app/api/projects/_handlers.ts",
126
123
  "app/api/account/projects/route.ts",
127
124
  "app/api/account/projects/[id]/route.ts",
128
125
  "app/api/dashboard/projects/route.ts",
@@ -145,6 +142,14 @@ export const MODULE_STARTER_FILES = {
145
142
  ],
146
143
  };
147
144
 
145
+ export const RETIRED_MODULE_STARTER_FILES = {
146
+ projects: [
147
+ "app/(shell)/projetos/projetos-live-mounts.tsx",
148
+ "app/(shell)/projetos/projetos-server-mounts.tsx",
149
+ "app/api/projects/_handlers.ts",
150
+ ],
151
+ };
152
+
148
153
  export const PLATFORM_STARTER_FILES = [
149
154
  "app/(auth)/auth-provider.tsx",
150
155
  "app/(auth)/layout.tsx",
@@ -182,13 +187,13 @@ export const PLATFORM_STARTER_FILES = [
182
187
 
183
188
  export const APP_DEPENDENCY_DEFAULTS = {
184
189
  "@brightweblabs/app-shell": "^0.16.0",
185
- "@brightweblabs/core-auth": "^0.11.0",
190
+ "@brightweblabs/core-auth": "^0.12.0",
186
191
  "@brightweblabs/infra": "^0.7.0",
187
- "@brightweblabs/module-admin": "^0.9.3",
188
- "@brightweblabs/module-crm": "^0.17.3",
189
- "@brightweblabs/module-marketing": "^0.4.23",
190
- "@brightweblabs/module-orgs": "^0.6.2",
191
- "@brightweblabs/module-projects": "^0.18.0",
192
+ "@brightweblabs/module-admin": "^0.9.4",
193
+ "@brightweblabs/module-crm": "^0.18.0",
194
+ "@brightweblabs/module-marketing": "^0.4.24",
195
+ "@brightweblabs/module-orgs": "^0.7.0",
196
+ "@brightweblabs/module-projects": "^0.19.0",
192
197
  "@brightweblabs/theme": "^0.8.3",
193
198
  "@brightweblabs/ui": "^1.5.4",
194
199
  "geist": "1.7.2",
package/src/doctor.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { stdout as output } from "node:process";
4
- import { cursorMigrationStatus } from "./migrations.mjs";
4
+ import { APP_DEPENDENCY_DEFAULTS, BRIGHTWEB_PACKAGE_NAMES } from "./constants.mjs";
5
+ import { cursorMigrationStatus, exactMigrationCompatibilityStatus } from "./migrations.mjs";
5
6
  import { findWorkspaceRoot, loadModuleCatalog, MODULE_PACKAGES, readAppManifest, readConfiguredModuleFlags, satisfiesVersion, validateAppManifest, writeAppManifest } from "./app-manifest.mjs";
6
7
  import { loadAppEnvironment, readFirstEnvironmentValue } from "./env.mjs";
7
8
  import { pathExists, readJsonIfPresent } from "./generator.mjs";
@@ -10,6 +11,143 @@ import { scaffoldDrift } from "./scaffold.mjs";
10
11
 
11
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`;
12
13
  const RUNTIME_PACKAGE_NAMES = ["react", "react-dom", "next"];
14
+ const LOCAL_DEPENDENCY_PREFIXES = ["file:", "link:", "workspace:", "patch:"];
15
+
16
+ async function findUp(startDir, fileName) {
17
+ let current = path.resolve(startDir);
18
+ while (true) {
19
+ const candidate = path.join(current, fileName);
20
+ if (await pathExists(candidate)) return candidate;
21
+ const parent = path.dirname(current);
22
+ if (parent === current) return null;
23
+ current = parent;
24
+ }
25
+ }
26
+
27
+ function exactVersionFromDefault(packageName) {
28
+ const requested = APP_DEPENDENCY_DEFAULTS[packageName];
29
+ return typeof requested === "string" ? requested.match(/^(?:\^|~)?(.+)$/)?.[1] ?? null : null;
30
+ }
31
+
32
+ function escapeRegExp(value) {
33
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
34
+ }
35
+
36
+ export function lockfileIntegrityForPackage(lockfile, packageName, version) {
37
+ const keyPattern = new RegExp(`^ ['\"]?${escapeRegExp(`${packageName}@${version}`)}['\"]?:$`);
38
+ const lines = lockfile.split("\n");
39
+ const start = lines.findIndex((line) => keyPattern.test(line));
40
+ if (start < 0) return null;
41
+ const block = [];
42
+ for (let index = start + 1; index < lines.length; index += 1) {
43
+ if (/^ \S/.test(lines[index])) break;
44
+ block.push(lines[index]);
45
+ }
46
+ return block.join("\n").match(/^ resolution:\s*\{[^}\n]*integrity:\s*([^,}\s]+)[^}\n]*\}/m)?.[1] ?? null;
47
+ }
48
+
49
+ export function lockfileImporterResolution(lockfile, importerKey, packageName) {
50
+ const lines = lockfile.split("\n");
51
+ const importersIndex = lines.findIndex((line) => /^importers:\s*$/.test(line));
52
+ if (importersIndex < 0) return null;
53
+ const normalizedKey = importerKey || ".";
54
+ const importerPattern = new RegExp(`^ ['\"]?${escapeRegExp(normalizedKey)}['\"]?:\\s*$`);
55
+ const importerStart = lines.findIndex((line, index) => index > importersIndex && importerPattern.test(line));
56
+ if (importerStart < 0) return null;
57
+ let importerEnd = lines.length;
58
+ for (let index = importerStart + 1; index < lines.length; index += 1) {
59
+ if (/^ \S/.test(lines[index])) { importerEnd = index; break; }
60
+ }
61
+ const dependencyPattern = new RegExp(`^ ['\"]?${escapeRegExp(packageName)}['\"]?:\\s*$`);
62
+ const dependencyStart = lines.findIndex(
63
+ (line, index) => index > importerStart && index < importerEnd && dependencyPattern.test(line),
64
+ );
65
+ if (dependencyStart < 0) return null;
66
+ const block = [];
67
+ for (let index = dependencyStart + 1; index < importerEnd; index += 1) {
68
+ if (/^ \S/.test(lines[index]) || /^ \S/.test(lines[index])) break;
69
+ block.push(lines[index]);
70
+ }
71
+ return {
72
+ specifier: block.join("\n").match(/^ specifier:\s*['\"]?([^'\"\s]+)['\"]?\s*$/m)?.[1] ?? null,
73
+ version: block.join("\n").match(/^ version:\s*['\"]?([^'\"\s]+)['\"]?\s*$/m)?.[1] ?? null,
74
+ };
75
+ }
76
+
77
+ async function inspectPackageProvenance(targetDir, dependencyMap) {
78
+ const issues = [];
79
+ const verified = [];
80
+ const lockPath = await findUp(targetDir, "pnpm-lock.yaml");
81
+ const installRoot = path.join(targetDir, "node_modules");
82
+ if (!lockPath || !(await pathExists(installRoot))) {
83
+ return { issues, verified, lockPath, available: false };
84
+ }
85
+ const lockfile = lockPath ? await fs.readFile(lockPath, "utf8") : null;
86
+ const importerKey = lockPath
87
+ ? path.relative(path.dirname(lockPath), targetDir).split(path.sep).join("/") || "."
88
+ : ".";
89
+ const workspaceManifest = lockPath ? await readJsonIfPresent(path.join(path.dirname(lockPath), "package.json")) : null;
90
+ const overrideMaps = [
91
+ workspaceManifest?.pnpm?.overrides,
92
+ workspaceManifest?.pnpm?.patchedDependencies,
93
+ workspaceManifest?.overrides,
94
+ workspaceManifest?.resolutions,
95
+ ].filter((value) => value && typeof value === "object");
96
+ for (const packageName of BRIGHTWEB_PACKAGE_NAMES) {
97
+ const requested = dependencyMap[packageName];
98
+ if (!requested) continue;
99
+ if (LOCAL_DEPENDENCY_PREFIXES.some((prefix) => String(requested).startsWith(prefix))) {
100
+ issues.push(`${packageName}: local override ${JSON.stringify(requested)} is forbidden`);
101
+ continue;
102
+ }
103
+ const overrideKey = overrideMaps.flatMap((overrides) => Object.keys(overrides)).find(
104
+ (key) => key === packageName || key.startsWith(`${packageName}@`),
105
+ );
106
+ if (overrideKey) {
107
+ issues.push(`${packageName}: workspace override or patch ${JSON.stringify(overrideKey)} is forbidden`);
108
+ continue;
109
+ }
110
+ const expectedVersion = exactVersionFromDefault(packageName);
111
+ if (!expectedVersion) {
112
+ issues.push(`${packageName}: create-bw-app has no exact compatibility version`);
113
+ continue;
114
+ }
115
+ const manifest = await readJsonIfPresent(path.join(targetDir, "node_modules", packageName, "package.json"));
116
+ if (!manifest) {
117
+ issues.push(`${packageName}: installed package manifest is missing`);
118
+ continue;
119
+ }
120
+ if (manifest.version !== expectedVersion) {
121
+ issues.push(`${packageName}: installed ${manifest.version ?? "unknown"}, expected exact ${expectedVersion}`);
122
+ continue;
123
+ }
124
+ if (!lockfile) {
125
+ issues.push(`${packageName}: pnpm-lock.yaml was not found`);
126
+ continue;
127
+ }
128
+ const importerResolution = lockfileImporterResolution(lockfile, importerKey, packageName);
129
+ if (!importerResolution) {
130
+ issues.push(`${packageName}: target importer ${importerKey} has no lockfile resolution`);
131
+ continue;
132
+ }
133
+ if (LOCAL_DEPENDENCY_PREFIXES.some((prefix) => String(importerResolution.version).startsWith(prefix))) {
134
+ issues.push(`${packageName}: target importer resolves through forbidden ${JSON.stringify(importerResolution.version)}`);
135
+ continue;
136
+ }
137
+ const resolvedVersion = importerResolution.version?.split("(")[0] ?? null;
138
+ if (resolvedVersion !== expectedVersion) {
139
+ issues.push(`${packageName}: target importer resolves ${resolvedVersion ?? "unknown"}, expected exact ${expectedVersion}`);
140
+ continue;
141
+ }
142
+ const integrity = lockfileIntegrityForPackage(lockfile, packageName, expectedVersion);
143
+ if (!integrity?.startsWith("sha512-")) {
144
+ issues.push(`${packageName}@${expectedVersion}: registry integrity is missing from pnpm-lock.yaml`);
145
+ continue;
146
+ }
147
+ verified.push(`${packageName}@${expectedVersion}`);
148
+ }
149
+ return { issues, verified, lockPath, available: true };
150
+ }
13
151
 
14
152
  function isLocalDeploymentUrl(value) {
15
153
  try {
@@ -202,6 +340,17 @@ export async function doctorBrightwebApp(argvOptions = {}, runtimeOptions = {})
202
340
  }
203
341
  add(packageProblems.length ? "FAIL" : "PASS", "packages", packageProblems.join("; ") || "Installed module packages agree with the manifest.");
204
342
 
343
+ const provenance = await inspectPackageProvenance(targetDir, dependencyMap);
344
+ if (!provenance.available) {
345
+ add("INFO", "package-provenance", "SKIP exact registry provenance check; install dependencies with a pnpm lockfile first.");
346
+ } else {
347
+ add(
348
+ provenance.issues.length ? "FAIL" : "PASS",
349
+ "package-provenance",
350
+ provenance.issues.join("; ") || `${provenance.verified.length} BrightWeb packages match the exact compatibility set and pnpm registry integrity records.`,
351
+ );
352
+ }
353
+
205
354
  const runtimeVersions = await findInstalledRuntimeVersions(targetDir);
206
355
  const duplicateRuntimes = Object.entries(runtimeVersions)
207
356
  .filter(([, versions]) => versions.length > 1)
@@ -276,6 +425,37 @@ export async function doctorBrightwebApp(argvOptions = {}, runtimeOptions = {})
276
425
  if (status.shipsMigrations && status.missing.length > 0) migrationProblems.push(`${key}: ${status.missing.join(", ")}`);
277
426
  }
278
427
  add(migrationProblems.length ? "FAIL" : "PASS", "migrations", migrationProblems.join("; ") || "Migration cursors and flattened files agree.");
428
+ for (const key of migrationKeys) {
429
+ const cursor = appManifest.migrationCursor?.[key];
430
+ if (cursor == null && appManifest.adoptionNotes?.allowUncursored) {
431
+ add("WARN", `migration-provenance-${key}`, `${key}: exact migration provenance is unavailable because uncursored adoption is enabled.`);
432
+ continue;
433
+ }
434
+ const status = await exactMigrationCompatibilityStatus({
435
+ targetDir,
436
+ moduleKey: key,
437
+ cursor,
438
+ catalogEntry: catalog[key],
439
+ allowDeferred: appManifest.migrationDeferrals?.[key]?.reason === "destructive"
440
+ && appManifest.migrationDeferrals[key].cursor === cursor,
441
+ });
442
+ if (!status.shipsMigrations) continue;
443
+ const deferral = appManifest.migrationDeferrals?.[key];
444
+ if (deferral && (!status.deferred?.length || deferral.nextMigration !== status.deferred[0] || !status.nextDeferredIsDestructive)) {
445
+ status.issues.push(`recorded destructive deferral ${deferral.nextMigration} does not match the next destructive package migration`);
446
+ }
447
+ add(
448
+ status.issues.length ? "FAIL" : "PASS",
449
+ `migration-provenance-${key}`,
450
+ status.issues.join("; ") || `${status.verified.length} ${key} migration files match compatible package provenance, source filename, current cursor, and sha256 content.`,
451
+ );
452
+ if (status.deferred?.length && status.issues.length === 0) {
453
+ add("INFO", `migration-deferred-${key}`, `${key}: ${status.deferred.length} later package migration${status.deferred.length === 1 ? " is" : "s are"} intentionally outside cursor ${cursor}: ${status.deferred.join(", ")}.`);
454
+ }
455
+ if (status.legacyEquivalent?.length) {
456
+ 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
+ }
458
+ }
279
459
  add("WARN", "db-objects", "SKIP live database checks are not available yet.");
280
460
  return finish(checks, argvOptions, appManifest, targetDir);
281
461
  }
package/src/generator.mjs CHANGED
@@ -1044,7 +1044,7 @@ export function createModuleToolbarControlsConfig(selectedModules) {
1044
1044
  }
1045
1045
  if (selectedModules.includes("projects")) {
1046
1046
  imports.push('import { ProjectBoardToolbarControls, ProjectsToolbarControls } from "@brightweblabs/module-projects/ui";');
1047
- entries.push(' projects: () => <ProjectsToolbarControls />,');
1047
+ entries.push(' projects: (viewer) => <ProjectsToolbarControls viewer={viewer} />,');
1048
1048
  entries.push(' "project-board": () => <ProjectBoardToolbarControls />,');
1049
1049
  }
1050
1050
  if (selectedModules.includes("marketing")) {
@@ -1060,13 +1060,15 @@ export function createModuleToolbarControlsConfig(selectedModules) {
1060
1060
  "// MANAGED BY BRIGHTWEB — regenerated when modules are added, removed, or updated.",
1061
1061
  ...imports,
1062
1062
  "",
1063
- "const toolbarControlBySurface: Partial<Record<ShellToolbarSurface, () => ReactNode>> = {",
1063
+ "type ModuleToolbarViewer = { isAdmin: boolean };",
1064
+ "",
1065
+ "const toolbarControlBySurface: Partial<Record<ShellToolbarSurface, (viewer: ModuleToolbarViewer) => ReactNode>> = {",
1064
1066
  ...entries,
1065
1067
  "};",
1066
1068
  "",
1067
- "export function getModuleToolbarControls(pathname: string, toolbarRoutes: ShellToolbarRouteConfig[]) {",
1069
+ "export function getModuleToolbarControls(pathname: string, toolbarRoutes: ShellToolbarRouteConfig[], viewer: ModuleToolbarViewer) {",
1068
1070
  " const surface = resolveShellToolbarSurface(pathname, toolbarRoutes);",
1069
- " return surface ? toolbarControlBySurface[surface]?.() ?? null : null;",
1071
+ " return surface ? toolbarControlBySurface[surface]?.(viewer) ?? null : null;",
1070
1072
  "}",
1071
1073
  "",
1072
1074
  ].join("\n");
@@ -1074,11 +1076,11 @@ export function createModuleToolbarControlsConfig(selectedModules) {
1074
1076
 
1075
1077
  function createOrganizationRoute(methods, enabled) {
1076
1078
  const lines = ['export const dynamic = "force-dynamic";', ""];
1077
- for (const { method, handler, context = false } of methods) {
1079
+ for (const { method, handler, context = false, packageName = "@brightweblabs/module-orgs" } of methods) {
1078
1080
  if (enabled) {
1079
1081
  lines.push(
1080
1082
  `export async function ${method}(request: Request${context ? ', context: { params: Promise<{ id: string }> }' : ""}) {`,
1081
- ` const { ${handler} } = await import("@brightweblabs/module-orgs");`,
1083
+ ` const { ${handler} } = await import("${packageName}");`,
1082
1084
  ` return ${handler}(request${context ? ", context" : ""});`,
1083
1085
  "}",
1084
1086
  "",
@@ -1214,20 +1216,34 @@ export function createOptionalModuleRouteFiles(selectedModules) {
1214
1216
  return {
1215
1217
  "app/api/invitations/_dependencies.ts": invitationDependencies,
1216
1218
  "app/api/organizations/route.ts": createOrganizationRoute([
1217
- { method: "POST", handler: "handleOrganizationsPostRequest" },
1219
+ crmEnabled
1220
+ ? { method: "POST", handler: "handleCrmOrganizationsPostRequest", packageName: "@brightweblabs/module-crm" }
1221
+ : { method: "POST", handler: "handleOrganizationsPostRequest" },
1218
1222
  ], orgsEnabled),
1219
1223
  "app/api/organizations/[id]/route.ts": createOrganizationRoute([
1220
- { method: "PATCH", handler: "handleOrganizationPatchRequest", context: true },
1224
+ crmEnabled
1225
+ ? { method: "PATCH", handler: "handleCrmOrganizationPatchRequest", packageName: "@brightweblabs/module-crm", context: true }
1226
+ : { method: "PATCH", handler: "handleOrganizationPatchRequest", context: true },
1221
1227
  { method: "DELETE", handler: "handleOrganizationDeleteRequest", context: true },
1222
1228
  ], orgsEnabled),
1223
1229
  "app/api/organizations/[id]/invitations/route.ts": createOrganizationRoute([
1224
1230
  { method: "GET", handler: "handleOrganizationInvitationsGetRequest", context: true },
1225
- { method: "POST", handler: "handleOrganizationInvitationsPostRequest", context: true },
1231
+ crmEnabled
1232
+ ? { method: "POST", handler: "handleCrmOrganizationInvitationsPostRequest", packageName: "@brightweblabs/module-crm", context: true }
1233
+ : { method: "POST", handler: "handleOrganizationInvitationsPostRequest", context: true },
1226
1234
  ], orgsEnabled),
1227
1235
  "app/api/organizations/[id]/invitations/[invitationId]/route.ts": orgsEnabled
1228
1236
  ? [
1229
1237
  'export const dynamic = "force-dynamic";',
1230
1238
  "",
1239
+ "export async function POST(",
1240
+ " request: Request,",
1241
+ " context: { params: Promise<{ id: string; invitationId: string }> },",
1242
+ ") {",
1243
+ ' const { handleOrganizationInvitationResendRequest } = await import("@brightweblabs/module-orgs");',
1244
+ " return handleOrganizationInvitationResendRequest(request, context);",
1245
+ "}",
1246
+ "",
1231
1247
  "export async function DELETE(",
1232
1248
  " request: Request,",
1233
1249
  " context: { params: Promise<{ id: string; invitationId: string }> },",
@@ -1242,6 +1258,10 @@ export function createOptionalModuleRouteFiles(selectedModules) {
1242
1258
  "",
1243
1259
  'type RouteContext = { params: Promise<{ id: string; invitationId: string }> };',
1244
1260
  "",
1261
+ "export async function POST(_request: Request, _context: RouteContext) {",
1262
+ " return new Response(null, { status: 404 });",
1263
+ "}",
1264
+ "",
1245
1265
  "export async function DELETE(_request: Request, _context: RouteContext) {",
1246
1266
  " return new Response(null, { status: 404 });",
1247
1267
  "}",
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { createHash } from "node:crypto";
3
4
  import { resolveSafeRelativePath } from "./safe-path.mjs";
4
5
  import { TEMPLATE_ROOT, pathExists } from "./generator.mjs";
5
6
 
@@ -49,13 +50,17 @@ export async function planMigrationAppends({
49
50
  }) {
50
51
  const migrationsDir = await findAppMigrationsDirectory(targetDir);
51
52
  const existing = (await pathExists(migrationsDir))
52
- ? (await fs.readdir(migrationsDir)).filter((fileName) => fileName.endsWith(".sql")).sort()
53
+ ? await Promise.all((await fs.readdir(migrationsDir)).filter((fileName) => fileName.endsWith(".sql")).sort().map(async (fileName) => ({
54
+ fileName,
55
+ targetPath: path.join(migrationsDir, fileName),
56
+ content: await fs.readFile(path.join(migrationsDir, fileName), "utf8"),
57
+ })))
53
58
  : [];
54
- let sequence = existing.reduce((maximum, fileName) => {
55
- const match = fileName.match(/^(\d+)_/);
59
+ let sequence = existing.reduce((maximum, entry) => {
60
+ const match = entry.fileName.match(/^(\d+)_/);
56
61
  return Math.max(maximum, Number(match?.[1] || 0));
57
62
  }, 0);
58
- const writes = [];
63
+ const appends = [];
59
64
  const deferred = [];
60
65
  const nextCursor = { ...migrationCursor };
61
66
  for (const moduleKey of moduleKeys) {
@@ -88,7 +93,7 @@ export async function planMigrationAppends({
88
93
  const targetFileName = `${String(sequence).padStart(4, "0")}_${moduleKey}__${entry.fileName}`;
89
94
  const source = await fs.readFile(entry.sourcePath, "utf8");
90
95
  const version = catalog[moduleKey]?.version || "unknown";
91
- writes.push({
96
+ appends.push({
92
97
  moduleKey,
93
98
  originalFileName: entry.fileName,
94
99
  targetFileName,
@@ -98,7 +103,7 @@ export async function planMigrationAppends({
98
103
  }
99
104
  if (migrationsInScope.length > 0) nextCursor[moduleKey] = migrationsInScope.at(-1).fileName;
100
105
  }
101
- return { writes, deferred, nextCursor };
106
+ return { writes: appends, repairs: [], appends, deferred, nextCursor };
102
107
  }
103
108
 
104
109
  export async function applyMigrationWrites(writes) {
@@ -131,3 +136,108 @@ export async function cursorMigrationStatus({ targetDir, moduleKey, cursor, cata
131
136
  })).map((entry) => entry.fileName),
132
137
  };
133
138
  }
139
+
140
+ function sha256(source) {
141
+ return createHash("sha256").update(source).digest("hex");
142
+ }
143
+
144
+ // Historical generated files are immutable. These hashes are reviewed SQL-equivalent
145
+ // renderings shipped by early consumers before canonical provenance was enforced.
146
+ const REVIEWED_LEGACY_MIGRATION_HASHES = new Map([
147
+ ["core/20260731120000_core_notifications.sql", new Set(["20fbd2158741871ac6b65c4033db5d7c9e889b03a5876d1f32f2b368b35cf24a"])],
148
+ ["projects/20260731121000_project_notification_audiences.sql", new Set(["1726f2f2403401cda677419e26b49ffa521e49ef0bfa72b56e42428ded7c6312"])],
149
+ ]);
150
+
151
+ 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;
159
+ }
160
+
161
+ export async function exactMigrationCompatibilityStatus({ targetDir, moduleKey, cursor, catalogEntry, allowDeferred = false }) {
162
+ const migrations = await getModuleMigrations(moduleKey, catalogEntry);
163
+ if (migrations.length === 0) return { shipsMigrations: false, issues: [], verified: [] };
164
+ const issues = [];
165
+ const latest = migrations.at(-1)?.fileName ?? null;
166
+ let migrationsInScope = [];
167
+ if (!cursor) {
168
+ issues.push("migration cursor is missing");
169
+ } else if (!migrations.some((entry) => entry.fileName === cursor)) {
170
+ issues.push(`cursor ${cursor} does not exist in the shipped migration history`);
171
+ } else {
172
+ migrationsInScope = migrations.filter((entry) => entry.fileName <= cursor);
173
+ if (cursor !== latest && !allowDeferred) {
174
+ issues.push(`cursor ${cursor} is stale; exact package compatibility requires ${latest}`);
175
+ }
176
+ }
177
+
178
+ const migrationsDir = await findAppMigrationsDirectory(targetDir);
179
+ const installed = [];
180
+ if (await pathExists(migrationsDir)) {
181
+ for (const fileName of await fs.readdir(migrationsDir)) {
182
+ if (!fileName.endsWith(".sql")) continue;
183
+ installed.push({ fileName, content: await fs.readFile(path.join(migrationsDir, fileName), "utf8") });
184
+ }
185
+ }
186
+
187
+ const verified = [];
188
+ const legacyEquivalent = [];
189
+ for (const entry of migrationsInScope) {
190
+ const matches = installed.filter(({ fileName, content }) => {
191
+ const header = content.match(/^\s*--\s*bw-module:\s*([^@\s]+)@([^\s]+)\s+([^\s]+)\s*\n/im);
192
+ return (header?.[1] === moduleKey && header?.[3] === entry.fileName)
193
+ || fileName === entry.fileName
194
+ || fileName.endsWith(`_${moduleKey}__${entry.fileName}`);
195
+ });
196
+ if (matches.length === 0) {
197
+ issues.push(`${entry.fileName}: generated migration file is missing`);
198
+ continue;
199
+ }
200
+ if (matches.length > 1) {
201
+ issues.push(`${entry.fileName}: appears in multiple generated migration files (${matches.map(({ fileName }) => fileName).join(", ")})`);
202
+ continue;
203
+ }
204
+ const [{ fileName, content }] = matches;
205
+ const header = content.match(/^\s*--\s*bw-module:\s*([^@\s]+)@([^\s]+)\s+([^\s]+)\s*\n/im);
206
+ const expectedVersion = catalogEntry?.version;
207
+ if (expectedVersion && header) {
208
+ const comparison = compareSemver(header[2], expectedVersion);
209
+ if (comparison == null || comparison > 0) {
210
+ issues.push(`${fileName}: provenance version ${header[2]} is incompatible with installed ${moduleKey}@${expectedVersion}`);
211
+ continue;
212
+ }
213
+ }
214
+ const source = await fs.readFile(entry.sourcePath, "utf8");
215
+ const generatedSource = header ? content.slice(header[0].length) : content;
216
+ const expectedHash = sha256(source);
217
+ const actualHash = sha256(generatedSource);
218
+ if (actualHash !== expectedHash) {
219
+ if (REVIEWED_LEGACY_MIGRATION_HASHES.get(`${moduleKey}/${entry.fileName}`)?.has(actualHash)) {
220
+ verified.push({ fileName, originalFileName: entry.fileName, sha256: actualHash });
221
+ legacyEquivalent.push({ fileName, originalFileName: entry.fileName, sha256: actualHash, canonicalSha256: expectedHash });
222
+ continue;
223
+ }
224
+ issues.push(`${fileName}: sha256 ${actualHash} does not match ${entry.fileName} sha256 ${expectedHash}`);
225
+ continue;
226
+ }
227
+ verified.push({ fileName, originalFileName: entry.fileName, sha256: expectedHash });
228
+ }
229
+
230
+ const deferredEntries = cursor && migrations.some((entry) => entry.fileName === cursor)
231
+ ? migrations.filter((entry) => entry.fileName > cursor)
232
+ : [];
233
+
234
+ return {
235
+ shipsMigrations: true,
236
+ latest,
237
+ issues,
238
+ verified,
239
+ legacyEquivalent,
240
+ deferred: deferredEntries.map((entry) => entry.fileName),
241
+ nextDeferredIsDestructive: deferredEntries[0]?.destructive === true,
242
+ };
243
+ }
package/src/upgrade.mjs CHANGED
@@ -6,6 +6,7 @@ import { pathExists, runInstall } from "./generator.mjs";
6
6
  import { applyMigrationWrites, getModuleMigrations, planMigrationAppends } from "./migrations.mjs";
7
7
  import { buildBrightwebAppUpdatePlan } from "./update.mjs";
8
8
  import { resolveSafeRelativePath } from "./safe-path.mjs";
9
+ import { RETIRED_MODULE_STARTER_FILES } from "./constants.mjs";
9
10
 
10
11
  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`;
11
12
 
@@ -40,6 +41,19 @@ export async function upgradeBrightwebApp(moduleKey, argvOptions = {}, runtimeOp
40
41
  if (await hashFile(filePath) !== record.hash) drifted.push(relativePath);
41
42
  }
42
43
  const protectedPaths = new Set([...drifted, ...intentional]);
44
+ const obsoleteScaffoldFiles = [];
45
+ if (argvOptions.refreshStarters) {
46
+ for (const moduleKey of Object.keys(appManifest.modules)) {
47
+ for (const relativePath of RETIRED_MODULE_STARTER_FILES[moduleKey] ?? []) {
48
+ const record = appManifest.scaffoldFiles[relativePath];
49
+ if (!record || protectedPaths.has(relativePath)) continue;
50
+ const targetPath = resolveSafeRelativePath(targetDir, relativePath, "Obsolete scaffold file path");
51
+ if (!(await pathExists(targetPath)) || await hashFile(targetPath) !== record.hash) continue;
52
+ obsoleteScaffoldFiles.push({ relativePath, targetPath });
53
+ }
54
+ }
55
+ }
56
+ plan.fileDeletes = obsoleteScaffoldFiles;
43
57
  plan.fileWrites = plan.fileWrites.filter((entry) => entry.type !== "starter" || !protectedPaths.has(entry.relativePath));
44
58
  plan.starterFilesToRefresh = plan.fileWrites.filter((entry) => entry.type === "starter").map((entry) => entry.relativePath);
45
59
  plan.starterFilesDrifted = Array.from(new Set([...plan.starterFilesDrifted, ...drifted]));
@@ -93,7 +107,7 @@ export async function upgradeBrightwebApp(moduleKey, argvOptions = {}, runtimeOp
93
107
  migrationCursor: appManifest.migrationCursor,
94
108
  migrationUpperBounds,
95
109
  });
96
- output.write(`bw upgrade\nPackages to update: ${plan.packageUpdates.length}\nManaged files to write: ${plan.fileWrites.length}\nMigrations to append: ${migrationPlan.writes.length}\n`);
110
+ 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`);
97
111
  if (throughMigration) output.write(`Migration cutoff: ${moduleKey} through ${throughMigration}\n`);
98
112
  for (const boundary of safetyBoundaries) {
99
113
  output.write(`Migration safety boundary: ${boundary.moduleKey} through ${boundary.boundary}; held ${boundary.destructiveMigration}\n`);
@@ -107,14 +121,33 @@ export async function upgradeBrightwebApp(moduleKey, argvOptions = {}, runtimeOp
107
121
  for (const relativePath of missing) output.write(`- missing: ${relativePath}\n`);
108
122
  for (const relativePath of drifted) output.write(`- drifted: ${relativePath}\n`);
109
123
  for (const relativePath of intentional) output.write(`- intent-protected: ${relativePath}\n`);
124
+ for (const entry of plan.fileDeletes) output.write(`- obsolete managed file: ${entry.relativePath}\n`);
110
125
  if (argvOptions.dryRun) return { dryRun: true, plan, migrationPlan, drifted, missing };
111
126
 
112
127
  for (const write of plan.fileWrites) {
113
128
  await fs.mkdir(path.dirname(write.targetPath), { recursive: true });
114
129
  await fs.writeFile(write.targetPath, write.content, "utf8");
115
130
  }
131
+ for (const deletion of plan.fileDeletes) {
132
+ await fs.unlink(deletion.targetPath);
133
+ delete appManifest.scaffoldFiles[deletion.relativePath];
134
+ }
116
135
  await applyMigrationWrites(migrationPlan.writes);
117
136
  appManifest.migrationCursor = migrationPlan.nextCursor;
137
+ appManifest.migrationDeferrals ??= {};
138
+ for (const key of moduleKeys) {
139
+ const firstDeferred = migrationPlan.deferred.find((entry) => entry.moduleKey === key);
140
+ if (firstDeferred?.destructive) {
141
+ appManifest.migrationDeferrals[key] = {
142
+ reason: "destructive",
143
+ cursor: migrationPlan.nextCursor[key],
144
+ nextMigration: firstDeferred.fileName,
145
+ };
146
+ } else {
147
+ delete appManifest.migrationDeferrals[key];
148
+ }
149
+ }
150
+ if (Object.keys(appManifest.migrationDeferrals).length === 0) delete appManifest.migrationDeferrals;
118
151
  for (const [key, entry] of Object.entries(appManifest.modules)) {
119
152
  const packageName = catalog[key]?.packageName;
120
153
  if (catalog[key]?.packageRoot || plan.targetVersions?.[packageName]) entry.version = catalog[key].version;
@@ -144,7 +177,7 @@ export async function upgradeBrightwebApp(moduleKey, argvOptions = {}, runtimeOp
144
177
  const runner = runtimeOptions.installRunner || runInstall;
145
178
  await runner(plan.packageManager, plan.dependencyMode === "workspace" && plan.workspaceRoot ? plan.workspaceRoot : targetDir);
146
179
  }
147
- output.write(`Applied ${plan.fileWrites.length} managed change${plan.fileWrites.length === 1 ? "" : "s"} and ${migrationPlan.writes.length} migration${migrationPlan.writes.length === 1 ? "" : "s"}.\n`);
180
+ output.write(`Applied ${plan.fileWrites.length} managed change${plan.fileWrites.length === 1 ? "" : "s"}, removed ${plan.fileDeletes.length} obsolete managed file${plan.fileDeletes.length === 1 ? "" : "s"}, and added ${migrationPlan.appends.length} new migration${migrationPlan.appends.length === 1 ? "" : "s"}.\n`);
148
181
  return { dryRun: false, plan, migrationPlan, drifted, missing };
149
182
  }
150
183
 
@@ -84,7 +84,7 @@ function ShellLayoutInner({
84
84
  viewer.email ||
85
85
  "Conta";
86
86
  const projectsBaseHref = pathname.startsWith("/projects") ? "/projects" : "/projetos";
87
- const toolbarControls = getModuleToolbarControls(pathname, toolbarRoutes);
87
+ const toolbarControls = getModuleToolbarControls(pathname, toolbarRoutes, viewer);
88
88
 
89
89
  const dispatchShellAction = useShellActionDispatch();
90
90
  useShellAction("projects-back-to-portfolio", () => {
@@ -1,5 +1,13 @@
1
1
  export const dynamic = "force-dynamic";
2
2
 
3
+ export async function POST(
4
+ request: Request,
5
+ context: { params: Promise<{ id: string; invitationId: string }> },
6
+ ) {
7
+ const { handleOrganizationInvitationResendRequest } = await import("@brightweblabs/module-orgs");
8
+ return handleOrganizationInvitationResendRequest(request, context);
9
+ }
10
+
3
11
  export async function DELETE(
4
12
  request: Request,
5
13
  context: { params: Promise<{ id: string; invitationId: string }> },
@@ -6,6 +6,6 @@ export async function GET(request: Request, context: { params: Promise<{ id: str
6
6
  }
7
7
 
8
8
  export async function POST(request: Request, context: { params: Promise<{ id: string }> }) {
9
- const { handleOrganizationInvitationsPostRequest } = await import("@brightweblabs/module-orgs");
10
- return handleOrganizationInvitationsPostRequest(request, context);
9
+ const { handleCrmOrganizationInvitationsPostRequest } = await import("@brightweblabs/module-crm");
10
+ return handleCrmOrganizationInvitationsPostRequest(request, context);
11
11
  }