create-bw-app 0.10.0 → 0.11.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 +100 -0
- package/src/adopt.mjs +176 -0
- package/src/app-manifest.mjs +250 -0
- package/src/bw.mjs +53 -0
- package/src/constants.mjs +22 -11
- package/src/diff.mjs +85 -0
- package/src/doctor.mjs +99 -0
- package/src/generator.mjs +68 -12
- package/src/migrations.mjs +92 -0
- package/src/remove.mjs +100 -0
- package/src/scaffold.mjs +82 -0
- package/src/update.mjs +51 -3
- package/src/upgrade.mjs +73 -0
- package/template/base/AGENTS.md +2 -0
- package/template/base/app/globals.css +30 -14
- package/template/base/app/page.tsx +1 -0
- package/template/base/config/bootstrap.ts +1 -1
- package/template/base/config/brand.ts +0 -2
- package/template/base/config/modules.ts +2 -2
- package/template/base/config/shell.overrides.ts +16 -0
- package/template/base/docs/ai/README.md +6 -3
- package/template/base/docs/ai/examples.md +4 -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/README.md +2 -0
- 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 +19 -0
- package/template/supabase/modules/orgs/README.md +4 -0
- package/template/supabase/modules/orgs/migrations/20260316091500_orgs_v1.sql +216 -0
- package/template/supabase/modules/projects/migrations/20260421201528_portal_read_indexes.sql +6 -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 (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 };
|
package/src/scaffold.mjs
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
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
|
+
for (const [relativePath, record] of Object.entries(scaffoldFiles)) {
|
|
62
|
+
const appPath = path.join(targetDir, relativePath);
|
|
63
|
+
if (!(await pathExists(appPath))) missing.push(relativePath);
|
|
64
|
+
else if (await hashFile(appPath) === record.hash) current.push(relativePath);
|
|
65
|
+
else drifted.push(relativePath);
|
|
66
|
+
}
|
|
67
|
+
return { current, drifted, missing };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function findTrackedTemplate({ relativePath, manifest, targetDir, workspaceRoot }) {
|
|
71
|
+
const record = manifest.scaffoldFiles?.[relativePath];
|
|
72
|
+
if (!record) return { record: null, templatePath: null, templateRoot: null };
|
|
73
|
+
const definition = trackedScaffoldDefinitions(Object.keys(manifest.modules || {}))
|
|
74
|
+
.find((entry) => entry.relativePath === relativePath && entry.moduleKey === record.module);
|
|
75
|
+
const templateRoot = await resolveTemplateRoot({ targetDir, workspaceRoot });
|
|
76
|
+
const templatePath = definition && templateRoot ? path.join(templateRoot, definition.templateRelativePath) : null;
|
|
77
|
+
return { record, templatePath: templatePath && await pathExists(templatePath) ? templatePath : null, templateRoot };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function readTextFile(filePath) {
|
|
81
|
+
return fs.readFile(filePath, "utf8");
|
|
82
|
+
}
|
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,73 @@
|
|
|
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
|
+
for (const [relativePath, record] of Object.entries(appManifest.scaffoldFiles)) {
|
|
22
|
+
const filePath = path.join(targetDir, relativePath);
|
|
23
|
+
if (!(await pathExists(filePath))) { missing.push(relativePath); continue; }
|
|
24
|
+
if (await hashFile(filePath) !== record.hash) drifted.push(relativePath);
|
|
25
|
+
}
|
|
26
|
+
const protectedPaths = new Set(drifted);
|
|
27
|
+
plan.fileWrites = plan.fileWrites.filter((entry) => entry.type !== "starter" || !protectedPaths.has(entry.relativePath));
|
|
28
|
+
plan.starterFilesDrifted = Array.from(new Set([...plan.starterFilesDrifted, ...drifted]));
|
|
29
|
+
plan.starterFilesMissing = Array.from(new Set([...plan.starterFilesMissing, ...missing]));
|
|
30
|
+
|
|
31
|
+
const catalog = await loadModuleCatalog({ targetDir, workspaceRoot });
|
|
32
|
+
for (const update of plan.packageUpdates) {
|
|
33
|
+
const key = Object.keys(catalog).find((candidate) => catalog[candidate].packageName === update.packageName);
|
|
34
|
+
if (key) catalog[key].version = cleanVersion(update.to) || catalog[key].version;
|
|
35
|
+
}
|
|
36
|
+
const moduleKeys = moduleKey ? [moduleKey] : Object.keys(appManifest.modules);
|
|
37
|
+
const uncursored = [];
|
|
38
|
+
for (const key of moduleKeys) {
|
|
39
|
+
if (appManifest.migrationCursor?.[key] == null && (await getModuleMigrations(key, catalog[key])).length > 0) uncursored.push(key);
|
|
40
|
+
}
|
|
41
|
+
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.`);
|
|
42
|
+
const migrationPlan = await planMigrationAppends({ targetDir, moduleKeys, catalog, migrationCursor: appManifest.migrationCursor });
|
|
43
|
+
output.write(`bw upgrade\nPackages to update: ${plan.packageUpdates.length}\nManaged files to write: ${plan.fileWrites.length}\nMigrations to append: ${migrationPlan.writes.length}\n`);
|
|
44
|
+
for (const relativePath of missing) output.write(`- missing: ${relativePath}\n`);
|
|
45
|
+
for (const relativePath of drifted) output.write(`- drifted: ${relativePath}\n`);
|
|
46
|
+
if (argvOptions.dryRun) return { dryRun: true, plan, migrationPlan, drifted, missing };
|
|
47
|
+
|
|
48
|
+
for (const write of plan.fileWrites) {
|
|
49
|
+
await fs.mkdir(path.dirname(write.targetPath), { recursive: true });
|
|
50
|
+
await fs.writeFile(write.targetPath, write.content, "utf8");
|
|
51
|
+
}
|
|
52
|
+
await applyMigrationWrites(migrationPlan.writes);
|
|
53
|
+
appManifest.migrationCursor = migrationPlan.nextCursor;
|
|
54
|
+
for (const update of plan.packageUpdates) {
|
|
55
|
+
const key = Object.keys(catalog).find((candidate) => catalog[candidate].packageName === update.packageName);
|
|
56
|
+
if (key && appManifest.modules[key]) appManifest.modules[key].version = cleanVersion(update.to) || appManifest.modules[key].version;
|
|
57
|
+
}
|
|
58
|
+
for (const relativePath of plan.starterFilesToRefresh || []) {
|
|
59
|
+
if (!protectedPaths.has(relativePath) && appManifest.scaffoldFiles[relativePath] && await pathExists(path.join(targetDir, relativePath))) {
|
|
60
|
+
appManifest.scaffoldFiles[relativePath].hash = await hashFile(path.join(targetDir, relativePath));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
await writeAppManifest(targetDir, appManifest);
|
|
64
|
+
const packageChanged = plan.fileWrites.some((entry) => entry.relativePath === "package.json");
|
|
65
|
+
if (argvOptions.install && packageChanged) {
|
|
66
|
+
const runner = runtimeOptions.installRunner || runInstall;
|
|
67
|
+
await runner(plan.packageManager, plan.dependencyMode === "workspace" && plan.workspaceRoot ? plan.workspaceRoot : targetDir);
|
|
68
|
+
}
|
|
69
|
+
output.write(`Applied ${plan.fileWrites.length} managed change${plan.fileWrites.length === 1 ? "" : "s"} and ${migrationPlan.writes.length} migration${migrationPlan.writes.length === 1 ? "" : "s"}.\n`);
|
|
70
|
+
return { dryRun: false, plan, migrationPlan, drifted, missing };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export { HELP as UPGRADE_HELP };
|
package/template/base/AGENTS.md
CHANGED
|
@@ -10,6 +10,7 @@ This generated project is a BrightWeb platform starter. Use this file as the loc
|
|
|
10
10
|
- `docs/ai/app-context.json`: machine-readable app summary for quick discovery.
|
|
11
11
|
- `components/`: local app components used by starter routes and future product surfaces.
|
|
12
12
|
- `config/brand.ts`: client identity, naming, and contact defaults.
|
|
13
|
+
- `app/globals.css`: global design tokens, theme mapping, and shared visual styling.
|
|
13
14
|
- `config/modules.ts`: selected module set and runtime enablement.
|
|
14
15
|
- `config/client.ts`: starter-facing derived state used by the home page and setup surfaces.
|
|
15
16
|
- `.env.local`: runtime service values for local development.
|
|
@@ -17,6 +18,7 @@ This generated project is a BrightWeb platform starter. Use this file as the loc
|
|
|
17
18
|
## Working rules
|
|
18
19
|
|
|
19
20
|
- Treat `/bootstrap`, `/preview/app-shell`, and `/playground/*` as starter validation surfaces. They are app-owned and can be removed after setup if links and references are cleaned up too.
|
|
21
|
+
- Keep identity/contact in `config/brand.ts`, and keep all color/theme tokens in `app/globals.css`.
|
|
20
22
|
- Check `config/modules.ts` before assuming CRM, Projects, or Admin routes exist.
|
|
21
23
|
- Prefer composing app-level routes and config before forking logic from `@brightweblabs/*` packages.
|
|
22
24
|
- Keep edits local to this app unless the change is intentionally shared across multiple BrightWeb projects.
|
|
@@ -1,20 +1,36 @@
|
|
|
1
1
|
@import "tailwindcss";
|
|
2
|
+
@import "@brightweblabs/theme/css";
|
|
2
3
|
|
|
3
4
|
:root {
|
|
4
5
|
color-scheme: light;
|
|
5
6
|
--font-sans: "IBM Plex Sans", "Segoe UI", sans-serif;
|
|
6
7
|
--font-display: Georgia, "Times New Roman", serif;
|
|
8
|
+
/* Raw brand tokens: customize these first per client. */
|
|
9
|
+
--brand-primary: #266946;
|
|
10
|
+
--brand-primary-rgb: 38, 105, 70;
|
|
11
|
+
--brand-secondary-rgb: 126, 170, 112;
|
|
12
|
+
--brand-highlight-rgb: 189, 140, 89;
|
|
13
|
+
--brand-positive-rgb: 113, 196, 139;
|
|
14
|
+
/* Semantic theme tokens consumed by the UI. */
|
|
7
15
|
--background: #f3efe5;
|
|
8
16
|
--foreground: #18241d;
|
|
9
17
|
--muted-foreground: #5a665c;
|
|
10
18
|
--border: rgba(24, 36, 29, 0.12);
|
|
11
19
|
--panel: rgba(255, 255, 255, 0.82);
|
|
12
20
|
--panel-strong: #ffffff;
|
|
13
|
-
--accent:
|
|
14
|
-
--accent-soft: rgba(
|
|
21
|
+
--accent: var(--brand-primary);
|
|
22
|
+
--accent-soft: rgba(var(--brand-primary-rgb), 0.12);
|
|
15
23
|
--danger: #b43f3f;
|
|
16
24
|
--ink-soft: #eef1e8;
|
|
17
|
-
--ring: rgba(
|
|
25
|
+
--ring: rgba(var(--brand-primary-rgb), 0.28);
|
|
26
|
+
--surface-glow-cool: rgba(var(--brand-secondary-rgb), 0.18);
|
|
27
|
+
--surface-glow-warm: rgba(var(--brand-highlight-rgb), 0.12);
|
|
28
|
+
--accent-glow-strong: rgba(var(--brand-primary-rgb), 0.18);
|
|
29
|
+
--accent-glow-mid: rgba(var(--brand-primary-rgb), 0.14);
|
|
30
|
+
--accent-glow-soft: rgba(var(--brand-primary-rgb), 0.08);
|
|
31
|
+
--positive-glow-strong: rgba(var(--brand-positive-rgb), 0.5);
|
|
32
|
+
--positive-glow-soft: rgba(var(--brand-positive-rgb), 0.14);
|
|
33
|
+
--positive-glow-subtle: rgba(var(--brand-positive-rgb), 0.15);
|
|
18
34
|
|
|
19
35
|
/* Compatibility aliases so both starters expose the same token surface. */
|
|
20
36
|
--bg: var(--background);
|
|
@@ -32,8 +48,8 @@ body {
|
|
|
32
48
|
margin: 0;
|
|
33
49
|
padding: 0;
|
|
34
50
|
background:
|
|
35
|
-
radial-gradient(circle at 15% 10%,
|
|
36
|
-
radial-gradient(circle at 85% 12%,
|
|
51
|
+
radial-gradient(circle at 15% 10%, var(--surface-glow-cool), transparent 22%),
|
|
52
|
+
radial-gradient(circle at 85% 12%, var(--surface-glow-warm), transparent 18%),
|
|
37
53
|
linear-gradient(180deg, #faf8f1 0%, var(--bg) 48%, #e8e0cf 100%);
|
|
38
54
|
color: var(--text);
|
|
39
55
|
font-family: var(--font-sans), sans-serif;
|
|
@@ -97,7 +113,7 @@ input {
|
|
|
97
113
|
overflow: hidden;
|
|
98
114
|
background:
|
|
99
115
|
linear-gradient(180deg, rgba(255, 255, 255, 0.86), rgba(247, 244, 235, 0.84)),
|
|
100
|
-
radial-gradient(circle at top right,
|
|
116
|
+
radial-gradient(circle at top right, var(--accent-glow-strong), transparent 45%);
|
|
101
117
|
}
|
|
102
118
|
|
|
103
119
|
.starter-hero-card::after {
|
|
@@ -107,7 +123,7 @@ input {
|
|
|
107
123
|
width: 150px;
|
|
108
124
|
height: 150px;
|
|
109
125
|
border-radius: 999px;
|
|
110
|
-
background:
|
|
126
|
+
background: var(--accent-glow-soft);
|
|
111
127
|
}
|
|
112
128
|
|
|
113
129
|
.starter-stat-grid {
|
|
@@ -185,7 +201,7 @@ input {
|
|
|
185
201
|
.preview-glass-card {
|
|
186
202
|
background:
|
|
187
203
|
linear-gradient(180deg, rgba(255, 255, 255, 0.84), rgba(246, 244, 236, 0.76)),
|
|
188
|
-
radial-gradient(circle at top right,
|
|
204
|
+
radial-gradient(circle at top right, var(--accent-glow-soft), transparent 45%);
|
|
189
205
|
}
|
|
190
206
|
|
|
191
207
|
.panel h2,
|
|
@@ -308,7 +324,7 @@ input {
|
|
|
308
324
|
border-radius: 28px;
|
|
309
325
|
background:
|
|
310
326
|
linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(241, 237, 226, 0.84)),
|
|
311
|
-
radial-gradient(circle at top,
|
|
327
|
+
radial-gradient(circle at top, var(--accent-soft), transparent 34%);
|
|
312
328
|
backdrop-filter: blur(16px);
|
|
313
329
|
box-shadow: 0 22px 60px rgba(24, 36, 29, 0.1);
|
|
314
330
|
}
|
|
@@ -328,7 +344,7 @@ input {
|
|
|
328
344
|
border-radius: 28px;
|
|
329
345
|
background:
|
|
330
346
|
linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(246, 243, 235, 0.86)),
|
|
331
|
-
radial-gradient(circle at right top,
|
|
347
|
+
radial-gradient(circle at right top, var(--accent-glow-mid), transparent 35%);
|
|
332
348
|
backdrop-filter: blur(16px);
|
|
333
349
|
}
|
|
334
350
|
|
|
@@ -374,7 +390,7 @@ input {
|
|
|
374
390
|
.preview-stage-panel {
|
|
375
391
|
background:
|
|
376
392
|
linear-gradient(180deg, rgba(23, 37, 29, 0.96), rgba(18, 30, 24, 0.95)),
|
|
377
|
-
radial-gradient(circle at top left,
|
|
393
|
+
radial-gradient(circle at top left, var(--positive-glow-subtle), transparent 34%);
|
|
378
394
|
color: #f4f1e8;
|
|
379
395
|
border-color: rgba(255, 255, 255, 0.1);
|
|
380
396
|
}
|
|
@@ -409,8 +425,8 @@ input {
|
|
|
409
425
|
|
|
410
426
|
.nav-chip.active,
|
|
411
427
|
.nav-chip:hover {
|
|
412
|
-
border-color:
|
|
413
|
-
background:
|
|
428
|
+
border-color: var(--positive-glow-strong);
|
|
429
|
+
background: var(--positive-glow-soft);
|
|
414
430
|
}
|
|
415
431
|
|
|
416
432
|
.preview-surface-grid {
|
|
@@ -510,7 +526,7 @@ input {
|
|
|
510
526
|
}
|
|
511
527
|
|
|
512
528
|
.status.ok {
|
|
513
|
-
background: rgba(
|
|
529
|
+
background: rgba(var(--brand-primary-rgb), 0.14);
|
|
514
530
|
color: var(--accent);
|
|
515
531
|
}
|
|
516
532
|
|
|
@@ -113,6 +113,7 @@ export default function HomePage() {
|
|
|
113
113
|
<h2>Starter controls</h2>
|
|
114
114
|
<ul className="list">
|
|
115
115
|
<li>`config/brand.ts` for client identity and contact details.</li>
|
|
116
|
+
<li>`app/globals.css` for color and theme token management.</li>
|
|
116
117
|
<li>`config/modules.ts` for enabled platform modules.</li>
|
|
117
118
|
<li>`config/env.ts` for infra requirements and readiness checks.</li>
|
|
118
119
|
<li>`.env.local` for per-client service credentials and local runtime overrides.</li>
|
|
@@ -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",
|
|
@@ -5,7 +5,6 @@ export type StarterBrandConfig = {
|
|
|
5
5
|
tagline: string;
|
|
6
6
|
contactEmail: string;
|
|
7
7
|
supportEmail: string;
|
|
8
|
-
primaryHex: string;
|
|
9
8
|
};
|
|
10
9
|
|
|
11
10
|
export const starterBrandConfig: StarterBrandConfig = {
|
|
@@ -15,5 +14,4 @@ export const starterBrandConfig: StarterBrandConfig = {
|
|
|
15
14
|
tagline: "A configurable Brightweb starter app for shipping new client instances without rebuilding the platform.",
|
|
16
15
|
contactEmail: "hello@example.com",
|
|
17
16
|
supportEmail: "support@example.com",
|
|
18
|
-
primaryHex: "#1f7a45",
|
|
19
17
|
};
|
|
@@ -23,10 +23,10 @@ export const starterModuleConfig: StarterModuleConfig[] = [
|
|
|
23
23
|
{
|
|
24
24
|
key: "crm",
|
|
25
25
|
label: "CRM",
|
|
26
|
-
description: "Contacts
|
|
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 = {};
|
|
@@ -20,11 +20,13 @@ This app is a normal Next.js App Router project with BrightWeb runtime wiring la
|
|
|
20
20
|
- `docs/ai/examples.md`: common setup and customization workflows.
|
|
21
21
|
- `README.md`: first-run setup steps.
|
|
22
22
|
- `components/`: local app component layer for starter surfaces and future product UI.
|
|
23
|
-
- `config/brand.ts`: client
|
|
23
|
+
- `config/brand.ts`: client identity, product naming, and contact inboxes.
|
|
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.
|
|
29
|
+
- `app/globals.css`: color tokens (raw brand + semantic mappings), typography, and global surface styling.
|
|
28
30
|
- `app/page.tsx`: starter landing page for the generated app.
|
|
29
31
|
- `app/bootstrap/page.tsx`: setup checklist surface.
|
|
30
32
|
- `app/preview/app-shell/page.tsx`: shell preview validation route.
|
|
@@ -34,9 +36,10 @@ This app is a normal Next.js App Router project with BrightWeb runtime wiring la
|
|
|
34
36
|
## Editing strategy
|
|
35
37
|
|
|
36
38
|
- Change client identity first in `config/brand.ts`.
|
|
39
|
+
- Change colors and theme tokens in `app/globals.css`.
|
|
37
40
|
- Check module presence in `config/modules.ts` before editing or creating module-specific routes.
|
|
38
41
|
- Add app-specific UI in `components/` before forking shared package code.
|
|
39
|
-
- 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.
|
|
40
43
|
- Use `config/bootstrap.ts` and `config/client.ts` when the setup checklist or readiness messaging is wrong.
|
|
41
44
|
- Keep starter validation routes until the real product routes replace their purpose.
|
|
42
45
|
|
|
@@ -8,16 +8,18 @@ Goal: get the generated starter running with real credentials.
|
|
|
8
8
|
|
|
9
9
|
- Review `.env.local` and replace placeholder values.
|
|
10
10
|
- Review `config/brand.ts` and confirm client identity.
|
|
11
|
+
- Review `app/globals.css` and confirm brand tokens map to the intended visual system.
|
|
11
12
|
- Review `config/modules.ts` before touching module routes.
|
|
12
13
|
- Run the local dev server for this app or workspace.
|
|
13
14
|
- Validate `/`, `/bootstrap`, `/preview/app-shell`, and `/playground/auth`.
|
|
14
|
-
- Validate `/
|
|
15
|
+
- Validate `/crm`, `/playground/projects`, and `/playground/admin` only when those modules are enabled.
|
|
15
16
|
|
|
16
17
|
## Change brand identity
|
|
17
18
|
|
|
18
19
|
Goal: update the starter to the real client name and support details.
|
|
19
20
|
|
|
20
21
|
- Edit `config/brand.ts`.
|
|
22
|
+
- Edit `app/globals.css` when palette or theme token mapping needs to change.
|
|
21
23
|
- Move route-specific presentation into `components/` when the home or preview surfaces need app-owned UI.
|
|
22
24
|
- Check `config/client.ts` or `config/bootstrap.ts` if starter copy still references old defaults.
|
|
23
25
|
- Validate the home page and `/preview/app-shell` after the change.
|
|
@@ -28,7 +30,7 @@ Goal: move from validation surfaces to product-owned pages.
|
|
|
28
30
|
|
|
29
31
|
- Build the real routes in `app/` first.
|
|
30
32
|
- Keep reusable route UI in `components/` so the app follows the expected Next.js folder split.
|
|
31
|
-
- Update `config/shell.ts` if navigation or toolbar behavior changes.
|
|
33
|
+
- Update `config/shell.overrides.ts` if navigation or toolbar behavior changes.
|
|
32
34
|
- Remove `/bootstrap`, `/preview/app-shell`, or `/playground/*` only after links and config references are cleaned up.
|
|
33
35
|
|
|
34
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>
|