UNPKG

bws-secure

Version:

Secure environment management with Bitwarden Secrets Manager

421 lines (375 loc) 15.5 kB
#!/usr/bin/env node /** * 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'); });