bws-secure
Version:
Secure environment management with Bitwarden Secrets Manager
883 lines (770 loc) • 29.3 kB
JavaScript
/**
* vercel.js
*
* This module contains all Vercel-specific operations for reading and updating
* environment variables. It provides the following functions:
*
* readVars(project, token) -> { found: {}, missing: [], excluded: [] }
* updateVars(project, token, vars) -> { updated: {} }
*
* The code interacts with Vercel via the CLI commands "vercel env ls --json" and
* "vercel env add".
*/
import util from 'node:util';
import { exec } from 'node:child_process';
const execPromise = util.promisify(exec);
import {
log,
handleError,
determineEnvironmentMapping,
loadEnvironmentVariables,
validateDeployment,
shouldPreserveVar
} from './utils.js';
import axios from 'axios';
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { fileURLToPath } from 'node:url';
// Get the directory name in ESM
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Cache for Vercel environment variables
const vercelEnvCache = new Map();
/**
* Helper function to retrieve a properly formatted Vercel token
*/
function getVercelToken(token) {
// First try token parameter
if (token) {
log('debug', 'Using provided token parameter');
return token.startsWith('Bearer ') ? token : `Bearer ${token}`;
}
// Then try VERCEL_AUTH_TOKEN (new)
const authToken = process.env.VERCEL_AUTH_TOKEN;
if (authToken) {
log('debug', 'Using VERCEL_AUTH_TOKEN from environment');
return authToken.startsWith('Bearer ') ? authToken : `Bearer ${authToken}`;
}
// Finally try VERCEL_TOKEN (legacy)
const envToken = process.env.VERCEL_TOKEN;
if (!envToken) {
throw new Error(
'No Vercel token found. Set VERCEL_AUTH_TOKEN env variable or provide token parameter.'
);
}
log('debug', 'Using VERCEL_TOKEN from environment');
return envToken.startsWith('Bearer ') ? envToken : `Bearer ${envToken}`;
}
/**
* Attempts to read project details from .vercel/project.json
* @returns {Promise<{projectId: string, orgId: string, projectName: string} | null>}
*/
async function getVercelProjectConfig() {
try {
const projectJsonPath = path.join(process.cwd(), '.vercel', 'project.json');
const configStr = await fs.promises.readFile(projectJsonPath, 'utf8');
const config = JSON.parse(configStr);
log('debug', `Found Vercel project config: ${JSON.stringify(config)}`);
return {
projectId: config.projectId,
orgId: config.orgId, // This is the team ID
projectName: config.name || null
};
} catch (error) {
log('debug', 'No .vercel/project.json found, will try to fetch project details from API');
return null;
}
}
/**
* Helper to fetch the project from Vercel by project name.
*/
async function getProjectIdFromName(project, token) {
const vercelToken = getVercelToken(token);
try {
// Get all teams the user has access to
let teams = [];
try {
const teamsResp = await axios.get('https://api.vercel.com/v2/teams', {
headers: { Authorization: vercelToken }
});
teams = teamsResp.data.teams || [];
log('debug', `Found ${teams.length} teams to search in`);
} catch (error) {
log('debug', `Error getting teams: ${error.message}`);
if (error.response?.data) {
log('debug', `Teams API response: ${JSON.stringify(error.response.data)}`);
}
}
// First try personal account (no team)
let projectFound = null;
log('debug', `Searching for project ${project.projectName} in personal account`);
try {
const projectsUrl = 'https://api.vercel.com/v9/projects';
const config = {
headers: { Authorization: vercelToken }
};
const projectsResp = await axios.get(projectsUrl, config);
const projects = projectsResp.data.projects || [];
log('debug', `Found ${projects.length} projects in personal account`);
projectFound = projects.find((p) => p.name === project.projectName);
if (projectFound) {
log(
'debug',
`Found project ${project.projectName} with ID ${projectFound.id} in personal account`
);
return {
projectId: projectFound.id,
teamId: null
};
}
} catch (error) {
log('debug', `Error searching in personal account: ${error.message}`);
}
// Then try each team
for (const team of teams) {
try {
log('debug', `Searching for project in team: ${team.slug} (${team.id})`);
const projectsUrl = 'https://api.vercel.com/v9/projects';
const config = {
headers: { Authorization: vercelToken },
params: { teamId: team.id }
};
const projectsResp = await axios.get(projectsUrl, config);
const projects = projectsResp.data.projects || [];
log('debug', `Found ${projects.length} projects in team ${team.slug}`);
projectFound = projects.find((p) => p.name === project.projectName);
if (projectFound) {
log(
'debug',
`Found project ${project.projectName} with ID ${projectFound.id} in team ${team.slug}`
);
return {
projectId: projectFound.id,
teamId: team.id
};
}
} catch (error) {
log('debug', `Error searching in team ${team.slug}: ${error.message}`);
}
}
throw new Error(`Project ${project.projectName} not found in any team or personal account`);
} catch (error) {
const msg = error.response?.data?.error?.message || error.message;
throw new Error(`Failed to get project ID: ${msg}`);
}
}
/**
* Retrieve current environment variables from the Vercel API
*/
async function getCurrentVercelEnvVars(projectId, token, teamId) {
const vercelToken = getVercelToken(token);
try {
const url = `https://api.vercel.com/v9/projects/${projectId}/env`;
const config = {
headers: { Authorization: vercelToken },
...(teamId ? { params: { teamId } } : {})
};
const resp = await axios.get(url, config);
const envs = resp.data.envs || [];
log('debug', `Fetched ${envs.length} environment variables`);
const envVars = {};
envs.forEach((entry) => {
envVars[entry.key] = entry.value || '';
});
return envVars;
} catch (error) {
const msg = error.response?.data?.error?.message || error.message;
throw new Error(`Failed to fetch environment vars: ${msg}`);
}
}
/**
* Encrypt a value using a project-specific key
*/
function encryptValue(value, projectId) {
const keyBase = `${projectId}_bws_secure_salt`;
const key = crypto.createHash('sha256').update(keyBase).digest();
const nonce = crypto.randomBytes(12); // 12 bytes is optimal for GCM
const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce);
let encrypted = cipher.update(value, 'utf8', 'base64');
encrypted += cipher.final('base64');
const authTag = cipher.getAuthTag();
return `BWS_ENC_${nonce.toString('base64')}:${authTag.toString('base64')}:${encrypted}`;
}
/**
* Read environment variables for a Vercel project
*/
async function readVars(project, token) {
const result = {
found: {},
missing: [],
excluded: project.exclusions || []
};
try {
log('debug', `Reading variables for Vercel project: ${project.projectName}`);
const { projectId, teamId } = await getProjectIdFromName(project, token);
if (!projectId) {
throw new Error(`Could not resolve projectId for ${project.projectName}`);
}
const platformVars = await getCurrentVercelEnvVars(projectId, token, teamId);
Object.entries(platformVars).forEach(([key, value]) => {
result.found[key] = value;
});
} catch (error) {
log('warn', `Failed to fetch Vercel environment: ${error.message}`);
}
return result;
}
/**
* Delete an environment variable from Vercel
*/
async function deleteVercelEnvVar(projectId, envId, token, teamId) {
const vercelToken = getVercelToken(token);
try {
const url = `https://api.vercel.com/v9/projects/${projectId}/env/${envId}`;
const config = {
headers: { Authorization: vercelToken },
...(teamId ? { params: { teamId } } : {})
};
await axios.delete(url, config);
return true;
} catch (error) {
const msg = error.response?.data?.error?.message || error.message;
throw new Error(`Failed to delete env var ${envId}: ${msg}`);
}
}
/**
* Finds the environment variable (by key) from the /v9/projects/:id/env response array
* so we can update or remove it. Returns the entire object if found, or null if not.
*/
function findVercelEnvByKey(envsArray, key) {
return envsArray.find((item) => item.key === key) || null;
}
/**
* Create a shared environment variable at the team level
*/
async function createSharedSecret(key, value, token, teamId) {
const vercelToken = getVercelToken(token);
try {
const url = 'https://api.vercel.com/v2/env';
const payload = {
key,
value,
type: 'secret',
teamId
};
const config = {
headers: {
'Authorization': vercelToken,
'Content-Type': 'application/json'
}
};
await axios.post(url, payload, config);
log('debug', `Created shared secret ${key}`);
return true;
} catch (error) {
const msg = error.response?.data?.error?.message || error.message;
throw new Error(`Failed to create shared secret: ${msg}`);
}
}
/**
* Create a new environment variable in Vercel
*/
async function createVercelEnvVar(projectId, key, value, token, target, teamId) {
const vercelToken = getVercelToken(token);
try {
const url = `https://api.vercel.com/v9/projects/${projectId}/env`;
const payload = {
key,
value,
target: target,
type: 'encrypted',
gitBranch: null,
sensitive: true
};
const config = {
headers: {
'Authorization': vercelToken,
'Content-Type': 'application/json'
},
...(teamId ? { params: { teamId } } : {})
};
await axios.post(url, payload, config);
log('debug', `Created sensitive env var ${key}`);
} catch (error) {
const msg = error.response?.data?.error?.message || error.message;
throw new Error(`Failed to create env var ${key}: ${msg}`);
}
}
/**
* Helper function to decrypt a value at runtime
* This should be used in your application code
*/
function decryptEnvVar(encryptedValue, projectId) {
if (!encryptedValue.startsWith('BWS_ENC_')) {
return encryptedValue;
}
try {
const [nonceBase64, authTagBase64, encrypted] = encryptedValue
.replace('BWS_ENC_', '')
.split(':');
const nonce = Buffer.from(nonceBase64, 'base64');
const authTag = Buffer.from(authTagBase64, 'base64');
const keyBase = `${projectId}_bws_secure_salt`;
const key = crypto.createHash('sha256').update(keyBase).digest();
const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encrypted, 'base64', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} catch (error) {
log('error', `Failed to decrypt value: ${error.message}`);
return encryptedValue;
}
}
/**
* Update environment variables for a Vercel project
*/
async function updateVars(project, token, vars, target) {
const updated = {};
try {
const projectId = await getProjectIdFromName(project, token);
if (!projectId) {
throw new Error(`Could not resolve projectId for ${project.projectName}`);
}
const vercelToken = getVercelToken(token);
// Get current env vars
const url = `https://api.vercel.com/v9/projects/${projectId}/env`;
const cached = vercelEnvCache.get(projectId);
const teamId = cached?.teamId;
const config = {
headers: { Authorization: vercelToken },
...(teamId ? { params: { teamId } } : {})
};
const resp = await axios.get(url, config);
const existingEnvs = resp.data.envs || [];
log('debug', `Setting variables for ${target.join(', ')} environment(s)`);
// Delete existing vars unless excluded/preserved
for (const { key, id, target: envTarget } of existingEnvs) {
// Skip if excluded/preserved
if (project.exclusions?.includes(key) || project.preserveVars?.includes(key)) {
log('debug', `Skipping ${key} (excluded/preserved)`);
continue;
}
// Only delete if the variable is for our current target environment(s)
const shouldDelete = envTarget.some((t) => target.includes(t));
if (shouldDelete) {
await deleteVercelEnvVar(projectId, id, vercelToken, teamId);
log('debug', `Deleted ${key} from ${envTarget.join(', ')}`);
}
}
// Create new vars
for (const [key, value] of Object.entries(vars)) {
if (project.exclusions?.includes(key)) {
log('debug', `Skipping excluded var ${key}`);
continue;
}
if (project.preserveVars?.includes(key)) {
log('debug', `Skipping preserved var ${key}`);
continue;
}
try {
await createVercelEnvVar(projectId, key, value, vercelToken, target, teamId);
log('debug', `Created ${key} for ${target.join(', ')}`);
updated[key] = value;
} catch (error) {
log('error', `Failed to create ${key}: ${error.message}`);
}
}
return { updated };
} catch (error) {
const msg = error.response?.data?.error?.message || error.message;
throw new Error(`Failed to update environment variables: ${msg}`);
}
}
/**
* Create multiple environment variables in Vercel in a single batch request
*/
async function batchCreateVercelEnvVars(projectId, envVars, token, teamId) {
const vercelToken = getVercelToken(token);
try {
const url = `https://api.vercel.com/v9/projects/${projectId}/env`;
const payload = envVars.map((envVar) => ({
key: envVar.key,
value: envVar.value,
target: envVar.target,
type: 'encrypted',
gitBranch: null,
sensitive: true
}));
const config = {
headers: {
'Authorization': vercelToken,
'Content-Type': 'application/json'
},
...(teamId ? { params: { teamId } } : {})
};
const response = await axios.post(url, payload, config);
const createdVars = response.data.envs || [];
// Fix the misleading log message - only log a single success message
if (createdVars.length > 0 || response.status === 200) {
log('debug', `Successfully created ${envVars.length} environment variables in batch`);
} else {
log('debug', `API returned success but no environment variables were created`);
}
return createdVars;
} catch (error) {
const msg = error.response?.data?.error?.message || error.message;
throw new Error(`Failed to create environment variables in batch: ${msg}`);
}
}
/**
* Delete multiple environment variables from Vercel in parallel batches
*/
async function batchDeleteVercelEnvVars(projectId, envVarsToDelete, token, teamId) {
const vercelToken = getVercelToken(token);
const results = {
successful: [],
failed: []
};
try {
// Create delete promises for all variables
const deletePromises = envVarsToDelete.map(({ id, key }) => {
return deleteVercelEnvVar(projectId, id, vercelToken, teamId)
.then(() => {
results.successful.push(key);
return { success: true, key };
})
.catch((error) => {
results.failed.push({ key, error: error.message });
return { success: false, key, error: error.message };
});
});
// Execute all delete operations in parallel
await Promise.all(deletePromises);
log(
'debug',
`Batch deletion complete: ${results.successful.length} successful, ${results.failed.length} failed`
);
return results;
} catch (error) {
log('error', `Error in batch deletion: ${error.message}`);
throw error;
}
}
async function updateVercelEnvVars(project) {
const validation = validateDeployment(project);
if (!validation.isValid) {
throw new Error(validation.error);
}
try {
// Load ALL variables into process.env first
const prodVars = loadEnvironmentVariables('.env.secure.prod', process.env.BWS_EPHEMERAL_KEY);
const devVars = loadEnvironmentVariables('.env.secure.dev', process.env.BWS_EPHEMERAL_KEY);
// After loading all vars
log(
'debug',
`Loaded ${Object.keys(prodVars || {}).length} total production variables into process.env`
);
log(
'debug',
`Loaded ${Object.keys(devVars || {}).length} total development variables into process.env`
);
// Now load required vars from requiredVars.env
const requiredVarsPath = path.join(process.cwd(), 'requiredVars.env');
const requiredVarsContent = await fs.promises.readFile(requiredVarsPath, 'utf8');
// Parse required vars (excluding comments and empty lines)
const requiredVars = new Set(
requiredVarsContent
.split('\n')
.filter((line) => line.trim() && !line.startsWith('#'))
.map((line) => line.trim())
);
// Filter vars to only include required ones for Vercel upload
const filteredProdVars = Object.fromEntries(
Object.entries(prodVars || {}).filter(([key]) => requiredVars.has(key))
);
const filteredDevVars = Object.fromEntries(
Object.entries(devVars || {}).filter(([key]) => requiredVars.has(key))
);
// After filtering
log(
'debug',
`Filtered to ${
Object.keys(filteredProdVars).length
} required production variables for Vercel upload`
);
log(
'debug',
`Filtered to ${
Object.keys(filteredDevVars).length
} required development variables for Vercel upload`
);
// Add BWS_PROJECT and BWS_ENV to the filtered vars
filteredProdVars.BWS_PROJECT = project.projectName;
filteredProdVars.BWS_ENV = 'prod';
filteredDevVars.BWS_PROJECT = project.projectName;
filteredDevVars.BWS_ENV = 'dev';
// Get the project ID and team ID by searching through all teams
log('debug', `Looking up project ${project.projectName} across all teams`);
const { projectId, teamId } = await getProjectIdFromName(project);
if (!projectId) {
throw new Error('Could not determine Vercel project ID');
}
log('debug', `Found project ID: ${projectId}${teamId ? `, team ID: ${teamId}` : ''}`);
// First, get all current variables to delete
const vercelToken = getVercelToken(null);
const url = `https://api.vercel.com/v9/projects/${projectId}/env`;
const config = {
headers: { Authorization: vercelToken },
...(teamId ? { params: { teamId } } : {})
};
const resp = await axios.get(url, config);
const existingEnvs = resp.data.envs || [];
// Gather IDs of variables to delete (excluding preserved ones)
const envVarsToDelete = existingEnvs.filter(({ key }) => !shouldPreserveVar(key, project));
log('debug', `Found ${envVarsToDelete.length} environment variables to delete`);
// Delete variables in batches with retry logic
if (envVarsToDelete.length > 0) {
log('debug', 'Deleting environment variables in batches');
// Process deletions in batches of 10
const batchSize = 10;
for (let i = 0; i < envVarsToDelete.length; i += batchSize) {
const batch = envVarsToDelete.slice(i, i + batchSize);
// Try up to 3 times to delete this batch
let attempts = 0;
let remainingToDelete = [...batch];
while (remainingToDelete.length > 0 && attempts < 3) {
attempts++;
try {
log(
'debug',
`Deleting batch ${Math.floor(i / batchSize) + 1} (${
remainingToDelete.length
} variables) (attempt ${attempts})`
);
// Delete in parallel
const results = await batchDeleteVercelEnvVars(
projectId,
remainingToDelete,
vercelToken,
teamId
);
// Keep track of variables that failed and need to be retried
if (results.failed.length === 0) {
log(
'debug',
`Successfully deleted all ${remainingToDelete.length} variables in batch`
);
remainingToDelete = [];
} else {
// Filter out successful ones and retry the failures
const failedKeys = results.failed.map((f) => f.key);
log(
'warn',
`${results.failed.length} variables failed to delete: ${failedKeys.join(', ')}`
);
// Only keep the failed ones for retry
remainingToDelete = remainingToDelete.filter((item) => failedKeys.includes(item.key));
// Skip retry for certain errors
remainingToDelete = remainingToDelete.filter((item) => {
const failedItem = results.failed.find((f) => f.key === item.key);
if (
failedItem &&
(failedItem.error.includes('internal error') ||
failedItem.error.includes('not found'))
) {
log('warn', `Skipping retry for ${item.key} due to Vercel API issue`);
return false;
}
return true;
});
log('debug', `${remainingToDelete.length} variables will be retried`);
}
// Add a delay between batch operations
if (i + batchSize < envVarsToDelete.length || remainingToDelete.length > 0) {
await new Promise((resolve) => setTimeout(resolve, 500));
}
} catch (error) {
log('warn', `Failed to process deletion batch (attempt ${attempts}): ${error.message}`);
// Wait longer between retries
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
if (remainingToDelete.length > 0) {
log(
'warn',
`Could not delete ${remainingToDelete.length} variables after ${attempts} attempts. Continuing with next batch.`
);
}
}
log('debug', `Completed environment variable deletion process`);
}
// Prepare production and development variables for creation
const prodEnvVars = [];
const devEnvVars = [];
// Process production variables
for (const [key, value] of Object.entries(filteredProdVars)) {
if (shouldPreserveVar(key, project)) {
log('debug', `Skipping preserved variable: ${key}`);
continue;
}
prodEnvVars.push({
key,
value,
target: ['production'],
type: 'encrypted',
gitBranch: null,
sensitive: true
});
}
// Process development variables
for (const [key, value] of Object.entries(filteredDevVars)) {
if (shouldPreserveVar(key, project)) {
log('debug', `Skipping preserved variable: ${key}`);
continue;
}
devEnvVars.push({
key,
value,
target: ['preview', 'development'],
type: 'encrypted',
gitBranch: null,
sensitive: true
});
}
// Combine all variables
const allEnvVars = [...prodEnvVars, ...devEnvVars];
// Create variables in batches with retry logic
if (allEnvVars.length > 0) {
log('debug', `Creating ${allEnvVars.length} environment variables in batches`);
// Process variables in batches of 10
const batchSize = 10;
for (let i = 0; i < allEnvVars.length; i += batchSize) {
const batch = allEnvVars.slice(i, i + batchSize);
// Try up to 3 times to create this batch
let success = false;
let attempts = 0;
while (!success && attempts < 3) {
attempts++;
try {
log(
'debug',
`Creating batch ${Math.floor(i / batchSize) + 1} (${
batch.length
} variables) (attempt ${attempts})`
);
// If first attempt and batch size > 1, try batch creation
if (attempts === 1 && batch.length > 1) {
try {
await batchCreateVercelEnvVars(projectId, batch, vercelToken, teamId);
success = true;
log('debug', `Successfully created batch of ${batch.length} variables`);
} catch (batchError) {
log(
'warn',
`Batch creation failed: ${batchError.message}. Falling back to individual creation.`
);
// Will fall back to individual creation on next attempt
}
} else {
// On subsequent attempts or for single items, create individually
const createPromises = batch.map((envVar) => {
log(
'debug',
`Creating ${envVar.key} for ${envVar.target.join(', ')} environment(s)`
);
return createVercelEnvVar(
projectId,
envVar.key,
envVar.value,
vercelToken,
envVar.target,
teamId
);
});
await Promise.all(createPromises);
success = true;
log('debug', `Successfully created ${batch.length} variables individually`);
}
// Add a small delay between batches to avoid rate limiting
if (i + batchSize < allEnvVars.length) {
await new Promise((resolve) => setTimeout(resolve, 500));
}
} catch (error) {
log('warn', `Failed to create batch (attempt ${attempts}): ${error.message}`);
// Wait longer between retries
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
if (!success) {
log(
'warn',
`Could not create variables batch after ${attempts} attempts. Skipping batch.`
);
}
}
log('debug', `Completed environment variable creation process`);
}
// Success message
log('info', `Successfully updated environment variables for project: ${project.projectName}`);
console.log(
'\x1b[32m╔════════════════════════════════════════════════════════════════════╗\x1b[0m'
);
console.log(
'\x1b[32m║ ║\x1b[0m'
);
console.log(
'\x1b[32m║ Successfully updated Vercel project ║\x1b[0m'
);
console.log(`\x1b[32m║ ${project.projectName.padEnd(46)}║\x1b[0m`);
console.log(
'\x1b[32m║ ║\x1b[0m'
);
console.log(
'\x1b[32m╚════════════════════════════════════════════════════════════════════╝\x1b[0m'
);
} catch (error) {
log('error', `Failed to update Vercel project ${project.projectName}: ${error.message}`);
// Format error message in red box, similar to success message
console.log(
'\x1b[31m╔════════════════════════════════════════════════════════════════════╗\x1b[0m'
);
console.log(
'\x1b[31m║ ║\x1b[0m'
);
console.log(
'\x1b[31m║ ERROR: Vercel Project Not Found ║\x1b[0m'
);
console.log(`\x1b[31m║ ${project.projectName.padEnd(46)}║\x1b[0m`);
console.log(
'\x1b[31m║ ║\x1b[0m'
);
if (error.message.includes('Could not determine Vercel project ID')) {
console.log(
'\x1b[31m║ Make sure the project exists and your token has access to it ║\x1b[0m'
);
console.log(
'\x1b[31m║ Run vercel-api-test to debug: pnpm run vercel-api-test ║\x1b[0m'
);
console.log(
'\x1b[31m║ ║\x1b[0m'
);
}
console.log(
'\x1b[31m╚════════════════════════════════════════════════════════════════════╝\x1b[0m'
);
throw error;
}
}
export {
readVars,
updateVars,
getProjectIdFromName,
getCurrentVercelEnvVars,
deleteVercelEnvVar,
decryptEnvVar,
updateVercelEnvVars,
batchCreateVercelEnvVars,
batchDeleteVercelEnvVars
};