bws-secure
Version:
Secure environment management with Bitwarden Secrets Manager
456 lines (389 loc) • 16.2 kB
JavaScript
// Project selector module for BWS secure
import fs from 'node:fs';
import { promises as fsPromises } from 'node:fs';
import path from 'node:path';
import readline from 'node:readline';
// Helper function to log messages
function log(level, message) {
if (level === 'debug' && !process.env.DEBUG) {
return;
}
const colors = {
info: '\u001B[36m', // Cyan
debug: '\u001B[90m', // Gray
warn: '\u001B[33m', // Yellow
error: '\u001B[31m' // Red
};
const color = colors[level] || '';
const reset = '\u001B[0m';
console.log(`${color}${message}${reset}`);
}
// Read configuration file
async function readConfigFile() {
try {
const configPath = path.join(process.cwd(), 'bwsconfig.json');
if (!fs.existsSync(configPath)) {
throw new Error('bwsconfig.json not found');
}
const configContent = await fsPromises.readFile(configPath);
return JSON.parse(configContent);
} catch (error) {
throw new Error(`Failed to read bwsconfig.json: ${error.message}`);
}
}
// Update the environment's BWS section in the .env file
async function updateEnvironmentBwsSection(project, environment, onlyToggleEnvironment = false) {
const environmentPath = path.join(process.cwd(), '.env');
try {
// Read current .env content
let content = '';
if (fs.existsSync(environmentPath)) {
content = await fsPromises.readFile(environmentPath, 'utf8');
}
const lines = content.split('\n').map((line) => line.trimEnd());
if (onlyToggleEnvironment) {
// Just toggle environment comments when BWS_ENV is explicitly set
const environmentSectionStart = lines.findIndex((line) =>
line.includes('Environment options (uncomment to switch)')
);
if (environmentSectionStart !== -1) {
for (let index = environmentSectionStart + 1; index < lines.length; index++) {
const line = lines[index];
if (!line.includes('BWS_ENV=')) {
continue;
}
if (
line === '' ||
(line.trim() !== '' && !line.includes('#') && !line.includes('BWS_ENV='))
) {
break;
}
if (line.includes('BWS_ENV=')) {
if (line.includes(`BWS_ENV=${environment}`)) {
lines[index] = line.replace(/^#\s*/, '');
} else if (!line.startsWith('#')) {
lines[index] = `# ${line}`;
}
}
}
}
} else {
// Define section markers
const beginMarker = '# === BEGIN BWS PROJECT CONFIGURATION ===';
const endMarker = '# === END BWS PROJECT CONFIGURATION ===';
// Find existing BWS section with markers
const beginIndex = lines.findIndex((line) => line.includes(beginMarker));
const endIndex = lines.findIndex((line) => line.includes(endMarker));
// Prepare new BWS section with markers
const bwsSection = [];
// Start with the begin marker, then project options
bwsSection.push(beginMarker, '', '# Project options (uncomment to switch)');
// Get all available projects
const config = await readConfigFile();
// Add all project options, with selected project uncommented
for (const p of config.projects) {
const line = `BWS_PROJECT=${p.projectName}`;
if (p.projectName === project.projectName) {
bwsSection.push(line);
} else {
bwsSection.push(`# ${line}`);
}
}
bwsSection.push('', '# Environment options (uncomment to switch)');
// Add all environment options for the selected project
for (const environment_ of ['local', 'dev', 'prod']) {
const line = `BWS_ENV=${environment_}`;
const comment =
environment_ === 'local'
? ' # For local development'
: environment_ === 'dev'
? ' # For development/preview deployments'
: ' # For production deployments';
if (environment_ === environment) {
bwsSection.push(`${line}${comment}`);
} else {
bwsSection.push(`# ${line}${comment}`);
}
}
bwsSection.push('', endMarker);
// Update or add the BWS section with markers
if (beginIndex !== -1 && endIndex !== -1 && beginIndex < endIndex) {
// Before replacing the section, check if there's content after the endMarker
// and preserve any blank lines that may be there (but not more than one)
const additionalLines = [];
let nonBlankFound = false;
// Look ahead after the end marker to preserve spacing before the next section
for (let index = endIndex + 1; index < lines.length; index++) {
if (lines[index].trim() === '') {
if (!nonBlankFound) {
additionalLines.push(''); // Add only one blank line initially
}
} else {
nonBlankFound = true;
break; // Stop once we find non-blank content
}
}
// Replace entire section between markers (inclusive)
lines.splice(beginIndex, endIndex - beginIndex + 1, ...bwsSection);
// If there was non-blank content after the marker and we didn't already add a blank line
// in the bwsSection array, add exactly ONE blank line to separate sections
if (nonBlankFound && additionalLines.length === 0) {
lines.splice(beginIndex + bwsSection.length, 0, '');
}
} else {
// No existing section with markers found
// First look for a BWS Project Configuration line
const configLine = lines.findIndex((line) => line.includes('BWS Project Configuration'));
if (configLine === -1) {
// No existing section at all, add after BWS_ACCESS_TOKEN if present
const accessTokenIndex = lines.findIndex((line) => line.includes('BWS_ACCESS_TOKEN'));
if (accessTokenIndex === -1) {
// No access token found, add to the end of the file
if (lines.length > 0 && lines.at(-1).trim() !== '') {
lines.push('', '');
}
lines.push(...bwsSection);
// Add one blank line at the end if we're not at the end of the file
if (lines.length > 0 && lines.at(-1).trim() !== '') {
lines.push('');
}
} else {
// Add after the access token section with proper spacing
// First, find the line after the access token (it could be a comment or the token itself)
let accessTokenSectionEnd = accessTokenIndex;
while (
accessTokenSectionEnd + 1 < lines.length &&
lines[accessTokenSectionEnd + 1].trim() !== '' &&
(lines[accessTokenSectionEnd + 1].includes('BWS_ACCESS_TOKEN') ||
lines[accessTokenSectionEnd + 1].trim().startsWith('#')) &&
!lines[accessTokenSectionEnd + 1].includes('=== BEGIN BWS PROJECT CONFIGURATION ===')
) {
accessTokenSectionEnd++;
}
// Insert point is right after the access token section
const insertIndex = accessTokenSectionEnd + 1;
// Clear any existing blank lines after the access token section
while (insertIndex < lines.length && lines[insertIndex].trim() === '') {
lines.splice(insertIndex, 1);
}
// Always add exactly one blank line before the BWS section
lines.splice(insertIndex, 0, '');
// Add our BWS section
lines.splice(insertIndex + 1, 0, ...bwsSection);
// Add one blank line to separate from next section if it exists
if (
insertIndex + 1 + bwsSection.length < lines.length &&
lines[insertIndex + 1 + bwsSection.length].trim() !== ''
) {
lines.splice(insertIndex + 1 + bwsSection.length, 0, '');
}
}
} else {
// Find a reasonable place to end the old section
let oldSectionEnd = lines.length - 1;
for (let index = configLine + 1; index < lines.length; index++) {
if (
index > configLine + 15 || // Limit how far we look
(lines[index].trim().startsWith('#') &&
!lines[index].includes('Project options') &&
!lines[index].includes('Environment options'))
) {
oldSectionEnd = index - 1;
break;
}
}
// Replace from config line to estimated end
lines.splice(configLine, oldSectionEnd - configLine + 1, ...bwsSection);
// Add one blank line to separate from next section if it exists
if (
configLine + bwsSection.length < lines.length &&
lines[configLine + bwsSection.length].trim() !== ''
) {
lines.splice(configLine + bwsSection.length, 0, '');
}
}
}
}
// Remove any trailing empty lines at the end of the file
// This ensures we don't keep adding empty lines
while (lines.length > 0 && lines.at(-1).trim() === '') {
lines.pop();
}
// Add exactly one empty line at the end of the file
lines.push('');
// Write updated content back to file
await fsPromises.writeFile(environmentPath, lines.join('\n'));
log('debug', `Updated .env file with BWS settings for ${project.projectName}`);
} catch (error) {
log('warn', `Failed to update .env file: ${error.message}`);
}
}
// Check if a project is already set in the .env file
async function getProjectFromEnvironmentFile() {
try {
const environmentPath = path.join(process.cwd(), '.env');
if (!fs.existsSync(environmentPath)) {
return null;
}
const content = await fsPromises.readFile(environmentPath, 'utf8');
const lines = content.split('\n');
// Look for an uncommented BWS_PROJECT line
const projectLine = lines.find((line) => {
const trimmed = line.trim();
return trimmed.startsWith('BWS_PROJECT=') && !trimmed.startsWith('#');
});
if (projectLine) {
const projectName = projectLine.split('=')[1].trim();
return projectName;
}
return null;
} catch (error) {
log('warn', `Failed to read project from .env: ${error.message}`);
return null;
}
}
// Prompt user to select a project
async function promptForProject(projects) {
// Capture currently saved project if it exists
let currentProject = null;
// First check if there's a project in environment variable
if (process.env.BWS_PROJECT) {
currentProject = projects.find((p) => p.projectName === process.env.BWS_PROJECT);
if (currentProject) {
log('debug', `Using project from environment variable: ${currentProject.projectName}`);
await updateEnvironmentBwsSection(currentProject, process.env.BWS_ENV || 'local');
return currentProject;
}
}
// If not in environment, check the .env file
if (!currentProject) {
const savedProjectName = await getProjectFromEnvironmentFile();
if (savedProjectName) {
currentProject = projects.find((p) => p.projectName === savedProjectName);
if (currentProject) {
// Set the environment variable to match what's in the file
process.env.BWS_PROJECT = currentProject.projectName;
log('debug', `Using project from .env file: ${currentProject.projectName}`);
await updateEnvironmentBwsSection(currentProject, process.env.BWS_ENV || 'local');
return currentProject;
}
}
}
// If single project, use it automatically without prompting
if (projects.length === 1) {
const selectedProject = projects[0];
process.env.BWS_PROJECT = selectedProject.projectName;
log('debug', `Using single available project: ${selectedProject.projectName}`);
await updateEnvironmentBwsSection(selectedProject, process.env.BWS_ENV || 'local');
return selectedProject;
}
// For platform builds, use first project as default without prompting
const isNetlify = process.env.NETLIFY === 'true';
const isVercel = process.env.VERCEL === '1';
if (isNetlify || isVercel) {
const selectedProject = projects[0];
process.env.BWS_PROJECT = selectedProject.projectName;
log(
'debug',
`Automatically selecting first project for platform build: ${selectedProject.projectName}`
);
await updateEnvironmentBwsSection(selectedProject, process.env.BWS_ENV || 'local');
return selectedProject;
}
// Multiple projects in local development - prompt only if we don't have a valid current project
if (currentProject) {
log('debug', `Using current project: ${currentProject.projectName}`);
await updateEnvironmentBwsSection(currentProject, process.env.BWS_ENV || 'local');
return currentProject;
}
// Only prompt if we have multiple projects and no current selection
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
console.log('\nAvailable projects:');
for (const [index, p] of projects.entries()) {
// Show current selection with an indicator
const isCurrent = currentProject && p.projectName === currentProject.projectName;
console.log(`${index + 1}. ${p.projectName} (${p.platform})${isCurrent ? ' (current)' : ''}`);
}
try {
const defaultOption = currentProject ? projects.indexOf(currentProject) + 1 : 1; // Default to first project if none selected
const prompt = `\nSelect a project number [${defaultOption}]: `;
const answer = await new Promise((resolve) => {
rl.question(prompt, (input) => resolve(input || defaultOption.toString()));
});
rl.close();
const selection = Number.parseInt(answer, 10) - 1;
if (selection >= 0 && selection < projects.length) {
const selectedProject = projects[selection];
process.env.BWS_PROJECT = selectedProject.projectName;
await updateEnvironmentBwsSection(selectedProject, process.env.BWS_ENV || 'local');
return selectedProject;
}
throw new Error('Invalid selection');
} catch (error) {
rl.close();
throw new Error(`Project selection failed: ${error.message}`);
}
}
// Normalize environment names to our standard format
function normalizeEnvironment(environmentName) {
if (!environmentName) {
return 'local'; // Default to local if no environment specified
}
// Convert to lowercase for consistent matching
const environment = environmentName.toLowerCase();
// Map various environment names to our standard ones
if (environment === 'production' || environment === 'prod') {
return 'prod';
}
if (environment === 'dev' || environment === 'develop' || environment === 'development') {
return 'dev';
}
// Check other development-like environments
const developmentEnvironments = [
'preview',
'deploy-preview',
'branch-deploy',
'deploy/preview',
'branch',
'test',
'staging'
];
if (developmentEnvironments.includes(environment)) {
return 'dev';
}
if (environment === 'local' || environment === 'development-local') {
return 'local';
}
// If no match found, warn but return the original to help with debugging
console.warn(
`Unknown environment "${environmentName}" - will try to load .env.secure.${environment}`
);
return environment;
}
// Determine environment based on platform or defaults
function determineEnvironment() {
// First check if BWS_ENV is explicitly set - give this highest priority
if (process.env.BWS_ENV) {
return normalizeEnvironment(process.env.BWS_ENV);
}
// Platform-specific environment detection (only if BWS_ENV isn't set)
if (process.env.NETLIFY === 'true') {
return normalizeEnvironment(process.env.CONTEXT || 'dev');
}
if (process.env.VERCEL === '1') {
return normalizeEnvironment(process.env.VERCEL_ENV || 'dev');
}
// Default to local if nothing else matches
return 'local';
}
export {
promptForProject,
updateEnvironmentBwsSection,
determineEnvironment,
normalizeEnvironment,
readConfigFile,
log
};