|
#!/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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>');
|
|
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>');
|
|
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));
|
|
}
|