bws-secure
Version:
Secure environment management with Bitwarden Secrets Manager
421 lines (375 loc) • 15.5 kB
JavaScript
/**
* updateEnvVars.js
*
* Entry point for the environment variable update script. This file:
* 1. Parses CLI arguments.
* 2. Loads configuration from bwsconfig.json.
* 3. Reads required environment variables from requiredVars.env.
* 4. Delegates tasks to Netlify and Vercel modules for reading/updating variables.
* 5. Logs and summarizes the results.
*
* Usage:
* node updateEnvVars.js --platform netlify
* node updateEnvVars.js --platform vercel
* node updateEnvVars.js --help
*
* @module main
*/
import fs from 'node:fs'; // Regular fs for sync operations
import { promises as fsPromises } from 'node:fs'; // For async operations
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import dotenv from 'dotenv';
import { hideBin } from 'yargs/helpers';
import yargs from 'yargs/yargs';
import {
readVars as readNetlifyVariables,
updateNetlifyEnvVars as updateNetlifyEnvironmentVariables
} from './netlify.js';
import {
log,
handleError,
readEnvFile as readEnvironmentFile,
validateValue,
shouldPreserveVar as shouldPreserveVariable,
getBuildOrAuthToken,
validateDeployment,
decryptContent
} from './utils.js';
import {
readVars as readVercelVariables,
updateVars as updateVercelVariables,
updateVercelEnvVars as updateVercelEnvironmentVariables
} from './vercel.js';
dotenv.config();
// Get the directory name in ESM
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
function isRunningOnPlatform() {
const isPlatform = process.env.VERCEL === '1' || process.env.NETLIFY === 'true';
log(
'debug',
`Platform check: VERCEL=${process.env.VERCEL}, NETLIFY=${process.env.NETLIFY}, isPlatform=${isPlatform}`
);
return isPlatform;
}
/**
* Reads the bwsconfig.json file (shared by both Netlify and Vercel operations)
* @returns {Object} config object containing array of projects
*/
async function readConfigFile() {
try {
// Define potential config paths
const configPaths = [
path.join(process.cwd(), 'bwsconfig.json'),
path.join(__dirname, '../../../bwsconfig.json'),
path.join(__dirname, '../../bwsconfig.json'),
path.join(__dirname, '../bwsconfig.json')
];
// Find first existing config file
let configPath = null;
for (const testPath of configPaths) {
try {
await fsPromises.access(testPath);
configPath = testPath;
break;
} catch (e) {
// File doesn't exist at this path, continue to next
}
}
if (!configPath) {
throw new Error('No bwsconfig.json file found in any expected location');
}
const data = await fsPromises.readFile(configPath);
return JSON.parse(data);
} catch (error) {
handleError(error, 'Failed to read configuration file');
}
}
/**
* Gathers environment variables by reading both local env files and
* platform variables depending on project settings.
*
* @param {string[]} requiredVars - List of required variables from requiredVars.env
* @param {Object[]} projects - Array of project configs (from bwsconfig.json)
* @returns {Object} An object containing a mapping of found: {}, missing: [], excluded: []
*/
async function gatherAllVariables(requiredVariables, projects) {
const found = {};
const missing = [];
const excluded = [];
// Skip local environment variables when running on a platform
const isPlatform = process.env.VERCEL === '1' || process.env.NETLIFY === 'true';
for (const variableLine of requiredVariables) {
const variableCandidate = variableLine.trim();
if (!variableCandidate || variableCandidate.startsWith('#')) {
continue;
}
// Only check local environment if not running on a platform
if (!isPlatform && process.env[variableCandidate]) {
log('debug', `Found locally: ${variableCandidate} (local development only)`);
found[variableCandidate] = process.env[variableCandidate];
} else {
missing.push(variableCandidate);
}
}
// For each platform (netlify or vercel), read environment variables and see if we can fill in the missing pieces
for (const project of projects) {
const platform = project.platform.toLowerCase();
let platformVariables = null;
try {
// Decide which readVars function to call
if (platform === 'netlify') {
platformVariables = await readNetlifyVariables(project);
} else if (platform === 'vercel') {
platformVariables = await readVercelVariables(project);
} else {
log('warn', `Unknown platform: ${platform}`);
continue;
}
// The readVars function returns an object like:
// { found: { VAR1: '', VAR2: '' }, missing: [], excluded: [] }
// We'll use it to fill in found vars if they are still missing
if (platformVariables && platformVariables.found) {
for (const [key, value] of Object.entries(platformVariables.found)) {
// Only fill in if we previously didn't have a local value
if (!found[key] && !excluded.includes(key)) {
found[key] = value;
// If key was in missing, remove it
const mIndex = missing.indexOf(key);
if (mIndex >= 0) {
missing.splice(mIndex, 1);
}
}
}
}
// Combine excluded variables for logging
if (platformVariables && platformVariables.excluded) {
for (const exVariable of platformVariables.excluded) {
if (!excluded.includes(exVariable)) {
excluded.push(exVariable);
}
}
}
} catch (error) {
log('warn', `Failed reading environment for project (${project.platform}): ${error.message}`);
}
}
return { found, missing, excluded };
}
/**
* For each project in bwsconfig.json, attempts to update the remote platform with relevant environment variables.
*
* @param {Object[]} projects - Array of project configs
* @param {Object} baseVars - Object of all environment variables that we have found so far
*/
async function updateProjects(projects, baseVariables) {
for (const project of projects) {
const platform = project.platform.toLowerCase();
const variablesToUpdate = {};
// netlify or vercel
// Filter out excluded and preserved variables for that project
for (const [variableName, value] of Object.entries(baseVariables)) {
// Exclusion
if (project.exclusions && project.exclusions.includes(variableName)) {
log(
'debug',
`Excluding var ${variableName} for project ${project.siteSlug || project.projectName}`
);
continue;
}
// Preservation
if (shouldPreserveVariable(variableName, project)) {
log(
'debug',
`Preserving var ${variableName} for project ${project.siteSlug || project.projectName}`
);
continue;
}
// Validate value correctness
const check = validateValue(variableName, value);
if (!check.isValid) {
log(
'warn',
`Skipping update for var ${variableName} due to invalid format (${check.error})`
);
continue;
}
// Passed all checks, queue for update
variablesToUpdate[variableName] = check.value;
}
// If no vars to update, skip
if (Object.keys(variablesToUpdate).length === 0) {
log(
'info',
`No environment variables to update for project: ${project.siteSlug || project.projectName}`
);
continue;
}
// Update time
try {
let result = null;
if (platform === 'netlify') {
// Call updateNetlifyEnvVars directly
await updateNetlifyEnvironmentVariables(project);
result = { updated: variablesToUpdate };
} else if (platform === 'vercel') {
result = await updateVercelEnvironmentVariables(project);
}
// Log the result if returned
if (result && result.updated) {
log(
'info',
`Updated variables for project ${project.siteSlug || project.projectName}: ${Object.keys(
result.updated
).join(', ')}`
);
}
} catch (error) {
log('error', `Failed to update environment variables for project: ${error.message}`);
}
}
}
/**
* Main function that orchestrates reading config, gathering variables, running validations,
* and delegating read/update operations across platforms.
*/
async function main() {
try {
log('debug', 'Checking if running on platform...');
if (!isRunningOnPlatform()) {
log('debug', 'Not running on platform, exiting');
process.exit(0);
}
// Load platform tokens ONCE at the start
if (fs.existsSync('.env.secure')) {
try {
const content = fs.readFileSync('.env.secure', 'utf8');
const key = process.env.BWS_PLATFORM_KEY || process.env.BWS_EPHEMERAL_KEY;
log('debug', `Attempting to decrypt platform tokens with key: ${key.slice(0, 8)}...`);
const decrypted = decryptContent(content, key);
const platformTokens = dotenv.parse(decrypted);
// Set tokens in environment
Object.assign(process.env, platformTokens);
if (process.env.DEBUG === 'true') {
log('debug', 'Loaded platform tokens:');
log(
'debug',
`NETLIFY_AUTH_TOKEN: ${process.env.NETLIFY_AUTH_TOKEN ? '✓ Present' : '❌ Missing'}`
);
log(
'debug',
`VERCEL_AUTH_TOKEN: ${process.env.VERCEL_AUTH_TOKEN ? '✓ Present' : '❌ Missing'}`
);
}
} catch (error) {
log('warn', `Failed to load platform tokens: ${error.message}`);
}
}
log('info', 'Starting platform variable sync...');
const config = await readConfigFile();
// Filter projects based on current platform
const currentPlatform =
process.env.NETLIFY === 'true' ? 'netlify' : process.env.VERCEL === '1' ? 'vercel' : null;
// Check for SITE_NAME environment variable when on Netlify
let currentProjectName = process.env.BWS_PROJECT;
if (process.env.NETLIFY === 'true' && process.env.SITE_NAME) {
// If SITE_NAME exists, use it instead and log the change
log('info', `Running on Netlify - detected SITE_NAME: ${process.env.SITE_NAME}`);
if (currentProjectName) {
log('info', `BWS_PROJECT is set to: ${currentProjectName}`);
}
// Check if there's a project matching the SITE_NAME
const siteNameMatch = config.projects.find(
(project) =>
project.platform.toLowerCase() === 'netlify' &&
project.projectName === process.env.SITE_NAME
);
if (siteNameMatch) {
log('info', `Matched project using Netlify SITE_NAME: ${process.env.SITE_NAME}`);
if (currentProjectName !== process.env.SITE_NAME) {
log('info', `Setting BWS_PROJECT=${process.env.SITE_NAME} to match Netlify SITE_NAME`);
currentProjectName = process.env.SITE_NAME;
// Update environment variable for downstream processes
process.env.BWS_PROJECT = currentProjectName;
}
// Important: Process ONLY the project matching SITE_NAME
log('info', `Will update only project: ${currentProjectName}`);
// Filter the projects array to include only the matching project
config.projects = config.projects.filter(
(project) =>
project.platform.toLowerCase() === 'netlify' &&
project.projectName === currentProjectName
);
if (config.projects.length === 1) {
log(
'info',
`Processing project ${currentProjectName} - matched using SITE_NAME (${process.env.SITE_NAME})`
);
}
}
}
const currentProject = config.projects.find(
(project) =>
project.platform.toLowerCase() === currentPlatform &&
project.projectName === currentProjectName
);
if (!currentProject) {
log('warn', `No matching project found for ${currentPlatform}/${currentProjectName}`);
process.exit(0);
}
// Visual separator for clarity
console.log(`\n${'='.repeat(80)}`);
console.log(`Starting update for project: ${currentProject.projectName}`);
console.log(`${'='.repeat(80)}\n`);
try {
if (currentProject.platform === 'vercel' && process.env.VERCEL === '1') {
await updateVercelEnvironmentVariables(currentProject);
} else if (currentProject.platform === 'netlify' && process.env.NETLIFY === 'true') {
await updateNetlifyEnvironmentVariables(currentProject);
}
} catch (platformError) {
// Handle Vercel auth errors
if (
currentProject.platform === 'vercel' &&
(platformError.message.includes('Not authorized') ||
platformError.message.includes('forbidden'))
) {
// prettier-ignore
{
console.warn('\u001B[33m╔════════════════════════════════════════════════════════╗\u001B[0m');
console.warn('\u001B[33m║ ║\u001B[0m');
console.warn('\u001B[33m║ WARNING: VERCEL TOKEN INVALID ║\u001B[0m');
console.warn('\u001B[33m║ Update VERCEL_AUTH_TOKEN in BWS for auto-sync ║\u001B[0m');
console.warn('\u001B[33m║ Platform variables may be out of sync ║\u001B[0m');
console.warn('\u001B[33m║ ║\u001B[0m');
console.warn('\u001B[33m╚════════════════════════════════════════════════════════╝\u001B[0m');
}
// Handle Netlify auth errors
} else if (
currentProject.platform === 'netlify' &&
(platformError.message.includes('unauthorized') || platformError.message.includes('403'))
) {
// prettier-ignore
{
console.warn('\u001B[33m╔════════════════════════════════════════════════════════╗\u001B[0m');
console.warn('\u001B[33m║ ║\u001B[0m');
console.warn('\u001B[33m║ WARNING: NETLIFY TOKEN INVALID ║\u001B[0m');
console.warn('\u001B[33m║ Update NETLIFY_AUTH_TOKEN in BWS for auto-sync ║\u001B[0m');
console.warn('\u001B[33m║ Platform variables may be out of sync ║\u001B[0m');
console.warn('\u001B[33m║ ║\u001B[0m');
console.warn('\u001B[33m╚════════════════════════════════════════════════════════╝\u001B[0m');
}
} else {
log('warn', `Platform sync skipped: ${platformError.message}`);
}
}
} catch (error) {
log('warn', `Unable to sync platform variables: ${error.message}`);
}
}
// Run the main function
main().catch((error) => {
handleError(error, 'An unexpected error occurred in updateEnvVars.js');
});