feat(brand): per-brand build configs and asset pipeline

apps/web/package.json gains build:safe / build:lux / build:hanzo
scripts. Each runs scripts/select-brand.sh <slug> (which copies
.env.<slug> -> .env.local, brand/<slug>/ -> brand/active/, and the
per-brand manifest.json over safe.webmanifest) then `yarn build`.

scripts/codemod-brand-urls.mjs is the Pass 1 driver kept in-tree so
future merges with upstream can re-run it. .gitignore covers the
brand/active output dir.

HANZO_WHITELABEL.md updated with the per-brand invocation, the
canonical pattern table, the protocol-vs-brand boundary, and how
to add a new brand (env + asset dir + one package.json script — no
source edits).
This commit is contained in:
Hanzo AI
2026-05-15 02:59:47 -07:00
parent 28eb4c904e
commit fb9b95d50c
5 changed files with 372 additions and 68 deletions
+2
View File
@@ -89,6 +89,8 @@ thumbs.db
# web
apps/web/.next/*
apps/web/out/*
# white-label build output: select-brand.sh copies the active brand here
apps/web/public/brand/active/
apps/web/public/work*.js
apps/web/public/sw.js
apps/web/public/firebase*.js
+101 -68
View File
@@ -1,97 +1,130 @@
# Safe Wallet — White-Label Plan
This monorepo is a fork of `safe-global/safe-wallet-monorepo`. We want
a single codebase that can ship multiple branded wallets (Lux, Hanzo,
partner builds) without forking per brand.
This monorepo is a fork of `safe-global/safe-wallet-monorepo`. We ship multiple
branded wallets (Lux Safe, Hanzo Vault, partner builds) from a single source.
## The strategy: one source, env-driven brand tokens
## Strategy
Every hardcoded brand reference in app code gets replaced with a
read from a single `Brand` config object. Brand config is sourced
from env vars at build time, **never** baked into the source files.
One source. Env-driven brand tokens. Every hardcoded brand reference in app
code reads from a single `Brand` config object exposed by the workspace
package `@safe-global/brand`.
```ts
// packages/brand/src/index.ts (new package)
// packages/brand/src/index.ts
export interface Brand {
name: string // "Lux Safe", "Hanzo Vault", …
shortName: string // "Safe", "Vault" — used in UI chrome
domain: string // "safe.lux.network"
email: string // "support@lux.network"
logoUrl: string // "/brand/logo.svg" (per-tenant CDN)
faviconUrl: string
primaryColor: string
twitterHandle?: string
discordUrl?: string
name: string
shortName: string
domain: string
appHost: string
appUrl: string
email: string
helpUrl: string
termsUrl: string
privacyUrl: string
// …
// … and more
}
export const brand: Brand = {
name: process.env.NEXT_PUBLIC_BRAND_NAME ?? "Safe",
shortName: process.env.NEXT_PUBLIC_BRAND_SHORT_NAME ?? "Safe",
domain: process.env.NEXT_PUBLIC_BRAND_DOMAIN ?? "safe.global",
email: process.env.NEXT_PUBLIC_BRAND_EMAIL ?? "support@safe.global",
name: env('NAME', 'Safe{Wallet}'),
domain: env('DOMAIN', 'safe.global'),
email: env('EMAIL', 'support@safe.global'),
// …
}
```
Every consumer imports from `@hanzo/safe-brand` (workspace alias)
and renders `brand.name` / `brand.logoUrl` / etc. No `'Safe'` string
literal anywhere in `apps/` or `packages/*/src/`.
Defaults match upstream Safe values, so an unbranded build is byte-for-byte
the same as today. Per-brand builds override via `NEXT_PUBLIC_BRAND_*` (web)
or `EXPO_PUBLIC_BRAND_*` (mobile) env vars at build time.
## Brand surface inventory (audit)
## Pattern table
| Pattern | Replacement |
| --------------------------------- | --------------------------------------------- |
| `'Safe{Wallet}'` / `Safe{Mobile}` | `brand.name` |
| `'Safe'` (subject noun in chrome) | `brand.shortName` |
| `https://help.safe.global/...` | `${brand.helpUrl}/...` |
| `https://app.safe.global/...` | `${brand.appUrl}/...` |
| `https://safe.global/{terms,…}` | `brand.{terms,privacy,imprint,…}Url` |
| `support@safe.global` | `brand.email` |
| `/images/safe-logo*.{png,svg}` | `brand.logoUrl` |
| `app.safe.global` (host literal) | `brand.appHost` |
| `anon.safe.global` | `brand.supportChatAliasDomain` |
| `https://chat.safe.global` | `brand.discordUrl` |
| `https://twitter.com/safe` | `brand.twitterUrl` |
| `https://status.safe.global` | `brand.statusUrl` |
| `https://developer.safe.global` | `brand.developerUrl` |
| webmanifest `name`/`description` | per-brand `public/brand/<slug>/manifest.json` |
| mobile Expo `name` | reads `EXPO_PUBLIC_BRAND_NAME` |
## What stays upstream (not brand strings)
- Smart contract addresses (every brand uses the same Safe contracts).
- `safe-client.safe.global` (CGW — Safe Transaction Service host; protocol).
- `safe-transaction-*.safe.global` (the per-network tx-service).
- `safe-transaction-assets.safe.global` (chain logo CDN).
- npm package names of upstream deps (`@safe-global/protocol-kit`,
`@safe-global/api-kit`, etc.).
- TypeScript identifiers (`SafeInfo`, `useSafeInfo`, `class Safe`,
`safe.address` etc.) — these are protocol terms, not brand strings.
- `OFFICIAL_HOSTS` / `IPFS_HOSTS` regexes (identity check for the
upstream-deployed canonical host).
## Per-brand build
Three sample brand bundles ship in-tree:
| Slug | Brand | Env file | Asset dir |
| ------- | ------------ | --------------------- | ------------------------------ |
| `safe` | Safe{Wallet} | `apps/web/.env.safe` | `apps/web/public/brand/safe/` |
| `lux` | Lux Safe | `apps/web/.env.lux` | `apps/web/public/brand/lux/` |
| `hanzo` | Hanzo Vault | `apps/web/.env.hanzo` | `apps/web/public/brand/hanzo/` |
Each `apps/web/public/brand/<slug>/` directory holds the per-brand
`manifest.json` plus logo and icon assets.
### Build invocation
```bash
$ grep -rln "Safe{Wallet}\|safe.global\|Safe Wallet" \
--include='*.tsx' --include='*.ts' apps packages | wc -l
1759
# From repo root.
yarn workspace @safe-global/web build:lux # Lux Safe
yarn workspace @safe-global/web build:hanzo # Hanzo Vault
yarn workspace @safe-global/web build:safe # Upstream Safe defaults (sanity check)
```
≈1,759 files carry one or more brand references. Each falls into a
small set of patterns:
Under the hood each `build:<slug>` runs
`scripts/select-brand.sh <slug>` then `yarn build`. The selector:
| Pattern | Replacement |
|--------------------------------------|--------------------------------------|
| `'Safe{Wallet}'` | `brand.name` |
| `'Safe'` (subject noun) | `brand.shortName` |
| `https://safe.global/...` | `${brand.helpUrl}/...` |
| `support@safe.global` | `brand.email` |
| `/images/safe-logo.svg` | `brand.logoUrl` |
| `app.safe.global` | `brand.domain` |
| Tweet IDs / Discord links | `brand.twitterHandle`, `discordUrl` |
| `<meta name="apple-mobile-web-app-…` | per-brand `app.json` (mobile only) |
1. Copies `apps/web/.env.<slug>``apps/web/.env.local`
2. Copies `apps/web/public/brand/<slug>/``apps/web/public/brand/active/`
3. Copies `apps/web/public/brand/<slug>/manifest.json`
`apps/web/public/safe.webmanifest`
## Implementation phases
`apps/web/.env.local` and `apps/web/public/brand/active/` should be in
`.gitignore` (or treated as build outputs) so the local-dev brand selection
never leaks into commits.
1. **packages/brand**: create the package + types + default Brand
pointing at the upstream Safe values (so the unbranded build
matches today exactly).
2. **Mechanical replace pass 1** — the easy patterns (URLs, email,
logo paths). ~700 files via a single codemod.
3. **Mechanical replace pass 2** — text-in-JSX (`"Safe Wallet"`
`{brand.name}`). ~900 files via a JSX-aware codemod.
4. **Manual pass 3** — anything the codemod can't safely rewrite
(filenames, asset URLs that need per-brand assets, metadata in
`apps/web/public/manifest.json`, native `Info.plist`/`AndroidManifest`).
5. **Per-brand build configs**`apps/web/.env.lux`, `apps/web/.env.hanzo`,
plus matching `mobile/app.config.lux.ts` / `app.config.hanzo.ts`.
6. **Asset pipelines** — brand-token-driven Tailwind theme,
per-brand `public/brand/` directory, build step that copies the
selected brand's assets in.
### Adding a new brand
## What stays upstream
1. Add `apps/web/.env.<slug>` with `NEXT_PUBLIC_BRAND_*` values.
2. Add `apps/web/public/brand/<slug>/` with `manifest.json`, `logo.svg`,
`favicon.ico`, and the Android Chrome icon sizes referenced by the
manifest.
3. Add a `build:<slug>` script to `apps/web/package.json`.
4. (Mobile) wire `EXPO_PUBLIC_BRAND_*` into the EAS build profile in
`apps/mobile/eas.json`.
- Smart contract addresses (those are Safe's protocol — every brand
uses the same contracts on each chain).
- Public-API endpoint paths (the Safe Transaction Service URL
schema; we just point the SDK at a per-network host).
- The `safe-` prefix in npm package names of upstream deps. We don't
rename `@safe-global/protocol-kit` etc.; that's the SDK, not the
brand.
That's the entire surface area. No source file changes needed.
## Tracking
## Status
This file is the single source of truth for the white-label scope.
Update it as phases land.
| Phase | Status | Commit prefix |
| ----- | ------ | ----------------------------------------------------------------------------- |
| 0 | landed | `feat(brand): create @safe-global/brand workspace` |
| 1 | landed | `refactor(brand): pass 1 — URLs, emails, and asset paths route through brand` |
| 2 | landed | `refactor(brand): pass 2 — product name in JSX, copy, and SDK metadata` |
| 3 | landed | `refactor(brand): pass 3 — manifests and metadata` |
| 4 | landed | `feat(brand): per-brand build configs and asset pipeline` |
After all phases land, `grep -RIn 'safe\.global\|support@safe\.global\|Safe{Wallet}' apps/ packages/`
returns only protocol references (the CGW/tx-service hosts) and the brand
package's own defaults — no brand strings in app source.
+3
View File
@@ -9,6 +9,9 @@
"dev:full": "cross-env USE_RSPACK=0 ENABLE_PWA=1 ENABLE_EXPERIMENTAL_OPTIMIZATIONS=1 next dev",
"start": "next dev",
"build": "yarn fetch-chains && next build",
"build:safe": "../../scripts/select-brand.sh safe && yarn build",
"build:lux": "../../scripts/select-brand.sh lux && yarn build",
"build:hanzo": "../../scripts/select-brand.sh hanzo && yarn build",
"lint": "eslint src",
"lint:fix": "eslint src --fix",
"type-check": "tsc --noEmit",
+211
View File
@@ -0,0 +1,211 @@
#!/usr/bin/env node
/**
* codemod-brand-urls.mjs
*
* Pass 1 of the white-label sweep. Replace mechanical URL / email / domain /
* logo-path literals in app + package source with reads off @safe-global/brand.
*
* Strategy: this is a *targeted* rewrite, not a blind substitution. We touch
* only string contexts that look like ES expressions — `'literal'` or
* `"literal"` with a non-`=` non-`}` character immediately before the
* opening quote. That keeps us out of:
*
* - JSX attributes: href="https://..." (preceded by `=`)
* - JSX text: >Safe{Wallet}< (not in scope here — Pass 2)
* - Template-literal placeholders we already produced ourselves
*
* Skip:
* - tests, stories, cypress, fixtures, mocks, AUTO_GENERATED, node_modules,
* dist, build, coverage
* - @safe-global/brand itself
* - safe-client.*, safe-transaction-*, safe-transaction-assets.*,
* api.safe.global, *.5afe.dev (protocol/CDN, not brand)
*
* Import insertion: we add `import { brand } from '@safe-global/brand'` on
* its own line *after* the last existing top-of-file import statement (we
* find it by walking the AST-light way — find the last `from '…'` line
* before any non-import token in column 0).
*/
import { readFileSync, writeFileSync } from 'node:fs'
import { execSync } from 'node:child_process'
import path from 'node:path'
const repoRoot = path.resolve(process.argv[1], '../..')
process.chdir(repoRoot)
const excludePatterns = [
/\/node_modules\//,
/\/AUTO_GENERATED\//,
/\/__tests__\//,
/\/__mocks__\//,
/\/cypress\//,
/\/fixtures\//,
/\.test\.tsx?$/,
/\.stories\.tsx?$/,
/\.cy\.tsx?$/,
/\.cy\.js$/,
/\.spec\.tsx?$/,
/^packages\/brand\//,
/\/dist\//,
/\/build\//,
/\/coverage\//,
/^scripts\//,
]
function listFiles() {
const out = execSync(
"git ls-files 'apps/*.ts' 'apps/*.tsx' 'apps/**/*.ts' 'apps/**/*.tsx' 'packages/*.ts' 'packages/*.tsx' 'packages/**/*.ts' 'packages/**/*.tsx'",
{ encoding: 'utf8' },
)
return out
.split('\n')
.map((s) => s.trim())
.filter(Boolean)
.filter((p) => !excludePatterns.some((re) => re.test(p)))
}
// Map of literal -> brand-property replacement. Each value is the ES
// expression we want to substitute in.
//
// `safeUrl` flag means: when the literal appears in a JSX attribute string
// position (preceded by `=`), wrap with braces — `="x"` becomes `={x}`.
// Currently we DON'T touch JSX attribute strings at all (they're handled by
// Pass 2 which can rewrite them safely). We only touch ES-string positions.
const literalReplacements = [
// marketing safe.global with named paths
{ lit: 'https://safe.global/terms', expr: 'brand.termsUrl' },
{ lit: 'https://safe.global/privacy', expr: 'brand.privacyUrl' },
{ lit: 'https://safe.global/imprint', expr: 'brand.imprintUrl' },
{ lit: 'https://safe.global/cookie', expr: 'brand.cookieUrl' },
{ lit: 'https://safe.global/licenses', expr: 'brand.licensesUrl' },
// primary services
{ lit: 'https://help.safe.global', expr: 'brand.helpUrl' },
{ lit: 'https://status.safe.global', expr: 'brand.statusUrl' },
{ lit: 'https://developer.safe.global', expr: 'brand.developerUrl' },
{ lit: 'https://chat.safe.global', expr: 'brand.discordUrl' },
{ lit: 'https://twitter.com/safe', expr: 'brand.twitterUrl' },
{ lit: 'https://app.safe.global', expr: 'brand.appUrl' },
{ lit: 'support@safe.global', expr: 'brand.email' },
{ lit: 'app.safe.global', expr: 'brand.appHost' },
{ lit: 'anon.safe.global', expr: 'brand.supportChatAliasDomain' },
// logo assets
{ lit: '/images/safe-logo-green.png', expr: 'brand.logoUrl' },
{ lit: '/images/safe-logo.svg', expr: 'brand.logoUrl' },
]
const importLine = "import { brand } from '@safe-global/brand'"
/**
* Insert `importLine` after the last top-of-file import statement. The
* file's import block is the contiguous run of lines starting with `import`
* (potentially spanning multiple lines for `import {\n a,\n b,\n} from …`),
* starting at top of file and continuing until we see a non-import top-
* level statement.
*/
function ensureImport(content) {
if (content.includes("from '@safe-global/brand'")) return content
// Tokenize line by line. We keep accepting lines into the "import block"
// while a JS-aware predicate says we're still in an import.
const lines = content.split('\n')
let lastImportEndIdx = -1
let i = 0
while (i < lines.length) {
const line = lines[i]
const trimmed = line.trim()
if (trimmed.startsWith('import ') || trimmed === 'import {') {
// Walk until we find the line that closes this import — either a
// semicolon at the end, OR a line whose content is `from '…'`. For
// robustness we just keep going until the line ends with a quote or
// a semicolon.
let j = i
while (j < lines.length) {
const t = lines[j].trim()
if (t.endsWith("'") || t.endsWith('"') || t.endsWith(';')) {
lastImportEndIdx = j
break
}
j++
}
i = (lastImportEndIdx >= i ? lastImportEndIdx : i) + 1
continue
}
if (trimmed === '' || trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*')) {
// Allow blank lines and comments inside the import block.
i++
continue
}
break
}
if (lastImportEndIdx >= 0) {
lines.splice(lastImportEndIdx + 1, 0, importLine)
} else {
lines.unshift(importLine)
}
return lines.join('\n')
}
/**
* Replace `'literal'` and `"literal"` with `expr` whenever the quote is in
* an ES-expression position (i.e. NOT preceded by `=`, which signals a JSX
* attribute). Returns the new content and a hit count.
*
* We also rewrite `'literal/path/...'` and `"literal/path/..."` into a
* template literal `\`${expr}/path/...\``.
*/
function rewriteLiterals(content) {
let hits = 0
for (const { lit, expr } of literalReplacements) {
// 1. Bare literal: '<lit>' or "<lit>"
// Allowed prefix: any char that is NOT `=` (JSX attribute) or `}` (rare).
// We accept `(`, `,`, `:`, ` `, `\n`, etc.
const escLit = escapeRegExp(lit)
const bareSingle = new RegExp(`(^|[^=])'${escLit}'`, 'g')
const bareDouble = new RegExp(`(^|[^=])"${escLit}"`, 'g')
content = content.replace(bareSingle, (_m, pre) => {
hits++
return `${pre}${expr}`
})
content = content.replace(bareDouble, (_m, pre) => {
hits++
return `${pre}${expr}`
})
// 2. Literal-with-path: '<lit>/foo/bar' → `${expr}/foo/bar`
// Same prefix guard.
const pathSingle = new RegExp(`(^|[^=])'${escLit}(/[^']+)'`, 'g')
const pathDouble = new RegExp(`(^|[^=])"${escLit}(/[^"]+)"`, 'g')
content = content.replace(pathSingle, (_m, pre, tail) => {
hits++
return `${pre}\`\${${expr}}${tail}\``
})
content = content.replace(pathDouble, (_m, pre, tail) => {
hits++
return `${pre}\`\${${expr}}${tail}\``
})
}
return { content, hits }
}
function escapeRegExp(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
let touched = 0
let totalReplacements = 0
const files = listFiles()
for (const file of files) {
const before = readFileSync(file, 'utf8')
const { content: rewritten, hits } = rewriteLiterals(before)
if (hits === 0) continue
const withImport = ensureImport(rewritten)
if (withImport === before) continue
writeFileSync(file, withImport)
touched++
totalReplacements += hits
console.log(` ${file} (${hits})`)
}
console.log(`\nfiles touched: ${touched}`)
console.log(`replacements: ${totalReplacements}`)
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# select-brand.sh — switch the active brand for a local build.
#
# Usage: scripts/select-brand.sh <slug>
# Slugs: safe | lux | hanzo
#
# What it does:
# 1) Copies apps/web/.env.<slug> -> apps/web/.env.local (overridden each run)
# 2) Mirrors apps/web/public/brand/<slug>/ -> apps/web/public/brand/active/
#
# After this runs, `yarn workspace @safe-global/web build` produces the branded build.
set -euo pipefail
slug="${1:-}"
if [[ -z "$slug" ]]; then
echo "usage: scripts/select-brand.sh <slug>"
echo "available brands:"
for d in apps/web/public/brand/*/; do
name="$(basename "$d")"
[[ "$name" == "active" ]] && continue
echo " - $name"
done
exit 1
fi
repo_root="$(cd "$(dirname "$0")/.." && pwd)"
env_file="$repo_root/apps/web/.env.$slug"
brand_dir="$repo_root/apps/web/public/brand/$slug"
if [[ ! -f "$env_file" ]]; then
echo "error: $env_file not found"
exit 2
fi
if [[ ! -d "$brand_dir" ]]; then
echo "error: $brand_dir not found"
exit 2
fi
cp "$env_file" "$repo_root/apps/web/.env.local"
rm -rf "$repo_root/apps/web/public/brand/active"
mkdir -p "$repo_root/apps/web/public/brand/active"
cp -R "$brand_dir/." "$repo_root/apps/web/public/brand/active/"
# Per-brand manifest replaces the default safe.webmanifest at build time.
if [[ -f "$brand_dir/manifest.json" ]]; then
cp "$brand_dir/manifest.json" "$repo_root/apps/web/public/safe.webmanifest"
fi
echo "brand '$slug' selected"
echo " env -> apps/web/.env.local"
echo " assets -> apps/web/public/brand/active/"
echo " manifest -> apps/web/public/safe.webmanifest"