Project

General

Profile

Feature #10307 » switch-tenant.js

Thirupathirao Uppu, 08/10/2026 11:36 AM

 
#!/usr/bin/env node
/**
* switch-tenant.js
*
* Production white-label switcher for React Native CLI.
*
* Usage:
* node scripts/switch-tenant.js <tenantId>
* npm run tenant:switch -- regal-solar
* npm run tenant:list
* npm run tenant:validate -- demo-brand
*
* What it does (atomically where possible):
* 1. Validates tenants/<id>/config.json against a strict schema
* 2. Writes root .env for react-native-config (native + JS)
* 3. Generates src/config/tenant.generated.ts (typed JS config)
* 4. Copies logo → src/assets/brand/logo.png
* 5. Copies Firebase configs into android/ and ios/
* 6. Patches Android applicationId + strings app_name
* 7. Patches iOS CFBundleDisplayName + PRODUCT_BUNDLE_IDENTIFIER + permission strings
* 8. Updates app.json displayName
*/

'use strict';

const fs = require('fs');
const path = require('path');

const ROOT = path.resolve(__dirname, '..');
const TENANTS_DIR = path.join(ROOT, 'tenants');
const GENERATED_TS = path.join(ROOT, 'src', 'config', 'tenant.generated.ts');
const BRAND_LOGO_DEST = path.join(ROOT, 'src', 'assets', 'brand', 'logo.png');
const ENV_PATH = path.join(ROOT, '.env');
const ENV_EXAMPLE_PATH = path.join(ROOT, '.env.example');
const ACTIVE_TENANT_PATH = path.join(ROOT, 'tenants', '.active-tenant');

const HEX_RE = /^#([0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/;
const URL_RE = /^https?:\/\/.+/i;
const BUNDLE_RE = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/i;
const TENANT_ID_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;

function fail(message, details) {
console.error(`\n✖ Tenant switch failed: ${message}`);
if (details && details.length) {
for (const d of details) {
console.error(` • ${d}`);
}
}
process.exit(1);
}

function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}

function isNonEmptyString(v) {
return typeof v === 'string' && v.trim().length > 0;
}

function readJson(filePath) {
try {
const raw = fs.readFileSync(filePath, 'utf8');
return JSON.parse(raw);
} catch (err) {
fail(`Cannot read/parse JSON: ${filePath}`, [err.message]);
}
}

function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true });
}

function copyFileRequired(src, dest) {
if (!fs.existsSync(src)) {
fail(`Required file missing: ${src}`);
}
ensureDir(path.dirname(dest));
fs.copyFileSync(src, dest);
}

function listTenants() {
if (!fs.existsSync(TENANTS_DIR)) {
return [];
}
return fs
.readdirSync(TENANTS_DIR, { withFileTypes: true })
.filter(d => d.isDirectory() && !d.name.startsWith('.') && !d.name.startsWith('_'))
.map(d => d.name)
.sort();
}

