create-bw-app 0.27.3 → 0.27.5

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "create-bw-app",
3
3
  "private": false,
4
- "version": "0.27.3",
4
+ "version": "0.27.5",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "create-bw-app": "bin/create-bw-app.mjs",
@@ -27,6 +27,7 @@
27
27
  "dependencies": {
28
28
  "@inquirer/prompts": "^7.10.1",
29
29
  "@supabase/supabase-js": "^2.110.8",
30
+ "js-yaml": "5.4.1",
30
31
  "semver": "^7.8.5"
31
32
  }
32
33
  }
package/src/add.mjs CHANGED
@@ -6,7 +6,7 @@ import { SELECTABLE_MODULES } from "./constants.mjs";
6
6
  import { TEMPLATE_ROOT, createDbInstallPlan, createManagedPlatformFiles, getDbModuleRegistry, getVersionMap, pathExists, readJsonIfPresent } from "./generator.mjs";
7
7
  import { collectScaffoldFiles, findWorkspaceRoot, loadModuleCatalog, MODULE_PACKAGES, readAppManifest, resolveModuleClosure, satisfiesVersion, writeAppManifest } from "./app-manifest.mjs";
8
8
  import { assertNoUntrackedScaffoldWrites, scaffoldDrift } from "./scaffold.mjs";
9
- import { applyMigrationWrites, planMigrationAppends } from "./migrations.mjs";
9
+ import { assertMigrationMutationTargets, applyMigrationWrites, planMigrationAppends } from "./migrations.mjs";
10
10
 
