#!/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, 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} config — variable name → value * @param {Set} 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} — 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} — 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} — 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} 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} 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(); }