/**
* Strict runtime schema validation — no external JSON Schema dependency.
* Returns string[] of errors (empty = valid).
*/
function validateTenantConfig(config, tenantId) {
const errors = [];

const req = (obj, key, label) => {
if (obj == null || obj[key] === undefined || obj[key] === null) {
errors.push(`Missing required field: ${label || key}`);
return false;
}
return true;
};

if (!config || typeof config !== 'object' || Array.isArray(config)) {
return ['config.json must be a JSON object'];
}

if (!req(config, 'tenantId') || config.tenantId !== tenantId) {
errors.push(
`tenantId must equal folder name ("${tenantId}"), got "${config.tenantId}"`,
);
}
if (!TENANT_ID_RE.test(String(config.tenantId || ''))) {
errors.push('tenantId must be kebab-case (a-z0-9 and hyphens)');
}

for (const key of [
'appName',
'displayName',
'slug',
'companyLegalName',
'supportEmail',
]) {
if (!isNonEmptyString(config[key])) {
errors.push(`${key} must be a non-empty string`);
}
}

if (typeof config.supportPhone !== 'string') {
errors.push('supportPhone must be a string (can be empty)');
}

if (!config.bundleId || typeof config.bundleId !== 'object') {
errors.push('bundleId must be an object with ios and android');
} else {
for (const platform of ['ios', 'android']) {
const id = config.bundleId[platform];
if (!isNonEmptyString(id) || !BUNDLE_RE.test(id)) {
errors.push(`bundleId.${platform} must be a valid reverse-DNS id`);
}
}
}

if (!config.api || typeof config.api !== 'object') {
errors.push('api must be an object');
} else {
if (!isNonEmptyString(config.api.baseUrl) || !URL_RE.test(config.api.baseUrl)) {
errors.push('api.baseUrl must be an http(s) URL');
}
if (!isNonEmptyString(config.api.socketUrl) || !URL_RE.test(config.api.socketUrl)) {
errors.push('api.socketUrl must be an http(s) URL');
}
if (
typeof config.api.timeoutMs !== 'number' ||
!Number.isFinite(config.api.timeoutMs) ||
config.api.timeoutMs < 1000
) {
errors.push('api.timeoutMs must be a number >= 1000');
}
}

const brandKeys = [
'primary',
'primaryLight',
'primaryDark',
'secondary',
'accent',
'accentLight',
'onSecondary',
'tabActive',
];
if (!config.brand || typeof config.brand !== 'object') {
errors.push('brand must be an object');
} else {
for (const key of brandKeys) {
if (!isNonEmptyString(config.brand[key]) || !HEX_RE.test(config.brand[key])) {
errors.push(`brand.${key} must be a #RRGGBB or #RRGGBBAA hex color`);
}
}
}

if (!config.features || typeof config.features !== 'object') {
errors.push('features must be an object');
} else {
for (const key of ['liveChat', 'fieldStaffNav', 'networkLogger', 'multiLanguage']) {
if (typeof config.features[key] !== 'boolean') {
errors.push(`features.${key} must be a boolean`);
}
}
}

if (!config.notifications || typeof config.notifications !== 'object') {
errors.push('notifications must be an object');
} else {
for (const key of ['androidChannelId', 'androidChannelName', 'defaultTitle']) {
if (!isNonEmptyString(config.notifications[key])) {
errors.push(`notifications.${key} must be a non-empty string`);
}
}
}

if (!config.assets || typeof config.assets !== 'object') {
errors.push('assets must be an object');
} else {
if (!isNonEmptyString(config.assets.logo)) {
errors.push('assets.logo must be a relative path string');
}
}

if (!config.native || typeof config.native !== 'object') {
errors.push('native must be an object');
} else {
if (!isNonEmptyString(config.native.androidGoogleServices)) {
errors.push('native.androidGoogleServices is required');
}
if (!isNonEmptyString(config.native.iosGoogleServices)) {
errors.push('native.iosGoogleServices is required');
}
}

if (!config.permissionsCopy || typeof config.permissionsCopy !== 'object') {
errors.push('permissionsCopy must be an object');
} else {
for (const key of [
'locationAlways',
'locationWhenInUse',
'camera',
'photoLibrary',
'photoLibraryAdd',
]) {
if (!isNonEmptyString(config.permissionsCopy[key])) {
errors.push(`permissionsCopy.${key} must be a non-empty string`);
}
}
}

return errors;
}

function validateTenantFiles(tenantDir, config) {
const errors = [];
const logoPath = path.join(tenantDir, config.assets.logo);
if (!fs.existsSync(logoPath)) {
errors.push(`Logo not found: ${logoPath}`);
}
const androidGs = path.join(tenantDir, config.native.androidGoogleServices);
if (!fs.existsSync(androidGs)) {
errors.push(`Android google-services.json not found: ${androidGs}`);
}
const iosGs = path.join(tenantDir, config.native.iosGoogleServices);
if (!fs.existsSync(iosGs)) {
errors.push(`iOS GoogleService-Info.plist not found: ${iosGs}`);
}
return errors;
}