11
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`;
12
12
 
@@ -109,7 +109,8 @@ export async function addBrightwebModule(moduleKey, argvOptions = {}, runtimeOpt
109
109
  }
110
110
  }
111
111
 
112
- await assertMutationTargets(targetDir, ["package.json", ".brightweb/app-manifest.json", ...Object.keys(managedWrites), ...overlayFiles, ...migrationPlan.writes.map((write) => path.relative(targetDir, write.targetPath))]);
112
+ await assertMutationTargets(targetDir, ["package.json", ".brightweb/app-manifest.json", ...Object.keys(managedWrites), ...overlayFiles]);
113
+ await assertMigrationMutationTargets(targetDir, migrationPlan.writes);
113
114
 
114
115
  const summary = [
115
116
  "bw add",
package/src/constants.mjs CHANGED
@@ -140,6 +140,10 @@ export const MODULE_STARTER_FILES = {
140
140
  "app/api/projects/[id]/milestones/[itemId]/route.ts",
141
141
  "app/api/projects/[id]/tasks/route.ts",
142
142
  "app/api/projects/[id]/tasks/[itemId]/route.ts",
143
+ "app/api/projects/[id]/client-tasks/route.ts",
144
+ "app/api/projects/[id]/client-tasks/[itemId]/route.ts",
145
+ "app/api/account/projects/[id]/tasks/route.ts",
146
+ "app/api/account/projects/[id]/tasks/[itemId]/route.ts",
143
147
  ],
144
148
  };
145
149
 
@@ -187,14 +191,14 @@ export const PLATFORM_STARTER_FILES = [
187
191
  ];
188
192
 
189
193
  export const APP_DEPENDENCY_DEFAULTS = {
190
- "@brightweblabs/app-shell": "^0.16.3",
194
+ "@brightweblabs/app-shell": "^0.16.4",
191
195
  "@brightweblabs/core-auth": "^0.12.2",
192
196
  "@brightweblabs/infra": "^0.7.0",
193
- "@brightweblabs/module-admin": "^0.9.7",
194
- "@brightweblabs/module-crm": "^0.18.4",
195
- "@brightweblabs/module-marketing": "^0.5.2",
196
- "@brightweblabs/module-orgs": "^0.7.3",
197
- "@brightweblabs/module-projects": "^0.19.3",
197
+ "@brightweblabs/module-admin": "^0.9.9",
198
+ "@brightweblabs/module-crm": "^0.18.6",
199
+ "@brightweblabs/module-marketing": "^0.5.3",
200
+ "@brightweblabs/module-orgs": "^0.7.5",
201
+ "@brightweblabs/module-projects": "^0.20.0",
198
202
  "@brightweblabs/theme": "^0.8.4",
199
203
  "@brightweblabs/ui": "^1.5.6",
200
204
  "geist": "1.7.2",
package/src/generator.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { orderFreshMigrationSources } from "./migration-order.mjs";
1
2
  import { fileOwnership, assertGeneratedFileInventory, MANAGED_PLATFORM_FILES, MODULE_SELECTED_FILES } from "./file-policy.mjs";
2
3
  import fs from "node:fs/promises";
3
4
  import path from "node:path";
@@ -1388,7 +1389,7 @@ async function writeSupabaseCliMigrations({ targetDir, dbInstallPlan }) {
1388
1389
  await ensureDirectory(targetMigrationsDir);
1389
1390
  await fs.writeFile(path.join(targetSupabaseDir, "config.toml"), createGeneratedSupabaseConfig(path.basename(targetDir)), "utf8");
1390
1391
 
1391
- let sequence = 1;
1392
+ const sources = [];
1392
1393
  for (const moduleKey of dbInstallPlan.resolvedOrder) {
1393
1394
  const sourceModuleDir = path.join(TEMPLATE_SUPABASE_ROOT, "modules", moduleKey, "migrations");
1394
1395
  if (!(await pathExists(sourceModuleDir))) {
@@ -1400,14 +1401,13 @@ async function writeSupabaseCliMigrations({ targetDir, dbInstallPlan }) {
1400
1401
  .sort();
1401
1402
 
1402
1403
  for (const fileName of fileNames) {
1403
- const targetFileName = `${String(sequence).padStart(4, "0")}_${moduleKey}__${fileName}`;
1404
- await fs.copyFile(
1405
- path.join(sourceModuleDir, fileName),
1406
- path.join(targetMigrationsDir, targetFileName),
1407
- );
1408
- sequence += 1;
1404
+ sources.push({ moduleKey, fileName, sourcePath: path.join(sourceModuleDir, fileName) });
1409
1405
  }
1410
1406
  }
1407
+ for (const [index, source] of orderFreshMigrationSources(sources).entries()) {
1408
+ const targetFileName = `${String(index + 1).padStart(4, "0")}_${source.moduleKey}__${source.fileName}`;
1409
+ await fs.copyFile(source.sourcePath, path.join(targetMigrationsDir, targetFileName));
1410
+ }
1411
1411
  }
1412
1412
 
1413
1413
  async function writeBundledSupabaseBaseline({
@@ -1449,9 +1449,13 @@ async function writeBundledSupabaseBaseline({
1449
1449
  await writeSupabaseCliMigrations({ targetDir, dbInstallPlan });
1450
1450
  }
1451
1451
 
1452
- export async function runInstall(command, cwd) {
1452
+ export async function runInstall(command, cwd, { updateLockfile = false } = {}) {
1453
+ // Explicit upgrades change dependency inputs and must reconcile their lockfile,
1454
+ // including in CI. Ordinary/scaffold installs retain the caller's policy.
1455
+ const args = ["install"];
1456
+ if (command === "pnpm" && updateLockfile) args.push("--no-frozen-lockfile");
1453
1457
  return new Promise((resolve, reject) => {
1454
- const child = spawn(command, ["install"], {
1458
+ const child = spawn(command, args, {
1455
1459
  cwd,
1456
1460
  stdio: "inherit",
1457
1461
  shell: process.platform === "win32",
@@ -0,0 +1,8 @@
1
+ // Inputs are grouped in dependency order. Stable sorting keeps that order for
2
+ // equal timestamps, while later cross-module changes run after earlier baselines.
3
+ // Only fresh assemblies use this; existing numbered history is immutable.
4
+ export function orderFreshMigrationSources(sources) {
5
+ return [...sources].sort((left, right) =>
6
+ left.fileName.split("_")[0].localeCompare(right.fileName.split("_")[0]),
7
+ );
8
+ }
@@ -1,4 +1,6 @@
1
1
  import semver from "semver";
2
+ import { load as loadYaml } from "js-yaml";
3
+ import { assertMutationTargets } from "./mutation-paths.mjs";
2
4
  import fs from "node:fs/promises";
3
5
  import path from "node:path";
4
6
  import { createHash } from "node:crypto";
@@ -16,6 +18,39 @@ export async function findAppMigrationsDirectory(targetDir) {
16
18
  }
17
19
  }
18
20
 
21
+ // Database migrations may belong to the containing consumer workspace, while
22
+ // app files remain bounded to the app itself. Never realpath the migration
23
+ // directory: that would hide links below the legitimate workspace boundary.
24
+ export async function assertMigrationMutationTargets(targetDir, writes) {
25
+ if (writes.length === 0) return;
26
+ const appRoot = await fs.realpath(path.resolve(targetDir));
27
+ let boundary = appRoot;
28
+ let current = appRoot;
29
+ while (true) {
30
+ const workspacePath = path.join(current, "pnpm-workspace.yaml");
31
+ const workspaceConfig = await pathExists(workspacePath)
32
+ ? loadYaml(await fs.readFile(workspacePath, "utf8")) : null;
33
+ const manifestPath = path.join(current, "package.json");
34
+ const manifest = await pathExists(manifestPath)
35
+ ? JSON.parse(await fs.readFile(manifestPath, "utf8")) : null;
36
+ if ((workspaceConfig != null && Object.hasOwn(workspaceConfig, "packages"))
37
+ || manifest?.workspaces || await pathExists(path.join(current, ".git"))) {
38
+ boundary = current;
39
+ break;
40
+ }
41
+ const parent = path.dirname(current);
42
+ if (parent === current) break;
43
+ current = parent;
44
+ }
45
+ const migrationsDir = await findAppMigrationsDirectory(appRoot);
46
+ for (const write of writes) {
47
+ if (path.dirname(write.targetPath) !== migrationsDir) {
48
+ throw new Error("Migration write must target the app's discovered migrations directory.");
49
+ }
50
+ }
51
+ await assertMutationTargets(boundary, writes.map((write) => path.relative(boundary, write.targetPath)));
52
+ }
53
+
19
54
  export async function getModuleMigrations(moduleKey, catalogEntry = {}) {
20
55
  const candidates = [];
21
56
  const configuredPath = catalogEntry.manifest?.database?.migrations;
@@ -62,8 +97,11 @@ export async function planMigrationAppends({
62
97
  return Math.max(maximum, Number(match?.[1] || 0));
63
98
  }, 0);
64
99
  const appends = [];
100
+ const recovered = [];
65
101
  const deferred = [];
66
102
  const nextCursor = { ...migrationCursor };
103
+ let pendingGap = false;
104
+ let lastRecoveredFileName = null;
67
105
  for (const moduleKey of moduleKeys) {
68
106
  const migrations = await getModuleMigrations(moduleKey, catalog[moduleKey]);
69
107
  const cursor = migrationCursor[moduleKey];
@@ -90,10 +128,39 @@ export async function planMigrationAppends({
90
128
  ? migrationsInScope.filter((entry) => entry.fileName > cursor)
91
129
  : migrationsInScope;
92
130
  for (const entry of pending) {
93
- sequence += 1;
94
- const targetFileName = `${String(sequence).padStart(4, "0")}_${moduleKey}__${entry.fileName}`;
95
131
  const source = await fs.readFile(entry.sourcePath, "utf8");
96
132
  const version = catalog[moduleKey]?.version || "unknown";
133
+ const headerPattern = /^[ \t]*--[ \t]*bw-module:\s*([^@\s]+)@([^\s]+)\s+([^\s]+)[ \t]*\r?\n/;
134
+ const matches = existing.filter(file => {
135
+ const header = file.content.match(headerPattern);
136
+ return file.fileName === entry.fileName
137
+ || file.fileName.endsWith(`_${moduleKey}__${entry.fileName}`)
138
+ || (header?.[1] === moduleKey && header?.[3] === entry.fileName);
139
+ });
140
+ if (matches.length) {
141
+ if (matches.length !== 1) throw new Error(`Cannot recover migration ${moduleKey}/${entry.fileName}: multiple files claim the same source.`);
142
+ const previous = matches[0];
143
+ if (pendingGap || (lastRecoveredFileName && previous.fileName <= lastRecoveredFileName)) {
144
+ throw new Error(`Cannot recover migration ${moduleKey}/${entry.fileName}: existing SQL is not a contiguous ordered prefix. Reconcile interrupted history before upgrading.`);
145
+ }
146
+ lastRecoveredFileName = previous.fileName;
147
+ const header = previous.content.match(headerPattern);
148
+ const comparison = header && catalog[moduleKey]?.version ? compareSemver(header[2], version) : 0;
149
+ const sameIdentity = !header || (header[1] === moduleKey && header[3] === entry.fileName);
150
+ const previousSource = header ? previous.content.slice(header[0].length) : previous.content;
151
+ if (!sameIdentity || comparison == null || comparison > 0 || sha256(previousSource) !== sha256(source)) {
152
+ throw new Error(`Cannot recover migration ${moduleKey}/${entry.fileName}: existing source bytes or provenance conflict. Existing SQL was left untouched.`);
153
+ }
154
+ const owner = path.dirname(path.dirname(migrationsDir));
155
+ await assertMutationTargets(owner, [path.relative(owner, previous.targetPath)]);
156
+ // The previous attempt may have persisted SQL before its cursor write.
157
+ // Recover only an exact source match; never rewrite or duplicate history.
158
+ recovered.push({ moduleKey, originalFileName: entry.fileName, targetPath: previous.targetPath });
159
+ continue;
160
+ }
161
+ pendingGap = true;
162
+ sequence += 1;
163
+ const targetFileName = `${String(sequence).padStart(4, "0")}_${moduleKey}__${entry.fileName}`;
97
164
  appends.push({
98
165
  moduleKey,
99
166
  originalFileName: entry.fileName,
@@ -104,13 +171,13 @@ export async function planMigrationAppends({
104
171
  }
105
172
  if (migrationsInScope.length > 0) nextCursor[moduleKey] = migrationsInScope.at(-1).fileName;
106
173
  }
107
- return { writes: appends, repairs: [], appends, deferred, nextCursor };
174
+ return { writes: appends, repairs: [], appends, recovered, deferred, nextCursor };
108
175
  }
109
176
 
110
177
  export async function applyMigrationWrites(writes) {
111
178
  for (const write of writes) {
112
179
  await fs.mkdir(path.dirname(write.targetPath), { recursive: true });
113
- await fs.writeFile(write.targetPath, write.content, "utf8");
180
+ await fs.writeFile(write.targetPath, write.content, { encoding: "utf8", flag: "wx" });
114
181
  }
115
182
  }
116
183
 
@@ -186,7 +253,7 @@ export async function exactMigrationCompatibilityStatus({ targetDir, moduleKey,
186
253
  const legacyEquivalent = [];
187
254
  for (const entry of migrationsInScope) {
188
255
  const matches = installed.filter(({ fileName, content }) => {
189
- const header = content.match(/^\s*--\s*bw-module:\s*([^@\s]+)@([^\s]+)\s+([^\s]+)\s*\n/im);
256
+ const header = content.match(/^\s*--\s*bw-module:\s*([^@\s]+)@([^\s]+)\s+([^\s]+)[ \t]*\r?\n/im);
190
257
  return (header?.[1] === moduleKey && header?.[3] === entry.fileName)
191
258
  || fileName === entry.fileName
192
259
  || fileName.endsWith(`_${moduleKey}__${entry.fileName}`);
@@ -200,7 +267,7 @@ export async function exactMigrationCompatibilityStatus({ targetDir, moduleKey,
200
267
  continue;
201
268
  }
202
269
  const [{ fileName, content }] = matches;
203
- const header = content.match(/^\s*--\s*bw-module:\s*([^@\s]+)@([^\s]+)\s+([^\s]+)\s*\n/im);
270
+ const header = content.match(/^\s*--\s*bw-module:\s*([^@\s]+)@([^\s]+)\s+([^\s]+)[ \t]*\r?\n/im);
204
271
  const expectedVersion = catalogEntry?.version;
205
272
  if (expectedVersion && header) {
206
273
  const comparison = compareSemver(header[2], expectedVersion);
package/src/update.mjs CHANGED
@@ -1,6 +1,10 @@
1
1
  import { assertMutationTargets } from "./mutation-paths.mjs";
2
2
  import { MANAGED_PLATFORM_FILES, MANAGED_SITE_FILES, MODULE_SELECTED_FILES, isAppOwnedSeed } from "./file-policy.mjs";
3
3
  import fs from "node:fs/promises";
4
+ import semver from "semver";
5
+ import { createHash } from "node:crypto";
6
+ import { homedir } from "node:os";
7
+ import { load as loadYaml } from "js-yaml";
4
8
  import path from "node:path";
5
9
  import { stdout as output } from "node:process";
6
10
  import {
@@ -87,6 +91,7 @@ async function resolvePublishedBrightwebVersions(installedBrightwebPackages, opt
87
91
  throw new Error("Published updates require fetch support to resolve BrightWeb package versions from npm.");
88
92
  }
89
93
 
94
+ const registryUrl = (process.env.npm_config_registry || "https://registry.npmjs.org").replace(/\/$/, "");
90
95
  const fallbackVersionMap = options.fallbackVersionMap || {};
91
96
  const allowStaleFallback = options.allowStaleFallback === true;
92
97
  const resolvedVersions = {};
@@ -95,7 +100,7 @@ async function resolvePublishedBrightwebVersions(installedBrightwebPackages, opt
95
100
  await Promise.all(
96
101
  packageNames.map(async (packageName) => {
97
102
  try {
98
- const response = await fetchImpl(`https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`);
103
+ const response = await fetchImpl(`${registryUrl}/${encodeURIComponent(packageName)}/latest`);
99
104
  if (!response?.ok) {
100
105
  throw new Error(`npm registry responded with ${response?.status ?? "an unknown error"}`);
101
106
  }
@@ -432,6 +437,50 @@ function renderPlanSummary(plan, options = {}) {
432
437
  return `${lines.join("\n")}\n`;
433
438
  }
434
439
 
440
+ // A version-pinned app patch is part of the app's customization contract.
441
+ // Check target versions even when an earlier failed upgrade already wrote them.
442
+ async function assertTargetPatchCompatibility(targetDir, targetVersions) {
443
+ let directory = targetDir;
444
+ let lockOwner = null;
445
+ while (!(await pathExists(path.join(directory, "pnpm-workspace.yaml")))) {
446
+ if (!lockOwner && await pathExists(path.join(directory, "pnpm-lock.yaml"))) lockOwner = directory;
447
+ const parent = path.dirname(directory);
448
+ if (parent === directory) { directory = lockOwner ?? targetDir; break; }
449
+ directory = parent;
450
+ }
451
+ // pnpm selects the nearest settings file even without a packages list. A
452
+ // child lockfile does not hide an enclosing pnpm workspace's configuration.
453
+ const manifest = await readJsonIfPresent(path.join(directory, "package.json"));
454
+ const workspacePath = path.join(directory, "pnpm-workspace.yaml");
455
+ const workspace = await pathExists(workspacePath)
456
+ ? loadYaml(await fs.readFile(workspacePath, "utf8")) : null;
457
+ const source = workspace && Object.hasOwn(workspace, "patchedDependencies") ? "pnpm-workspace.yaml" : "package.json";
458
+ const patches = source === "pnpm-workspace.yaml" ? workspace.patchedDependencies : manifest?.pnpm?.patchedDependencies;
459
+ if (patches == null) return;
460
+ if (typeof patches !== "object" || Array.isArray(patches)) {
461
+ throw new Error(`Upgrade blocked: invalid patchedDependencies in ${path.join(directory, source)}. Review the app's patch configuration before retrying.`);
462
+ }
463
+ for (const selector of Object.keys(patches)) {
464
+ const separator = selector.indexOf("@", 1);
465
+ const packageName = separator < 0 ? selector : selector.slice(0, separator);
466
+ if (!Object.hasOwn(targetVersions, packageName)) continue;
467
+ const target = targetVersions[packageName];
468
+ const range = separator < 0 ? "*" : selector.slice(separator + 1);
469
+ const version = semver.validRange(target) && semver.minVersion(target)?.version;
470
+ if (version && semver.validRange(range) && semver.satisfies(version, range)) {
471
+ // Keep the resolver inside the patch's declared support. Exact/finite
472
+ // selectors must not drift to another release allowed by a caret.
473
+ if (!semver.subset(target, range)) targetVersions[packageName] = version;
474
+ continue;
475
+ }
476
+ throw new Error(
477
+ `Upgrade blocked before writes: app-owned patch ${selector} in ${path.join(directory, source)} does not cover target ${packageName}@${target}.\n`
478
+ + "Keep the patch and customization intact. Review/recreate it against the target package with pnpm patch and pnpm patch-commit, then verify the customized behavior before retrying.\n"
479
+ + "If a previous attempt already wrote package or SQL files, this refusal preserves that state. After a reviewed compatible patch is ready, rerun the same command with --install; existing migration files/cursors are verified rather than appended twice.",
480
+ );
481
+ }
482
+ }
483
+
435
484
  export async function buildBrightwebAppUpdatePlan(argvOptions = {}, runtimeOptions = {}) {
436
485
  const targetDir = await fs.realpath(resolveUpdateTargetDirectory(runtimeOptions, argvOptions));
437
486
  const appManifest = await readAppManifest(targetDir, { required: false });
@@ -485,6 +534,7 @@ export async function buildBrightwebAppUpdatePlan(argvOptions = {}, runtimeOptio
485
534
  ...brightwebVersionOverrides,
486
535
  },
487
536
  });
