create-bw-app 0.27.0 → 0.27.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -1
- 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 -9
- 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/base/app/globals.css +2 -0
- package/template/site/base/app/globals.css +2 -0
- package/template/supabase/modules/admin/migrations/20260906101616_atomic_admin_invitation_acceptance.sql +50 -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/README.md
CHANGED
|
@@ -62,7 +62,7 @@ Current updater behavior:
|
|
|
62
62
|
- fails the update if npm resolution fails unless you pass `--allow-stale-fallback`
|
|
63
63
|
- re-syncs managed BrightWeb config files such as `next.config.ts`, `config/modules.ts`, and `config/shell.ts`
|
|
64
64
|
- preserves app-owned shell customizations in the scaffolded `config/shell.overrides.ts`
|
|
65
|
-
- reports missing or drifted scaffold mounts and only rewrites them with `--refresh-starters`
|
|
65
|
+
- reports missing or drifted scaffold mounts and only rewrites them with `--refresh-starters`; tracked `owned` and `skipped` files remain protected, including managed config routes
|
|
66
66
|
- prints the follow-up install command unless `--install` is passed
|
|
67
67
|
- preserves unrelated third-party dependencies and app-owned product pages
|
|
68
68
|
|
|
@@ -139,3 +139,11 @@ on generated paths. Existing JSON and page files are never replaced, even when
|
|
|
139
139
|
empty or customized; reruns preserve them. These files are not managed templates
|
|
140
140
|
or refreshable starters, and `bw update --refresh-starters` preserves them and
|
|
141
141
|
shell overrides. New marketing apps do not receive this feature until opted in.
|
|
142
|
+
|
|
143
|
+
## File and compatibility safety
|
|
144
|
+
|
|
145
|
+
Lifecycle commands resolve the selected app root and reject symlinks or invalid file types beneath it before writing planned outputs. Root aliases are supported; concurrent filesystem edits are outside this preflight guarantee. App-owned brand, theme, shell override and social content seeds are preserved. The generated app context lists exact generated/scaffold paths, defaults unknown paths to app ownership, and shares its generated inventory with the CLI.
|
|
146
|
+
|
|
147
|
+
Upgrade refuses unknown migration cursors and uses standard semantic-version ranges. Module removal refuses surviving literal imports of the removed package before changing files; reconcile the app-owned dependents explicitly. This conservative scan is not a complete bundler analysis.
|
|
148
|
+
|
|
149
|
+
Forced re-adoption and module removal preserve exact-path `owned` and `skipped` decisions. Re-adding a module respects retained decisions; use `bw scaffold manage` after re-adding to reset a path deliberately. Every pending destructive migration requires opt-in, even after a previous destructive step was approved. Managed platform outputs share one renderer across scaffolding, add, remove, and update, with enforced ownership-inventory parity.
|
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.2",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"create-bw-app": "bin/create-bw-app.mjs",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"@inquirer/prompts": "^7.10.1",
|
|
29
|
-
"@supabase/supabase-js": "^2.110.8"
|
|
29
|
+
"@supabase/supabase-js": "^2.110.8",
|
|
30
|
+
"semver": "^7.8.5"
|
|
30
31
|
}
|
|
31
32
|
}
|
package/src/add.mjs
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
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";
|
|
4
5
|
import { SELECTABLE_MODULES } from "./constants.mjs";
|
|
5
|
-
import { TEMPLATE_ROOT,
|
|
6
|
+
import { TEMPLATE_ROOT, createDbInstallPlan, createManagedPlatformFiles, getDbModuleRegistry, getVersionMap, pathExists, readJsonIfPresent } from "./generator.mjs";
|
|
6
7
|
import { collectScaffoldFiles, findWorkspaceRoot, loadModuleCatalog, MODULE_PACKAGES, readAppManifest, resolveModuleClosure, satisfiesVersion, writeAppManifest } from "./app-manifest.mjs";
|
|
8
|
+
import { assertNoUntrackedScaffoldWrites, scaffoldDrift } from "./scaffold.mjs";
|
|
7
9
|
import { applyMigrationWrites, planMigrationAppends } from "./migrations.mjs";
|
|
8
10
|
|
|
9
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`;
|
|
@@ -13,7 +15,7 @@ export async function addBrightwebModule(moduleKey, argvOptions = {}, runtimeOpt
|
|
|
13
15
|
output.write(`${HELP}\n`);
|
|
14
16
|
return { help: true };
|
|
15
17
|
}
|
|
16
|
-
const targetDir = path.resolve(runtimeOptions.targetDir || argvOptions.targetDir || process.cwd());
|
|
18
|
+
const targetDir = await fs.realpath(path.resolve(runtimeOptions.targetDir || argvOptions.targetDir || process.cwd()));
|
|
17
19
|
const appManifest = await readAppManifest(targetDir);
|
|
18
20
|
if (appManifest.app.template !== "platform") throw new Error("bw add is only available for platform apps.");
|
|
19
21
|
const packageJsonPath = path.join(targetDir, "package.json");
|
|
@@ -63,15 +65,51 @@ export async function addBrightwebModule(moduleKey, argvOptions = {}, runtimeOpt
|
|
|
63
65
|
const definition = SELECTABLE_MODULES.find((entry) => entry.key === key);
|
|
64
66
|
if (definition && await pathExists(path.join(TEMPLATE_ROOT, "modules", definition.templateFolder))) overlays.push({ key, source: path.join(TEMPLATE_ROOT, "modules", definition.templateFolder) });
|
|
65
67
|
}
|
|
66
|
-
const managedWrites = {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
68
|
+
const managedWrites = await createManagedPlatformFiles({ slug: appManifest.app.slug, selectedModules: installedModuleKeys, dbInstallPlan });
|
|
69
|
+
|
|
70
|
+
const live = await scaffoldDrift(targetDir, appManifest.scaffoldFiles);
|
|
71
|
+
const { protectedPaths } = live;
|
|
72
|
+
for (const relativePath of protectedPaths) delete managedWrites[relativePath];
|
|
73
|
+
|
|
74
|
+
if (newModules.length === 0) for (const relativePath of Object.keys(managedWrites)) delete managedWrites[relativePath];
|
|
75
|
+
|
|
76
|
+
await assertNoUntrackedScaffoldWrites({
|
|
77
|
+
targetDir,
|
|
78
|
+
scaffoldFiles: appManifest.scaffoldFiles,
|
|
79
|
+
moduleKeys: installedModuleKeys,
|
|
80
|
+
relativePaths: Object.keys(managedWrites),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const overlayFiles = [];
|
|
84
|
+
// Refuse unknown overlay collisions before package, configuration or migration writes.
|
|
85
|
+
for (const overlay of overlays) {
|
|
86
|
+
const pending = [overlay.source];
|
|
87
|
+
while (pending.length) {
|
|
88
|
+
const directory = pending.pop();
|
|
89
|
+
for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
|
|
90
|
+
const source = path.join(directory, entry.name);
|
|
91
|
+
const relativePath = path.relative(overlay.source, source);
|
|
92
|
+
const destination = path.join(targetDir, relativePath);
|
|
93
|
+
const existing = await fs.lstat(destination).catch((error) => {
|
|
94
|
+
if (error.code === "ENOENT") return null;
|
|
95
|
+
throw error;
|
|
96
|
+
});
|
|
97
|
+
if (entry.isDirectory()) {
|
|
98
|
+
if (existing && !existing.isDirectory()) {
|
|
99
|
+
throw new Error(`Cannot add ${moduleKey}: module scaffold requires a directory at ${relativePath}, but an app file or link exists. Reconcile it explicitly before adding the module.`);
|
|
100
|
+
}
|
|
101
|
+
pending.push(source);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
overlayFiles.push(relativePath);
|
|
105
|
+
if (!appManifest.scaffoldFiles[relativePath] && existing) {
|
|
106
|
+
throw new Error(`Cannot add ${moduleKey}: untracked app file conflicts with module scaffold: ${relativePath}. Move or reconcile it explicitly before adding the module.`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
await assertMutationTargets(targetDir, ["package.json", ".brightweb/app-manifest.json", ...Object.keys(managedWrites), ...overlayFiles, ...migrationPlan.writes.map((write) => path.relative(targetDir, write.targetPath))]);
|
|
75
113
|
|
|
76
114
|
const summary = [
|
|
77
115
|
"bw add",
|
|
@@ -83,8 +121,19 @@ export async function addBrightwebModule(moduleKey, argvOptions = {}, runtimeOpt
|
|
|
83
121
|
output.write(`${summary.join("\n")}\n`);
|
|
84
122
|
if (argvOptions.dryRun) return { dryRun: true, newModules, migrationPlan };
|
|
85
123
|
|
|
124
|
+
if (newModules.length === 0) return { dryRun: false, newModules, migrationPlan };
|
|
125
|
+
|
|
86
126
|
if (newModules.length > 0) await fs.writeFile(packageJsonPath, `${JSON.stringify(nextPackageJson, null, 2)}\n`, "utf8");
|
|
87
|
-
|
|
127
|
+
const copiedPaths = new Set();
|
|
128
|
+
for (const overlay of overlays) await fs.cp(overlay.source, targetDir, {
|
|
129
|
+
recursive: true,
|
|
130
|
+
filter: async (source, destination) => {
|
|
131
|
+
const relativePath = path.relative(targetDir, destination);
|
|
132
|
+
if (protectedPaths.has(relativePath)) return false;
|
|
133
|
+
if ((await fs.stat(source)).isFile()) copiedPaths.add(relativePath);
|
|
134
|
+
return true;
|
|
135
|
+
},
|
|
136
|
+
});
|
|
88
137
|
for (const [relativePath, content] of Object.entries(managedWrites)) {
|
|
89
138
|
const targetPath = path.join(targetDir, relativePath);
|
|
90
139
|
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
@@ -95,13 +144,13 @@ export async function addBrightwebModule(moduleKey, argvOptions = {}, runtimeOpt
|
|
|
95
144
|
for (const key of newModules) appManifest.modules[key] = { version: catalog[key].version, installedAt: now, exposed: true };
|
|
96
145
|
appManifest.migrationCursor = migrationPlan.nextCursor;
|
|
97
146
|
const collectedScaffoldFiles = await collectScaffoldFiles(targetDir, installedModuleKeys);
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
147
|
+
for (const [relativePath, record] of Object.entries(collectedScaffoldFiles)) {
|
|
148
|
+
// Existing baselines describe generated bytes, never arbitrary app edits.
|
|
149
|
+
// Refresh only tracked outputs actually rendered by this command.
|
|
150
|
+
if (Object.hasOwn(managedWrites, relativePath) || copiedPaths.has(relativePath)) {
|
|
151
|
+
appManifest.scaffoldFiles[relativePath] = { ...appManifest.scaffoldFiles[relativePath], ...record };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
105
154
|
await writeAppManifest(targetDir, appManifest);
|
|
106
155
|
output.write(`Installed ${newModules.length} module${newModules.length === 1 ? "" : "s"}. ${migrationPlan.writes.length > 0 ? "Run your Supabase migration apply command. " : ""}Run your package manager install command next.\n`);
|
|
107
156
|
return { dryRun: false, newModules, migrationPlan };
|
package/src/adopt.mjs
CHANGED
|
@@ -10,15 +10,16 @@ import {
|
|
|
10
10
|
findWorkspaceRoot,
|
|
11
11
|
loadModuleCatalog,
|
|
12
12
|
readConfiguredModuleFlags,
|
|
13
|
+
readAppManifest,
|
|
13
14
|
writeAppManifest,
|
|
14
15
|
} from "./app-manifest.mjs";
|
|
15
16
|
import { pathExists, readJsonIfPresent } from "./generator.mjs";
|
|
16
17
|
import { findAppMigrationsDirectory, getModuleMigrations } from "./migrations.mjs";
|
|
17
|
-
import { inventoryScaffoldFiles, resolveTemplateRoot } from "./scaffold.mjs";
|
|
18
|
+
import { inventoryScaffoldFiles, preserveScaffoldDecisions, resolveTemplateRoot, scaffoldDrift } from "./scaffold.mjs";
|
|
18
19
|
import { detectDependencyMode, detectTemplate } from "./update.mjs";
|
|
19
20
|
|
|
20
21
|
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
21
|
-
const HELP = `Usage: bw adopt [options]\n\nOptions:\n --target-dir <path> App directory (defaults to cwd)\n --workspace-root <path> BrightWeb workspace root\n --cursor <key>=<migrationFilename> Override a migration cursor (repeatable)\n --owned-surface <name> Record an app-owned surface (repeatable)\n --own <path> Mark an existing scaffold file app-owned (repeatable)\n --skip <path> Mark a missing scaffold file intentionally absent (repeatable)\n --allow-uncursored Allow doctor to warn instead of fail on null cursors\n --force
|
|
22
|
+
const HELP = `Usage: bw adopt [options]\n\nOptions:\n --target-dir <path> App directory (defaults to cwd)\n --workspace-root <path> BrightWeb workspace root\n --cursor <key>=<migrationFilename> Override a migration cursor (repeatable)\n --owned-surface <name> Record an app-owned surface (repeatable)\n --own <path> Mark an existing scaffold file app-owned (repeatable)\n --skip <path> Mark a missing scaffold file intentionally absent (repeatable)\n --allow-uncursored Allow doctor to warn instead of fail on null cursors\n --force Reconcile an existing manifest; preserve ownership decisions\n --dry-run Print the manifest and warnings without writing\n --help Show this help`;
|
|
22
23
|
|
|
23
24
|
function asList(value) {
|
|
24
25
|
if (value == null) return [];
|
|
@@ -54,13 +55,19 @@ function leadingBaselineDomain(content) {
|
|
|
54
55
|
return line?.match(/^\s*--\s*Brightweb\s+(.+?)\s+v1\s+baseline\.?\s*$/i)?.[1]?.trim().toLowerCase() || null;
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
async function bootstrapCursor({ moduleKey, catalogEntry, appMigrations, override, warnings }) {
|
|
58
|
+
async function bootstrapCursor({ moduleKey, catalogEntry, appMigrations, override, retainedCursor, warnings }) {
|
|
58
59
|
const shipped = await getModuleMigrations(moduleKey, catalogEntry);
|
|
59
|
-
if (shipped.length === 0) return { shipsMigrations: false, cursor: undefined, strategy: "none" };
|
|
60
60
|
if (override) {
|
|
61
61
|
if (!shipped.some((entry) => entry.fileName === override)) throw new Error(`Cursor override for ${moduleKey} does not name a shipped migration: ${override}`);
|
|
62
62
|
return { shipsMigrations: true, cursor: override, strategy: "override" };
|
|
63
63
|
}
|
|
64
|
+
if (retainedCursor !== undefined) {
|
|
65
|
+
if (!shipped.some((entry) => entry.fileName === retainedCursor)) {
|
|
66
|
+
throw new Error(`Retained migration cursor for removed module ${moduleKey} does not name a shipped migration: ${retainedCursor}. Reconcile it with an explicit --cursor ${moduleKey}=<migrationFilename>.`);
|
|
67
|
+
}
|
|
68
|
+
return { shipsMigrations: true, cursor: retainedCursor, strategy: "retained" };
|
|
69
|
+
}
|
|
70
|
+
if (shipped.length === 0) return { shipsMigrations: false, cursor: undefined, strategy: "none" };
|
|
64
71
|
|
|
65
72
|
const escapedKey = moduleKey.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
66
73
|
const provenance = latestShippedMatch(shipped, (shippedName) => appMigrations.some(({ content }) => {
|
|
@@ -89,9 +96,10 @@ async function bootstrapCursor({ moduleKey, catalogEntry, appMigrations, overrid
|
|
|
89
96
|
|
|
90
97
|
export async function adoptBrightwebApp(argvOptions = {}, runtimeOptions = {}) {
|
|
91
98
|
if (argvOptions.help) { output.write(`${HELP}\n`); return { help: true }; }
|
|
92
|
-
const targetDir = path.resolve(runtimeOptions.targetDir || argvOptions.targetDir || process.cwd());
|
|
99
|
+
const targetDir = await fs.realpath(path.resolve(runtimeOptions.targetDir || argvOptions.targetDir || process.cwd()));
|
|
93
100
|
const existingManifestPath = path.join(targetDir, APP_MANIFEST_PATH);
|
|
94
|
-
if (await pathExists(existingManifestPath) && !argvOptions.force) throw new Error(`Refusing to overwrite existing ${APP_MANIFEST_PATH}; pass --force to
|
|
101
|
+
if (await pathExists(existingManifestPath) && !argvOptions.force) throw new Error(`Refusing to overwrite existing ${APP_MANIFEST_PATH}; pass --force to reconcile it while preserving ownership decisions.`);
|
|
102
|
+
const previousManifest = await readAppManifest(targetDir, { required: false });
|
|
95
103
|
const packageJson = await readJsonIfPresent(path.join(targetDir, "package.json"));
|
|
96
104
|
if (!packageJson) throw new Error(`Target directory does not contain package.json: ${targetDir}`);
|
|
97
105
|
|
|
@@ -123,6 +131,11 @@ export async function adoptBrightwebApp(argvOptions = {}, runtimeOptions = {}) {
|
|
|
123
131
|
const scaffold = template === "platform"
|
|
124
132
|
? await inventoryScaffoldFiles({ targetDir, moduleKeys: Object.keys(modules), templateRoot })
|
|
125
133
|
: { records: {}, unsupported: [] };
|
|
134
|
+
const previousScaffold = await scaffoldDrift(targetDir, previousManifest?.scaffoldFiles);
|
|
135
|
+
scaffold.records = preserveScaffoldDecisions(scaffold.records, previousManifest?.scaffoldFiles, previousScaffold.protectedPaths);
|
|
136
|
+
for (const entry of (await scaffoldDrift(targetDir, scaffold.records)).entries) {
|
|
137
|
+
scaffold.records[entry.relativePath].status = entry.status;
|
|
138
|
+
}
|
|
126
139
|
const ownedPaths = new Set(asList(argvOptions.own).map(String));
|
|
127
140
|
const skippedPaths = new Set(asList(argvOptions.skip).map(String));
|
|
128
141
|
for (const relativePath of [...ownedPaths, ...skippedPaths]) {
|
|
@@ -144,14 +157,23 @@ export async function adoptBrightwebApp(argvOptions = {}, runtimeOptions = {}) {
|
|
|
144
157
|
}
|
|
145
158
|
|
|
146
159
|
const overrides = parseCursorOverrides(argvOptions.cursor);
|
|
147
|
-
const unknownOverride = Object.keys(overrides).find((key) => !catalog
|
|
160
|
+
const unknownOverride = Object.keys(overrides).find((key) => !Object.hasOwn(catalog, key));
|
|
148
161
|
if (unknownOverride) throw new Error(`Unknown module key in --cursor: ${unknownOverride}`);
|
|
149
162
|
const appMigrations = template === "platform" ? await migrationFiles(targetDir) : [];
|
|
150
163
|
const migrationCursor = {};
|
|
151
164
|
const cursorStrategies = {};
|
|
152
|
-
const
|
|
165
|
+
const installedMigrationKeys = new Set(template === "platform" ? ["core", "admin", ...Object.keys(modules)] : []);
|
|
166
|
+
const migrationKeys = template === "platform"
|
|
167
|
+
? Array.from(new Set([...installedMigrationKeys, ...Object.keys(previousManifest?.migrationCursor || {}), ...Object.keys(overrides)]))
|
|
168
|
+
: [];
|
|
153
169
|
for (const moduleKey of migrationKeys) {
|
|
154
|
-
|
|
170
|
+
if (!Object.hasOwn(catalog, moduleKey)) throw new Error(`Unknown retained migration module: ${moduleKey}. Reconcile its history before re-adopting.`);
|
|
171
|
+
// Removing package wiring never removes applied history. Preserve an exact
|
|
172
|
+
// retained cursor rather than advancing it from files copied by older tools.
|
|
173
|
+
const retainedCursor = !installedMigrationKeys.has(moduleKey)
|
|
174
|
+
? previousManifest?.migrationCursor?.[moduleKey]
|
|
175
|
+
: undefined;
|
|
176
|
+
const result = await bootstrapCursor({ moduleKey, catalogEntry: catalog[moduleKey], appMigrations, override: overrides[moduleKey], retainedCursor, warnings });
|
|
155
177
|
if (result.shipsMigrations) {
|
|
156
178
|
migrationCursor[moduleKey] = result.cursor;
|
|
157
179
|
cursorStrategies[moduleKey] = result.strategy;
|
|
@@ -172,7 +194,7 @@ export async function adoptBrightwebApp(argvOptions = {}, runtimeOptions = {}) {
|
|
|
172
194
|
scaffoldFiles: scaffold.records,
|
|
173
195
|
managedFiles: template === "platform" ? MANAGED_APP_FILES : ["docs/ai/app-context.json"],
|
|
174
196
|
migrationCursor,
|
|
175
|
-
ownedSurfaces: Array.from(new Set(asList(argvOptions.ownedSurface).map(String))),
|
|
197
|
+
ownedSurfaces: Array.from(new Set([...(previousManifest?.ownedSurfaces || []), ...asList(argvOptions.ownedSurface).map(String)])),
|
|
176
198
|
adoptionNotes: {
|
|
177
199
|
allowUncursored: argvOptions.allowUncursored === true,
|
|
178
200
|
cursorStrategies,
|
package/src/app-manifest.mjs
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { MANAGED_PLATFORM_FILES, isAppOwnedSeed } from "./file-policy.mjs";
|
|
2
|
+
import semver from "semver";
|
|
3
|
+
import { assertMutationTargets } from "./mutation-paths.mjs";
|
|
1
4
|
import crypto from "node:crypto";
|
|
2
5
|
import fs from "node:fs/promises";
|
|
3
6
|
import path from "node:path";
|
|
@@ -17,13 +20,7 @@ async function readJsonIfPresent(filePath) {
|
|
|
17
20
|
}
|
|
18
21
|
|
|
19
22
|
export const APP_MANIFEST_PATH = path.join(".brightweb", "app-manifest.json");
|
|
20
|
-
export const MANAGED_APP_FILES =
|
|
21
|
-
"next.config.ts",
|
|
22
|
-
"app/globals.css",
|
|
23
|
-
"config/modules.ts",
|
|
24
|
-
"config/shell.ts",
|
|
25
|
-
"docs/ai/app-context.json",
|
|
26
|
-
];
|
|
23
|
+
export const MANAGED_APP_FILES = MANAGED_PLATFORM_FILES;
|
|
27
24
|
|
|
28
25
|
export const MODULE_PACKAGES = {
|
|
29
26
|
admin: "@brightweblabs/module-admin",
|
|
@@ -44,36 +41,13 @@ const FALLBACK_REQUIRES = {
|
|
|
44
41
|
|
|
45
42
|
export function cleanVersion(version) {
|
|
46
43
|
if (typeof version !== "string") return null;
|
|
47
|
-
|
|
48
|
-
return match?.[1] || null;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function compareVersions(left, right) {
|
|
52
|
-
const a = cleanVersion(left)?.split("-")[0].split(".").map(Number) || [];
|
|
53
|
-
const b = cleanVersion(right)?.split("-")[0].split(".").map(Number) || [];
|
|
54
|
-
for (let index = 0; index < 3; index += 1) {
|
|
55
|
-
if ((a[index] || 0) !== (b[index] || 0)) return (a[index] || 0) - (b[index] || 0);
|
|
56
|
-
}
|
|
57
|
-
return 0;
|
|
44
|
+
return semver.valid(version.trim().replace(/^[~^]\s*/, ""));
|
|
58
45
|
}
|
|
59
46
|
|
|
60
47
|
export function satisfiesVersion(version, range) {
|
|
61
|
-
|
|
62
|
-
if (
|
|
63
|
-
|
|
64
|
-
const expectedMatch = range.match(/(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
|
|
65
|
-
const expected = expectedMatch ? `${expectedMatch[1]}.${expectedMatch[2] || "0"}.${expectedMatch[3] || "0"}` : null;
|
|
66
|
-
if (!expected) return false;
|
|
67
|
-
if (range.trim().startsWith(">=")) return compareVersions(normalized, expected) >= 0;
|
|
68
|
-
if (range.trim().startsWith("^")) {
|
|
69
|
-
return normalized.split(".")[0] === expected.split(".")[0] && compareVersions(normalized, expected) >= 0;
|
|
70
|
-
}
|
|
71
|
-
if (range.trim().startsWith("~")) {
|
|
72
|
-
const actualParts = normalized.split(".");
|
|
73
|
-
const expectedParts = expected.split(".");
|
|
74
|
-
return actualParts[0] === expectedParts[0] && actualParts[1] === expectedParts[1] && compareVersions(normalized, expected) >= 0;
|
|
75
|
-
}
|
|
76
|
-
return normalized === expected;
|
|
48
|
+
if (typeof version !== "string" || typeof range !== "string" || !semver.valid(version)) return false;
|
|
49
|
+
if (range === "workspace:*") return true;
|
|
50
|
+
return semver.satisfies(version, range);
|
|
77
51
|
}
|
|
78
52
|
|
|
79
53
|
export async function hashFile(filePath) {
|
|
@@ -82,6 +56,7 @@ export async function hashFile(filePath) {
|
|
|
82
56
|
}
|
|
83
57
|
|
|
84
58
|
export async function readAppManifest(targetDir, { required = true } = {}) {
|
|
59
|
+
await assertMutationTargets(targetDir, [APP_MANIFEST_PATH]);
|
|
85
60
|
const manifestPath = path.join(targetDir, APP_MANIFEST_PATH);
|
|
86
61
|
const manifest = await readJsonIfPresent(manifestPath);
|
|
87
62
|
if (!manifest && required) {
|
|
@@ -95,6 +70,7 @@ export async function readAppManifest(targetDir, { required = true } = {}) {
|
|
|
95
70
|
}
|
|
96
71
|
|
|
97
72
|
export async function writeAppManifest(targetDir, manifest) {
|
|
73
|
+
await assertMutationTargets(targetDir, [APP_MANIFEST_PATH]);
|
|
98
74
|
const manifestPath = path.join(targetDir, APP_MANIFEST_PATH);
|
|
99
75
|
await fs.mkdir(path.dirname(manifestPath), { recursive: true });
|
|
100
76
|
const temporaryPath = `${manifestPath}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.tmp`;
|
|
@@ -178,7 +154,7 @@ export async function collectScaffoldFiles(targetDir, selectedModules) {
|
|
|
178
154
|
const result = {};
|
|
179
155
|
for (const [relativePath, moduleKey] of Array.from(files.entries()).sort()) {
|
|
180
156
|
const targetPath = resolveSafeRelativePath(targetDir, relativePath, "Scaffold file path");
|
|
181
|
-
if (await pathExists(targetPath)) result[relativePath] = { module: moduleKey, hash: await hashFile(targetPath), status: "current" };
|
|
157
|
+
if (await pathExists(targetPath)) result[relativePath] = { module: moduleKey, hash: await hashFile(targetPath), status: "current", ...(isAppOwnedSeed(relativePath) ? { intent: "owned" } : {}) };
|
|
182
158
|
}
|
|
183
159
|
return result;
|
|
184
160
|
}
|
package/src/constants.mjs
CHANGED
|
@@ -186,16 +186,16 @@ export const PLATFORM_STARTER_FILES = [
|
|
|
186
186
|
];
|
|
187
187
|
|
|
188
188
|
export const APP_DEPENDENCY_DEFAULTS = {
|
|
189
|
-
"@brightweblabs/app-shell": "^0.16.
|
|
190
|
-
"@brightweblabs/core-auth": "^0.12.
|
|
189
|
+
"@brightweblabs/app-shell": "^0.16.3",
|
|
190
|
+
"@brightweblabs/core-auth": "^0.12.2",
|
|
191
191
|
"@brightweblabs/infra": "^0.7.0",
|
|
192
|
-
"@brightweblabs/module-admin": "^0.9.
|
|
193
|
-
"@brightweblabs/module-crm": "^0.18.
|
|
194
|
-
"@brightweblabs/module-marketing": "^0.5.
|
|
195
|
-
"@brightweblabs/module-orgs": "^0.7.
|
|
196
|
-
"@brightweblabs/module-projects": "^0.19.
|
|
197
|
-
"@brightweblabs/theme": "^0.8.
|
|
198
|
-
"@brightweblabs/ui": "^1.5.
|
|
192
|
+
"@brightweblabs/module-admin": "^0.9.7",
|
|
193
|
+
"@brightweblabs/module-crm": "^0.18.4",
|
|
194
|
+
"@brightweblabs/module-marketing": "^0.5.2",
|
|
195
|
+
"@brightweblabs/module-orgs": "^0.7.3",
|
|
196
|
+
"@brightweblabs/module-projects": "^0.19.3",
|
|
197
|
+
"@brightweblabs/theme": "^0.8.4",
|
|
198
|
+
"@brightweblabs/ui": "^1.5.6",
|
|
199
199
|
"geist": "1.7.2",
|
|
200
200
|
"lucide-react": "^1.8.0",
|
|
201
201
|
"next": "^16.0.0",
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { MODULE_STARTER_FILES, PLATFORM_STARTER_FILES } from "./constants.mjs";
|
|
2
|
+
|
|
3
|
+
export const MODULE_SELECTED_FILES = [
|
|
4
|
+
"app/api/invitations/_dependencies.ts",
|
|
5
|
+
"app/api/organizations/route.ts",
|
|
6
|
+
"app/api/organizations/[id]/route.ts",
|
|
7
|
+
"app/api/organizations/[id]/invitations/route.ts",
|
|
8
|
+
"app/api/organizations/[id]/invitations/[invitationId]/route.ts",
|
|
9
|
+
"app/api/organizations/[id]/members/[profileId]/route.ts",
|
|
10
|
+
];
|
|
11
|
+
export const MANAGED_PLATFORM_FILES = [
|
|
12
|
+
"next.config.ts", "app/globals.css", "config/module-toolbar-controls.tsx",
|
|
13
|
+
"config/modules.ts", "config/shell.ts", ...MODULE_SELECTED_FILES,
|
|
14
|
+
"docs/ai/app-context.json",
|
|
15
|
+
];
|
|
16
|
+
export const MANAGED_SITE_FILES = ["docs/ai/app-context.json"];
|
|
17
|
+
export const APP_OWNED_FILES = [
|
|
18
|
+
"config/brand.ts", "config/shell.overrides.ts", "app/theme.css", "app/fonts.ts",
|
|
19
|
+
"config/social-media-plan.json", "app/(shell)/marketing/social-media/page.tsx",
|
|
20
|
+
"public/brand/logo-mark.svg", "public/brand/logo-light.svg", "public/brand/logo-dark.svg",
|
|
21
|
+
"docs/ai/README.md", "docs/ai/examples.md", "AGENTS.md", "README.md",
|
|
22
|
+
];
|
|
23
|
+
export function fileOwnership({ template = "platform", modules = [] } = {}) {
|
|
24
|
+
const generated = template === "platform" ? MANAGED_PLATFORM_FILES : MANAGED_SITE_FILES;
|
|
25
|
+
const appOwned = template === "platform" ? APP_OWNED_FILES : ["config/site.ts", "app/fonts.ts", "app/globals.css", "app/layout.tsx", "next.config.ts", "postcss.config.mjs", "tsconfig.json", "docs/ai/README.md", "docs/ai/examples.md", "AGENTS.md", "README.md"];
|
|
26
|
+
const scaffold = template === "platform" ? [...new Set([...PLATFORM_STARTER_FILES, ...modules.flatMap((key) => MODULE_STARTER_FILES[key] || [])])].filter((p) => !generated.includes(p) && !appOwned.includes(p)) : [];
|
|
27
|
+
return { default: "app-owned", appOwned: [...appOwned], scaffoldManaged: scaffold, generated: [...generated], precedence: ["explicit-intent-or-drift", "exact-path-policy", "recorded-scaffold", "app-owned-default"] };
|
|
28
|
+
}
|
|
29
|
+
export function isAppOwnedSeed(relativePath) { return APP_OWNED_FILES.includes(relativePath); }
|
|
30
|
+
|
|
31
|
+
/** Fail before writing when a renderer and its ownership policy disagree. */
|
|
32
|
+
export function assertGeneratedFileInventory(files, expectedPaths, label) {
|
|
33
|
+
const actualPaths = Object.keys(files);
|
|
34
|
+
const missing = expectedPaths.filter((relativePath) => !Object.hasOwn(files, relativePath));
|
|
35
|
+
const unexpected = actualPaths.filter((relativePath) => !expectedPaths.includes(relativePath));
|
|
36
|
+
if (missing.length || unexpected.length) {
|
|
37
|
+
throw new Error(`${label} inventory mismatch: missing ${missing.join(", ") || "none"}; unexpected ${unexpected.join(", ") || "none"}.`);
|
|
38
|
+
}
|
|
39
|
+
return files;
|
|
40
|
+
}
|
package/src/generator.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { fileOwnership, assertGeneratedFileInventory, MANAGED_PLATFORM_FILES, MODULE_SELECTED_FILES } from "./file-policy.mjs";
|
|
1
2
|
import fs from "node:fs/promises";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { fileURLToPath } from "node:url";
|
|
@@ -690,20 +691,7 @@ export function createAppContextFile({
|
|
|
690
691
|
},
|
|
691
692
|
starterRoutes: getSiteStarterRoutes(),
|
|
692
693
|
ownership: {
|
|
693
|
-
|
|
694
|
-
"config/**",
|
|
695
|
-
"docs/ai/**",
|
|
696
|
-
"public/**",
|
|
697
|
-
"AGENTS.md",
|
|
698
|
-
"README.md",
|
|
699
|
-
],
|
|
700
|
-
scaffoldManaged: [
|
|
701
|
-
"app/layout.tsx",
|
|
702
|
-
"app/globals.css",
|
|
703
|
-
"next.config.ts",
|
|
704
|
-
"postcss.config.mjs",
|
|
705
|
-
"tsconfig.json",
|
|
706
|
-
],
|
|
694
|
+
...fileOwnership({ template: "site" }),
|
|
707
695
|
packageOwned: [],
|
|
708
696
|
},
|
|
709
697
|
agentRules: {
|
|
@@ -753,22 +741,7 @@ export function createAppContextFile({
|
|
|
753
741
|
},
|
|
754
742
|
starterRoutes: getPlatformStarterRoutes(selectedModules),
|
|
755
743
|
ownership: {
|
|
756
|
-
|
|
757
|
-
"config/**",
|
|
758
|
-
"docs/ai/**",
|
|
759
|
-
"public/brand/**",
|
|
760
|
-
"AGENTS.md",
|
|
761
|
-
"README.md",
|
|
762
|
-
],
|
|
763
|
-
scaffoldManaged: [
|
|
764
|
-
"app/layout.tsx",
|
|
765
|
-
"app/globals.css",
|
|
766
|
-
"app/**/page.tsx",
|
|
767
|
-
"app/**/route.ts",
|
|
768
|
-
"next.config.ts",
|
|
769
|
-
"postcss.config.mjs",
|
|
770
|
-
"tsconfig.json",
|
|
771
|
-
],
|
|
744
|
+
...fileOwnership({ template: "platform", modules: selectedModules }),
|
|
772
745
|
packageOwned: [
|
|
773
746
|
...CORE_PACKAGES,
|
|
774
747
|
...(selectedModules.includes("crm") || selectedModules.includes("marketing") || selectedModules.includes("projects") ? [ORGS_PACKAGE_NAME] : []),
|
|
@@ -1182,7 +1155,7 @@ export function createOptionalModuleRouteFiles(selectedModules) {
|
|
|
1182
1155
|
'const acceptOrganizationInvitation = async (_client: never, _input: unknown): Promise<never> => { throw new Error("Convite não encontrado."); };',
|
|
1183
1156
|
] : []),
|
|
1184
1157
|
...(!crmEnabled ? [
|
|
1185
|
-
"const ensureCrmContactForProfile =
|
|
1158
|
+
"const ensureCrmContactForProfile = undefined;",
|
|
1186
1159
|
] : []),
|
|
1187
1160
|
(!adminEnabled || !orgsEnabled || !crmEnabled) ? "" : null,
|
|
1188
1161
|
"export const invitationHttpDependencies = {",
|
|
@@ -1213,7 +1186,7 @@ export function createOptionalModuleRouteFiles(selectedModules) {
|
|
|
1213
1186
|
"",
|
|
1214
1187
|
].filter((line) => line !== null).join("\n");
|
|
1215
1188
|
|
|
1216
|
-
return {
|
|
1189
|
+
return assertGeneratedFileInventory({
|
|
1217
1190
|
"app/api/invitations/_dependencies.ts": invitationDependencies,
|
|
1218
1191
|
"app/api/organizations/route.ts": createOrganizationRoute([
|
|
1219
1192
|
crmEnabled
|
|
@@ -1287,7 +1260,20 @@ export function createOptionalModuleRouteFiles(selectedModules) {
|
|
|
1287
1260
|
"}",
|
|
1288
1261
|
"",
|
|
1289
1262
|
].join("\n"),
|
|
1290
|
-
};
|
|
1263
|
+
}, MODULE_SELECTED_FILES, "Module-selected routes");
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
/** The same module-aware outputs drive initial scaffolding and every topology refresh. */
|
|
1267
|
+
export async function createManagedPlatformFiles({ slug, selectedModules, dbInstallPlan }) {
|
|
1268
|
+
return assertGeneratedFileInventory({
|
|
1269
|
+
"next.config.ts": createNextConfig({ template: "platform", selectedModules }),
|
|
1270
|
+
"app/globals.css": await createPlatformGlobalsCss(selectedModules),
|
|
1271
|
+
"config/module-toolbar-controls.tsx": createModuleToolbarControlsConfig(selectedModules),
|
|
1272
|
+
"config/modules.ts": createPlatformModulesConfigFile(selectedModules),
|
|
1273
|
+
"config/shell.ts": createShellConfig(selectedModules),
|
|
1274
|
+
...createOptionalModuleRouteFiles(selectedModules),
|
|
1275
|
+
"docs/ai/app-context.json": createAppContextFile({ slug, template: "platform", selectedModules, dbInstallPlan }),
|
|
1276
|
+
}, MANAGED_PLATFORM_FILES, "Managed platform files");
|
|
1291
1277
|
}
|
|
1292
1278
|
|
|
1293
1279
|
function createSiteConfigFile(slug) {
|
|
@@ -1628,6 +1614,7 @@ async function scaffoldPlatformProject({
|
|
|
1628
1614
|
dbRegistry,
|
|
1629
1615
|
}) {
|
|
1630
1616
|
const brandValues = createDerivedBrandValues(answers.slug);
|
|
1617
|
+
const managedFiles = await createManagedPlatformFiles({ slug: answers.slug, selectedModules, dbInstallPlan });
|
|
1631
1618
|
const baseTemplateDir = path.join(TEMPLATE_ROOT, "base");
|
|
1632
1619
|
|
|
1633
1620
|
await ensureDirectory(path.dirname(targetDir));
|
|
@@ -1659,30 +1646,14 @@ async function scaffoldPlatformProject({
|
|
|
1659
1646
|
2,
|
|
1660
1647
|
)}\n`,
|
|
1661
1648
|
);
|
|
1662
|
-
await fs.writeFile(path.join(targetDir, "next.config.ts"), createNextConfig({ template: "platform", selectedModules }));
|
|
1663
|
-
await fs.writeFile(path.join(targetDir, "app", "globals.css"), await createPlatformGlobalsCss(selectedModules));
|
|
1664
1649
|
await fs.writeFile(
|
|
1665
1650
|
path.join(targetDir, "config", "brand.ts"),
|
|
1666
1651
|
createPlatformBrandConfigFile({ slug: answers.slug, brandValues }),
|
|
1667
1652
|
);
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
path.join(targetDir, "config", "module-toolbar-controls.tsx"),
|
|
1671
|
-
createModuleToolbarControlsConfig(selectedModules),
|
|
1672
|
-
);
|
|
1673
|
-
await fs.writeFile(path.join(targetDir, "config", "shell.ts"), createShellConfig(selectedModules));
|
|
1674
|
-
for (const [relativePath, content] of Object.entries(createOptionalModuleRouteFiles(selectedModules))) {
|
|
1653
|
+
for (const [relativePath, content] of Object.entries(managedFiles)) {
|
|
1654
|
+
await ensureDirectory(path.dirname(path.join(targetDir, relativePath)));
|
|
1675
1655
|
await fs.writeFile(path.join(targetDir, relativePath), content);
|
|
1676
1656
|
}
|
|
1677
|
-
await fs.writeFile(
|
|
1678
|
-
path.join(targetDir, "docs", "ai", "app-context.json"),
|
|
1679
|
-
createAppContextFile({
|
|
1680
|
-
slug: answers.slug,
|
|
1681
|
-
template: "platform",
|
|
1682
|
-
selectedModules,
|
|
1683
|
-
dbInstallPlan,
|
|
1684
|
-
}),
|
|
1685
|
-
);
|
|
1686
1657
|
|
|
1687
1658
|
const vercelConfig = createVercelConfig(answers.supabaseRegion);
|
|
1688
1659
|
|
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
|
+
}
|