function escapeEnvValue(value) {
const s = String(value);
if (/[\s#"']/.test(s)) {
return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
}
return s;
}

function writeEnvFile(config) {
const lines = [
'# GENERATED by scripts/switch-tenant.js — do not edit by hand',
`# Active tenant: ${config.tenantId}`,
`# Generated at: ${new Date().toISOString()}`,
'',
`APP_TENANT=${escapeEnvValue(config.tenantId)}`,
`APP_NAME=${escapeEnvValue(config.appName)}`,
`APP_DISPLAY_NAME=${escapeEnvValue(config.displayName)}`,
`APP_SLUG=${escapeEnvValue(config.slug)}`,
`ANDROID_APP_ID=${escapeEnvValue(config.bundleId.android)}`,
`IOS_BUNDLE_ID=${escapeEnvValue(config.bundleId.ios)}`,
`API_BASE_URL=${escapeEnvValue(config.api.baseUrl)}`,
`SOCKET_URL=${escapeEnvValue(config.api.socketUrl)}`,
`API_TIMEOUT_MS=${escapeEnvValue(config.api.timeoutMs)}`,
`COMPANY_LEGAL_NAME=${escapeEnvValue(config.companyLegalName)}`,
`SUPPORT_EMAIL=${escapeEnvValue(config.supportEmail)}`,
`PRIMARY_COLOR=${escapeEnvValue(config.brand.primary)}`,
`SECONDARY_COLOR=${escapeEnvValue(config.brand.secondary)}`,
'',
];
fs.writeFileSync(ENV_PATH, lines.join('\n'), 'utf8');

// Keep a committed example in sync with keys (values are placeholders)
const example = lines
.map(line => {
if (line.startsWith('#') || line.trim() === '') return line;
const eq = line.indexOf('=');
if (eq === -1) return line;
const key = line.slice(0, eq);
return `${key}=`;
})
.join('\n');
fs.writeFileSync(ENV_EXAMPLE_PATH, example, 'utf8');
}

function writeGeneratedTs(config) {
ensureDir(path.dirname(GENERATED_TS));
const payload = {
generatedAt: new Date().toISOString(),
activeTenantId: config.tenantId,
config,
};
const body = `/**
* AUTO-GENERATED by scripts/switch-tenant.js
* Do not edit manually. Re-run: npm run tenant:switch -- ${config.tenantId}
*/

import type { GeneratedTenantModule } from './tenant.types';

const generatedTenant: GeneratedTenantModule = ${JSON.stringify(payload, null, 2)};

export default generatedTenant;
`;
fs.writeFileSync(GENERATED_TS, body, 'utf8');
}

function patchAndroidApplicationId(androidAppId) {
const gradlePath = path.join(ROOT, 'android', 'app', 'build.gradle');
if (!fs.existsSync(gradlePath)) {
fail(`Android build.gradle not found: ${gradlePath}`);
}
let content = fs.readFileSync(gradlePath, 'utf8');

// Preferred: applicationId is driven by react-native-config (.env ANDROID_APP_ID)
if (content.includes('project.env.get("ANDROID_APP_ID")')) {
console.log(
` · Android applicationId already env-driven (ANDROID_APP_ID=${androidAppId})`,
);
return;
}

if (!/applicationId\s+"[^"]+"/.test(content)) {
fail('Could not find applicationId in android/app/build.gradle');
}

content = content.replace(
/applicationId\s+"[^"]+"/,
`applicationId "${androidAppId}" // WHITE_LABEL_APPLICATION_ID`,
);

fs.writeFileSync(gradlePath, content, 'utf8');
}

function patchAndroidAppName(displayName) {
const stringsPath = path.join(
ROOT,
'android',
'app',
'src',
'main',
'res',
'values',
'strings.xml',
);
if (!fs.existsSync(stringsPath)) {
fail(`strings.xml not found: ${stringsPath}`);
}
let content = fs.readFileSync(stringsPath, 'utf8');
if (!/<string name="app_name">[\s\S]*?<\/string>/.test(content)) {
fail('Could not find app_name in strings.xml');
}
const safe = displayName
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
content = content.replace(
/<string name="app_name">[\s\S]*?<\/string>/,
`<string name="app_name">${safe}</string>`,
);
fs.writeFileSync(stringsPath, content, 'utf8');
}

