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 +2 -1
- package/src/add.mjs +3 -2
- package/src/constants.mjs +10 -6
- package/src/generator.mjs +13 -9
- package/src/migration-order.mjs +8 -0
- package/src/migrations.mjs +73 -6
- package/src/update.mjs +152 -5
- package/src/upgrade.mjs +92 -14
- package/template/modules/projects/app/api/account/projects/[id]/tasks/[itemId]/route.ts +2 -0
- package/template/modules/projects/app/api/account/projects/[id]/tasks/route.ts +2 -0
- package/template/modules/projects/app/api/projects/[id]/client-tasks/[itemId]/route.ts +2 -0
- package/template/modules/projects/app/api/projects/[id]/client-tasks/route.ts +2 -0
- package/template/supabase/modules/admin/migrations/20261014183138_auth_verified_admin_acceptance.sql +232 -0
- package/template/supabase/modules/core/migrations/20261014183138_protect_profile_identity.sql +272 -0
- package/template/supabase/modules/orgs/migrations/20261014183138_auth_verified_organization_membership.sql +194 -0
- package/template/supabase/modules/projects/migrations/20261015135430_enforce_task_milestone_project.sql +52 -0
- package/template/supabase/modules/projects/migrations/20261015143936_client_task_collaboration.sql +763 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-bw-app",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "0.27.
|
|
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
|
|
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.
|
|
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.
|
|
194
|
-
"@brightweblabs/module-crm": "^0.18.
|
|
195
|
-
"@brightweblabs/module-marketing": "^0.5.
|
|
196
|
-
"@brightweblabs/module-orgs": "^0.7.
|
|
197
|
-
"@brightweblabs/module-projects": "^0.
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
+
}
|
package/src/migrations.mjs
CHANGED
|
@@ -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]+)\
|
|
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]+)\
|
|
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(
|
|
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
|
|
645
|
-
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
-
|
|
172
|
-
if (argvOptions.install
|
|
173
|
-
|
|
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 };
|