diff --git a/configure.js b/configure.js new file mode 100644 index 0000000..8121dcf --- /dev/null +++ b/configure.js @@ -0,0 +1,1481 @@ +#!/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: true, + default: null, // derived from frontend port at runtime + description: 'Comma-separated list of allowed CORS origins for the backend', + 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) { + 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 + } + } else if (descriptor.name === 'REACT_APP_API_BASE') { + if (confirmedPort !== null) { + currentValue = null; // Will use derived default below + } + } else if (descriptor.name === 'REACT_APP_API_HOST') { + 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' }; + } else if (descriptor.name === 'REACT_APP_API_BASE' && currentValue === null && descriptor.default === null) { + const port = confirmedPort || '3001'; + effectiveDescriptor = { ...descriptor, default: `http://localhost:${port}/api` }; + } else if (descriptor.name === 'REACT_APP_API_HOST' && currentValue === null && descriptor.default === null) { + const port = confirmedPort || '3001'; + effectiveDescriptor = { ...descriptor, default: `http://localhost:${port}` }; + } + + 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(); +} diff --git a/package-lock.json b/package-lock.json index f7270d6..6f208ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "sqlite3": "^5.1.7" }, "devDependencies": { - "fast-check": "^4.7.0", + "fast-check": "^4.8.0", "jest": "^30.3.0" } }, @@ -2910,9 +2910,9 @@ } }, "node_modules/fast-check": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz", - "integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==", + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.8.0.tgz", + "integrity": "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==", "dev": true, "funding": [ { diff --git a/package.json b/package.json index c1b090b..784cfeb 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "sqlite3": "^5.1.7" }, "devDependencies": { - "fast-check": "^4.7.0", + "fast-check": "^4.8.0", "jest": "^30.3.0" } }