function patchIosBundleAndDisplay(config) {
const infoPlistPath = path.join(
ROOT,
'ios',
'RegalSolarEnergy',
'Info.plist',
);
const pbxprojPath = path.join(
ROOT,
'ios',
'RegalSolarEnergy.xcodeproj',
'project.pbxproj',
);

if (!fs.existsSync(infoPlistPath)) {
fail(`Info.plist not found: ${infoPlistPath}`);
}
if (!fs.existsSync(pbxprojPath)) {
fail(`project.pbxproj not found: ${pbxprojPath}`);
}

let plist = fs.readFileSync(infoPlistPath, 'utf8');

const replacePlistString = (key, value) => {
const re = new RegExp(
`(<key>${key}<\\/key>\\s*<string>)([\\s\\S]*?)(<\\/string>)`,
);
if (!re.test(plist)) {
throw new Error(`Info.plist missing key ${key}`);
}
const safe = value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
plist = plist.replace(re, `$1${safe}$3`);
};

try {
replacePlistString('CFBundleDisplayName', config.displayName);
replacePlistString(
'NSLocationAlwaysAndWhenInUseUsageDescription',
config.permissionsCopy.locationAlways,
);
replacePlistString(
'NSLocationWhenInUseUsageDescription',
config.permissionsCopy.locationWhenInUse,
);
replacePlistString('NSCameraUsageDescription', config.permissionsCopy.camera);
replacePlistString(
'NSPhotoLibraryUsageDescription',
config.permissionsCopy.photoLibrary,
);
replacePlistString(
'NSPhotoLibraryAddUsageDescription',
config.permissionsCopy.photoLibraryAdd,
);
} catch (err) {
fail(err.message);
}

fs.writeFileSync(infoPlistPath, plist, 'utf8');

let pbx = fs.readFileSync(pbxprojPath, 'utf8');
if (!/PRODUCT_BUNDLE_IDENTIFIER = [^;]+;/.test(pbx)) {
fail('Could not find PRODUCT_BUNDLE_IDENTIFIER in project.pbxproj');
}
pbx = pbx.replace(
/PRODUCT_BUNDLE_IDENTIFIER = [^;]+;/g,
`PRODUCT_BUNDLE_IDENTIFIER = ${config.bundleId.ios};`,
);
fs.writeFileSync(pbxprojPath, pbx, 'utf8');
}

function patchAppJson(config) {
const appJsonPath = path.join(ROOT, 'app.json');
if (!fs.existsSync(appJsonPath)) {
return;
}
const appJson = readJson(appJsonPath);
appJson.name = config.appName;
appJson.displayName = config.displayName;
fs.writeFileSync(appJsonPath, `${JSON.stringify(appJson, null, 2)}\n`, 'utf8');
}

