Files
cve-dashboard/configure.js

1483 lines
46 KiB
JavaScript

#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
// =============================================================================
// Constants & Configuration
// =============================================================================
/**
* Ordered list of variable groups presented during the wizard flow.
*/
const GROUP_ORDER = [
'Core Settings',
'Database',
'Session',
'NVD API',
'Ivanti Integration',
'Atlas Integration',
'Jira Integration',
'CARD Integration',
'GitLab Integration',
'Frontend Settings'
];
/**
* One-line description per group (max 120 characters each).
*/
const GROUP_DESCRIPTIONS = {
'Core Settings': 'Server port, hostname, and CORS configuration',
'Database': 'PostgreSQL connection string for persistent storage',
'Session': 'Secret key for signing session cookies',
'NVD API': 'National Vulnerability Database API key for CVE lookups',
'Ivanti Integration': 'RiskSense platform credentials for vulnerability sync',
'Atlas Integration': 'Atlas InfoSec API for action plan management',
'Jira Integration': 'Jira Data Center for ticket creation and tracking',
'CARD Integration': 'CARD asset ownership API for host lookups',
'GitLab Integration': 'GitLab API for feedback submission (bug reports)',
'Frontend Settings': 'React app API endpoint configuration'
};
/**
* Groups that present a skip prompt before entering.
*/
const SKIPPABLE_GROUPS = [
'NVD API',
'Ivanti Integration',
'Atlas Integration',
'Jira Integration',
'CARD Integration',
'GitLab Integration'
];
/**
* Variables whose values are masked in display (passwords, secrets, API keys, tokens).
*/
const SENSITIVE_VARS = [
'SESSION_SECRET',
'NVD_API_KEY',
'IVANTI_API_KEY',
'ATLAS_API_PASS',
'JIRA_API_TOKEN',
'JIRA_PAT',
'CARD_API_PASS',
'GITLAB_PAT',
'DATABASE_URL'
];
/**
* Complete registry of all 32 managed environment variables with metadata.
* Each descriptor contains:
* name — environment variable name
* group — which GROUP_ORDER group it belongs to
* required — whether a non-empty value is mandatory
* default — factory default value (null if none; 'derived' handled at runtime)
* description — max 120 chars explaining what the variable controls
* docUrl — URL or instruction for obtaining the value (null if N/A)
* sensitive — whether to mask the value in display
* validator — name of validation function to apply (null if none)
*/
const VARIABLE_DESCRIPTORS = [
// --- Core Settings ---
{
name: 'PORT',
group: 'Core Settings',
required: true,
default: '3001',
description: 'TCP port the backend Express server listens on',
docUrl: null,
sensitive: false,
validator: 'validatePort'
},
{
name: 'API_HOST',
group: 'Core Settings',
required: true,
default: 'localhost',
description: 'Hostname or IP address the backend binds to',
docUrl: null,
sensitive: false,
validator: null
},
{
name: 'CORS_ORIGINS',
group: 'Core Settings',
required: false,
default: null, // derived from frontend port at runtime
description: 'Allowed CORS origins (only needed if frontend dev server runs on a separate port)',
docUrl: null,
sensitive: false,
validator: 'validateCorsOrigins'
},
// --- Database ---
{
name: 'DATABASE_URL',
group: 'Database',
required: true,
default: null, // derived from docker-compose.yml or fallback
description: 'PostgreSQL connection string (or "sqlite" for SQLite mode)',
docUrl: null,
sensitive: true,
validator: 'validateDatabaseUrl'
},
// --- Session ---
{
name: 'SESSION_SECRET',
group: 'Session',
required: true,
default: null,
description: 'Secret key for signing session cookies — generate with: openssl rand -base64 32',
docUrl: null,
sensitive: true,
validator: 'validateSessionSecret'
},
// --- NVD API ---
{
name: 'NVD_API_KEY',
group: 'NVD API',
required: false,
default: null,
description: 'API key to increase NVD rate limit from 5 to 50 requests per 30 seconds',
docUrl: 'https://nvd.nist.gov/developers/request-an-api-key',
sensitive: true,
validator: null
},
// --- Ivanti Integration ---
{
name: 'IVANTI_API_KEY',
group: 'Ivanti Integration',
required: false,
default: null,
description: 'RiskSense API key from your profile settings (does not expire like session cookies)',
docUrl: 'https://platform4.risksense.com — Profile > API Keys',
sensitive: true,
validator: null
},
{
name: 'IVANTI_CLIENT_ID',
group: 'Ivanti Integration',
required: false,
default: '1550',
description: 'RiskSense client/organization ID for API requests',
docUrl: 'https://platform4.risksense.com — visible in URL after login',
sensitive: false,
validator: null
},
{
name: 'IVANTI_FIRST_NAME',
group: 'Ivanti Integration',
required: false,
default: null,
description: 'First name of the service account user for Ivanti API authentication',
docUrl: null,
sensitive: false,
validator: null
},
{
name: 'IVANTI_LAST_NAME',
group: 'Ivanti Integration',
required: false,
default: null,
description: 'Last name of the service account user for Ivanti API authentication',
docUrl: null,
sensitive: false,
validator: null
},
{
name: 'IVANTI_BU_FILTER',
group: 'Ivanti Integration',
required: false,
default: 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM',
description: 'Comma-separated BU values to sync from Ivanti into the local findings cache',
docUrl: null,
sensitive: false,
validator: null
},
{
name: 'IVANTI_MANAGED_BUS',
group: 'Ivanti Integration',
required: false,
default: 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM',
description: 'Comma-separated BUs considered "managed" for drift classification in the archive',
docUrl: null,
sensitive: false,
validator: null
},
{
name: 'IVANTI_SKIP_TLS',
group: 'Ivanti Integration',
required: false,
default: 'false',
description: 'Set to true if behind Charter SSL inspection proxy (disables TLS cert verification)',
docUrl: null,
sensitive: false,
validator: null
},
// --- Atlas Integration ---
{
name: 'ATLAS_API_URL',
group: 'Atlas Integration',
required: false,
default: null,
description: 'Base URL for the Atlas InfoSec API (e.g. https://atlas-infosec.caas.charterlab.com)',
docUrl: 'https://atlas-infosec.caas.charterlab.com — API documentation',
sensitive: false,
validator: null
},
{
name: 'ATLAS_API_USER',
group: 'Atlas Integration',
required: false,
default: null,
description: 'Service account username for Atlas API Basic Auth',
docUrl: null,
sensitive: false,
validator: null
},
{
name: 'ATLAS_API_PASS',
group: 'Atlas Integration',
required: false,
default: null,
description: 'Service account password for Atlas API Basic Auth',
docUrl: null,
sensitive: true,
validator: null
},
{
name: 'ATLAS_SKIP_TLS',
group: 'Atlas Integration',
required: false,
default: 'false',
description: 'Set to true if behind Charter SSL inspection proxy (disables TLS cert verification)',
docUrl: null,
sensitive: false,
validator: null
},
// --- Jira Integration ---
{
name: 'JIRA_BASE_URL',
group: 'Jira Integration',
required: false,
default: null,
description: 'Base URL of the Jira Data Center instance (VPN or Charter Network required)',
docUrl: 'Jira instance URL — requires VPN or Charter Network connection',
sensitive: false,
validator: null
},
{
name: 'JIRA_AUTH_METHOD',
group: 'Jira Integration',
required: false,
default: 'basic',
description: 'Authentication method: "basic" for service account or "pat" for Personal Access Token',
docUrl: null,
sensitive: false,
validator: null
},
{
name: 'JIRA_API_USER',
group: 'Jira Integration',
required: false,
default: null,
description: 'Service account username for Jira Basic Auth (used when JIRA_AUTH_METHOD=basic)',
docUrl: null,
sensitive: false,
validator: null
},
{
name: 'JIRA_API_TOKEN',
group: 'Jira Integration',
required: false,
default: null,
description: 'Service account password/token for Jira Basic Auth',
docUrl: null,
sensitive: true,
validator: null
},
{
name: 'JIRA_PAT',
group: 'Jira Integration',
required: false,
default: null,
description: 'Personal Access Token for Jira (used when JIRA_AUTH_METHOD=pat, requires ATLSUP approval)',
docUrl: 'PAT naming convention: Function - Team - ATLSUP-XXXXX',
sensitive: true,
validator: null
},
{
name: 'JIRA_PROJECT_KEY',
group: 'Jira Integration',
required: false,
default: null,
description: 'Default Jira project key for creating issues from the dashboard',
docUrl: null,
sensitive: false,
validator: null
},
{
name: 'JIRA_ISSUE_TYPE',
group: 'Jira Integration',
required: false,
default: 'Task',
description: 'Default issue type when creating Jira tickets from the dashboard',
docUrl: null,
sensitive: false,
validator: null
},
{
name: 'JIRA_SKIP_TLS',
group: 'Jira Integration',
required: false,
default: 'false',
description: 'Set to true if behind Charter SSL inspection proxy (disables TLS cert verification)',
docUrl: null,
sensitive: false,
validator: null
},
// --- CARD Integration ---
{
name: 'CARD_API_URL',
group: 'CARD Integration',
required: false,
default: null,
description: 'Base URL for the CARD asset ownership API (card.charter.com or staging)',
docUrl: 'https://card.charter.com — service account must be onboarded with CARD team',
sensitive: false,
validator: null
},
{
name: 'CARD_API_USER',
group: 'CARD Integration',
required: false,
default: null,
description: 'Service account username for CARD API OAuth token acquisition',
docUrl: null,
sensitive: false,
validator: null
},
{
name: 'CARD_API_PASS',
group: 'CARD Integration',
required: false,
default: null,
description: 'Service account password for CARD API OAuth token acquisition',
docUrl: null,
sensitive: true,
validator: null
},
{
name: 'CARD_SKIP_TLS',
group: 'CARD Integration',
required: false,
default: 'false',
description: 'Set to true if behind Charter SSL inspection proxy (disables TLS cert verification)',
docUrl: null,
sensitive: false,
validator: null
},
// --- GitLab Integration ---
{
name: 'GITLAB_URL',
group: 'GitLab Integration',
required: false,
default: 'http://steam-gitlab.charterlab.com',
description: 'Base URL of the GitLab instance for feedback submission',
docUrl: null,
sensitive: false,
validator: null
},
{
name: 'GITLAB_PROJECT_ID',
group: 'GitLab Integration',
required: false,
default: null,
description: 'Numeric project ID from GitLab project settings (Settings > General)',
docUrl: 'GitLab project > Settings > General — numeric Project ID',
sensitive: false,
validator: null
},
{
name: 'GITLAB_PAT',
group: 'GitLab Integration',
required: false,
default: null,
description: 'GitLab Personal Access Token with "api" scope for creating issues',
docUrl: 'GitLab > Preferences > Access Tokens — requires "api" scope',
sensitive: true,
validator: null
},
// --- Frontend Settings ---
{
name: 'REACT_APP_API_BASE',
group: 'Frontend Settings',
required: true,
default: null, // derived from PORT at runtime
description: 'Full URL to the backend API including /api path (used by React fetch calls)',
docUrl: null,
sensitive: false,
validator: null
},
{
name: 'REACT_APP_API_HOST',
group: 'Frontend Settings',
required: true,
default: null, // derived from PORT at runtime
description: 'Backend host URL without /api path (used for direct file/download URLs)',
docUrl: null,
sensitive: false,
validator: null
}
];
// =============================================================================
// Parsing Functions
// =============================================================================
/**
* Resolves shell variable substitution syntax ${VAR:-default} by extracting
* the default value. Returns the original string if the pattern is not found.
*
* @param {string} str — input string potentially containing ${VAR:-default}
* @returns {string} — the extracted default value, or the original string
*/
function resolveShellDefault(str) {
const match = str.match(/\$\{[^:}]+:-([^}]+)\}/);
return match ? match[1] : str;
}
/**
* Parses a docker-compose.yml file to extract Postgres service configuration.
* Uses a simple line-by-line state machine to find the postgres service and
* extract POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB, and the host port.
*
* @param {string} filePath — path to docker-compose.yml
* @returns {{ user: string, password: string, database: string, port: string } | null}
*/
function parseDockerCompose(filePath) {
let content;
try {
content = fs.readFileSync(filePath, 'utf8');
} catch (e) {
return null;
}
const lines = content.split('\n');
// State machine states
let inServices = false;
let inPostgres = false;
let inEnvironment = false;
let inPorts = false;
let postgresIndent = -1;
let sectionIndent = -1;
let user = null;
let password = null;
let database = null;
let port = null;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trimStart();
const indent = line.length - trimmed.length;
// Look for services: block
if (trimmed === 'services:' || trimmed.startsWith('services:')) {
inServices = true;
continue;
}
if (!inServices) continue;
// Look for postgres service (a line like " postgres:" under services)
if (!inPostgres) {
if (indent > 0 && /^postgres\s*:/.test(trimmed)) {
inPostgres = true;
postgresIndent = indent;
continue;
}
continue;
}
// If we're in the postgres service, check if we've left it
// (a line at the same or lesser indent that isn't blank)
if (trimmed.length > 0 && indent <= postgresIndent) {
// We've exited the postgres service block
break;
}
// Look for environment: or ports: section within postgres
if (!inEnvironment && !inPorts) {
if (/^environment\s*:/.test(trimmed)) {
inEnvironment = true;
sectionIndent = indent;
continue;
}
if (/^ports\s*:/.test(trimmed)) {
inPorts = true;
sectionIndent = indent;
continue;
}
continue;
}
// Inside environment section
if (inEnvironment) {
// Check if we've left the environment section
if (trimmed.length > 0 && indent <= sectionIndent) {
inEnvironment = false;
// Check if this line starts a new section
if (/^ports\s*:/.test(trimmed)) {
inPorts = true;
sectionIndent = indent;
continue;
}
continue;
}
// Parse environment variables (format: KEY: value or KEY: ${VAR:-default})
const envMatch = trimmed.match(/^(POSTGRES_\w+)\s*:\s*(.+)$/);
if (envMatch) {
const key = envMatch[1];
let value = envMatch[2].trim();
// Remove surrounding quotes if present
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
value = resolveShellDefault(value);
if (key === 'POSTGRES_USER') user = value;
else if (key === 'POSTGRES_PASSWORD') password = value;
else if (key === 'POSTGRES_DB') database = value;
}
continue;
}
// Inside ports section
if (inPorts) {
// Check if we've left the ports section
if (trimmed.length > 0 && indent <= sectionIndent) {
inPorts = false;
// Check if this line starts a new section
if (/^environment\s*:/.test(trimmed)) {
inEnvironment = true;
sectionIndent = indent;
continue;
}
continue;
}
// Parse port mapping (format: - "host:container" or - host:container)
const portMatch = trimmed.match(/^-\s*"?(\d+)\s*:\s*\d+"?/);
if (portMatch) {
port = portMatch[1];
}
continue;
}
}
// Return null if we couldn't extract all required values
if (!user || !password || !database || !port) {
return null;
}
return { user, password, database, port };
}
/**
* Set of all managed variable names for O(1) lookup during env file parsing.
*/
const MANAGED_VARIABLE_NAMES = new Set(VARIABLE_DESCRIPTORS.map(d => d.name));
/**
* Parses a .env file, separating managed variables from unmanaged lines.
*
* For each non-empty line:
* - Lines starting with '#' are treated as comments
* - Other lines are split on the first '=' to extract key and value
* - Surrounding double quotes are stripped from the value if present
* - If the key matches a managed variable name, it goes into the managed Map
* - Otherwise, the raw line (and any preceding comment lines) go into unmanaged
*
* @param {string} filePath — path to the .env file
* @returns {{ managed: Map<string, string>, unmanaged: string[] }}
*/
function parseEnvFile(filePath) {
try {
const content = fs.readFileSync(filePath, 'utf8');
const lines = content.split('\n');
const managed = new Map();
const unmanaged = [];
const pendingComments = [];
for (const line of lines) {
// Skip empty lines
if (line.trim() === '') {
// Flush pending comments as unmanaged since they aren't attached to a variable
if (pendingComments.length > 0) {
unmanaged.push(...pendingComments);
pendingComments.length = 0;
}
continue;
}
// Comment line
if (line.trimStart().startsWith('#')) {
pendingComments.push(line);
continue;
}
// Key=value line — split on first '='
const eqIndex = line.indexOf('=');
if (eqIndex === -1) {
// Malformed line — treat as unmanaged
if (pendingComments.length > 0) {
unmanaged.push(...pendingComments);
pendingComments.length = 0;
}
unmanaged.push(line);
continue;
}
const key = line.substring(0, eqIndex).trim();
let value = line.substring(eqIndex + 1);
// Strip surrounding double quotes from value
if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
value = value.slice(1, -1);
}
if (MANAGED_VARIABLE_NAMES.has(key)) {
// Managed variable — discard preceding comments (they're group headers)
pendingComments.length = 0;
managed.set(key, value);
} else {
// Unmanaged variable — preserve preceding comments
if (pendingComments.length > 0) {
unmanaged.push(...pendingComments);
pendingComments.length = 0;
}
unmanaged.push(line);
}
}
// Flush any trailing pending comments
if (pendingComments.length > 0) {
unmanaged.push(...pendingComments);
}
return { managed, unmanaged };
} catch (err) {
// File doesn't exist or can't be read
return { managed: new Map(), unmanaged: [] };
}
}
// =============================================================================
// Validation Functions
// =============================================================================
/**
* Validates that the value is a valid TCP port number (integer between 1 and 65535).
* Leading and trailing whitespace is trimmed before validation.
*
* @param {string} value — user-provided port value
* @returns {{ valid: boolean, message?: string }}
*/
function validatePort(value) {
const trimmed = value.trim();
const num = Number(trimmed);
if (!Number.isInteger(num) || num < 1 || num > 65535) {
return { valid: false, message: 'PORT must be an integer between 1 and 65535' };
}
return { valid: true };
}
/**
* Validates that each comma-separated CORS origin starts with http:// or https://.
* Whitespace is trimmed from each entry before validation.
*
* @param {string} value — comma-separated list of origins
* @returns {{ valid: boolean, message?: string }}
*/
function validateCorsOrigins(value) {
const entries = value.split(',').map(entry => entry.trim());
for (const entry of entries) {
if (!entry.startsWith('http://') && !entry.startsWith('https://')) {
return { valid: false, message: 'Each CORS origin must start with http:// or https://' };
}
}
return { valid: true };
}
/**
* Validates that the DATABASE_URL starts with postgresql:// or equals "sqlite".
*
* @param {string} value — database connection string
* @returns {{ valid: boolean, message?: string }}
*/
function validateDatabaseUrl(value) {
if (value.startsWith('postgresql://') || value === 'sqlite') {
return { valid: true };
}
return { valid: false, message: 'DATABASE_URL must start with postgresql:// or be "sqlite"' };
}
/**
* Validates that the SESSION_SECRET is at least 16 characters long.
*
* @param {string} value — session secret value
* @returns {{ valid: boolean, message?: string }}
*/
function validateSessionSecret(value) {
if (value.length >= 16) {
return { valid: true };
}
return { valid: false, message: 'SESSION_SECRET must be at least 16 characters long' };
}
/**
* Validates that the value is non-empty and not whitespace-only.
*
* @param {string} value — user-provided value
* @returns {{ valid: boolean, message?: string }}
*/
function validateRequired(value) {
if (value.trim().length === 0) {
return { valid: false, message: 'This field is required and cannot be empty' };
}
return { valid: true };
}
// =============================================================================
// Display Functions
// =============================================================================
/**
* Prints a welcome message explaining the wizard's purpose.
*/
function printWelcome() {
console.log('\nCVE Dashboard Configuration Wizard');
console.log('===================================');
console.log('This wizard will guide you through configuring backend and frontend');
console.log('environment variables. Press Ctrl+C at any time to cancel.\n');
}
/**
* Prints a group section header with its description from GROUP_DESCRIPTIONS.
*
* @param {string} group — the group name from GROUP_ORDER
*/
function printGroupHeader(group) {
const description = GROUP_DESCRIPTIONS[group] || '';
console.log(`\n=== ${group} ===`);
console.log(description + '\n');
}
/**
* Masks a sensitive variable value by showing only the first 4 and last 4
* characters with asterisks in between. If the value is 8 characters or fewer,
* returns the full value. If the variable name is not in SENSITIVE_VARS,
* returns the value unchanged.
*
* @param {string} name — the variable name
* @param {string} value — the variable value
* @returns {string} — the masked or original value
*/
function maskSensitive(name, value) {
if (!SENSITIVE_VARS.includes(name)) {
return value;
}
if (value.length <= 8) {
return value;
}
return value.slice(0, 4) + '****' + value.slice(-4);
}
/**
* Displays a summary table organized by Variable_Group showing all configured
* variables with their values. Sensitive variables are masked. Skipped groups
* are excluded. Shows target file paths and whether each file already exists.
*
* @param {Map<string, string>} config — variable name → value
* @param {Set<string>} skippedGroups — groups the user declined
*/
function printSummary(config, skippedGroups) {
console.log('\n===================================');
console.log('Configuration Summary');
console.log('===================================\n');
for (const group of GROUP_ORDER) {
if (skippedGroups.has(group)) {
continue;
}
const groupVars = VARIABLE_DESCRIPTORS.filter(
d => d.group === group && config.has(d.name)
);
if (groupVars.length === 0) {
continue;
}
console.log(`--- ${group} ---`);
for (const descriptor of groupVars) {
const value = config.get(descriptor.name);
const displayValue = maskSensitive(descriptor.name, value);
console.log(` ${descriptor.name} = ${displayValue}`);
}
console.log('');
}
// Show skipped groups
const skippedList = GROUP_ORDER.filter(g => skippedGroups.has(g));
if (skippedList.length > 0) {
console.log('Skipped groups:');
for (const group of skippedList) {
console.log(` - ${group}`);
}
console.log('');
}
// Show target file paths and existence status
const backendEnvPath = path.join('backend', '.env');
const frontendEnvPath = path.join('frontend', '.env');
const backendExists = fs.existsSync(backendEnvPath);
const frontendExists = fs.existsSync(frontendEnvPath);
console.log('Target files:');
console.log(` ${backendEnvPath} ${backendExists ? '(exists — will overwrite)' : '(new file)'}`);
console.log(` ${frontendEnvPath} ${frontendExists ? '(exists — will overwrite)' : '(new file)'}`);
console.log('');
}
// =============================================================================
// Prompt Functions
// =============================================================================
/**
* Map of validator function names to their implementations.
* Used to look up validators by string name from variable descriptors.
*/
const VALIDATORS = {
validatePort,
validateCorsOrigins,
validateDatabaseUrl,
validateSessionSecret,
validateRequired
};
/**
* Prompts the user for a single variable value. Displays the variable label,
* description, documentation URL, and default value. Validates input and
* re-prompts on failure.
*
* @param {object} rl — readline interface
* @param {object} descriptor — variable descriptor with name, description, etc.
* @param {string|null} currentValue — existing value from a parsed env file
* @returns {Promise<string>} — resolves with the final validated value
*/
function promptVariable(rl, descriptor, currentValue) {
return new Promise((resolve) => {
const ask = () => {
// Line 1: [REQUIRED] or [OPTIONAL] label + variable name
const label = descriptor.required ? '[REQUIRED]' : '[OPTIONAL]';
console.log(` ${label} ${descriptor.name}`);
// Line 2: Description
console.log(` ${descriptor.description}`);
// Line 3: Documentation URL (if present)
if (descriptor.docUrl !== null) {
console.log(` Docs: ${descriptor.docUrl}`);
}
// Line 4: Default value display
let defaultValue = null;
let defaultLabel = '';
if (currentValue !== null && currentValue !== undefined) {
defaultValue = currentValue;
defaultLabel = '[current]';
} else if (descriptor.default !== null) {
defaultValue = descriptor.default;
defaultLabel = '';
}
if (defaultValue !== null) {
let displayDefault = defaultValue;
if (descriptor.sensitive) {
displayDefault = maskSensitive(descriptor.name, defaultValue);
}
const labelSuffix = defaultLabel ? ` ${defaultLabel}` : '';
console.log(` (${displayDefault}${labelSuffix})`);
}
// Prompt
rl.question(' > ', (answer) => {
const trimmedAnswer = answer.trim();
// If user enters empty string, use default
if (trimmedAnswer === '') {
if (defaultValue !== null) {
resolve(defaultValue);
return;
}
// No default exists — if required, validate and re-prompt
if (descriptor.required) {
const result = validateRequired('');
console.log(` Error: ${result.message}`);
ask();
return;
}
// Optional with no default — return empty string
resolve('');
return;
}
// Apply validator if one is specified
if (descriptor.validator) {
const validatorFn = VALIDATORS[descriptor.validator];
if (validatorFn) {
const result = validatorFn(trimmedAnswer);
if (!result.valid) {
console.log(` Error: ${result.message}`);
ask();
return;
}
}
}
resolve(trimmedAnswer);
});
};
ask();
});
}
/**
* Prompts the user with a yes/no question.
*
* @param {object} rl — readline interface
* @param {string} question — the question to display
* @param {boolean} defaultNo — if true, default is "no" ([y/N]); if false, default is "yes" ([Y/n])
* @returns {Promise<boolean>} — resolves to true for yes, false for no
*/
function promptYesNo(rl, question, defaultNo) {
return new Promise((resolve) => {
const hint = defaultNo ? '[y/N]' : '[Y/n]';
rl.question(` ${question} ${hint} `, (answer) => {
const trimmed = answer.trim().toLowerCase();
if (trimmed === '') {
resolve(!defaultNo);
return;
}
if (trimmed === 'y' || trimmed === 'yes') {
resolve(true);
return;
}
if (trimmed === 'n' || trimmed === 'no') {
resolve(false);
return;
}
// Invalid input — use default
resolve(!defaultNo);
});
});
}
/**
* Prompts the user to confirm overwriting an existing file. Offers three options:
* overwrite, create backup then overwrite, or skip.
*
* @param {object} rl — readline interface
* @param {string} filePath — path to the existing file
* @returns {Promise<string>} — resolves to 'overwrite', 'backup', or 'skip'
*/
function promptOverwrite(rl, filePath) {
return new Promise((resolve) => {
console.log(`\n File already exists: ${filePath}`);
console.log(' What would you like to do?');
console.log(' 1) Overwrite');
console.log(' 2) Create backup, then overwrite');
console.log(' 3) Skip (do not write this file)');
rl.question(' > ', (answer) => {
const trimmed = answer.trim();
if (trimmed === '1') {
resolve('overwrite');
} else if (trimmed === '2') {
resolve('backup');
} else {
resolve('skip');
}
});
});
}
// =============================================================================
// File Writing Functions
// =============================================================================
/**
* Determines whether a value needs to be wrapped in double quotes.
* Values containing spaces, '#', or quote characters require quoting.
*
* @param {string} value — the variable value to check
* @returns {boolean} — true if the value needs double-quote wrapping
*/
function needsQuoting(value) {
return value.includes(' ') || value.includes('#') || value.includes('"') || value.includes("'");
}
/**
* Generates the complete .env file content string from the provided configuration.
*
* For each group in groupOrder (that isn't in skippedGroups):
* - Writes a comment header: `# --- Group Name ---`
* - For each VARIABLE_DESCRIPTOR in that group:
* - If the variable has a value in the variables map, writes `KEY=value`
* - If the value contains spaces, `#`, or quote characters, wraps it in double quotes
* - If the variable is optional and has no value (not in the map), omits it
* - Adds a blank line between groups
*
* After all groups, if unmanagedLines has entries, adds a `# --- Custom Variables ---`
* section and appends them.
*
* @param {Map<string, string>} variables — variable name → value
* @param {string[]} groupOrder — array of group names in order
* @param {object} groupDescriptions — group name → description (unused in output but kept for API consistency)
* @param {string[]} unmanagedLines — raw lines to preserve in Custom Variables section
* @param {Set<string>} skippedGroups — groups that were skipped by the user
* @returns {string} — the complete .env file content
*/
function generateEnvContent(variables, groupOrder, groupDescriptions, unmanagedLines, skippedGroups) {
const sections = [];
for (const group of groupOrder) {
if (skippedGroups.has(group)) {
continue;
}
const groupVars = VARIABLE_DESCRIPTORS.filter(d => d.group === group);
const lines = [];
for (const descriptor of groupVars) {
if (variables.has(descriptor.name)) {
const value = variables.get(descriptor.name);
if (needsQuoting(value)) {
lines.push(`${descriptor.name}="${value}"`);
} else {
lines.push(`${descriptor.name}=${value}`);
}
} else {
// Optional variable with no value — omit it
}
}
// Only add the group section if it has at least one variable line
if (lines.length > 0) {
sections.push(`# --- ${group} ---\n${lines.join('\n')}`);
}
}
let content = sections.join('\n\n');
// Append unmanaged lines in a Custom Variables section
if (unmanagedLines && unmanagedLines.length > 0) {
if (content.length > 0) {
content += '\n\n';
}
content += `# --- Custom Variables ---\n${unmanagedLines.join('\n')}`;
}
// Ensure file ends with a newline
if (content.length > 0 && !content.endsWith('\n')) {
content += '\n';
}
return content;
}
/**
* Creates a timestamped backup copy of an existing file in the same directory.
* The backup filename follows the pattern: {filename}.backup.{YYYYMMDD_HHmmss}
*
* @param {string} filePath — path to the file to back up
* @returns {string} — the backup file path
*/
function createBackup(filePath) {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
const timestamp = `${year}${month}${day}_${hours}${minutes}${seconds}`;
const backupPath = `${filePath}.backup.${timestamp}`;
fs.copyFileSync(filePath, backupPath);
return backupPath;
}
/**
* Writes content to a file path. Returns a result object indicating success or failure.
*
* @param {string} filePath — path to write the file to
* @param {string} content — the content to write
* @returns {{ success: boolean, error?: string }} — result object
*/
function writeEnvFile(filePath, content) {
try {
fs.writeFileSync(filePath, content);
return { success: true };
} catch (err) {
return { success: false, error: `Failed to write ${filePath}: ${err.message}` };
}
}
// =============================================================================
// Main Flow
// =============================================================================
/**
* Main entry point that orchestrates the entire wizard flow.
* Validates project structure, parses existing config, prompts the user
* through all variable groups, and writes the resulting env files.
*/
async function main() {
const readline = require('readline');
// -------------------------------------------------------------------------
// 1. Validate project root
// -------------------------------------------------------------------------
const backendDir = path.join(process.cwd(), 'backend');
const frontendDir = path.join(process.cwd(), 'frontend');
if (!fs.existsSync(backendDir) || !fs.existsSync(frontendDir)) {
console.error('Error: This script must be run from the project root (backend/ and frontend/ directories not found). Run from the directory containing both folders.');
process.exit(1);
}
// -------------------------------------------------------------------------
// 2. Parse existing env files for pre-filling
// -------------------------------------------------------------------------
const backendEnvPath = path.join('backend', '.env');
const frontendEnvPath = path.join('frontend', '.env');
const existingBackend = parseEnvFile(backendEnvPath);
const existingFrontend = parseEnvFile(frontendEnvPath);
// -------------------------------------------------------------------------
// 3. Parse docker-compose.yml for DATABASE_URL default
// -------------------------------------------------------------------------
const composePath = path.join(process.cwd(), 'docker-compose.yml');
const composeResult = parseDockerCompose(composePath);
const FALLBACK_DATABASE_URL = 'postgresql://steam:sV4xmC9xAUCFop0ypxMVS056QgPqGrX@localhost:5433/cve_dashboard';
let derivedDatabaseUrl = FALLBACK_DATABASE_URL;
let databaseUrlSource = 'fallback';
if (composeResult) {
derivedDatabaseUrl = `postgresql://${composeResult.user}:${composeResult.password}@localhost:${composeResult.port}/${composeResult.database}`;
databaseUrlSource = 'compose';
}
// -------------------------------------------------------------------------
// 4. Set up readline and SIGINT handler
// -------------------------------------------------------------------------
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
process.on('SIGINT', () => {
rl.close();
console.log('\n\nConfiguration cancelled. No files were written.');
process.exit(1);
});
// -------------------------------------------------------------------------
// 5. Display welcome
// -------------------------------------------------------------------------
let restart = true;
while (restart) {
restart = false;
printWelcome();
// -----------------------------------------------------------------------
// 6. Group loop — prompt for each variable group
// -----------------------------------------------------------------------
const values = new Map();
const skippedGroups = new Set();
let confirmedPort = null;
for (const group of GROUP_ORDER) {
// Auto-derive Frontend Settings from PORT and API_HOST — no need to prompt
if (group === 'Frontend Settings') {
const port = confirmedPort || '3001';
const host = values.get('API_HOST') || 'localhost';
const apiBase = `http://${host}:${port}/api`;
const apiHost = `http://${host}:${port}`;
values.set('REACT_APP_API_BASE', apiBase);
values.set('REACT_APP_API_HOST', apiHost);
console.log(`\n=== Frontend Settings ===`);
console.log(` Auto-configured from your backend settings:`);
console.log(` REACT_APP_API_BASE = ${apiBase}`);
console.log(` REACT_APP_API_HOST = ${apiHost}`);
continue;
}
printGroupHeader(group);
// For skippable groups, ask if user wants to configure
if (SKIPPABLE_GROUPS.includes(group)) {
const configure = await promptYesNo(rl, `Configure ${group}?`, true);
if (!configure) {
skippedGroups.add(group);
console.log(` Skipping ${group}.`);
continue;
}
}
// Get variables for this group
const groupVars = VARIABLE_DESCRIPTORS.filter(d => d.group === group);
for (const descriptor of groupVars) {
// Determine the effective current value for pre-filling
let currentValue = null;
// Check existing env values first
if (descriptor.group === 'Frontend Settings') {
if (existingFrontend.managed.has(descriptor.name)) {
currentValue = existingFrontend.managed.get(descriptor.name);
}
} else {
if (existingBackend.managed.has(descriptor.name)) {
currentValue = existingBackend.managed.get(descriptor.name);
}
}
// If no current value, determine derived defaults for special variables
if (currentValue === null && descriptor.default === null) {
if (descriptor.name === 'DATABASE_URL') {
currentValue = null; // Will use derived default below
} else if (descriptor.name === 'CORS_ORIGINS') {
if (confirmedPort !== null) {
currentValue = null; // Will use derived default below
}
}
}
// Build a modified descriptor with the effective default for derived variables
let effectiveDescriptor = descriptor;
if (descriptor.name === 'DATABASE_URL' && currentValue === null && descriptor.default === null) {
const sourceMsg = databaseUrlSource === 'compose'
? ' (auto-derived from docker-compose.yml)'
: ' (fallback from deploy script — docker-compose.yml could not be used)';
effectiveDescriptor = {
...descriptor,
default: derivedDatabaseUrl,
description: descriptor.description + sourceMsg
};
} else if (descriptor.name === 'CORS_ORIGINS' && currentValue === null && descriptor.default === null) {
effectiveDescriptor = { ...descriptor, default: 'http://localhost:3000' };
}
const result = await promptVariable(rl, effectiveDescriptor, currentValue);
// Store the result if non-empty (or if required)
if (result !== '') {
values.set(descriptor.name, result);
}
// Track confirmed PORT for deriving other defaults
if (descriptor.name === 'PORT') {
confirmedPort = result || '3001';
}
}
// After Core Settings group completes, derive defaults for later groups
if (group === 'Core Settings') {
// CORS_ORIGINS default is already handled inline above
// REACT_APP_API_BASE and REACT_APP_API_HOST defaults use confirmedPort
}
}
// -----------------------------------------------------------------------
// 7. Display summary
// -----------------------------------------------------------------------
printSummary(values, skippedGroups);
// -----------------------------------------------------------------------
// 8. Confirmation
// -----------------------------------------------------------------------
const confirmed = await promptYesNo(rl, 'Write these settings to disk?', false);
if (!confirmed) {
// Ask restart or exit
const restartChoice = await promptYesNo(rl, 'Restart the wizard from the beginning?', true);
if (restartChoice) {
restart = true;
continue;
} else {
rl.close();
console.log('\nExiting without writing files.');
process.exit(1);
}
}
// -----------------------------------------------------------------------
// 9. Handle existing files — prompt for overwrite/backup
// -----------------------------------------------------------------------
let writeBackend = true;
let writeFrontend = true;
if (fs.existsSync(backendEnvPath)) {
const choice = await promptOverwrite(rl, backendEnvPath);
if (choice === 'skip') {
writeBackend = false;
} else if (choice === 'backup') {
const backupPath = createBackup(backendEnvPath);
console.log(` Backup created: ${backupPath}`);
}
}
if (fs.existsSync(frontendEnvPath)) {
const choice = await promptOverwrite(rl, frontendEnvPath);
if (choice === 'skip') {
writeFrontend = false;
} else if (choice === 'backup') {
const backupPath = createBackup(frontendEnvPath);
console.log(` Backup created: ${backupPath}`);
}
}
// -----------------------------------------------------------------------
// 10. Write files
// -----------------------------------------------------------------------
// Separate variables into backend and frontend
const backendVars = new Map();
const frontendVars = new Map();
for (const [key, value] of values) {
const descriptor = VARIABLE_DESCRIPTORS.find(d => d.name === key);
if (descriptor && descriptor.group === 'Frontend Settings') {
frontendVars.set(key, value);
} else {
backendVars.set(key, value);
}
}
// Backend groups = all groups except Frontend Settings
const backendGroups = GROUP_ORDER.filter(g => g !== 'Frontend Settings');
const frontendGroups = ['Frontend Settings'];
if (writeBackend) {
const backendContent = generateEnvContent(
backendVars,
backendGroups,
GROUP_DESCRIPTIONS,
existingBackend.unmanaged,
skippedGroups
);
const result = writeEnvFile(backendEnvPath, backendContent);
if (!result.success) {
console.error(`Error: ${result.error}`);
rl.close();
process.exit(1);
}
}
if (writeFrontend) {
const frontendContent = generateEnvContent(
frontendVars,
frontendGroups,
GROUP_DESCRIPTIONS,
existingFrontend.unmanaged,
skippedGroups
);
const result = writeEnvFile(frontendEnvPath, frontendContent);
if (!result.success) {
console.error(`Error: ${result.error}`);
rl.close();
process.exit(1);
}
}
// -----------------------------------------------------------------------
// 11. Success message
// -----------------------------------------------------------------------
console.log('\nConfiguration complete!\n');
console.log('Next steps:');
console.log(' 1. Run `node backend/setup.js` to initialize the database');
console.log(' 2. Start the servers with `./start-servers.sh`');
console.log('');
}
// -------------------------------------------------------------------------
// 12. Exit code 0
// -------------------------------------------------------------------------
rl.close();
process.exit(0);
}
// =============================================================================
// Exports (conditional — only when required as a module, not when run directly)
// =============================================================================
if (typeof require !== 'undefined' && require.main !== module) {
module.exports = {
VARIABLE_DESCRIPTORS,
GROUP_ORDER,
GROUP_DESCRIPTIONS,
SKIPPABLE_GROUPS,
SENSITIVE_VARS,
MANAGED_VARIABLE_NAMES,
VALIDATORS,
resolveShellDefault,
parseDockerCompose,
parseEnvFile,
validatePort,
validateCorsOrigins,
validateDatabaseUrl,
validateSessionSecret,
validateRequired,
printWelcome,
printGroupHeader,
printSummary,
maskSensitive,
promptVariable,
promptYesNo,
promptOverwrite,
needsQuoting,
generateEnvContent,
createBackup,
writeEnvFile,
main
};
}
// Run the wizard when executed directly
if (require.main === module) {
main();
}