create-bw-app 0.27.1 → 0.27.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -2
- package/package.json +3 -2
- package/src/add.mjs +68 -19
- package/src/adopt.mjs +32 -10
- package/src/app-manifest.mjs +11 -35
- package/src/constants.mjs +9 -8
- package/src/doctor.mjs +16 -4
- package/src/file-policy.mjs +40 -0
- package/src/generator.mjs +22 -51
- package/src/migrations.mjs +8 -10
- package/src/mutation-paths.mjs +28 -0
- package/src/removal-dependents.mjs +32 -0
- package/src/remove.mjs +30 -29
- package/src/scaffold-cmd.mjs +7 -7
- package/src/scaffold.mjs +43 -2
- package/src/setup.mjs +6 -16
- package/src/update.mjs +42 -50
- package/src/upgrade.mjs +13 -17
- package/template/supabase/modules/admin/migrations/20260906101616_atomic_admin_invitation_acceptance.sql +50 -0
- package/template/supabase/modules/admin/migrations/20260908120000_admin_profile_directory_visibility.sql +9 -0
- package/template/supabase/modules/core/migrations/20260906111323_restrict_profile_identity_sync.sql +4 -0
- package/template/supabase/modules/crm/migrations/20260906101616_atomic_invitation_contact_link.sql +31 -0
- package/template/supabase/modules/marketing/migrations/20260906120900_atomic_marketing_workflow_nodes.sql +97 -0
- package/template/supabase/modules/orgs/migrations/20260906101616_atomic_organization_invitation_acceptance.sql +61 -0
- package/template/supabase/modules/orgs/migrations/20260906113855_atomic_organization_member_assignment.sql +76 -0
package/src/migrations.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import semver from "semver";
|
|
1
2
|
import fs from "node:fs/promises";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { createHash } from "node:crypto";
|
|
@@ -66,6 +67,9 @@ export async function planMigrationAppends({
|
|
|
66
67
|
for (const moduleKey of moduleKeys) {
|
|
67
68
|
const migrations = await getModuleMigrations(moduleKey, catalog[moduleKey]);
|
|
68
69
|
const cursor = migrationCursor[moduleKey];
|
|
70
|
+
if (cursor != null && !migrations.some((entry) => entry.fileName === cursor)) {
|
|
71
|
+
throw new Error(`Migration cursor ${cursor} does not exist in the shipped ${moduleKey} history; reconcile it explicitly before upgrading.`);
|
|
72
|
+
}
|
|
69
73
|
const upperBound = migrationUpperBounds[moduleKey];
|
|
70
74
|
let migrationsInScope = migrations;
|
|
71
75
|
if (upperBound) {
|
|
@@ -75,9 +79,6 @@ export async function planMigrationAppends({
|
|
|
75
79
|
}
|
|
76
80
|
if (cursor) {
|
|
77
81
|
const cursorIndex = migrations.findIndex((entry) => entry.fileName === cursor);
|
|
78
|
-
if (cursorIndex === -1) {
|
|
79
|
-
throw new Error(`Migration cutoff blocked: current cursor ${cursor} does not exist in the shipped ${moduleKey} migration history.`);
|
|
80
|
-
}
|
|
81
82
|
if (upperBoundIndex < cursorIndex) {
|
|
82
83
|
throw new Error(`Migration cutoff ${upperBound} is before the current ${moduleKey} cursor ${cursor}.`);
|
|
83
84
|
}
|
|
@@ -117,6 +118,9 @@ export async function cursorMigrationStatus({ targetDir, moduleKey, cursor, cata
|
|
|
117
118
|
const migrations = await getModuleMigrations(moduleKey, catalogEntry);
|
|
118
119
|
if (migrations.length === 0) return { shipsMigrations: false, missing: [] };
|
|
119
120
|
if (!cursor) return { shipsMigrations: true, missing: ["migration cursor"] };
|
|
121
|
+
if (!migrations.some((entry) => entry.fileName === cursor)) {
|
|
122
|
+
return { shipsMigrations: true, missing: [`cursor ${cursor} does not exist in the shipped migration history`] };
|
|
123
|
+
}
|
|
120
124
|
const expected = migrations.filter((entry) => entry.fileName <= cursor);
|
|
121
125
|
const migrationsDir = await findAppMigrationsDirectory(targetDir);
|
|
122
126
|
const installed = [];
|
|
@@ -149,13 +153,7 @@ const REVIEWED_LEGACY_MIGRATION_HASHES = new Map([
|
|
|
149
153
|
]);
|
|
150
154
|
|
|
151
155
|
function compareSemver(left, right) {
|
|
152
|
-
|
|
153
|
-
const rightParts = String(right).split(".").map((value) => Number.parseInt(value, 10));
|
|
154
|
-
if (leftParts.length !== 3 || rightParts.length !== 3 || [...leftParts, ...rightParts].some(Number.isNaN)) return null;
|
|
155
|
-
for (let index = 0; index < 3; index += 1) {
|
|
156
|
-
if (leftParts[index] !== rightParts[index]) return leftParts[index] < rightParts[index] ? -1 : 1;
|
|
157
|
-
}
|
|
158
|
-
return 0;
|
|
156
|
+
return semver.valid(left) && semver.valid(right) ? semver.compare(left, right) : null;
|
|
159
157
|
}
|
|
160
158
|
|
|
161
159
|
export async function exactMigrationCompatibilityStatus({ targetDir, moduleKey, cursor, catalogEntry, allowDeferred = false }) {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { normalizeSafeRelativePath } from "./safe-path.mjs";
|
|
4
|
+
|
|
5
|
+
// The caller-selected app root may be an alias (including macOS /var). Resolve
|
|
6
|
+
// that boundary once; links beneath it are never writable lifecycle targets.
|
|
7
|
+
// This preflight assumes no concurrent filesystem edits; it is not a sandbox.
|
|
8
|
+
export async function assertMutationTargets(targetDir, relativePaths) {
|
|
9
|
+
const root = await fs.realpath(path.resolve(targetDir));
|
|
10
|
+
if (!(await fs.stat(root)).isDirectory()) throw new Error("App root must be a directory.");
|
|
11
|
+
for (const input of new Set(relativePaths)) {
|
|
12
|
+
const relativePath = normalizeSafeRelativePath(input, "Mutation target");
|
|
13
|
+
let current = root;
|
|
14
|
+
const parts = relativePath.split("/");
|
|
15
|
+
for (const [index, part] of parts.entries()) {
|
|
16
|
+
current = path.join(current, part);
|
|
17
|
+
const stat = await fs.lstat(current).catch((error) => {
|
|
18
|
+
if (error.code === "ENOENT") return null;
|
|
19
|
+
throw error;
|
|
20
|
+
});
|
|
21
|
+
if (!stat) break;
|
|
22
|
+
if (stat.isSymbolicLink()) throw new Error(`Lifecycle writes do not follow symlinks: ${relativePath}`);
|
|
23
|
+
if (index < parts.length - 1 ? !stat.isDirectory() : !stat.isFile()) {
|
|
24
|
+
throw new Error(`Unexpected filesystem type at mutation target: ${relativePath}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
// Conservative literal-import diagnostic, not a complete application bundler.
|
|
5
|
+
// Generated replacements and deliberate clean deletions are outside the surviving set.
|
|
6
|
+
export async function findSurvivingPackageImports(targetDir, packageName, excludedPaths) {
|
|
7
|
+
const escaped = packageName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
8
|
+
const imports = new RegExp(`(?:\\bfrom\\s*|\\bimport\\s*(?:\\(\\s*)?|\\brequire\\s*\\(\\s*)["']${escaped}(?:/[^"']*)?["']`);
|
|
9
|
+
const stylesheetImports = new RegExp(`@(?:import|reference)\\s*(?:url\\(\\s*)?["']?${escaped}(?:/[^"'\\s);]+)?(?=["'\\s);]|$)`, "i");
|
|
10
|
+
const sourceExtension = /\.(?:[cm]?[jt]sx?|mdx|css)$/i;
|
|
11
|
+
const findings = [];
|
|
12
|
+
const ignored = new Set(["node_modules", ".git", ".next", ".brightweb", "supabase", "dist", "build"]);
|
|
13
|
+
async function visit(directory) {
|
|
14
|
+
for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
|
|
15
|
+
if (ignored.has(entry.name)) continue;
|
|
16
|
+
const fullPath = path.join(directory, entry.name);
|
|
17
|
+
const relativePath = path.relative(targetDir, fullPath);
|
|
18
|
+
if (excludedPaths.has(relativePath)) continue;
|
|
19
|
+
if (entry.isSymbolicLink()) {
|
|
20
|
+
// Do not read outside the app or silently declare linked source safe.
|
|
21
|
+
if (entry.name.includes(".") && !sourceExtension.test(entry.name)) continue;
|
|
22
|
+
findings.push(`${relativePath} (linked source; inspect dependencies explicitly)`);
|
|
23
|
+
} else if (entry.isDirectory()) await visit(fullPath);
|
|
24
|
+
else if (sourceExtension.test(entry.name)) {
|
|
25
|
+
const source = await fs.readFile(fullPath, "utf8");
|
|
26
|
+
if ((/\.css$/i.test(entry.name) ? stylesheetImports : imports).test(source)) findings.push(relativePath);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
await visit(targetDir);
|
|
31
|
+
return findings.sort();
|
|
32
|
+
}
|
package/src/remove.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { findSurvivingPackageImports } from "./removal-dependents.mjs";
|
|
2
|
+
import { assertMutationTargets } from "./mutation-paths.mjs";
|
|
1
3
|
import fs from "node:fs/promises";
|
|
2
4
|
import path from "node:path";
|
|
3
5
|
import { stdout as output } from "node:process";
|
|
@@ -10,18 +12,13 @@ import {
|
|
|
10
12
|
writeAppManifest,
|
|
11
13
|
} from "./app-manifest.mjs";
|
|
12
14
|
import {
|
|
13
|
-
createAppContextFile,
|
|
14
15
|
createDbInstallPlan,
|
|
15
|
-
|
|
16
|
-
createNextConfig,
|
|
17
|
-
createOptionalModuleRouteFiles,
|
|
18
|
-
createPlatformGlobalsCss,
|
|
19
|
-
createPlatformModulesConfigFile,
|
|
20
|
-
createShellConfig,
|
|
16
|
+
createManagedPlatformFiles,
|
|
21
17
|
getDbModuleRegistry,
|
|
22
18
|
pathExists,
|
|
23
19
|
readJsonIfPresent,
|
|
24
20
|
} from "./generator.mjs";
|
|
21
|
+
import { assertNoUntrackedScaffoldWrites, preserveScaffoldDecisions, scaffoldDrift } from "./scaffold.mjs";
|
|
25
22
|
import { resolveSafeRelativePath } from "./safe-path.mjs";
|
|
26
23
|
|
|
27
24
|
const HELP = `Usage: bw remove <moduleKey> [options]\n\nOptions:\n --target-dir <path> App directory (defaults to cwd)\n --workspace-root <path> BrightWeb workspace root\n --dry-run Print the removal plan without writing\n --yes Apply the removal plan\n --help Show this help`;
|
|
@@ -38,7 +35,7 @@ function databaseNotice(moduleKey, ownedObjects) {
|
|
|
38
35
|
|
|
39
36
|
export async function removeBrightwebModule(moduleKey, argvOptions = {}, runtimeOptions = {}) {
|
|
40
37
|
if (!moduleKey || argvOptions.help) { output.write(`${HELP}\n`); return { help: true }; }
|
|
41
|
-
const targetDir = path.resolve(runtimeOptions.targetDir || argvOptions.targetDir || process.cwd());
|
|
38
|
+
const targetDir = await fs.realpath(path.resolve(runtimeOptions.targetDir || argvOptions.targetDir || process.cwd()));
|
|
42
39
|
const appManifest = await readAppManifest(targetDir);
|
|
43
40
|
if (!appManifest.modules[moduleKey]) throw new Error(`Module ${moduleKey} is not installed according to .brightweb/app-manifest.json.`);
|
|
44
41
|
const workspaceRoot = runtimeOptions.workspaceRoot || argvOptions.workspaceRoot || await findWorkspaceRoot(targetDir);
|
|
@@ -61,34 +58,34 @@ export async function removeBrightwebModule(moduleKey, argvOptions = {}, runtime
|
|
|
61
58
|
workspaceMode: Object.values(nextPackageJson.dependencies || {}).some((value) => String(value).startsWith("workspace:")),
|
|
62
59
|
registry: dbRegistry,
|
|
63
60
|
});
|
|
64
|
-
const managedWrites = {
|
|
65
|
-
"next.config.ts": createNextConfig({ template: "platform", selectedModules: remainingModules }),
|
|
66
|
-
"app/globals.css": await createPlatformGlobalsCss(remainingModules),
|
|
67
|
-
"config/module-toolbar-controls.tsx": createModuleToolbarControlsConfig(remainingModules),
|
|
68
|
-
"config/modules.ts": createPlatformModulesConfigFile(remainingModules),
|
|
69
|
-
"config/shell.ts": createShellConfig(remainingModules),
|
|
70
|
-
"docs/ai/app-context.json": createAppContextFile({ slug: appManifest.app.slug, template: "platform", selectedModules: remainingModules.filter((key) => key !== "orgs"), dbInstallPlan }),
|
|
71
|
-
...createOptionalModuleRouteFiles(remainingModules),
|
|
72
|
-
};
|
|
61
|
+
const managedWrites = await createManagedPlatformFiles({ slug: appManifest.app.slug, selectedModules: remainingModules, dbInstallPlan });
|
|
73
62
|
|
|
74
|
-
const
|
|
75
|
-
const
|
|
76
|
-
for (const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
63
|
+
const live = await scaffoldDrift(targetDir, appManifest.scaffoldFiles);
|
|
64
|
+
const { protectedPaths } = live;
|
|
65
|
+
for (const relativePath of protectedPaths) delete managedWrites[relativePath];
|
|
66
|
+
await assertNoUntrackedScaffoldWrites({
|
|
67
|
+
targetDir,
|
|
68
|
+
scaffoldFiles: appManifest.scaffoldFiles,
|
|
69
|
+
moduleKeys: remainingModules,
|
|
70
|
+
relativePaths: Object.keys(managedWrites),
|
|
71
|
+
});
|
|
72
|
+
const retainedFiles = live.entries.filter((entry) => entry.module === moduleKey && protectedPaths.has(entry.relativePath)).map((entry) => entry.relativePath);
|
|
73
|
+
const moduleFiles = live.entries.filter((entry) => entry.module === moduleKey && entry.status !== "missing");
|
|
74
|
+
const cleanFiles = moduleFiles.filter((entry) => !protectedPaths.has(entry.relativePath)).map((entry) => entry.relativePath);
|
|
75
|
+
const driftedFiles = moduleFiles.filter((entry) => protectedPaths.has(entry.relativePath)).map((entry) => entry.relativePath);
|
|
76
|
+
await assertMutationTargets(targetDir, ["package.json", ".brightweb/app-manifest.json", ...cleanFiles, ...Object.keys(managedWrites)]);
|
|
77
|
+
const dependentsOnDisk = await findSurvivingPackageImports(targetDir, packageName, new Set([...cleanFiles, ...Object.keys(managedWrites)]));
|
|
78
|
+
if (dependentsOnDisk.length) throw new Error(`Cannot remove ${moduleKey}: surviving app files depend on ${packageName}: ${dependentsOnDisk.join(", ")}. Reconcile those imports before removal; app-owned content was not changed.`);
|
|
83
79
|
const notice = databaseNotice(moduleKey, catalog[moduleKey]?.manifest?.database?.ownedObjects || []);
|
|
84
80
|
const apply = argvOptions.yes === true && argvOptions.dryRun !== true;
|
|
85
81
|
output.write(`bw remove ${moduleKey}${apply ? "" : " (plan only; pass --yes to apply)"}\n`);
|
|
86
82
|
output.write(`Dependency to remove: ${packageName}\n`);
|
|
87
83
|
output.write(`Clean scaffold files to remove: ${cleanFiles.join(", ") || "none"}\n`);
|
|
84
|
+
output.write(`Scaffold decisions retained for re-add: ${retainedFiles.join(", ") || "none"}\n`);
|
|
88
85
|
output.write(`Drifted scaffold files left in place: ${driftedFiles.join(", ") || "none"}\n`);
|
|
89
86
|
for (const relativePath of driftedFiles) output.write(`WARN ${relativePath} is drifted and will be left in place.\n`);
|
|
90
87
|
for (const line of notice) output.write(`${line}\n`);
|
|
91
|
-
if (!apply) return { dryRun: true, moduleKey, cleanFiles, driftedFiles, notice };
|
|
88
|
+
if (!apply) return { dryRun: true, moduleKey, cleanFiles, driftedFiles, retainedFiles, notice };
|
|
92
89
|
|
|
93
90
|
await fs.writeFile(packagePath, `${JSON.stringify(nextPackageJson, null, 2)}\n`, "utf8");
|
|
94
91
|
for (const relativePath of cleanFiles) await fs.rm(resolveSafeRelativePath(targetDir, relativePath, "Manifest scaffold file path"));
|
|
@@ -101,7 +98,11 @@ export async function removeBrightwebModule(moduleKey, argvOptions = {}, runtime
|
|
|
101
98
|
if (appManifest.modules.orgs) {
|
|
102
99
|
appManifest.modules.orgs.exposed = remainingModules.some((key) => ["crm", "marketing", "projects"].includes(key));
|
|
103
100
|
}
|
|
104
|
-
|
|
101
|
+
appManifest.scaffoldFiles = preserveScaffoldDecisions(
|
|
102
|
+
Object.fromEntries(Object.entries(appManifest.scaffoldFiles).filter(([, record]) => record.module !== moduleKey)),
|
|
103
|
+
appManifest.scaffoldFiles,
|
|
104
|
+
protectedPaths,
|
|
105
|
+
);
|
|
105
106
|
for (const relativePath of Object.keys(managedWrites)) {
|
|
106
107
|
const record = appManifest.scaffoldFiles[relativePath];
|
|
107
108
|
if (!record) continue;
|
|
@@ -112,7 +113,7 @@ export async function removeBrightwebModule(moduleKey, argvOptions = {}, runtime
|
|
|
112
113
|
}
|
|
113
114
|
await writeAppManifest(targetDir, appManifest);
|
|
114
115
|
output.write(`Removed ${moduleKey} package wiring and ${cleanFiles.length} clean scaffold file${cleanFiles.length === 1 ? "" : "s"}. Install dependencies next.\n`);
|
|
115
|
-
return { dryRun: false, moduleKey, cleanFiles, driftedFiles, notice };
|
|
116
|
+
return { dryRun: false, moduleKey, cleanFiles, driftedFiles, retainedFiles, notice };
|
|
116
117
|
}
|
|
117
118
|
|
|
118
119
|
export { HELP as REMOVE_HELP };
|
package/src/scaffold-cmd.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import { stdout as output } from "node:process";
|
|
3
4
|
import { findWorkspaceRoot, hashFile, readAppManifest, writeAppManifest } from "./app-manifest.mjs";
|
|
4
5
|
import { pathExists } from "./generator.mjs";
|
|
5
|
-
import {
|
|
6
|
+
import { canonicalScaffoldHash, scaffoldDrift } from "./scaffold.mjs";
|
|
6
7
|
import { normalizeSafeRelativePath, resolveSafeRelativePath } from "./safe-path.mjs";
|
|
7
8
|
|
|
8
9
|
const HELP = `Usage: bw scaffold <action> [paths...] [options]\n\nActions:\n list List tracked files, live status, and intent\n own <path>... Mark existing tracked files as app-owned\n skip <path>... Mark missing tracked files as intentionally absent\n manage <path>... Return tracked files to BrightWeb management\n\nOptions:\n --target-dir <path> App directory (defaults to cwd)\n --workspace-root <path> BrightWeb workspace root\n --help Show this help`;
|
|
@@ -14,7 +15,7 @@ export async function scaffoldBrightwebApp(action, paths = [], argvOptions = {},
|
|
|
14
15
|
if (action !== "list" && paths.length === 0) throw new Error(`bw scaffold ${action} requires at least one tracked <path>.`);
|
|
15
16
|
if (action === "list" && paths.length > 0) throw new Error("bw scaffold list does not accept file paths.");
|
|
16
17
|
|
|
17
|
-
const targetDir = path.resolve(runtimeOptions.targetDir || argvOptions.targetDir || process.cwd());
|
|
18
|
+
const targetDir = await fs.realpath(path.resolve(runtimeOptions.targetDir || argvOptions.targetDir || process.cwd()));
|
|
18
19
|
const manifest = await readAppManifest(targetDir);
|
|
19
20
|
const live = await scaffoldDrift(targetDir, manifest.scaffoldFiles);
|
|
20
21
|
if (action === "list") {
|
|
@@ -43,15 +44,14 @@ export async function scaffoldBrightwebApp(action, paths = [], argvOptions = {},
|
|
|
43
44
|
const appPath = resolveSafeRelativePath(targetDir, relativePath, "Manifest scaffold file path");
|
|
44
45
|
const exists = await pathExists(appPath);
|
|
45
46
|
if (action === "manage") {
|
|
46
|
-
const
|
|
47
|
-
if (!
|
|
48
|
-
const templateHash = await hashFile(located.templatePath);
|
|
47
|
+
const templateHash = await canonicalScaffoldHash({ relativePath, manifest, targetDir, workspaceRoot });
|
|
48
|
+
if (!templateHash) throw new Error(`Installed-package template unavailable for ${relativePath}; cannot manage it safely.`);
|
|
49
49
|
if (exists) {
|
|
50
|
-
record.
|
|
51
|
-
record.status = record.hash === templateHash ? "current" : "drifted";
|
|
50
|
+
record.status = await hashFile(appPath) === templateHash ? "current" : "drifted";
|
|
52
51
|
} else {
|
|
53
52
|
record.status = "missing";
|
|
54
53
|
}
|
|
54
|
+
record.hash = templateHash;
|
|
55
55
|
} else {
|
|
56
56
|
record.status = liveByPath.get(relativePath).status;
|
|
57
57
|
}
|
package/src/scaffold.mjs
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { isAppOwnedSeed } from "./file-policy.mjs";
|
|
2
|
+
import { assertMutationTargets } from "./mutation-paths.mjs";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
1
4
|
import fs from "node:fs/promises";
|
|
2
5
|
import path from "node:path";
|
|
3
6
|
import { fileURLToPath } from "node:url";
|
|
@@ -34,7 +37,22 @@ export function trackedScaffoldDefinitions(moduleKeys = []) {
|
|
|
34
37
|
return definitions.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
|
|
35
38
|
}
|
|
36
39
|
|
|
40
|
+
/** Missing ownership history is not permission to replace an existing scaffold. */
|
|
41
|
+
export async function assertNoUntrackedScaffoldWrites({ targetDir, scaffoldFiles, moduleKeys, relativePaths }) {
|
|
42
|
+
const tracked = new Set(trackedScaffoldDefinitions(moduleKeys).map((entry) => entry.relativePath));
|
|
43
|
+
for (const relativePath of relativePaths) {
|
|
44
|
+
if (!tracked.has(relativePath) || scaffoldFiles[relativePath]) continue;
|
|
45
|
+
const targetPath = resolveSafeRelativePath(targetDir, relativePath, "Scaffold output path");
|
|
46
|
+
const exists = await fs.lstat(targetPath).then(() => true, (error) => {
|
|
47
|
+
if (error.code === "ENOENT") return false;
|
|
48
|
+
throw error;
|
|
49
|
+
});
|
|
50
|
+
if (exists) throw new Error(`Untracked app file conflicts with scaffold output: ${relativePath}. Move or reconcile it explicitly before continuing.`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
37
54
|
export async function inventoryScaffoldFiles({ targetDir, moduleKeys, templateRoot }) {
|
|
55
|
+
await assertMutationTargets(targetDir, trackedScaffoldDefinitions(moduleKeys).map((entry) => entry.relativePath));
|
|
38
56
|
const records = {};
|
|
39
57
|
const unsupported = [];
|
|
40
58
|
for (const definition of trackedScaffoldDefinitions(moduleKeys)) {
|
|
@@ -47,6 +65,7 @@ export async function inventoryScaffoldFiles({ targetDir, moduleKeys, templateRo
|
|
|
47
65
|
const templateHash = await hashFile(templatePath);
|
|
48
66
|
const exists = await pathExists(appPath);
|
|
49
67
|
records[definition.relativePath] = {
|
|
68
|
+
...(isAppOwnedSeed(definition.relativePath) ? { intent: "owned" } : {}),
|
|
50
69
|
module: definition.moduleKey,
|
|
51
70
|
hash: templateHash,
|
|
52
71
|
status: !exists ? "missing" : await hashFile(appPath) === templateHash ? "current" : "drifted",
|
|
@@ -55,14 +74,26 @@ export async function inventoryScaffoldFiles({ targetDir, moduleKeys, templateRo
|
|
|
55
74
|
return { records, unsupported };
|
|
56
75
|
}
|
|
57
76
|
|
|
77
|
+
/** Ownership belongs to an exact app path, independently of installed modules. */
|
|
78
|
+
export function preserveScaffoldDecisions(records, previousRecords = {}, protectedPaths = new Set()) {
|
|
79
|
+
const reconciled = { ...records };
|
|
80
|
+
for (const [relativePath, record] of Object.entries(previousRecords)) {
|
|
81
|
+
if (["owned", "skipped"].includes(record.intent) || protectedPaths.has(relativePath)) {
|
|
82
|
+
reconciled[relativePath] = { ...record };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return reconciled;
|
|
86
|
+
}
|
|
87
|
+
|
|
58
88
|
export async function scaffoldDrift(targetDir, scaffoldFiles = {}) {
|
|
89
|
+
await assertMutationTargets(targetDir, Object.keys(scaffoldFiles));
|
|
59
90
|
const current = [];
|
|
60
91
|
const drifted = [];
|
|
61
92
|
const missing = [];
|
|
62
93
|
const entries = [];
|
|
63
94
|
for (const [relativePath, record] of Object.entries(scaffoldFiles)) {
|
|
64
95
|
const appPath = resolveSafeRelativePath(targetDir, relativePath, "Manifest scaffold file path");
|
|
65
|
-
const intent = record.intent || "managed";
|
|
96
|
+
const intent = record.intent === "skipped" ? "skipped" : isAppOwnedSeed(relativePath) ? "owned" : record.intent || "managed";
|
|
66
97
|
let status = "missing";
|
|
67
98
|
if (await pathExists(appPath)) {
|
|
68
99
|
const matchesRecordedHash = await hashFile(appPath) === record.hash;
|
|
@@ -73,7 +104,9 @@ export async function scaffoldDrift(targetDir, scaffoldFiles = {}) {
|
|
|
73
104
|
else if (status === "current") current.push(relativePath);
|
|
74
105
|
else drifted.push(relativePath);
|
|
75
106
|
}
|
|
76
|
-
|
|
107
|
+
const intentional = entries.filter((entry) => entry.intent !== "managed").map((entry) => entry.relativePath);
|
|
108
|
+
const protectedPaths = new Set([...drifted, ...intentional]);
|
|
109
|
+
return { current, drifted, missing, entries, intentional, protectedPaths };
|
|
77
110
|
}
|
|
78
111
|
|
|
79
112
|
export async function findTrackedTemplate({ relativePath, manifest, targetDir, workspaceRoot }) {
|
|
@@ -89,3 +122,11 @@ export async function findTrackedTemplate({ relativePath, manifest, targetDir, w
|
|
|
89
122
|
export async function readTextFile(filePath) {
|
|
90
123
|
return fs.readFile(filePath, "utf8");
|
|
91
124
|
}
|
|
125
|
+
|
|
126
|
+
export async function canonicalScaffoldHash({ relativePath, manifest, targetDir, workspaceRoot }) {
|
|
127
|
+
const { createOptionalModuleRouteFiles } = await import("./generator.mjs");
|
|
128
|
+
const generated = createOptionalModuleRouteFiles(Object.keys(manifest.modules || {}))[relativePath];
|
|
129
|
+
if (generated != null) return `sha256:${createHash("sha256").update(generated).digest("hex")}`;
|
|
130
|
+
const located = await findTrackedTemplate({ relativePath, manifest, targetDir, workspaceRoot });
|
|
131
|
+
return located.templatePath ? hashFile(located.templatePath) : null;
|
|
132
|
+
}
|
package/src/setup.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
|
+
import { assertMutationTargets } from "./mutation-paths.mjs";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { createRequire } from "node:module";
|
|
4
5
|
import { readAppManifest, readConfiguredModuleFlags } from "./app-manifest.mjs";
|
|
@@ -26,23 +27,12 @@ export default function Page() {
|
|
|
26
27
|
}
|
|
27
28
|
`;
|
|
28
29
|
|
|
29
|
-
// Generated paths must remain real directories/files inside the app, even when
|
|
30
|
-
// an intermediate directory or an existing target is a symlink.
|
|
31
30
|
async function inspectTarget(root, relativePath) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
try { stat = await fs.lstat(current); } catch (error) {
|
|
38
|
-
if (error.code === "ENOENT") return null;
|
|
39
|
-
throw error;
|
|
40
|
-
}
|
|
41
|
-
if (stat.isSymbolicLink()) throw new Error(`Setup does not follow symlinks: ${relativePath}`);
|
|
42
|
-
if (index < parts.length - 1 && !stat.isDirectory()) throw new Error(`Expected a directory at ${current}`);
|
|
43
|
-
if (index === parts.length - 1 && !stat.isFile()) throw new Error(`Expected a file at ${relativePath}`);
|
|
44
|
-
}
|
|
45
|
-
return fs.readFile(current, "utf8");
|
|
31
|
+
await assertMutationTargets(root, [relativePath]);
|
|
32
|
+
return fs.readFile(path.join(root, relativePath), "utf8").catch((error) => {
|
|
33
|
+
if (error.code === "ENOENT") return null;
|
|
34
|
+
throw error;
|
|
35
|
+
});
|
|
46
36
|
}
|
|
47
37
|
|
|
48
38
|
function navigationSource(source) {
|
package/src/update.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { assertMutationTargets } from "./mutation-paths.mjs";
|
|
2
|
+
import { MANAGED_PLATFORM_FILES, MANAGED_SITE_FILES, MODULE_SELECTED_FILES, isAppOwnedSeed } from "./file-policy.mjs";
|
|
1
3
|
import fs from "node:fs/promises";
|
|
2
4
|
import path from "node:path";
|
|
3
5
|
import { stdout as output } from "node:process";
|
|
@@ -13,13 +15,8 @@ import {
|
|
|
13
15
|
TEMPLATE_ROOT,
|
|
14
16
|
createAppContextFile,
|
|
15
17
|
createDbInstallPlan,
|
|
16
|
-
|
|
17
|
-
createNextConfig,
|
|
18
|
-
createOptionalModuleRouteFiles,
|
|
18
|
+
createManagedPlatformFiles,
|
|
19
19
|
createPackageJson,
|
|
20
|
-
createPlatformGlobalsCss,
|
|
21
|
-
createPlatformModulesConfigFile,
|
|
22
|
-
createShellConfig,
|
|
23
20
|
detectPackageManager,
|
|
24
21
|
getDbModuleRegistry,
|
|
25
22
|
getVersionMap,
|
|
@@ -27,33 +24,10 @@ import {
|
|
|
27
24
|
readJsonIfPresent,
|
|
28
25
|
runInstall,
|
|
29
26
|
} from "./generator.mjs";
|
|
30
|
-
import {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
path.join("app", "globals.css"),
|
|
35
|
-
path.join("config", "module-toolbar-controls.tsx"),
|
|
36
|
-
path.join("config", "modules.ts"),
|
|
37
|
-
path.join("config", "shell.ts"),
|
|
38
|
-
path.join("app", "api", "invitations", "_dependencies.ts"),
|
|
39
|
-
path.join("app", "api", "organizations", "route.ts"),
|
|
40
|
-
path.join("app", "api", "organizations", "[id]", "route.ts"),
|
|
41
|
-
path.join("app", "api", "organizations", "[id]", "invitations", "route.ts"),
|
|
42
|
-
path.join("app", "api", "organizations", "[id]", "invitations", "[invitationId]", "route.ts"),
|
|
43
|
-
path.join("docs", "ai", "app-context.json"),
|
|
44
|
-
];
|
|
45
|
-
|
|
46
|
-
const MANAGED_SITE_FILES = [
|
|
47
|
-
path.join("docs", "ai", "app-context.json"),
|
|
48
|
-
];
|
|
49
|
-
|
|
50
|
-
const MODULE_SELECTED_PLATFORM_FILES = new Set([
|
|
51
|
-
path.join("app", "api", "invitations", "_dependencies.ts"),
|
|
52
|
-
path.join("app", "api", "organizations", "route.ts"),
|
|
53
|
-
path.join("app", "api", "organizations", "[id]", "route.ts"),
|
|
54
|
-
path.join("app", "api", "organizations", "[id]", "invitations", "route.ts"),
|
|
55
|
-
path.join("app", "api", "organizations", "[id]", "invitations", "[invitationId]", "route.ts"),
|
|
56
|
-
]);
|
|
27
|
+
import { assertNoUntrackedScaffoldWrites, scaffoldDrift } from "./scaffold.mjs";
|
|
28
|
+
import { hashFile, writeAppManifest, readAppManifest } from "./app-manifest.mjs";
|
|
29
|
+
|
|
30
|
+
const MODULE_SELECTED_PLATFORM_FILES = new Set(MODULE_SELECTED_FILES);
|
|
57
31
|
|
|
58
32
|
const REFRESHABLE_PLATFORM_STARTER_FILES = new Set([
|
|
59
33
|
path.join("app", "api", "notifications", "route.ts"),
|
|
@@ -459,8 +433,9 @@ function renderPlanSummary(plan, options = {}) {
|
|
|
459
433
|
}
|
|
460
434
|
|
|
461
435
|
export async function buildBrightwebAppUpdatePlan(argvOptions = {}, runtimeOptions = {}) {
|
|
462
|
-
const targetDir = resolveUpdateTargetDirectory(runtimeOptions, argvOptions);
|
|
463
|
-
await readAppManifest(targetDir, { required: false });
|
|
436
|
+
const targetDir = await fs.realpath(resolveUpdateTargetDirectory(runtimeOptions, argvOptions));
|
|
437
|
+
const appManifest = await readAppManifest(targetDir, { required: false });
|
|
438
|
+
await assertMutationTargets(targetDir, ["package.json", ...Object.keys(appManifest?.scaffoldFiles || {})]);
|
|
464
439
|
const packageJsonPath = path.join(targetDir, "package.json");
|
|
465
440
|
const manifest = await readJsonIfPresent(packageJsonPath);
|
|
466
441
|
|
|
@@ -527,20 +502,7 @@ export async function buildBrightwebAppUpdatePlan(argvOptions = {}, runtimeOptio
|
|
|
527
502
|
}
|
|
528
503
|
|
|
529
504
|
if (template === "platform") {
|
|
530
|
-
const canonicalConfigFiles = {
|
|
531
|
-
"next.config.ts": createNextConfig({ template: "platform", selectedModules: installedModules }),
|
|
532
|
-
[path.join("app", "globals.css")]: await createPlatformGlobalsCss(installedModules),
|
|
533
|
-
[path.join("config", "module-toolbar-controls.tsx")]: createModuleToolbarControlsConfig(installedModules),
|
|
534
|
-
[path.join("config", "modules.ts")]: createPlatformModulesConfigFile(installedModules),
|
|
535
|
-
[path.join("config", "shell.ts")]: createShellConfig(installedModules),
|
|
536
|
-
...createOptionalModuleRouteFiles(installedModules),
|
|
537
|
-
[path.join("docs", "ai", "app-context.json")]: createAppContextFile({
|
|
538
|
-
slug: manifest.name || path.basename(targetDir),
|
|
539
|
-
template: "platform",
|
|
540
|
-
selectedModules: installedModules,
|
|
541
|
-
dbInstallPlan,
|
|
542
|
-
}),
|
|
543
|
-
};
|
|
505
|
+
const canonicalConfigFiles = await createManagedPlatformFiles({ slug: manifest.name || path.basename(targetDir), selectedModules: installedModules, dbInstallPlan });
|
|
544
506
|
|
|
545
507
|
for (const relativePath of MANAGED_PLATFORM_FILES) {
|
|
546
508
|
const targetPath = path.join(targetDir, relativePath);
|
|
@@ -588,7 +550,8 @@ export async function buildBrightwebAppUpdatePlan(argvOptions = {}, runtimeOptio
|
|
|
588
550
|
|
|
589
551
|
if (argvOptions.refreshStarters) {
|
|
590
552
|
for (const entry of starterFiles.filter((candidate) =>
|
|
591
|
-
|
|
553
|
+
!MANAGED_PLATFORM_FILES.includes(candidate.relativePath)
|
|
554
|
+
&& candidate.status !== "current" && (candidate.status === "missing" || candidate.refreshable !== false))) {
|
|
592
555
|
fileWrites.push({
|
|
593
556
|
moduleKey: entry.moduleKey,
|
|
594
557
|
relativePath: entry.relativePath,
|
|
@@ -599,6 +562,22 @@ export async function buildBrightwebAppUpdatePlan(argvOptions = {}, runtimeOptio
|
|
|
599
562
|
}
|
|
600
563
|
}
|
|
601
564
|
|
|
565
|
+
// Legacy update may explicitly refresh drift, but never overrides ownership intent.
|
|
566
|
+
const liveScaffold = await scaffoldDrift(targetDir, appManifest?.scaffoldFiles);
|
|
567
|
+
const intentionalPaths = new Set(liveScaffold.intentional);
|
|
568
|
+
for (let index = fileWrites.length - 1; index >= 0; index -= 1) {
|
|
569
|
+
if (intentionalPaths.has(fileWrites[index].relativePath) || isAppOwnedSeed(fileWrites[index].relativePath)) fileWrites.splice(index, 1);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (appManifest) await assertNoUntrackedScaffoldWrites({
|
|
573
|
+
targetDir,
|
|
574
|
+
scaffoldFiles: appManifest.scaffoldFiles,
|
|
575
|
+
moduleKeys: installedModules,
|
|
576
|
+
relativePaths: fileWrites.map((entry) => entry.relativePath),
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
await assertMutationTargets(targetDir, [".brightweb/app-manifest.json", ...fileWrites.map((write) => write.relativePath)]);
|
|
580
|
+
|
|
602
581
|
const modulesConfigMismatch = template === "platform"
|
|
603
582
|
? await detectModulesConfigMismatch(targetDir, installedModules)
|
|
604
583
|
: null;
|
|
@@ -613,6 +592,7 @@ export async function buildBrightwebAppUpdatePlan(argvOptions = {}, runtimeOptio
|
|
|
613
592
|
installedModules,
|
|
614
593
|
installedBrightwebPackages: Array.from(installedBrightwebPackagesMap.keys()).sort(),
|
|
615
594
|
targetVersions: canonicalVersions,
|
|
595
|
+
appManifest,
|
|
616
596
|
packageUpdates: packageJsonUpdate.packageUpdates,
|
|
617
597
|
configFilesToWrite: fileWrites.filter((entry) => entry.type === "config").map((entry) => entry.relativePath),
|
|
618
598
|
starterFilesMissing: starterFilesMissing.map((entry) => entry.relativePath),
|
|
@@ -648,6 +628,18 @@ export async function updateBrightwebApp(argvOptions = {}, runtimeOptions = {})
|
|
|
648
628
|
await fs.writeFile(fileWrite.targetPath, fileWrite.content, "utf8");
|
|
649
629
|
}
|
|
650
630
|
|
|
631
|
+
if (plan.appManifest) {
|
|
632
|
+
let scaffoldChanged = false;
|
|
633
|
+
for (const write of plan.fileWrites) {
|
|
634
|
+
const record = plan.appManifest.scaffoldFiles[write.relativePath];
|
|
635
|
+
if (!record) continue;
|
|
636
|
+
record.hash = await hashFile(write.targetPath);
|
|
637
|
+
record.status = "current";
|
|
638
|
+
scaffoldChanged = true;
|
|
639
|
+
}
|
|
640
|
+
if (scaffoldChanged) await writeAppManifest(plan.targetDir, plan.appManifest);
|
|
641
|
+
}
|
|
642
|
+
|
|
651
643
|
const packageJsonChanged = plan.fileWrites.some((entry) => entry.relativePath === "package.json");
|
|
652
644
|
if (argvOptions.install && packageJsonChanged) {
|
|
653
645
|
const installRunner = runtimeOptions.installRunner || runInstall;
|
package/src/upgrade.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertMutationTargets } from "./mutation-paths.mjs";
|
|
1
2
|
import fs from "node:fs/promises";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { stdout as output } from "node:process";
|
|
@@ -6,6 +7,7 @@ import { pathExists, runInstall } from "./generator.mjs";
|
|
|
6
7
|
import { applyMigrationWrites, getModuleMigrations, planMigrationAppends } from "./migrations.mjs";
|
|
7
8
|
import { buildBrightwebAppUpdatePlan } from "./update.mjs";
|
|
8
9
|
import { resolveSafeRelativePath } from "./safe-path.mjs";
|
|
10
|
+
import { scaffoldDrift } from "./scaffold.mjs";
|
|
9
11
|
import { RETIRED_MODULE_STARTER_FILES } from "./constants.mjs";
|
|
10
12
|
|
|
11
13
|
const HELP = `Usage: bw upgrade [moduleKey] [options]\n\nOptions:\n --target-dir <path> App directory (defaults to cwd)\n --workspace-root <path> BrightWeb workspace root\n --through-migration <file> Advance the named module only through this migration\n --include-destructive-migrations Explicitly include held destructive migrations\n --allow-stale-fallback Use baked-in versions if npm lookup fails\n --install Install changed dependencies\n --refresh-starters Refresh unchanged starter files\n --dry-run Print the upgrade plan without writing\n --help Show this help`;
|
|
@@ -25,36 +27,30 @@ export async function upgradeBrightwebApp(moduleKey, argvOptions = {}, runtimeOp
|
|
|
25
27
|
if (includeDestructiveMigrations && !moduleKey) {
|
|
26
28
|
throw new Error("--include-destructive-migrations requires an explicit module key so destructive scope cannot expand implicitly.");
|
|
27
29
|
}
|
|
28
|
-
const targetDir = path.resolve(runtimeOptions.targetDir || argvOptions.targetDir || process.cwd());
|
|
30
|
+
const targetDir = await fs.realpath(path.resolve(runtimeOptions.targetDir || argvOptions.targetDir || process.cwd()));
|
|
29
31
|
const appManifest = await readAppManifest(targetDir);
|
|
30
32
|
if (moduleKey && !appManifest.modules[moduleKey]) throw new Error(`Module ${moduleKey} is not installed according to ${path.join(".brightweb", "app-manifest.json")}.`);
|
|
31
33
|
const workspaceRoot = runtimeOptions.workspaceRoot || argvOptions.workspaceRoot || await findWorkspaceRoot(targetDir);
|
|
32
34
|
const updateOptions = { ...argvOptions, targetDir, ...(workspaceRoot ? { workspaceRoot } : {}) };
|
|
33
35
|
const plan = await buildBrightwebAppUpdatePlan(updateOptions, runtimeOptions);
|
|
34
|
-
const
|
|
35
|
-
const missing =
|
|
36
|
-
const intentional = [];
|
|
37
|
-
for (const [relativePath, record] of Object.entries(appManifest.scaffoldFiles)) {
|
|
38
|
-
if (["owned", "skipped"].includes(record.intent)) intentional.push(relativePath);
|
|
39
|
-
const filePath = resolveSafeRelativePath(targetDir, relativePath, "Manifest scaffold file path");
|
|
40
|
-
if (!(await pathExists(filePath))) { missing.push(relativePath); continue; }
|
|
41
|
-
if (await hashFile(filePath) !== record.hash) drifted.push(relativePath);
|
|
42
|
-
}
|
|
43
|
-
const protectedPaths = new Set([...drifted, ...intentional]);
|
|
36
|
+
const live = await scaffoldDrift(targetDir, appManifest.scaffoldFiles);
|
|
37
|
+
const { drifted, missing, intentional, protectedPaths } = live;
|
|
44
38
|
const obsoleteScaffoldFiles = [];
|
|
45
39
|
if (argvOptions.refreshStarters) {
|
|
40
|
+
const currentPaths = new Set(live.current);
|
|
46
41
|
for (const moduleKey of Object.keys(appManifest.modules)) {
|
|
47
42
|
for (const relativePath of RETIRED_MODULE_STARTER_FILES[moduleKey] ?? []) {
|
|
48
43
|
const record = appManifest.scaffoldFiles[relativePath];
|
|
49
44
|
if (!record || protectedPaths.has(relativePath)) continue;
|
|
50
45
|
const targetPath = resolveSafeRelativePath(targetDir, relativePath, "Obsolete scaffold file path");
|
|
51
|
-
if (!(
|
|
46
|
+
if (!currentPaths.has(relativePath)) continue;
|
|
52
47
|
obsoleteScaffoldFiles.push({ relativePath, targetPath });
|
|
53
48
|
}
|
|
54
49
|
}
|
|
55
50
|
}
|
|
56
51
|
plan.fileDeletes = obsoleteScaffoldFiles;
|
|
57
|
-
plan.fileWrites = plan.fileWrites.filter((entry) =>
|
|
52
|
+
plan.fileWrites = plan.fileWrites.filter((entry) => !protectedPaths.has(entry.relativePath));
|
|
53
|
+
plan.configFilesToWrite = plan.fileWrites.filter((entry) => entry.type === "config").map((entry) => entry.relativePath);
|
|
58
54
|
plan.starterFilesToRefresh = plan.fileWrites.filter((entry) => entry.type === "starter").map((entry) => entry.relativePath);
|
|
59
55
|
plan.starterFilesDrifted = Array.from(new Set([...plan.starterFilesDrifted, ...drifted]));
|
|
60
56
|
plan.starterFilesMissing = Array.from(new Set([...plan.starterFilesMissing, ...missing]));
|
|
@@ -81,7 +77,8 @@ export async function upgradeBrightwebApp(moduleKey, argvOptions = {}, runtimeOp
|
|
|
81
77
|
const safetyBoundaries = [];
|
|
82
78
|
for (const key of moduleKeys) {
|
|
83
79
|
const migrations = await getModuleMigrations(key, catalog[key]);
|
|
84
|
-
const
|
|
80
|
+
const cursorIndex = migrations.findIndex((entry) => entry.fileName === appManifest.migrationCursor?.[key]);
|
|
81
|
+
const destructiveIndex = migrations.findIndex((entry, index) => index > cursorIndex && entry.destructive);
|
|
85
82
|
if (throughMigration && key === moduleKey) {
|
|
86
83
|
const cutoffIndex = migrations.findIndex((entry) => entry.fileName === throughMigration);
|
|
87
84
|
if (cutoffIndex >= destructiveIndex && destructiveIndex >= 0 && !includeDestructiveMigrations) {
|
|
@@ -91,8 +88,6 @@ export async function upgradeBrightwebApp(moduleKey, argvOptions = {}, runtimeOp
|
|
|
91
88
|
continue;
|
|
92
89
|
}
|
|
93
90
|
if (includeDestructiveMigrations || destructiveIndex < 0) continue;
|
|
94
|
-
const cursorIndex = migrations.findIndex((entry) => entry.fileName === appManifest.migrationCursor?.[key]);
|
|
95
|
-
if (cursorIndex >= destructiveIndex) continue;
|
|
96
91
|
if (destructiveIndex === 0) {
|
|
97
92
|
throw new Error(`Migration upgrade blocked: ${key} begins with destructive migration ${migrations[0].fileName}; use --include-destructive-migrations after review.`);
|
|
98
93
|
}
|
|
@@ -107,6 +102,7 @@ export async function upgradeBrightwebApp(moduleKey, argvOptions = {}, runtimeOp
|
|
|
107
102
|
migrationCursor: appManifest.migrationCursor,
|
|
108
103
|
migrationUpperBounds,
|
|
109
104
|
});
|
|
105
|
+
await assertMutationTargets(targetDir, [".brightweb/app-manifest.json", ...plan.fileWrites.map((write) => write.relativePath), ...plan.fileDeletes.map((entry) => entry.relativePath), ...migrationPlan.writes.map((write) => path.relative(targetDir, write.targetPath))]);
|
|
110
106
|
output.write(`bw upgrade\nPackages to update: ${plan.packageUpdates.length}\nManaged files to write: ${plan.fileWrites.length}\nObsolete managed files to remove: ${plan.fileDeletes.length}\nMigrations to append: ${migrationPlan.appends.length}\n`);
|
|
111
107
|
if (throughMigration) output.write(`Migration cutoff: ${moduleKey} through ${throughMigration}\n`);
|
|
112
108
|
for (const boundary of safetyBoundaries) {
|
|
@@ -157,7 +153,7 @@ export async function upgradeBrightwebApp(moduleKey, argvOptions = {}, runtimeOp
|
|
|
157
153
|
const existingRecord = appManifest.scaffoldFiles[relativePath];
|
|
158
154
|
if (!existingRecord && write.type !== "starter") continue;
|
|
159
155
|
const targetPath = resolveSafeRelativePath(targetDir, relativePath, "Manifest scaffold file path");
|
|
160
|
-
if (
|
|
156
|
+
if (protectedPaths.has(relativePath)) continue;
|
|
161
157
|
if (!(await pathExists(targetPath))) continue;
|
|
162
158
|
const hash = await hashFile(targetPath);
|
|
163
159
|
if (existingRecord) {
|