function switchTenant(tenantId) {
if (!isNonEmptyString(tenantId) || !TENANT_ID_RE.test(tenantId)) {
fail('Usage: node scripts/switch-tenant.js <tenant-id>', [
`Available: ${listTenants().join(', ') || '(none)'}`,
]);
}

const tenantDir = path.join(TENANTS_DIR, tenantId);
if (!fs.existsSync(tenantDir)) {
fail(`Unknown tenant "${tenantId}"`, [
`Expected folder: ${tenantDir}`,
`Available: ${listTenants().join(', ') || '(none)'}`,
]);
}

const configPath = path.join(tenantDir, 'config.json');
if (!fs.existsSync(configPath)) {
fail(`Missing config.json in ${tenantDir}`);
}

const config = readJson(configPath);
const schemaErrors = validateTenantConfig(config, tenantId);
if (schemaErrors.length) {
fail(`Invalid config for tenant "${tenantId}"`, schemaErrors);
}

const fileErrors = validateTenantFiles(tenantDir, config);
if (fileErrors.length) {
fail(`Missing tenant assets for "${tenantId}"`, fileErrors);
}

console.log(`\n→ Switching white-label tenant to "${tenantId}"…\n`);

// 1) .env for react-native-config
writeEnvFile(config);
console.log(' ✓ Wrote .env + .env.example');

// 2) Typed generated module
writeGeneratedTs(config);
console.log(' ✓ Generated src/config/tenant.generated.ts');

// 3) Brand logo for JS imports
copyFileRequired(path.join(tenantDir, config.assets.logo), BRAND_LOGO_DEST);
console.log(' ✓ Copied logo → src/assets/brand/logo.png');

// 4) Firebase
copyFileRequired(
path.join(tenantDir, config.native.androidGoogleServices),
path.join(ROOT, 'android', 'app', 'google-services.json'),
);
copyFileRequired(
path.join(tenantDir, config.native.iosGoogleServices),
path.join(ROOT, 'ios', 'RegalSolarEnergy', 'GoogleService-Info.plist'),
);
// Keep root ios copy in sync if present
const iosRootGs = path.join(ROOT, 'ios', 'GoogleService-Info.plist');
if (fs.existsSync(path.dirname(iosRootGs))) {
copyFileRequired(
path.join(tenantDir, config.native.iosGoogleServices),
iosRootGs,
);
}
// Keep src/google-services.json in sync if used by tooling
const srcGs = path.join(ROOT, 'src', 'google-services.json');
if (fs.existsSync(path.dirname(srcGs))) {
copyFileRequired(
path.join(tenantDir, config.native.androidGoogleServices),
srcGs,
);
}
console.log(' ✓ Copied Firebase google-services files');

// 5) Native identifiers / display names
patchAndroidApplicationId(config.bundleId.android);
patchAndroidAppName(config.displayName);
console.log(' ✓ Patched Android applicationId + app_name');

patchIosBundleAndDisplay(config);
console.log(' ✓ Patched iOS bundle id + display name + permission copy');

patchAppJson(config);
console.log(' ✓ Updated app.json');

fs.writeFileSync(ACTIVE_TENANT_PATH, `${tenantId}\n`, 'utf8');

console.log(`
✔ Tenant "${tenantId}" is active.

Next steps (required for native id / Firebase changes):
1. Stop Metro if running
2. Android: cd android && ./gradlew clean && cd .. && npm run android
3. iOS: cd ios && bundle exec pod install && cd .. && npm run ios

JS-only brand/API changes hot-reload after Metro restart:
npm start -- --reset-cache
`);
}

function printList() {
const tenants = listTenants();
let active = null;
if (fs.existsSync(ACTIVE_TENANT_PATH)) {
active = fs.readFileSync(ACTIVE_TENANT_PATH, 'utf8').trim();
}
console.log('\nAvailable tenants:\n');
if (!tenants.length) {
console.log(' (none — create tenants/<id>/config.json)');
return;
}
for (const id of tenants) {
const marker = id === active ? ' (active)' : '';
console.log(` • ${id}${marker}`);
}
console.log('');
}

function validateOnly(tenantId) {
const id = tenantId || (fs.existsSync(ACTIVE_TENANT_PATH)
? fs.readFileSync(ACTIVE_TENANT_PATH, 'utf8').trim()
: null);
if (!id) {
fail('Pass a tenant id or run tenant:switch first');
}
const tenantDir = path.join(TENANTS_DIR, id);
const config = readJson(path.join(tenantDir, 'config.json'));
const schemaErrors = validateTenantConfig(config, id);
const fileErrors = validateTenantFiles(tenantDir, config);
const all = [...schemaErrors, ...fileErrors];
if (all.length) {
fail(`Validation failed for "${id}"`, all);
}
console.log(`✔ Tenant "${id}" is valid`);
}

function main() {
const args = process.argv.slice(2);
const cmd = args[0];

if (cmd === 'list' || cmd === '--list') {
printList();
return;
}
if (cmd === 'validate' || cmd === '--validate') {
validateOnly(args[1]);
return;
}
if (!cmd || cmd === '--help' || cmd === '-h') {
console.log(`
White-label tenant switcher

node scripts/switch-tenant.js <tenantId>
node scripts/switch-tenant.js list
node scripts/switch-tenant.js validate [tenantId]

Examples:
npm run tenant:switch -- regal-solar
npm run tenant:switch -- demo-brand
npm run tenant:list
`);
return;
}

switchTenant(cmd);
}

try {
main();
} catch (err) {
fail(err && err.message ? err.message : String(err));
}
(2-2/2)