537
+ if (dependencyMode === "published") await assertTargetPatchCompatibility(targetDir, canonicalVersions);
488
538
  const packageJsonUpdate = mergeManagedPackageUpdates({
489
539
  manifest,
490
540
  targetVersions: canonicalVersions,
@@ -604,6 +654,105 @@ export async function buildBrightwebAppUpdatePlan(argvOptions = {}, runtimeOptio
604
654
  };
605
655
  }
606
656
 
657
+ // A successful install is reusable only while its inputs and installed shared
658
+ // package versions still match. Failed/incomplete installs never create proof.
659
+ export async function reconcileDependencyInstallation(plan, runtimeOptions = {}, appManifest = plan.appManifest) {
660
+ const installCwd = plan.dependencyMode === "workspace" && plan.workspaceRoot ? plan.workspaceRoot : plan.targetDir;
661
+ async function configurationHash(owner) {
662
+ const directories = new Set([installCwd, owner]);
663
+ const relativeTarget = path.relative(owner, plan.targetDir);
664
+ if (relativeTarget !== ".." && !relativeTarget.startsWith(`..${path.sep}`) && !path.isAbsolute(relativeTarget)) {
665
+ for (let directory = plan.targetDir; ; directory = path.dirname(directory)) {
666
+ directories.add(directory);
667
+ if (directory === owner) break;
668
+ }
669
+ } else directories.add(plan.targetDir);
670
+ const hashes = [];
671
+ for (const directory of directories) {
672
+ const patches = {};
673
+ for (const name of ["package.json", "pnpm-workspace.yaml", ".npmrc"]) {
674
+ const file = path.join(directory, name);
675
+ hashes.push(await pathExists(file) ? await hashFile(file) : null);
676
+ if (name === ".npmrc" || !(await pathExists(file))) continue;
677
+ const source = await fs.readFile(file, "utf8");
678
+ const config = name === "package.json" ? JSON.parse(source).pnpm : loadYaml(source);
679
+ Object.assign(patches, config?.patchedDependencies ?? {});
680
+ }
681
+ for (const patch of Object.values(patches)) {
682
+ const relativePath = typeof patch === "string" ? patch : patch?.path;
683
+ if (typeof relativePath !== "string") return null;
684
+ const file = path.resolve(directory, relativePath);
685
+ hashes.push(await pathExists(file) ? await hashFile(file) : null);
686
+ }
687
+ }
688
+ const environment = Object.entries(process.env)
689
+ .filter(([name]) => /^(npm_config_|pnpm_|NODE_ENV$|CI$)/i.test(name)).sort(([a], [b]) => a.localeCompare(b));
690
+ const userConfigs = new Set([path.join(homedir(), ".npmrc")]);
691
+ for (const [name, value] of environment) {
692
+ if (/(?:userconfig|globalconfig)$/i.test(name) && value) userConfigs.add(path.resolve(installCwd, value));
693
+ }
694
+ for (const file of userConfigs) hashes.push(await pathExists(file) ? await hashFile(file) : null);
695
+ // Configuration can contain credentials; retain only this aggregate digest.
696
+ return createHash("sha256").update(JSON.stringify({ hashes, environment })).digest("hex");
697
+ }
698
+ async function inputs() {
699
+ let directory = installCwd;
700
+ while (true) {
701
+ for (const name of ["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lock", "bun.lockb"]) {
702
+ const lock = path.join(directory, name);
703
+ if (await pathExists(lock)) return {
704
+ packageManager: plan.packageManager,
705
+ appPackageHash: await hashFile(path.join(plan.targetDir, "package.json")),
706
+ installPackageHash: await pathExists(path.join(directory, "package.json")) ? await hashFile(path.join(directory, "package.json")) : null,
707
+ lockFile: name, lockHash: await hashFile(lock), configurationHash: await configurationHash(directory),
708
+ };
709
+ }
710
+ const parent = path.dirname(directory);
711
+ if (parent === directory) return { packageManager: plan.packageManager, appPackageHash: await hashFile(path.join(plan.targetDir, "package.json")), installPackageHash: null, lockFile: null, lockHash: null };
712
+ directory = parent;
713
+ }
714
+ }
715
+ async function installedPackagesMatch() {
716
+ const pkg = JSON.parse(await fs.readFile(path.join(plan.targetDir, "package.json"), "utf8"));
717
+ const requirements = Object.entries({ ...pkg.dependencies, ...pkg.devDependencies })
718
+ .filter(([name]) => BRIGHTWEB_PACKAGE_NAMES.includes(name));
719
+ if (!requirements.length) return false;
720
+ for (const [name, range] of requirements) {
721
+ let directory = plan.targetDir, installed = null;
722
+ while (true) {
723
+ const candidate = path.join(directory, "node_modules", ...name.split("/"), "package.json");
724
+ if (await pathExists(candidate)) {
725
+ try { installed = JSON.parse(await fs.readFile(candidate, "utf8")); }
726
+ catch (error) { if (error instanceof SyntaxError || error.code === "ENOENT") return false; throw error; }
727
+ break;
728
+ }
729
+ const parent = path.dirname(directory);
730
+ if (parent === directory) break;
731
+ directory = parent;
732
+ }
733
+ if (!installed || !semver.valid(installed.version) || (!String(range).startsWith("workspace:") && !semver.satisfies(installed.version, String(range)))) return false;
734
+ }
735
+ return true;
736
+ }
737
+ const before = await inputs();
738
+ if (before.lockHash && before.configurationHash && appManifest?.lastSuccessfulInstall
739
+ && JSON.stringify(appManifest.lastSuccessfulInstall) === JSON.stringify(before)
740
+ && await installedPackagesMatch()) {
741
+ output.write("Installed dependencies already match the verified installation; no installation required.\n");
742
+ return { installed: false };
743
+ }
744
+ if (appManifest?.lastSuccessfulInstall) {
745
+ delete appManifest.lastSuccessfulInstall;
746
+ await writeAppManifest(plan.targetDir, appManifest);
747
+ }
748
+ await (runtimeOptions.installRunner || runInstall)(plan.packageManager, installCwd, { updateLockfile: true });
749
+ if (appManifest) {
750
+ appManifest.lastSuccessfulInstall = await inputs();
751
+ await writeAppManifest(plan.targetDir, appManifest);
752
+ }
753
+ return { installed: true };
754
+ }
755
+
607
756
  export async function updateBrightwebApp(argvOptions = {}, runtimeOptions = {}) {
608
757
  const plan = await buildBrightwebAppUpdatePlan(argvOptions, runtimeOptions);
609
758
  const installCommand = renderInstallCommand({
@@ -641,10 +790,8 @@ export async function updateBrightwebApp(argvOptions = {}, runtimeOptions = {})
641
790
  }
642
791
 
643
792
  const packageJsonChanged = plan.fileWrites.some((entry) => entry.relativePath === "package.json");
644
- if (argvOptions.install && packageJsonChanged) {
645
- const installRunner = runtimeOptions.installRunner || runInstall;
646
- const installCwd = plan.dependencyMode === "workspace" && plan.workspaceRoot ? plan.workspaceRoot : plan.targetDir;
647
- await installRunner(plan.packageManager, installCwd);
793
+ if (argvOptions.install) {
794
+ await reconcileDependencyInstallation(plan, runtimeOptions);
648
795
  }
649
796
 
650
797
  if (!argvOptions.install && packageJsonChanged) {
package/src/upgrade.mjs CHANGED
@@ -1,16 +1,57 @@
1
1
  import { assertMutationTargets } from "./mutation-paths.mjs";
2
2
  import fs from "node:fs/promises";
3
+ import { load as loadYaml } from "js-yaml";
3
4
  import path from "node:path";
4
5
  import { stdout as output } from "node:process";
5
- import { hashFile, findWorkspaceRoot, loadModuleCatalog, readAppManifest, writeAppManifest, cleanVersion } from "./app-manifest.mjs";
6
- import { pathExists, runInstall } from "./generator.mjs";
7
- import { applyMigrationWrites, getModuleMigrations, planMigrationAppends } from "./migrations.mjs";
8
- import { buildBrightwebAppUpdatePlan } from "./update.mjs";
6
+ import { hashFile, findWorkspaceRoot, loadModuleCatalog, readAppManifest, writeAppManifest, cleanVersion, moduleVersion } from "./app-manifest.mjs";
7
+ import { pathExists } from "./generator.mjs";
8
+ import { assertMigrationMutationTargets, applyMigrationWrites, findAppMigrationsDirectory, getModuleMigrations, planMigrationAppends, exactMigrationCompatibilityStatus } from "./migrations.mjs";
9
+ import { buildBrightwebAppUpdatePlan, reconcileDependencyInstallation } from "./update.mjs";
9
10
  import { resolveSafeRelativePath } from "./safe-path.mjs";
10
11
  import { scaffoldDrift } from "./scaffold.mjs";
11
12
  import { RETIRED_MODULE_STARTER_FILES } from "./constants.mjs";
12
13
 
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`;
14
+ const IDENTITY_CORE_MIGRATION = "20261014183138_protect_profile_identity.sql";
15
+ const IDENTITY_DEPENDENT_MIGRATIONS = {
16
+ admin: "20261014183138_auth_verified_admin_acceptance.sql",
17
+ orgs: "20261014183138_auth_verified_organization_membership.sql",
18
+ };
19
+
20
+ function orderSelectedModules(catalog, selected) {
21
+ const order = [], visited = new Set(), visiting = new Set();
22
+ const keys = new Set(selected);
23
+ function visit(key) {
24
+ if (visited.has(key)) return;
25
+ if (visiting.has(key)) throw new Error(`Circular migration dependency: ${key}`);
26
+ visiting.add(key);
27
+ for (const dependency of Object.keys(catalog[key]?.requires ?? {})) {
28
+ if (keys.has(dependency)) visit(dependency);
29
+ }
30
+ visiting.delete(key);
31
+ visited.add(key);
32
+ order.push(key);
33
+ }
34
+ for (const key of selected) visit(key);
35
+ return order;
36
+ }
37
+
38
+ async function assertIdentityMigrationPrerequisites({ targetDir, catalog, migrationPlan }) {
39
+ const dependent = [...migrationPlan.appends, ...migrationPlan.recovered]
40
+ .filter(entry => IDENTITY_DEPENDENT_MIGRATIONS[entry.moduleKey] === entry.originalFileName);
41
+ if (!dependent.length) return;
42
+ const instruction = `Identity migration prerequisite missing or invalid. Run bw upgrade core, apply ${IDENTITY_CORE_MIGRATION} successfully to the database, then retry the Admin/Orgs upgrade. Explicit cutoffs are preserved; generated files and cursors do not prove database application.`;
43
+ const coreIndex = migrationPlan.appends.findIndex(entry => entry.moduleKey === "core" && entry.originalFileName === IDENTITY_CORE_MIGRATION);
44
+ if (coreIndex >= 0) {
45
+ if (dependent.some(entry => migrationPlan.appends.indexOf(entry) <= coreIndex)) throw new Error(instruction);
46
+ return;
47
+ }
48
+ const status = await exactMigrationCompatibilityStatus({ targetDir, moduleKey: "core", cursor: IDENTITY_CORE_MIGRATION, catalogEntry: catalog.core, allowDeferred: true });
49
+ if (status.issues.length) throw new Error(`${instruction} Core SQL verification: ${status.issues.join("; ")}`);
50
+ const coreFile = status.verified.find(entry => entry.originalFileName === IDENTITY_CORE_MIGRATION)?.fileName;
51
+ if (!coreFile || dependent.some(entry => path.basename(entry.targetPath) <= coreFile)) throw new Error(instruction);
52
+ }
53
+
54
+ 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 dependencies, including retrying a failed install\n --refresh-starters Refresh unchanged starter files\n --dry-run Print the upgrade plan without writing\n --help Show this help`;
14
55
 
15
56
  export async function upgradeBrightwebApp(moduleKey, argvOptions = {}, runtimeOptions = {}) {
16
57
  if (argvOptions.help) { output.write(`${HELP}\n`); return { help: true }; }
@@ -29,7 +70,7 @@ export async function upgradeBrightwebApp(moduleKey, argvOptions = {}, runtimeOp
29
70
  }
30
71
  const targetDir = await fs.realpath(path.resolve(runtimeOptions.targetDir || argvOptions.targetDir || process.cwd()));
31
72
  const appManifest = await readAppManifest(targetDir);
32
- if (moduleKey && !appManifest.modules[moduleKey]) throw new Error(`Module ${moduleKey} is not installed according to ${path.join(".brightweb", "app-manifest.json")}.`);
73
+ if (moduleKey && !appManifest.modules[moduleKey] && !(moduleKey === "core" && appManifest.app?.template === "platform")) throw new Error(`Module ${moduleKey} is not installed according to ${path.join(".brightweb", "app-manifest.json")}.`);
33
74
  const workspaceRoot = runtimeOptions.workspaceRoot || argvOptions.workspaceRoot || await findWorkspaceRoot(targetDir);
34
75
  const updateOptions = { ...argvOptions, targetDir, ...(workspaceRoot ? { workspaceRoot } : {}) };
35
76
  const plan = await buildBrightwebAppUpdatePlan(updateOptions, runtimeOptions);
@@ -58,16 +99,26 @@ export async function upgradeBrightwebApp(moduleKey, argvOptions = {}, runtimeOp
58
99
  const catalog = await loadModuleCatalog({ targetDir, workspaceRoot });
59
100
  for (const entry of Object.values(catalog)) {
60
101
  const targetVersion = plan.targetVersions?.[entry.packageName];
61
- if (targetVersion) entry.version = cleanVersion(targetVersion) || entry.version;
102
+ if (!targetVersion) continue;
103
+ const version = cleanVersion(targetVersion) || entry.version;
104
+ if (!workspaceRoot && version !== entry.version) {
105
+ if (version !== moduleVersion(entry.key)) {
106
+ throw new Error(`Migration sources for ${entry.key}@${version} are unavailable. Update create-bw-app to a release with the matching migration bundle before upgrading.`);
107
+ }
108
+ // Installed packages still belong to the previous release until --install
109
+ // runs. The CLI bundle carries the matching target release's SQL.
110
+ entry.packageRoot = null;
111
+ }
112
+ entry.version = version;
62
113
  }
63
114
  for (const update of plan.packageUpdates) {
64
115
  const key = Object.keys(catalog).find((candidate) => catalog[candidate].packageName === update.packageName);
65
116
  if (key) catalog[key].version = cleanVersion(update.to) || catalog[key].version;
66
117
  }
67
118
  const installedMigrationKeys = Object.keys(appManifest.migrationCursor ?? {}).filter((key) => catalog[key]);
68
- const moduleKeys = moduleKey
119
+ const moduleKeys = orderSelectedModules(catalog, moduleKey
69
120
  ? [moduleKey]
70
- : Array.from(new Set([...installedMigrationKeys, ...Object.keys(appManifest.modules)]));
121
+ : Array.from(new Set([...installedMigrationKeys, ...Object.keys(appManifest.modules)])));
71
122
  const uncursored = [];
72
123
  for (const key of moduleKeys) {
73
124
  if (appManifest.migrationCursor?.[key] == null && (await getModuleMigrations(key, catalog[key])).length > 0) uncursored.push(key);
@@ -95,6 +146,8 @@ export async function upgradeBrightwebApp(moduleKey, argvOptions = {}, runtimeOp
95
146
  migrationUpperBounds[key] = boundary;
96
147
  safetyBoundaries.push({ moduleKey: key, boundary, destructiveMigration: migrations[destructiveIndex].fileName });
97
148
  }
149
+ const migrationsDir = await findAppMigrationsDirectory(targetDir);
150
+ const migrationOwner = path.dirname(path.dirname(migrationsDir));
98
151
  const migrationPlan = await planMigrationAppends({
99
152
  targetDir,
100
153
  moduleKeys,
@@ -102,8 +155,34 @@ export async function upgradeBrightwebApp(moduleKey, argvOptions = {}, runtimeOp
102
155
  migrationCursor: appManifest.migrationCursor,
103
156
  migrationUpperBounds,
104
157
  });
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))]);
158
+ await assertMigrationMutationTargets(targetDir, migrationPlan.writes);
159
+ if (migrationOwner !== targetDir) {
160
+ let current = targetDir;
161
+ let declaredOwner = null;
162
+ while (current !== path.dirname(current)) {
163
+ const manifestPath = path.join(current, "package.json");
164
+ const workspacePath = path.join(current, "pnpm-workspace.yaml");
165
+ // Generated single apps also carry a pnpm settings file; only a package
166
+ // selection declares a shared workspace ownership boundary.
167
+ const workspaceConfig = await pathExists(workspacePath)
168
+ ? loadYaml(await fs.readFile(workspacePath, "utf8")) : null;
169
+ const workspaceMarker = workspaceConfig != null
170
+ && Object.hasOwn(workspaceConfig, "packages");
171
+ const manifest = await pathExists(manifestPath) ? JSON.parse(await fs.readFile(manifestPath, "utf8")) : null;
172
+ if (workspaceMarker || manifest?.workspaces) { declaredOwner = current; break; }
173
+ current = path.dirname(current);
174
+ }
175
+ if (declaredOwner !== migrationOwner) {
176
+ throw new Error("Migration upgrade blocked: ancestor SQL directory is outside the app's declared workspace. Keep SQL inside the app or declare the owning workspace before upgrading.");
177
+ }
178
+ }
179
+ await assertIdentityMigrationPrerequisites({ targetDir, catalog, migrationPlan });
180
+ await assertMutationTargets(targetDir, [".brightweb/app-manifest.json", ...plan.fileWrites.map((write) => write.relativePath), ...plan.fileDeletes.map((entry) => entry.relativePath)]);
181
+ // Monorepo clients keep SQL beside the app. Validate that owner boundary
182
+ // separately; app-managed paths must still remain inside the app itself.
183
+ await assertMutationTargets(migrationOwner, migrationPlan.writes.map(write => path.relative(migrationOwner, write.targetPath)));
106
184
  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`);
185
+ if (migrationPlan.recovered.length) output.write(`Previously written migrations verified for recovery: ${migrationPlan.recovered.length}\n`);
107
186
  if (throughMigration) output.write(`Migration cutoff: ${moduleKey} through ${throughMigration}\n`);
108
187
  for (const boundary of safetyBoundaries) {
109
188
  output.write(`Migration safety boundary: ${boundary.moduleKey} through ${boundary.boundary}; held ${boundary.destructiveMigration}\n`);
@@ -168,10 +247,9 @@ export async function upgradeBrightwebApp(moduleKey, argvOptions = {}, runtimeOp
168
247
  }
169
248
  }
170
249
  await writeAppManifest(targetDir, appManifest);
171
- const packageChanged = plan.fileWrites.some((entry) => entry.relativePath === "package.json");
172
- if (argvOptions.install && packageChanged) {
173
- const runner = runtimeOptions.installRunner || runInstall;
174
- await runner(plan.packageManager, plan.dependencyMode === "workspace" && plan.workspaceRoot ? plan.workspaceRoot : targetDir);
250
+ // Explicit installation also reconciles a previous failed install after files were updated.
251
+ if (argvOptions.install) {
252
+ await reconcileDependencyInstallation({ ...plan, targetDir }, runtimeOptions, appManifest);
175
253
  }
176
254
  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`);
177
255
  return { dryRun: false, plan, migrationPlan, drifted, missing };
@@ -0,0 +1,2 @@
1
+ export const dynamic = "force-dynamic";
2
+ export { clientProjectTaskPost as POST } from "@brightweblabs/module-projects/route-handlers";
@@ -0,0 +1,2 @@
1
+ export const dynamic = "force-dynamic";
2
+ export { clientProjectTasksGet as GET } from "@brightweblabs/module-projects/route-handlers";
@@ -0,0 +1,2 @@
1
+ export const dynamic = "force-dynamic";
2
+ export { projectClientTaskPatch as PATCH } from "@brightweblabs/module-projects/route-handlers";
@@ -0,0 +1,2 @@
1
+ export const dynamic = "force-dynamic";
2
+ export { projectClientTasksGet as GET, projectClientTasksPost as POST } from "@brightweblabs/module-projects/route-handlers";