create-bw-app 0.10.1 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -1
- package/bin/bw.mjs +8 -0
- package/package.json +5 -2
- package/src/add.mjs +101 -0
- package/src/adopt.mjs +190 -0
- package/src/app-manifest.mjs +257 -0
- package/src/bw.mjs +55 -0
- package/src/constants.mjs +22 -10
- package/src/diff.mjs +85 -0
- package/src/doctor.mjs +113 -0
- package/src/generator.mjs +66 -8
- package/src/migrations.mjs +92 -0
- package/src/remove.mjs +100 -0
- package/src/scaffold-cmd.mjs +74 -0
- package/src/scaffold.mjs +90 -0
- package/src/update.mjs +51 -3
- package/src/upgrade.mjs +78 -0
- package/template/base/app/globals.css +1 -0
- package/template/base/config/bootstrap.ts +1 -1
- package/template/base/config/modules.ts +1 -1
- package/template/base/config/shell.overrides.ts +16 -0
- package/template/base/docs/ai/README.md +3 -2
- package/template/base/docs/ai/examples.md +2 -2
- package/template/base/public/brand/logo-dark.svg +2 -2
- package/template/base/public/brand/logo-light.svg +2 -2
- package/template/base/public/brand/logo-mark.svg +2 -2
- package/template/module-manifests/admin/brightweb.module.json +6 -0
- package/template/module-manifests/crm/brightweb.module.json +7 -0
- package/template/module-manifests/orgs/brightweb.module.json +6 -0
- package/template/module-manifests/projects/brightweb.module.json +6 -0
- package/template/modules/crm/app/api/crm/contacts/route.ts +10 -0
- package/template/modules/crm/app/api/crm/timeline/route.ts +8 -0
- package/template/modules/crm/app/crm/layout.tsx +5 -0
- package/template/modules/crm/app/crm/page.tsx +5 -0
- package/template/supabase/module-registry.json +9 -3
- package/template/supabase/modules/crm/migrations/20260316092000_crm_v1.sql +3 -253
- package/template/supabase/modules/crm/migrations/20260316092010_crm_org_integration.sql +66 -0
- package/template/supabase/modules/crm/migrations/20260421201523_portal_read_indexes.sql +0 -3
- package/template/supabase/modules/orgs/README.md +4 -0
- package/template/supabase/modules/orgs/migrations/20260316091500_orgs_v1.sql +216 -0
- package/template/modules/crm/app/playground/crm/page.tsx +0 -103
package/src/remove.mjs
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { stdout as output } from "node:process";
|
|
4
|
+
import {
|
|
5
|
+
MODULE_PACKAGES,
|
|
6
|
+
findWorkspaceRoot,
|
|
7
|
+
hashFile,
|
|
8
|
+
loadModuleCatalog,
|
|
9
|
+
readAppManifest,
|
|
10
|
+
writeAppManifest,
|
|
11
|
+
} from "./app-manifest.mjs";
|
|
12
|
+
import {
|
|
13
|
+
createAppContextFile,
|
|
14
|
+
createDbInstallPlan,
|
|
15
|
+
createNextConfig,
|
|
16
|
+
createPlatformModulesConfigFile,
|
|
17
|
+
createShellConfig,
|
|
18
|
+
getDbModuleRegistry,
|
|
19
|
+
pathExists,
|
|
20
|
+
readJsonIfPresent,
|
|
21
|
+
} from "./generator.mjs";
|
|
22
|
+
|
|
23
|
+
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`;
|
|
24
|
+
|
|
25
|
+
function databaseNotice(moduleKey, ownedObjects) {
|
|
26
|
+
const names = ownedObjects.length > 0 ? ownedObjects.join(", ") : "none declared";
|
|
27
|
+
return [
|
|
28
|
+
`-- Database objects owned by ${moduleKey}: ${names}`,
|
|
29
|
+
"-- No database objects or migration files were changed.",
|
|
30
|
+
"-- Dropping owned objects is a deliberate manual data-removal act.",
|
|
31
|
+
"-- Applied migrations remain in app history under the append-only migration principle.",
|
|
32
|
+
];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function removeBrightwebModule(moduleKey, argvOptions = {}, runtimeOptions = {}) {
|
|
36
|
+
if (!moduleKey || argvOptions.help) { output.write(`${HELP}\n`); return { help: true }; }
|
|
37
|
+
const targetDir = path.resolve(runtimeOptions.targetDir || argvOptions.targetDir || process.cwd());
|
|
38
|
+
const appManifest = await readAppManifest(targetDir);
|
|
39
|
+
if (!appManifest.modules[moduleKey]) throw new Error(`Module ${moduleKey} is not installed according to .brightweb/app-manifest.json.`);
|
|
40
|
+
const workspaceRoot = runtimeOptions.workspaceRoot || argvOptions.workspaceRoot || await findWorkspaceRoot(targetDir);
|
|
41
|
+
const catalog = await loadModuleCatalog({ targetDir, workspaceRoot });
|
|
42
|
+
const dependents = Object.keys(appManifest.modules)
|
|
43
|
+
.filter((key) => key !== moduleKey && catalog[key]?.requires?.[moduleKey]);
|
|
44
|
+
if (dependents.length > 0) throw new Error(`Cannot remove ${moduleKey}; installed module${dependents.length === 1 ? "" : "s"} ${dependents.join(", ")} require${dependents.length === 1 ? "s" : ""} it.`);
|
|
45
|
+
|
|
46
|
+
const packagePath = path.join(targetDir, "package.json");
|
|
47
|
+
const packageJson = await readJsonIfPresent(packagePath);
|
|
48
|
+
if (!packageJson) throw new Error(`Target directory does not contain package.json: ${targetDir}`);
|
|
49
|
+
const nextPackageJson = structuredClone(packageJson);
|
|
50
|
+
const packageName = MODULE_PACKAGES[moduleKey];
|
|
51
|
+
for (const section of ["dependencies", "devDependencies"]) if (nextPackageJson[section]) delete nextPackageJson[section][packageName];
|
|
52
|
+
|
|
53
|
+
const remainingModules = Object.keys(appManifest.modules).filter((key) => key !== moduleKey);
|
|
54
|
+
const dbRegistry = await getDbModuleRegistry(workspaceRoot);
|
|
55
|
+
const dbInstallPlan = createDbInstallPlan({
|
|
56
|
+
selectedModules: remainingModules.filter((key) => key !== "orgs"),
|
|
57
|
+
workspaceMode: Object.values(nextPackageJson.dependencies || {}).some((value) => String(value).startsWith("workspace:")),
|
|
58
|
+
registry: dbRegistry,
|
|
59
|
+
});
|
|
60
|
+
const managedWrites = {
|
|
61
|
+
"next.config.ts": createNextConfig({ template: "platform", selectedModules: remainingModules }),
|
|
62
|
+
"config/modules.ts": createPlatformModulesConfigFile(remainingModules),
|
|
63
|
+
"config/shell.ts": createShellConfig(remainingModules),
|
|
64
|
+
"docs/ai/app-context.json": createAppContextFile({ slug: appManifest.app.slug, template: "platform", selectedModules: remainingModules.filter((key) => key !== "orgs"), dbInstallPlan }),
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const cleanFiles = [];
|
|
68
|
+
const driftedFiles = [];
|
|
69
|
+
for (const [relativePath, record] of Object.entries(appManifest.scaffoldFiles || {})) {
|
|
70
|
+
if (record.module !== moduleKey) continue;
|
|
71
|
+
const filePath = path.join(targetDir, relativePath);
|
|
72
|
+
if (!(await pathExists(filePath))) continue;
|
|
73
|
+
if ((record.intent || "managed") === "managed" && await hashFile(filePath) === record.hash) cleanFiles.push(relativePath);
|
|
74
|
+
else driftedFiles.push(relativePath);
|
|
75
|
+
}
|
|
76
|
+
const notice = databaseNotice(moduleKey, catalog[moduleKey]?.manifest?.database?.ownedObjects || []);
|
|
77
|
+
const apply = argvOptions.yes === true && argvOptions.dryRun !== true;
|
|
78
|
+
output.write(`bw remove ${moduleKey}${apply ? "" : " (plan only; pass --yes to apply)"}\n`);
|
|
79
|
+
output.write(`Dependency to remove: ${packageName}\n`);
|
|
80
|
+
output.write(`Clean scaffold files to remove: ${cleanFiles.join(", ") || "none"}\n`);
|
|
81
|
+
output.write(`Drifted scaffold files left in place: ${driftedFiles.join(", ") || "none"}\n`);
|
|
82
|
+
for (const relativePath of driftedFiles) output.write(`WARN ${relativePath} is drifted and will be left in place.\n`);
|
|
83
|
+
for (const line of notice) output.write(`${line}\n`);
|
|
84
|
+
if (!apply) return { dryRun: true, moduleKey, cleanFiles, driftedFiles, notice };
|
|
85
|
+
|
|
86
|
+
await fs.writeFile(packagePath, `${JSON.stringify(nextPackageJson, null, 2)}\n`, "utf8");
|
|
87
|
+
for (const relativePath of cleanFiles) await fs.rm(path.join(targetDir, relativePath));
|
|
88
|
+
for (const [relativePath, content] of Object.entries(managedWrites)) {
|
|
89
|
+
const targetPath = path.join(targetDir, relativePath);
|
|
90
|
+
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
91
|
+
await fs.writeFile(targetPath, content, "utf8");
|
|
92
|
+
}
|
|
93
|
+
delete appManifest.modules[moduleKey];
|
|
94
|
+
for (const [relativePath, record] of Object.entries(appManifest.scaffoldFiles || {})) if (record.module === moduleKey) delete appManifest.scaffoldFiles[relativePath];
|
|
95
|
+
await writeAppManifest(targetDir, appManifest);
|
|
96
|
+
output.write(`Removed ${moduleKey} package wiring and ${cleanFiles.length} clean scaffold file${cleanFiles.length === 1 ? "" : "s"}. Install dependencies next.\n`);
|
|
97
|
+
return { dryRun: false, moduleKey, cleanFiles, driftedFiles, notice };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export { HELP as REMOVE_HELP };
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { stdout as output } from "node:process";
|
|
3
|
+
import { findWorkspaceRoot, hashFile, readAppManifest, writeAppManifest } from "./app-manifest.mjs";
|
|
4
|
+
import { pathExists } from "./generator.mjs";
|
|
5
|
+
import { findTrackedTemplate, scaffoldDrift } from "./scaffold.mjs";
|
|
6
|
+
|
|
7
|
+
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`;
|
|
8
|
+
|
|
9
|
+
function normalizeTrackedPath(relativePath) {
|
|
10
|
+
const normalized = path.normalize(String(relativePath)).replace(/^\.\//, "");
|
|
11
|
+
if (path.isAbsolute(String(relativePath)) || normalized === ".." || normalized.startsWith(`..${path.sep}`)) {
|
|
12
|
+
throw new Error(`Scaffold path must be relative to the app: ${relativePath}`);
|
|
13
|
+
}
|
|
14
|
+
return normalized;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function scaffoldBrightwebApp(action, paths = [], argvOptions = {}, runtimeOptions = {}) {
|
|
18
|
+
if (argvOptions.help || !action) { output.write(`${HELP}\n`); return { help: true }; }
|
|
19
|
+
if (!Array.isArray(paths)) paths = [paths];
|
|
20
|
+
if (!["list", "own", "skip", "manage"].includes(action)) throw new Error(`Unknown bw scaffold action: ${action}\n\n${HELP}`);
|
|
21
|
+
if (action !== "list" && paths.length === 0) throw new Error(`bw scaffold ${action} requires at least one tracked <path>.`);
|
|
22
|
+
if (action === "list" && paths.length > 0) throw new Error("bw scaffold list does not accept file paths.");
|
|
23
|
+
|
|
24
|
+
const targetDir = path.resolve(runtimeOptions.targetDir || argvOptions.targetDir || process.cwd());
|
|
25
|
+
const manifest = await readAppManifest(targetDir);
|
|
26
|
+
const live = await scaffoldDrift(targetDir, manifest.scaffoldFiles);
|
|
27
|
+
if (action === "list") {
|
|
28
|
+
output.write("PATH\tMODULE\tSTATUS\tINTENT\n");
|
|
29
|
+
for (const entry of live.entries) output.write(`${entry.relativePath}\t${entry.module}\t${entry.status}\t${entry.intent}\n`);
|
|
30
|
+
return { action, entries: live.entries };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const normalizedPaths = Array.from(new Set(paths.map(normalizeTrackedPath)));
|
|
34
|
+
for (const relativePath of normalizedPaths) {
|
|
35
|
+
if (!manifest.scaffoldFiles?.[relativePath]) throw new Error(`${relativePath} is not a tracked scaffold file.`);
|
|
36
|
+
}
|
|
37
|
+
const liveByPath = new Map(live.entries.map((entry) => [entry.relativePath, entry]));
|
|
38
|
+
for (const relativePath of normalizedPaths) {
|
|
39
|
+
const status = liveByPath.get(relativePath)?.status;
|
|
40
|
+
if (action === "own" && status === "missing") throw new Error(`Cannot own missing scaffold file: ${relativePath}`);
|
|
41
|
+
if (action === "skip" && status !== "missing") throw new Error(`Cannot skip existing scaffold file: ${relativePath}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const workspaceRoot = runtimeOptions.workspaceRoot || argvOptions.workspaceRoot || await findWorkspaceRoot(targetDir);
|
|
45
|
+
const changes = [];
|
|
46
|
+
for (const relativePath of normalizedPaths) {
|
|
47
|
+
const record = manifest.scaffoldFiles[relativePath];
|
|
48
|
+
const previousIntent = record.intent || "managed";
|
|
49
|
+
const nextIntent = action === "manage" ? "managed" : action === "own" ? "owned" : "skipped";
|
|
50
|
+
const appPath = path.join(targetDir, relativePath);
|
|
51
|
+
const exists = await pathExists(appPath);
|
|
52
|
+
if (action === "manage") {
|
|
53
|
+
const located = await findTrackedTemplate({ relativePath, manifest, targetDir, workspaceRoot });
|
|
54
|
+
if (!located.templatePath) throw new Error(`Installed-package template unavailable for ${relativePath}; cannot manage it safely.`);
|
|
55
|
+
const templateHash = await hashFile(located.templatePath);
|
|
56
|
+
if (exists) {
|
|
57
|
+
record.hash = await hashFile(appPath);
|
|
58
|
+
record.status = record.hash === templateHash ? "current" : "drifted";
|
|
59
|
+
} else {
|
|
60
|
+
record.status = "missing";
|
|
61
|
+
}
|
|
62
|
+
} else {
|
|
63
|
+
record.status = liveByPath.get(relativePath).status;
|
|
64
|
+
}
|
|
65
|
+
if (nextIntent === "managed") delete record.intent;
|
|
66
|
+
else record.intent = nextIntent;
|
|
67
|
+
changes.push({ relativePath, previousIntent, intent: nextIntent, status: record.status });
|
|
68
|
+
}
|
|
69
|
+
await writeAppManifest(targetDir, manifest);
|
|
70
|
+
for (const change of changes) output.write(`${change.relativePath}: intent ${change.previousIntent} -> ${change.intent} (${change.status})\n`);
|
|
71
|
+
return { action, changes, manifest };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export { HELP as SCAFFOLD_HELP };
|
package/src/scaffold.mjs
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { MODULE_STARTER_FILES, PLATFORM_STARTER_FILES, SELECTABLE_MODULES } from "./constants.mjs";
|
|
5
|
+
import { hashFile } from "./app-manifest.mjs";
|
|
6
|
+
import { pathExists } from "./generator.mjs";
|
|
7
|
+
|
|
8
|
+
const BUNDLED_TEMPLATE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "template");
|
|
9
|
+
|
|
10
|
+
export async function resolveTemplateRoot({ targetDir, workspaceRoot } = {}) {
|
|
11
|
+
const candidates = [
|
|
12
|
+
workspaceRoot && path.join(path.resolve(workspaceRoot), "packages", "create-bw-app", "template"),
|
|
13
|
+
targetDir && path.join(path.resolve(targetDir), "node_modules", "create-bw-app", "template"),
|
|
14
|
+
BUNDLED_TEMPLATE_ROOT,
|
|
15
|
+
].filter(Boolean);
|
|
16
|
+
for (const candidate of candidates) if (await pathExists(candidate)) return candidate;
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function trackedScaffoldDefinitions(moduleKeys = []) {
|
|
21
|
+
const definitions = PLATFORM_STARTER_FILES.map((relativePath) => ({
|
|
22
|
+
moduleKey: "platform-base",
|
|
23
|
+
relativePath,
|
|
24
|
+
templateRelativePath: path.join("base", relativePath),
|
|
25
|
+
}));
|
|
26
|
+
for (const moduleKey of moduleKeys) {
|
|
27
|
+
const folder = SELECTABLE_MODULES.find((entry) => entry.key === moduleKey)?.templateFolder;
|
|
28
|
+
if (!folder) continue;
|
|
29
|
+
for (const relativePath of MODULE_STARTER_FILES[moduleKey] || []) {
|
|
30
|
+
definitions.push({ moduleKey, relativePath, templateRelativePath: path.join("modules", folder, relativePath) });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return definitions.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function inventoryScaffoldFiles({ targetDir, moduleKeys, templateRoot }) {
|
|
37
|
+
const records = {};
|
|
38
|
+
const unsupported = [];
|
|
39
|
+
for (const definition of trackedScaffoldDefinitions(moduleKeys)) {
|
|
40
|
+
const templatePath = templateRoot && path.join(templateRoot, definition.templateRelativePath);
|
|
41
|
+
if (!templatePath || !(await pathExists(templatePath))) {
|
|
42
|
+
unsupported.push(definition.relativePath);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const appPath = path.join(targetDir, definition.relativePath);
|
|
46
|
+
const templateHash = await hashFile(templatePath);
|
|
47
|
+
const exists = await pathExists(appPath);
|
|
48
|
+
records[definition.relativePath] = {
|
|
49
|
+
module: definition.moduleKey,
|
|
50
|
+
hash: templateHash,
|
|
51
|
+
status: !exists ? "missing" : await hashFile(appPath) === templateHash ? "current" : "drifted",
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return { records, unsupported };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function scaffoldDrift(targetDir, scaffoldFiles = {}) {
|
|
58
|
+
const current = [];
|
|
59
|
+
const drifted = [];
|
|
60
|
+
const missing = [];
|
|
61
|
+
const entries = [];
|
|
62
|
+
for (const [relativePath, record] of Object.entries(scaffoldFiles)) {
|
|
63
|
+
const appPath = path.join(targetDir, relativePath);
|
|
64
|
+
const intent = record.intent || "managed";
|
|
65
|
+
let status = "missing";
|
|
66
|
+
if (await pathExists(appPath)) {
|
|
67
|
+
const matchesRecordedHash = await hashFile(appPath) === record.hash;
|
|
68
|
+
status = matchesRecordedHash && record.status !== "drifted" ? "current" : "drifted";
|
|
69
|
+
}
|
|
70
|
+
entries.push({ relativePath, module: record.module, status, intent });
|
|
71
|
+
if (status === "missing") missing.push(relativePath);
|
|
72
|
+
else if (status === "current") current.push(relativePath);
|
|
73
|
+
else drifted.push(relativePath);
|
|
74
|
+
}
|
|
75
|
+
return { current, drifted, missing, entries };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function findTrackedTemplate({ relativePath, manifest, targetDir, workspaceRoot }) {
|
|
79
|
+
const record = manifest.scaffoldFiles?.[relativePath];
|
|
80
|
+
if (!record) return { record: null, templatePath: null, templateRoot: null };
|
|
81
|
+
const definition = trackedScaffoldDefinitions(Object.keys(manifest.modules || {}))
|
|
82
|
+
.find((entry) => entry.relativePath === relativePath && entry.moduleKey === record.module);
|
|
83
|
+
const templateRoot = await resolveTemplateRoot({ targetDir, workspaceRoot });
|
|
84
|
+
const templatePath = definition && templateRoot ? path.join(templateRoot, definition.templateRelativePath) : null;
|
|
85
|
+
return { record, templatePath: templatePath && await pathExists(templatePath) ? templatePath : null, templateRoot };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function readTextFile(filePath) {
|
|
89
|
+
return fs.readFile(filePath, "utf8");
|
|
90
|
+
}
|
package/src/update.mjs
CHANGED
|
@@ -5,6 +5,8 @@ import {
|
|
|
5
5
|
BRIGHTWEB_PACKAGE_NAMES,
|
|
6
6
|
CLI_DISPLAY_NAME,
|
|
7
7
|
MODULE_STARTER_FILES,
|
|
8
|
+
ORGS_PACKAGE_NAME,
|
|
9
|
+
PLATFORM_STARTER_FILES,
|
|
8
10
|
SELECTABLE_MODULES,
|
|
9
11
|
} from "./constants.mjs";
|
|
10
12
|
import {
|
|
@@ -146,7 +148,7 @@ function parseConfiguredModules(content) {
|
|
|
146
148
|
return enabledModules;
|
|
147
149
|
}
|
|
148
150
|
|
|
149
|
-
async function detectTemplate(targetDir, installedBrightwebPackages) {
|
|
151
|
+
export async function detectTemplate(targetDir, installedBrightwebPackages) {
|
|
150
152
|
if (await pathExists(path.join(targetDir, "config", "modules.ts"))) {
|
|
151
153
|
return "platform";
|
|
152
154
|
}
|
|
@@ -154,7 +156,7 @@ async function detectTemplate(targetDir, installedBrightwebPackages) {
|
|
|
154
156
|
return installedBrightwebPackages.size > 0 ? "platform" : "site";
|
|
155
157
|
}
|
|
156
158
|
|
|
157
|
-
function detectDependencyMode(installedBrightwebPackages) {
|
|
159
|
+
export function detectDependencyMode(installedBrightwebPackages) {
|
|
158
160
|
for (const { version } of installedBrightwebPackages.values()) {
|
|
159
161
|
if (typeof version === "string" && version.startsWith("workspace:")) {
|
|
160
162
|
return "workspace";
|
|
@@ -220,6 +222,19 @@ function mergeManagedPackageUpdates({ manifest, targetVersions, installedBrightw
|
|
|
220
222
|
changed = true;
|
|
221
223
|
}
|
|
222
224
|
|
|
225
|
+
const requiredOrgsVersion = targetVersions[ORGS_PACKAGE_NAME];
|
|
226
|
+
if (requiredOrgsVersion && !installedBrightwebPackages.has(ORGS_PACKAGE_NAME)) {
|
|
227
|
+
nextManifest.dependencies = nextManifest.dependencies || {};
|
|
228
|
+
nextManifest.dependencies[ORGS_PACKAGE_NAME] = requiredOrgsVersion;
|
|
229
|
+
packageUpdates.push({
|
|
230
|
+
packageName: ORGS_PACKAGE_NAME,
|
|
231
|
+
from: null,
|
|
232
|
+
to: requiredOrgsVersion,
|
|
233
|
+
section: "dependencies",
|
|
234
|
+
});
|
|
235
|
+
changed = true;
|
|
236
|
+
}
|
|
237
|
+
|
|
223
238
|
return {
|
|
224
239
|
changed,
|
|
225
240
|
packageUpdates,
|
|
@@ -230,6 +245,38 @@ function mergeManagedPackageUpdates({ manifest, targetVersions, installedBrightw
|
|
|
230
245
|
async function getStarterFileStatus(targetDir, installedModules) {
|
|
231
246
|
const starterFiles = [];
|
|
232
247
|
|
|
248
|
+
for (const relativePath of PLATFORM_STARTER_FILES) {
|
|
249
|
+
const sourcePath = path.join(TEMPLATE_ROOT, "base", relativePath);
|
|
250
|
+
const targetPath = path.join(targetDir, relativePath);
|
|
251
|
+
const exists = await pathExists(targetPath);
|
|
252
|
+
|
|
253
|
+
if (!exists) {
|
|
254
|
+
starterFiles.push({
|
|
255
|
+
moduleKey: "platform-base",
|
|
256
|
+
relativePath,
|
|
257
|
+
sourcePath,
|
|
258
|
+
targetPath,
|
|
259
|
+
status: "missing",
|
|
260
|
+
refreshable: false,
|
|
261
|
+
});
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const [sourceContent, targetContent] = await Promise.all([
|
|
266
|
+
fs.readFile(sourcePath, "utf8"),
|
|
267
|
+
fs.readFile(targetPath, "utf8"),
|
|
268
|
+
]);
|
|
269
|
+
|
|
270
|
+
starterFiles.push({
|
|
271
|
+
moduleKey: "platform-base",
|
|
272
|
+
relativePath,
|
|
273
|
+
sourcePath,
|
|
274
|
+
targetPath,
|
|
275
|
+
status: sourceContent === targetContent ? "current" : "drifted",
|
|
276
|
+
refreshable: false,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
233
280
|
for (const moduleKey of installedModules) {
|
|
234
281
|
const templateFolder = SELECTABLE_MODULES.find((moduleDefinition) => moduleDefinition.key === moduleKey)?.templateFolder;
|
|
235
282
|
if (!templateFolder) continue;
|
|
@@ -498,7 +545,8 @@ export async function buildBrightwebAppUpdatePlan(argvOptions = {}, runtimeOptio
|
|
|
498
545
|
const starterFilesDrifted = starterFiles.filter((entry) => entry.status === "drifted");
|
|
499
546
|
|
|
500
547
|
if (argvOptions.refreshStarters) {
|
|
501
|
-
for (const entry of starterFiles.filter((candidate) =>
|
|
548
|
+
for (const entry of starterFiles.filter((candidate) =>
|
|
549
|
+
candidate.status !== "current" && (candidate.status === "missing" || candidate.refreshable !== false))) {
|
|
502
550
|
fileWrites.push({
|
|
503
551
|
relativePath: entry.relativePath,
|
|
504
552
|
targetPath: entry.targetPath,
|
package/src/upgrade.mjs
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { stdout as output } from "node:process";
|
|
4
|
+
import { hashFile, findWorkspaceRoot, loadModuleCatalog, readAppManifest, writeAppManifest, cleanVersion } from "./app-manifest.mjs";
|
|
5
|
+
import { pathExists, runInstall } from "./generator.mjs";
|
|
6
|
+
import { applyMigrationWrites, getModuleMigrations, planMigrationAppends } from "./migrations.mjs";
|
|
7
|
+
import { buildBrightwebAppUpdatePlan } from "./update.mjs";
|
|
8
|
+
|
|
9
|
+
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 --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`;
|
|
10
|
+
|
|
11
|
+
export async function upgradeBrightwebApp(moduleKey, argvOptions = {}, runtimeOptions = {}) {
|
|
12
|
+
if (argvOptions.help) { output.write(`${HELP}\n`); return { help: true }; }
|
|
13
|
+
const targetDir = path.resolve(runtimeOptions.targetDir || argvOptions.targetDir || process.cwd());
|
|
14
|
+
const appManifest = await readAppManifest(targetDir);
|
|
15
|
+
if (moduleKey && !appManifest.modules[moduleKey]) throw new Error(`Module ${moduleKey} is not installed according to ${path.join(".brightweb", "app-manifest.json")}.`);
|
|
16
|
+
const workspaceRoot = runtimeOptions.workspaceRoot || argvOptions.workspaceRoot || await findWorkspaceRoot(targetDir);
|
|
17
|
+
const updateOptions = { ...argvOptions, targetDir, ...(workspaceRoot ? { workspaceRoot } : {}) };
|
|
18
|
+
const plan = await buildBrightwebAppUpdatePlan(updateOptions, runtimeOptions);
|
|
19
|
+
const drifted = [];
|
|
20
|
+
const missing = [];
|
|
21
|
+
const intentional = [];
|
|
22
|
+
for (const [relativePath, record] of Object.entries(appManifest.scaffoldFiles)) {
|
|
23
|
+
if (["owned", "skipped"].includes(record.intent)) intentional.push(relativePath);
|
|
24
|
+
const filePath = path.join(targetDir, relativePath);
|
|
25
|
+
if (!(await pathExists(filePath))) { missing.push(relativePath); continue; }
|
|
26
|
+
if (await hashFile(filePath) !== record.hash) drifted.push(relativePath);
|
|
27
|
+
}
|
|
28
|
+
const protectedPaths = new Set([...drifted, ...intentional]);
|
|
29
|
+
plan.fileWrites = plan.fileWrites.filter((entry) => entry.type !== "starter" || !protectedPaths.has(entry.relativePath));
|
|
30
|
+
plan.starterFilesToRefresh = plan.fileWrites.filter((entry) => entry.type === "starter").map((entry) => entry.relativePath);
|
|
31
|
+
plan.starterFilesDrifted = Array.from(new Set([...plan.starterFilesDrifted, ...drifted]));
|
|
32
|
+
plan.starterFilesMissing = Array.from(new Set([...plan.starterFilesMissing, ...missing]));
|
|
33
|
+
|
|
34
|
+
const catalog = await loadModuleCatalog({ targetDir, workspaceRoot });
|
|
35
|
+
for (const update of plan.packageUpdates) {
|
|
36
|
+
const key = Object.keys(catalog).find((candidate) => catalog[candidate].packageName === update.packageName);
|
|
37
|
+
if (key) catalog[key].version = cleanVersion(update.to) || catalog[key].version;
|
|
38
|
+
}
|
|
39
|
+
const moduleKeys = moduleKey ? [moduleKey] : Object.keys(appManifest.modules);
|
|
40
|
+
const uncursored = [];
|
|
41
|
+
for (const key of moduleKeys) {
|
|
42
|
+
if (appManifest.migrationCursor?.[key] == null && (await getModuleMigrations(key, catalog[key])).length > 0) uncursored.push(key);
|
|
43
|
+
}
|
|
44
|
+
if (uncursored.length > 0) throw new Error(`Migration upgrade blocked: ${uncursored.join(", ")} ${uncursored.length === 1 ? "has" : "have"} a null migration cursor. Set an explicit cursor with bw adopt --force --cursor before upgrading.`);
|
|
45
|
+
const migrationPlan = await planMigrationAppends({ targetDir, moduleKeys, catalog, migrationCursor: appManifest.migrationCursor });
|
|
46
|
+
output.write(`bw upgrade\nPackages to update: ${plan.packageUpdates.length}\nManaged files to write: ${plan.fileWrites.length}\nMigrations to append: ${migrationPlan.writes.length}\n`);
|
|
47
|
+
for (const relativePath of missing) output.write(`- missing: ${relativePath}\n`);
|
|
48
|
+
for (const relativePath of drifted) output.write(`- drifted: ${relativePath}\n`);
|
|
49
|
+
for (const relativePath of intentional) output.write(`- intent-protected: ${relativePath}\n`);
|
|
50
|
+
if (argvOptions.dryRun) return { dryRun: true, plan, migrationPlan, drifted, missing };
|
|
51
|
+
|
|
52
|
+
for (const write of plan.fileWrites) {
|
|
53
|
+
await fs.mkdir(path.dirname(write.targetPath), { recursive: true });
|
|
54
|
+
await fs.writeFile(write.targetPath, write.content, "utf8");
|
|
55
|
+
}
|
|
56
|
+
await applyMigrationWrites(migrationPlan.writes);
|
|
57
|
+
appManifest.migrationCursor = migrationPlan.nextCursor;
|
|
58
|
+
for (const update of plan.packageUpdates) {
|
|
59
|
+
const key = Object.keys(catalog).find((candidate) => catalog[candidate].packageName === update.packageName);
|
|
60
|
+
if (key && appManifest.modules[key]) appManifest.modules[key].version = cleanVersion(update.to) || appManifest.modules[key].version;
|
|
61
|
+
}
|
|
62
|
+
for (const relativePath of plan.starterFilesToRefresh || []) {
|
|
63
|
+
if (!protectedPaths.has(relativePath) && appManifest.scaffoldFiles[relativePath] && await pathExists(path.join(targetDir, relativePath))) {
|
|
64
|
+
appManifest.scaffoldFiles[relativePath].hash = await hashFile(path.join(targetDir, relativePath));
|
|
65
|
+
appManifest.scaffoldFiles[relativePath].status = "current";
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
await writeAppManifest(targetDir, appManifest);
|
|
69
|
+
const packageChanged = plan.fileWrites.some((entry) => entry.relativePath === "package.json");
|
|
70
|
+
if (argvOptions.install && packageChanged) {
|
|
71
|
+
const runner = runtimeOptions.installRunner || runInstall;
|
|
72
|
+
await runner(plan.packageManager, plan.dependencyMode === "workspace" && plan.workspaceRoot ? plan.workspaceRoot : targetDir);
|
|
73
|
+
}
|
|
74
|
+
output.write(`Applied ${plan.fileWrites.length} managed change${plan.fileWrites.length === 1 ? "" : "s"} and ${migrationPlan.writes.length} migration${migrationPlan.writes.length === 1 ? "" : "s"}.\n`);
|
|
75
|
+
return { dryRun: false, plan, migrationPlan, drifted, missing };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export { HELP as UPGRADE_HELP };
|
|
@@ -104,7 +104,7 @@ export function getStarterBootstrapChecklist() {
|
|
|
104
104
|
{
|
|
105
105
|
label: "Preview CRM module",
|
|
106
106
|
done: hasModule(moduleKeys, "crm"),
|
|
107
|
-
detail: hasModule(moduleKeys, "crm") ? "/
|
|
107
|
+
detail: hasModule(moduleKeys, "crm") ? "/crm" : "CRM not enabled",
|
|
108
108
|
},
|
|
109
109
|
{
|
|
110
110
|
label: "Preview Projects module",
|
|
@@ -26,7 +26,7 @@ export const starterModuleConfig: StarterModuleConfig[] = [
|
|
|
26
26
|
description: "Contacts and CRM server/data layer, with marketing-adjacent operational data stored in Supabase.",
|
|
27
27
|
enabled: true,
|
|
28
28
|
packageName: "@brightweblabs/module-crm",
|
|
29
|
-
playgroundHref: "/
|
|
29
|
+
playgroundHref: "/crm",
|
|
30
30
|
placement: "primary",
|
|
31
31
|
},
|
|
32
32
|
{
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ShellRegistrationOverrides } from "@brightweblabs/app-shell";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* App-owned shell customizations. This scaffolded file is never overwritten by
|
|
5
|
+
* `create-bw-app update`.
|
|
6
|
+
*
|
|
7
|
+
* @example Rewrite a module href wherever it appears in its registration:
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { overrideNavHref } from "@brightweblabs/app-shell";
|
|
10
|
+
*
|
|
11
|
+
* const overrides: ShellRegistrationOverrides = {
|
|
12
|
+
* crm: (registration) => overrideNavHref(registration, "/admin/marketing", "/crm/marketing"),
|
|
13
|
+
* };
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export const shellRegistrationOverrides: ShellRegistrationOverrides = {};
|
|
@@ -24,7 +24,8 @@ This app is a normal Next.js App Router project with BrightWeb runtime wiring la
|
|
|
24
24
|
- `config/modules.ts`: module metadata and enablement flags for CRM, Projects, and Admin.
|
|
25
25
|
- `config/client.ts`: aggregated state consumed by starter pages.
|
|
26
26
|
- `config/bootstrap.ts`: bootstrap checklist content for `/bootstrap`.
|
|
27
|
-
- `config/shell.ts`: app-shell registration and navigation wiring.
|
|
27
|
+
- `config/shell.ts`: managed app-shell registration and navigation wiring.
|
|
28
|
+
- `config/shell.overrides.ts`: app-owned shell registration customizations preserved across updates.
|
|
28
29
|
- `app/globals.css`: color tokens (raw brand + semantic mappings), typography, and global surface styling.
|
|
29
30
|
- `app/page.tsx`: starter landing page for the generated app.
|
|
30
31
|
- `app/bootstrap/page.tsx`: setup checklist surface.
|
|
@@ -38,7 +39,7 @@ This app is a normal Next.js App Router project with BrightWeb runtime wiring la
|
|
|
38
39
|
- Change colors and theme tokens in `app/globals.css`.
|
|
39
40
|
- Check module presence in `config/modules.ts` before editing or creating module-specific routes.
|
|
40
41
|
- Add app-specific UI in `components/` before forking shared package code.
|
|
41
|
-
- Use `config/shell.ts` when navigation or toolbar behavior needs to change.
|
|
42
|
+
- Use `config/shell.overrides.ts` when navigation or toolbar behavior needs to change.
|
|
42
43
|
- Use `config/bootstrap.ts` and `config/client.ts` when the setup checklist or readiness messaging is wrong.
|
|
43
44
|
- Keep starter validation routes until the real product routes replace their purpose.
|
|
44
45
|
|
|
@@ -12,7 +12,7 @@ Goal: get the generated starter running with real credentials.
|
|
|
12
12
|
- Review `config/modules.ts` before touching module routes.
|
|
13
13
|
- Run the local dev server for this app or workspace.
|
|
14
14
|
- Validate `/`, `/bootstrap`, `/preview/app-shell`, and `/playground/auth`.
|
|
15
|
-
- Validate `/
|
|
15
|
+
- Validate `/crm`, `/playground/projects`, and `/playground/admin` only when those modules are enabled.
|
|
16
16
|
|
|
17
17
|
## Change brand identity
|
|
18
18
|
|
|
@@ -30,7 +30,7 @@ Goal: move from validation surfaces to product-owned pages.
|
|
|
30
30
|
|
|
31
31
|
- Build the real routes in `app/` first.
|
|
32
32
|
- Keep reusable route UI in `components/` so the app follows the expected Next.js folder split.
|
|
33
|
-
- Update `config/shell.ts` if navigation or toolbar behavior changes.
|
|
33
|
+
- Update `config/shell.overrides.ts` if navigation or toolbar behavior changes.
|
|
34
34
|
- Remove `/bootstrap`, `/preview/app-shell`, or `/playground/*` only after links and config references are cleaned up.
|
|
35
35
|
|
|
36
36
|
## Make a module-aware change
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<svg width="176" height="44" viewBox="0 0 176 44" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2
2
|
<rect width="44" height="44" rx="12" fill="#F5F4EC"/>
|
|
3
|
-
<path d="M12
|
|
4
|
-
<
|
|
3
|
+
<path d="M12 32V12H22.2C26.18 12 28.52 13.94 28.52 17.06C28.52 19.04 27.56 20.48 25.86 21.22C28.08 21.88 29.4 23.54 29.4 25.94C29.4 29.7 26.68 32 22.22 32H12ZM16.42 20.04H21.48C23.1 20.04 24.02 19.24 24.02 17.86C24.02 16.46 23.1 15.68 21.48 15.68H16.42V20.04ZM16.42 28.28H21.96C23.82 28.28 24.86 27.42 24.86 25.86C24.86 24.32 23.82 23.46 21.96 23.46H16.42V28.28Z" fill="#102015"/>
|
|
4
|
+
<path d="M30.2 12H34.58L36.72 23.86L39.32 12H43.1L39 32H34.88L32.38 19.64L29.9 32H25.82L25.1 28.56H29.1L30.2 12Z" fill="#2C8A53"/>
|
|
5
5
|
<text x="58" y="19" fill="#F5F4EC" font-family="Georgia, serif" font-size="11" letter-spacing="2">BRIGHTWEB</text>
|
|
6
6
|
<text x="58" y="33" fill="#C8D0C9" font-family="'IBM Plex Sans', sans-serif" font-size="15">starter client</text>
|
|
7
7
|
</svg>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<svg width="176" height="44" viewBox="0 0 176 44" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2
2
|
<rect width="44" height="44" rx="12" fill="#13251A"/>
|
|
3
|
-
<path d="M12
|
|
4
|
-
<
|
|
3
|
+
<path d="M12 32V12H22.2C26.18 12 28.52 13.94 28.52 17.06C28.52 19.04 27.56 20.48 25.86 21.22C28.08 21.88 29.4 23.54 29.4 25.94C29.4 29.7 26.68 32 22.22 32H12ZM16.42 20.04H21.48C23.1 20.04 24.02 19.24 24.02 17.86C24.02 16.46 23.1 15.68 21.48 15.68H16.42V20.04ZM16.42 28.28H21.96C23.82 28.28 24.86 27.42 24.86 25.86C24.86 24.32 23.82 23.46 21.96 23.46H16.42V28.28Z" fill="#F5F4EC"/>
|
|
4
|
+
<path d="M30.2 12H34.58L36.72 23.86L39.32 12H43.1L39 32H34.88L32.38 19.64L29.9 32H25.82L25.1 28.56H29.1L30.2 12Z" fill="#71C48B"/>
|
|
5
5
|
<text x="58" y="19" fill="#13251A" font-family="Georgia, serif" font-size="11" letter-spacing="2">BRIGHTWEB</text>
|
|
6
6
|
<text x="58" y="33" fill="#516252" font-family="'IBM Plex Sans', sans-serif" font-size="15">starter client</text>
|
|
7
7
|
</svg>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2
2
|
<rect width="64" height="64" rx="18" fill="#13251A"/>
|
|
3
|
-
<path d="
|
|
4
|
-
<
|
|
3
|
+
<path d="M15 44V20H28.02C32.8 20 35.76 22.42 35.76 26.24C35.76 28.58 34.62 30.28 32.58 31.18C35.22 32.02 36.84 34.04 36.84 36.86C36.84 41.36 33.42 44 28.02 44H15ZM20.36 29.58H27.08C29.08 29.58 30.24 28.62 30.24 26.96C30.24 25.3 29.08 24.36 27.08 24.36H20.36V29.58ZM20.36 39.62H27.64C29.98 39.62 31.28 38.56 31.28 36.68C31.28 34.84 29.98 33.78 27.64 33.78H20.36V39.62Z" fill="#F5F4EC"/>
|
|
4
|
+
<path d="M38.72 20H44.18L46.58 34.74L49.72 20H54.5L49.32 44H44.12L41.18 29.34L38.24 44H33.16L32.26 39.72H37.22L38.72 20Z" fill="#71C48B"/>
|
|
5
5
|
</svg>
|
|
@@ -6,3 +6,13 @@ export const GET = createModuleRouteHandler(
|
|
|
6
6
|
() => import("@brightweblabs/module-crm"),
|
|
7
7
|
"handleCrmContactsGetRequest",
|
|
8
8
|
);
|
|
9
|
+
|
|
10
|
+
export const POST = createModuleRouteHandler(
|
|
11
|
+
() => import("@brightweblabs/module-crm"),
|
|
12
|
+
"handleCrmContactsPostRequest",
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
export const PATCH = createModuleRouteHandler(
|
|
16
|
+
() => import("@brightweblabs/module-crm"),
|
|
17
|
+
"handleCrmContactsPatchRequest",
|
|
18
|
+
);
|