#!/usr/bin/env node 'use strict'; /** * CVE Dashboard — Unified Setup Script (Config Wizard) * * Single-file Node.js CLI that orchestrates the full setup lifecycle: * 1. Environment Configuration — interactive prompts with validation * 2. Postgres Provisioning — Docker container start + readiness wait * 3. Schema Initialization — execute db-schema.sql via psql or docker exec * 4. Dependency Installation — npm install in backend and frontend * 5. Data Migration — optional SQLite→Postgres migration * 6. Frontend Build — production bundle generation * * Zero npm dependencies — uses only Node.js built-in modules. * * Usage: node configure.js */ const readline = require('readline'); const { execSync, spawn } = require('child_process'); const fs = require('fs'); const path = require('path'); // ───────────────────────────────────────────────────────────────────────────── // Constants & Configuration // ───────────────────────────────────────────────────────────────────────────── /** * Substrings that mark a variable name as sensitive (case-insensitive match). * Variables matching these patterns have their values masked in display. */ const SENSITIVE_PATTERNS = ['PASSWORD', 'PASS', 'SECRET', 'KEY', 'TOKEN', 'PAT']; /** * Ordered list of variable group names. Variables are presented in this order * 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 for each variable group (max 120 chars). */ 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 can be skipped entirely during the wizard flow. * The user is prompted with a yes/no question before entering these groups. */ const OPTIONAL_GROUPS = [ 'NVD API', 'Ivanti Integration', 'Atlas Integration', 'Jira Integration', 'CARD Integration', 'GitLab Integration' ]; /** * Provisioning steps tracked for skip reporting in the completion banner. * Each step has a name and a manual command the user can run independently. */ const PROVISIONING_STEPS = [ { name: 'Environment file creation', manualCmd: 'node configure.js' }, { name: 'Postgres container startup', manualCmd: 'docker compose up -d' }, { name: 'Database initialization', manualCmd: 'psql $DATABASE_URL -f backend/db-schema.sql' }, { name: 'Dependency installation', manualCmd: 'cd backend && npm install --production && cd ../frontend && npm install' }, { name: 'Frontend build', manualCmd: 'cd frontend && npm run build' } ]; /** * Complete registry of all managed environment variables. * Each descriptor defines metadata used for prompting, validation, and file generation. * * @typedef {Object} VariableDescriptor * @property {string} name - Environment variable name (e.g. "PORT") * @property {string} group - Variable group name (must be in GROUP_ORDER) * @property {string} target - Target env file: "backend" or "frontend" * @property {boolean} required - Whether the variable must have a non-empty value * @property {string|null} default - Factory default value, or null if none * @property {string} description - What the variable controls (max 120 chars) * @property {string|null} docUrl - URL or instruction for obtaining the value (max 200 chars) * @property {boolean} sensitive - Whether to mask the value in display * @property {string|null} validator - Name of validation function to apply, or null */ /** * @type {VariableDescriptor[]} */ const VARIABLE_DESCRIPTORS = [ // ── Core Settings ────────────────────────────────────────────────────────── { name: 'PORT', group: 'Core Settings', target: 'backend', required: true, default: '3001', description: 'TCP port the Express server listens on', docUrl: null, sensitive: false, validator: 'validatePort' }, { name: 'API_HOST', group: 'Core Settings', target: 'backend', required: true, default: 'localhost', description: 'Hostname or IP address the server binds to', docUrl: null, sensitive: false, validator: null }, { name: 'CORS_ORIGINS', group: 'Core Settings', target: 'backend', required: true, default: null, description: 'Comma-separated list of allowed CORS origins', docUrl: null, sensitive: false, validator: 'validateCorsOrigins' }, // ── Database ─────────────────────────────────────────────────────────────── { name: 'DATABASE_URL', group: 'Database', target: 'backend', required: true, default: null, description: 'PostgreSQL connection string (postgresql://user:pass@host:port/db)', docUrl: null, sensitive: true, validator: 'validateDatabaseUrl' }, // ── Session ──────────────────────────────────────────────────────────────── { name: 'SESSION_SECRET', group: 'Session', target: 'backend', required: true, default: null, description: 'Secret key for signing session cookies (min 16 chars)', docUrl: null, sensitive: true, validator: 'validateSessionSecret' }, // ── NVD API ──────────────────────────────────────────────────────────────── { name: 'NVD_API_KEY', group: 'NVD API', target: 'backend', required: false, default: null, description: 'API key for NVD lookups (increases rate limit from 5 to 50 req/30s)', docUrl: 'Request at https://nvd.nist.gov/developers/request-an-api-key', sensitive: true, validator: null }, // ── Ivanti Integration ───────────────────────────────────────────────────── { name: 'IVANTI_API_KEY', group: 'Ivanti Integration', target: 'backend', required: false, default: null, description: 'RiskSense API key for vulnerability data sync', docUrl: 'Obtain from RiskSense admin console under API Keys', sensitive: true, validator: null }, { name: 'IVANTI_CLIENT_ID', group: 'Ivanti Integration', target: 'backend', required: false, default: '1550', description: 'RiskSense client ID for API requests', docUrl: 'Obtain from RiskSense admin console under API Keys', sensitive: false, validator: null }, { name: 'IVANTI_FIRST_NAME', group: 'Ivanti Integration', target: 'backend', required: false, default: null, description: 'First name associated with the Ivanti service account', docUrl: 'Obtain from RiskSense admin console under API Keys', sensitive: false, validator: null }, { name: 'IVANTI_LAST_NAME', group: 'Ivanti Integration', target: 'backend', required: false, default: null, description: 'Last name associated with the Ivanti service account', docUrl: 'Obtain from RiskSense admin console under API Keys', sensitive: false, validator: null }, { name: 'IVANTI_BU_FILTER', group: 'Ivanti Integration', target: 'backend', required: false, default: 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM', description: 'Comma-separated BU values to sync from Ivanti', docUrl: 'Obtain from RiskSense admin console under API Keys', sensitive: false, validator: null }, { name: 'IVANTI_MANAGED_BUS', group: 'Ivanti Integration', target: 'backend', required: false, default: 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM', description: 'Comma-separated BUs considered managed for drift classification', docUrl: 'Obtain from RiskSense admin console under API Keys', sensitive: false, validator: null }, { name: 'IVANTI_SKIP_TLS', group: 'Ivanti Integration', target: 'backend', required: false, default: 'false', description: 'Set to true to disable TLS verification (behind SSL inspection proxy)', docUrl: 'Obtain from RiskSense admin console under API Keys', sensitive: false, validator: null }, // ── Atlas Integration ────────────────────────────────────────────────────── { name: 'ATLAS_API_URL', group: 'Atlas Integration', target: 'backend', required: false, default: null, description: 'Base URL for the Atlas InfoSec API', docUrl: 'Contact InfoSec team for Atlas API credentials', sensitive: false, validator: null }, { name: 'ATLAS_API_USER', group: 'Atlas Integration', target: 'backend', required: false, default: null, description: 'Service account username for Atlas Basic Auth', docUrl: 'Contact InfoSec team for Atlas API credentials', sensitive: false, validator: null }, { name: 'ATLAS_API_PASS', group: 'Atlas Integration', target: 'backend', required: false, default: null, description: 'Service account password for Atlas Basic Auth', docUrl: 'Contact InfoSec team for Atlas API credentials', sensitive: true, validator: null }, { name: 'ATLAS_SKIP_TLS', group: 'Atlas Integration', target: 'backend', required: false, default: 'false', description: 'Set to true to disable TLS verification (behind SSL inspection proxy)', docUrl: 'Contact InfoSec team for Atlas API credentials', sensitive: false, validator: null }, // ── Jira Integration ─────────────────────────────────────────────────────── { name: 'JIRA_BASE_URL', group: 'Jira Integration', target: 'backend', required: false, default: null, description: 'Base URL for Jira Data Center REST API', docUrl: 'Generate token at Jira > Profile > Personal Access Tokens', sensitive: false, validator: null }, { name: 'JIRA_AUTH_METHOD', group: 'Jira Integration', target: 'backend', required: false, default: 'basic', description: 'Authentication method: basic (user+token) or pat (personal access token)', docUrl: 'Generate token at Jira > Profile > Personal Access Tokens', sensitive: false, validator: null }, { name: 'JIRA_API_USER', group: 'Jira Integration', target: 'backend', required: false, default: null, description: 'Service account username for Jira Basic Auth', docUrl: 'Generate token at Jira > Profile > Personal Access Tokens', sensitive: false, validator: null }, { name: 'JIRA_API_TOKEN', group: 'Jira Integration', target: 'backend', required: false, default: null, description: 'API token for Jira Basic Auth (paired with JIRA_API_USER)', docUrl: 'Generate token at Jira > Profile > Personal Access Tokens', sensitive: true, validator: null }, { name: 'JIRA_PAT', group: 'Jira Integration', target: 'backend', required: false, default: null, description: 'Personal Access Token for Jira PAT auth (alternative to basic)', docUrl: 'Generate token at Jira > Profile > Personal Access Tokens', sensitive: true, validator: null }, { name: 'JIRA_PROJECT_KEY', group: 'Jira Integration', target: 'backend', required: false, default: null, description: 'Default project key for creating Jira issues from the dashboard', docUrl: 'Generate token at Jira > Profile > Personal Access Tokens', sensitive: false, validator: null }, { name: 'JIRA_ISSUE_TYPE', group: 'Jira Integration', target: 'backend', required: false, default: 'Task', description: 'Default issue type when creating Jira tickets', docUrl: 'Generate token at Jira > Profile > Personal Access Tokens', sensitive: false, validator: null }, { name: 'JIRA_SKIP_TLS', group: 'Jira Integration', target: 'backend', required: false, default: 'false', description: 'Set to true to disable TLS verification (behind SSL inspection proxy)', docUrl: 'Generate token at Jira > Profile > Personal Access Tokens', sensitive: false, validator: null }, // ── CARD Integration ─────────────────────────────────────────────────────── { name: 'CARD_API_URL', group: 'CARD Integration', target: 'backend', required: false, default: null, description: 'Base URL for the CARD asset ownership API', docUrl: 'Contact CARD team for API credentials', sensitive: false, validator: null }, { name: 'CARD_API_USER', group: 'CARD Integration', target: 'backend', required: false, default: null, description: 'Service account username for CARD OAuth token acquisition', docUrl: 'Contact CARD team for API credentials', sensitive: false, validator: null }, { name: 'CARD_API_PASS', group: 'CARD Integration', target: 'backend', required: false, default: null, description: 'Service account password for CARD OAuth token acquisition', docUrl: 'Contact CARD team for API credentials', sensitive: true, validator: null }, { name: 'CARD_SKIP_TLS', group: 'CARD Integration', target: 'backend', required: false, default: 'false', description: 'Set to true to disable TLS verification (behind SSL inspection proxy)', docUrl: 'Contact CARD team for API credentials', sensitive: false, validator: null }, // ── GitLab Integration ───────────────────────────────────────────────────── { name: 'GITLAB_URL', group: 'GitLab Integration', target: 'backend', required: false, default: 'http://steam-gitlab.charterlab.com', description: 'Base URL for the GitLab instance', docUrl: 'Generate at GitLab > Settings > Access Tokens', sensitive: false, validator: null }, { name: 'GITLAB_PROJECT_ID', group: 'GitLab Integration', target: 'backend', required: false, default: null, description: 'Numeric project ID for feedback issue creation', docUrl: 'Generate at GitLab > Settings > Access Tokens', sensitive: false, validator: null }, { name: 'GITLAB_PAT', group: 'GitLab Integration', target: 'backend', required: false, default: null, description: 'Personal Access Token with api scope for GitLab', docUrl: 'Generate at GitLab > Settings > Access Tokens', sensitive: true, validator: null }, // ── Frontend Settings ────────────────────────────────────────────────────── { name: 'REACT_APP_API_BASE', group: 'Frontend Settings', target: 'frontend', required: true, default: null, description: 'Full URL to the backend API endpoint (e.g. http://localhost:3001/api)', docUrl: null, sensitive: false, validator: null }, { name: 'REACT_APP_API_HOST', group: 'Frontend Settings', target: 'frontend', required: true, default: null, description: 'Base URL of the backend server (e.g. http://localhost:3001)', docUrl: null, sensitive: false, validator: null } ]; // ───────────────────────────────────────────────────────────────────────────── // JSDoc Type Definitions (Object Shapes) // ───────────────────────────────────────────────────────────────────────────── /** * Infrastructure state detected at startup. * * @typedef {Object} InfraState * @property {boolean} postgresRunning - steam-postgres container is active * @property {boolean} backendNodeModules - backend/node_modules/ exists * @property {boolean} frontendNodeModules - frontend/node_modules/ exists * @property {boolean} frontendBuildExists - frontend/build/index.html exists * @property {boolean} backendEnvExists - backend/.env exists * @property {boolean} frontendEnvExists - frontend/.env exists * @property {boolean} dockerAvailable - docker command found on PATH * @property {boolean} psqlAvailable - psql command found on PATH * @property {boolean} npmAvailable - npm command found on PATH * @property {boolean} sqliteDbExists - backend/cve_database.db exists * @property {boolean} schemaFileExists - backend/db-schema.sql exists */ /** * Configuration state accumulated during the wizard flow. * * @typedef {Object} ConfigState * @property {Map} values - Variable name → entered value * @property {Set} skippedGroups - Groups the user declined * @property {Map} existingBackend - Parsed from existing backend/.env * @property {Map} existingFrontend - Parsed from existing frontend/.env * @property {string[]} unmanagedBackend - Lines not matching managed vars * @property {string[]} unmanagedFrontend - Lines not matching managed vars * @property {Object} derivedDefaults - Computed defaults from infrastructure * @property {string|null} derivedDefaults.DATABASE_URL * @property {'compose'|'fallback'} derivedDefaults.databaseUrlSource * @property {string|null} derivedDefaults.REACT_APP_API_BASE * @property {string|null} derivedDefaults.REACT_APP_API_HOST * @property {string|null} derivedDefaults.CORS_ORIGINS */ /** * Result of the provisioning pipeline, tracking what happened. * * @typedef {Object} ProvisioningResult * @property {string[]} skippedSteps - Names of steps that were skipped * @property {boolean} postgresStartedThisRun - Whether Postgres was started in this run * @property {boolean} schemaApplied - Whether schema DDL was executed * @property {boolean} depsInstalled - Whether npm install completed * @property {boolean} migrationRan - Whether data migration was executed * @property {boolean} frontendBuilt - Whether frontend build was executed */ // ───────────────────────────────────────────────────────────────────────────── // Preflight Functions // ───────────────────────────────────────────────────────────────────────────── /** * Verify Node.js >= 18. Exits with code 1 if the major version is below 18. */ function checkNodeVersion() { const versionStr = process.version; // e.g. "v18.17.0" const major = parseInt(versionStr.slice(1).split('.')[0], 10); if (major < 18) { console.error(`Error: Node.js >= 18 is required (detected ${versionStr}). Please upgrade.`); process.exit(1); } } /** * Verify that backend/ and frontend/ directories exist relative to CWD. * Exits with code 1 if either is missing. */ function checkProjectRoot() { const backendExists = fs.existsSync(path.join(process.cwd(), 'backend')); const frontendExists = fs.existsSync(path.join(process.cwd(), 'frontend')); if (!backendExists || !frontendExists) { console.error('Error: This script must be run from the project root (backend/ and frontend/ directories not found).'); process.exit(1); } } /** * Check if a CLI command exists on PATH. * @param {string} cmd - Command name to check (e.g. "docker", "psql", "npm") * @returns {boolean} true if the command is available */ function checkCommandExists(cmd) { try { execSync(`command -v ${cmd}`, { stdio: 'pipe' }); return true; } catch { return false; } } // ───────────────────────────────────────────────────────────────────────────── // State Detection Functions // ───────────────────────────────────────────────────────────────────────────── /** * Check if the steam-postgres Docker container is currently running. * @returns {boolean} true if the container is listed in `docker ps` */ function detectPostgresRunning() { try { const output = execSync("docker ps --format '{{.Names}}'", { stdio: 'pipe', encoding: 'utf8' }); const names = output.split('\n').map(line => line.trim()); return names.includes('steam-postgres'); } catch { return false; } } /** * Check if node_modules directories exist for backend and frontend. * @returns {{ backend: boolean, frontend: boolean }} */ function detectNodeModules() { return { backend: fs.existsSync(path.join(process.cwd(), 'backend', 'node_modules')), frontend: fs.existsSync(path.join(process.cwd(), 'frontend', 'node_modules')) }; } /** * Check if the frontend production build exists. * @returns {boolean} true if frontend/build/index.html exists */ function detectFrontendBuild() { return fs.existsSync(path.join(process.cwd(), 'frontend', 'build', 'index.html')); } /** * Orchestrate all infrastructure detection into a single InfraState object. * @returns {InfraState} */ function detectInfraState() { const nodeModules = detectNodeModules(); return { postgresRunning: detectPostgresRunning(), backendNodeModules: nodeModules.backend, frontendNodeModules: nodeModules.frontend, frontendBuildExists: detectFrontendBuild(), backendEnvExists: fs.existsSync(path.join(process.cwd(), 'backend', '.env')), frontendEnvExists: fs.existsSync(path.join(process.cwd(), 'frontend', '.env')), dockerAvailable: checkCommandExists('docker'), psqlAvailable: checkCommandExists('psql'), npmAvailable: checkCommandExists('npm'), sqliteDbExists: fs.existsSync(path.join(process.cwd(), 'backend', 'cve_database.db')), schemaFileExists: fs.existsSync(path.join(process.cwd(), 'backend', 'db-schema.sql')) }; } // ───────────────────────────────────────────────────────────────────────────── // Parsing Functions // ───────────────────────────────────────────────────────────────────────────── /** * Parse an existing .env file into managed and unmanaged entries. * Managed variables are those whose names appear in VARIABLE_DESCRIPTORS. * * @param {string} filePath - Path to the .env file * @returns {{ managed: Map, unmanaged: string[] }} */ function parseEnvFile(filePath) { const managed = new Map(); const unmanaged = []; if (!fs.existsSync(filePath)) { return { managed, unmanaged }; } let content; try { content = fs.readFileSync(filePath, 'utf8'); } catch { return { managed, unmanaged }; } const managedNames = new Set(VARIABLE_DESCRIPTORS.map(d => d.name)); const lines = content.split('\n'); for (const line of lines) { const trimmed = line.trim(); // Skip empty lines and comments if (trimmed === '' || trimmed.startsWith('#')) { continue; } // Split on first '=' only const eqIndex = trimmed.indexOf('='); if (eqIndex === -1) { unmanaged.push(line); continue; } const key = trimmed.substring(0, eqIndex).trim(); let value = trimmed.substring(eqIndex + 1); // Strip surrounding quotes from value if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { value = value.slice(1, -1); } if (managedNames.has(key)) { managed.set(key, value); } else { unmanaged.push(line); } } return { managed, unmanaged }; } /** * Parse docker-compose.yml to extract Postgres configuration. * Uses a line-by-line state machine (no YAML dependency). * * @param {string} filePath - Path to docker-compose.yml * @returns {{ user: string, password: string, database: string, port: string } | null} */ function parseDockerCompose(filePath) { if (!fs.existsSync(filePath)) { return null; } let content; try { content = fs.readFileSync(filePath, 'utf8'); } catch { return null; } const lines = content.split('\n'); let inServices = false; let inPostgres = false; let inEnvironment = false; let inPorts = false; let postgresIndent = -1; let envIndent = -1; let portsIndent = -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.trimEnd(); const stripped = trimmed.trimStart(); const indent = trimmed.length - stripped.length; // Detect services: top-level key if (stripped === 'services:' && indent === 0) { inServices = true; continue; } if (!inServices) continue; // Detect postgres service if (!inPostgres && stripped === 'postgres:' && indent > 0) { inPostgres = true; postgresIndent = indent; continue; } // If we're in postgres, check if we've left it (another service at same indent) if (inPostgres && indent <= postgresIndent && stripped.length > 0 && !stripped.startsWith('#')) { if (indent === postgresIndent && stripped !== 'postgres:') { break; // Left the postgres service block } } if (!inPostgres) continue; // Detect environment block within postgres if (stripped === 'environment:' && indent > postgresIndent) { inEnvironment = true; envIndent = indent; inPorts = false; continue; } // Detect ports block within postgres if (stripped === 'ports:' && indent > postgresIndent) { inPorts = true; portsIndent = indent; inEnvironment = false; continue; } // If we hit another key at the same level as environment/ports, exit those blocks if (inEnvironment && indent <= envIndent && stripped.length > 0 && !stripped.startsWith('#') && !stripped.startsWith('-')) { inEnvironment = false; } if (inPorts && indent <= portsIndent && stripped.length > 0 && !stripped.startsWith('#') && !stripped.startsWith('-')) { inPorts = false; } // Parse environment variables if (inEnvironment && indent > envIndent) { const envMatch = stripped.match(/^(\w+):\s*(.+)$/); if (envMatch) { const envKey = envMatch[1]; const envValue = resolveShellDefault(envMatch[2].trim()); if (envKey === 'POSTGRES_USER') user = envValue; else if (envKey === 'POSTGRES_PASSWORD') password = envValue; else if (envKey === 'POSTGRES_DB') database = envValue; } } // Parse ports mapping if (inPorts && stripped.startsWith('-')) { // Format: - "5433:5432" or - 5433:5432 const portMatch = stripped.match(/^-\s*["']?(\d+):\d+["']?$/); if (portMatch) { port = portMatch[1]; } } } if (user && password && database && port) { return { user, password, database, port }; } return null; } /** * Resolve shell variable substitution syntax: ${VAR:-default} * Extracts the default value from the pattern. Strips surrounding quotes. * * @param {string} value - The raw value string * @returns {string} The resolved default or the original string (quotes stripped) */ function resolveShellDefault(value) { // Strip surrounding quotes first let v = value.replace(/^['"]|['"]$/g, ''); const match = v.match(/\$\{[^:}]+:-([^}]+)\}/); return match ? match[1] : v; } /** * Compute derived default values from confirmed PORT, API_HOST, and compose parsing result. * * @param {string|number} port - The confirmed PORT value * @param {string} apiHost - The confirmed API_HOST value * @param {{ user: string, password: string, database: string, port: string } | null} composeResult - Parsed docker-compose result * @returns {{ DATABASE_URL: string, databaseUrlSource: 'compose'|'fallback', REACT_APP_API_BASE: string, REACT_APP_API_HOST: string, CORS_ORIGINS: string }} */ function computeDerivedDefaults(port, apiHost, composeResult) { const defaults = {}; // DATABASE_URL from docker-compose or fallback if (composeResult) { defaults.DATABASE_URL = `postgresql://${composeResult.user}:${composeResult.password}@localhost:${composeResult.port}/${composeResult.database}`; defaults.databaseUrlSource = 'compose'; } else { defaults.DATABASE_URL = 'postgresql://steam:sV4xmC9xAUCFop0ypxMVS056QgPqGrX@localhost:5433/cve_dashboard'; defaults.databaseUrlSource = 'fallback'; } // Frontend defaults derived from confirmed PORT and API_HOST defaults.REACT_APP_API_BASE = `http://${apiHost}:${port}/api`; defaults.REACT_APP_API_HOST = `http://${apiHost}:${port}`; defaults.CORS_ORIGINS = 'http://localhost:3000'; return defaults; } // ───────────────────────────────────────────────────────────────────────────── // Validation Functions // ───────────────────────────────────────────────────────────────────────────── /** * Validate a port number string. * Must be an integer in [1, 65535] with no leading zeros or decimal points. * * @param {string} value - The input value to validate * @returns {boolean} true if valid */ function validatePort(value) { const trimmed = value.trim(); if (trimmed === '') return false; const parsed = parseInt(trimmed, 10); if (isNaN(parsed)) return false; // Reject leading zeros, floats, or non-numeric characters if (trimmed !== String(parsed)) return false; return parsed >= 1 && parsed <= 65535; } /** * Validate CORS origins string. * Must be a comma-separated list where each non-empty entry starts with http:// or https:// * followed by at least one non-whitespace character. * * @param {string} value - The input value to validate * @returns {boolean} true if valid */ function validateCorsOrigins(value) { const entries = value.split(',') .map(entry => entry.trim()) .filter(entry => entry.length > 0); if (entries.length === 0) return false; return entries.every(entry => /^https?:\/\/\S+/.test(entry)); } /** * Validate a DATABASE_URL string. * Must start with "postgresql://" or equal "sqlite". * * @param {string} value - The input value to validate * @returns {boolean} true if valid */ function validateDatabaseUrl(value) { return value.startsWith('postgresql://') || value === 'sqlite'; } /** * Validate a session secret string. * Must be between 16 and 256 characters (inclusive). * * @param {string} value - The input value to validate * @returns {boolean} true if valid */ function validateSessionSecret(value) { return value.length >= 16 && value.length <= 256; } /** * Validate that a value is not empty or whitespace-only. * * @param {string} value - The input value to validate * @returns {boolean} true if the trimmed value has length > 0 */ function validateRequired(value) { return value.trim().length > 0; } // ───────────────────────────────────────────────────────────────────────────── // Display Functions // ───────────────────────────────────────────────────────────────────────────── /** * Mask a sensitive value for display. * If value length <= 8, return unchanged. Otherwise show first 4 + **** + last 4. * * @param {string} name - Variable name (unused, kept for API consistency) * @param {string} value - The value to potentially mask * @returns {string} The masked or original value */ function maskSensitive(name, value) { if (value.length <= 8) return value; return value.slice(0, 4) + '****' + value.slice(-4); } /** * Print the welcome banner listing all 6 phases the script will perform. */ function printWelcome() { console.log(''); console.log('╔══════════════════════════════════════════════════════════════╗'); console.log('║ CVE Dashboard — Unified Setup Wizard ║'); console.log('╠══════════════════════════════════════════════════════════════╣'); console.log('║ This wizard will guide you through: ║'); console.log('║ ║'); console.log('║ 1. Environment Configuration (interactive prompts) ║'); console.log('║ 2. Postgres Provisioning (Docker container) ║'); console.log('║ 3. Schema Initialization (database tables) ║'); console.log('║ 4. Dependency Installation (npm install) ║'); console.log('║ 5. Data Migration (SQLite to Postgres, if applicable) ║'); console.log('║ 6. Frontend Build (production bundle) ║'); console.log('║ ║'); console.log('║ Press Ctrl+C at any time to cancel without changes. ║'); console.log('╚══════════════════════════════════════════════════════════════╝'); console.log(''); } /** * Print a group header with name and description. * * @param {string} groupName - The group name to display * @param {string} description - One-line description of the group */ function printGroupHeader(groupName, description) { console.log(''); console.log(`── ${groupName} ──`); console.log(` ${description}`); console.log(''); } /** * Print a summary of configured values grouped by target file. * Masks sensitive values, shows file status, lists skipped groups. * * @param {ConfigState} config - The accumulated configuration state * @param {InfraState} infraState - The detected infrastructure state */ function printSummary(config, infraState) { console.log(''); console.log('═══════════════════════════════════════════════════════════════'); console.log(' Configuration Summary'); console.log('═══════════════════════════════════════════════════════════════'); // Group by target file const targets = ['backend', 'frontend']; for (const target of targets) { const filePath = path.join(target, '.env'); const exists = target === 'backend' ? infraState.backendEnvExists : infraState.frontendEnvExists; const status = exists ? '[EXISTS]' : '[NEW]'; console.log(''); console.log(` ${filePath} ${status}`); console.log(' ' + '─'.repeat(50)); const descriptors = VARIABLE_DESCRIPTORS.filter(d => d.target === target); for (const desc of descriptors) { if (config.skippedGroups.has(desc.group)) continue; const value = config.values.get(desc.name); if (value === undefined || value === '') continue; const displayValue = desc.sensitive ? '********' : value; console.log(` ${desc.name}=${displayValue}`); } } // List skipped groups if (config.skippedGroups.size > 0) { console.log(''); console.log(' Skipped Groups:'); for (const group of config.skippedGroups) { console.log(` ${group} [SKIPPED]`); } } console.log(''); console.log('═══════════════════════════════════════════════════════════════'); console.log(''); } /** * Print the completion banner with next steps and skipped step commands. * * @param {number|string} port - The configured port number * @param {string[]} skippedSteps - Names of provisioning steps that were skipped * @param {boolean} postgresStarted - Whether Postgres was started this run */ function printCompletionBanner(port, skippedSteps, postgresStarted) { console.log(''); console.log('╔══════════════════════════════════════════════════════════════╗'); console.log('║ Setup Complete! ║'); console.log('╚══════════════════════════════════════════════════════════════╝'); console.log(''); console.log(' Next steps:'); console.log(` 1. Start the server: cd backend && node server.js`); console.log(` 2. Open in browser: http://localhost:${port}`); console.log(' 3. Create admin user: cd backend && node setup.js'); console.log(''); if (postgresStarted) { console.log(' Note: Postgres container was started with default restart policy.'); console.log(' To persist across reboots: docker update --restart unless-stopped steam-postgres'); console.log(''); } if (skippedSteps.length > 0) { console.log(' Skipped steps (run manually if needed):'); for (const stepName of skippedSteps) { const step = PROVISIONING_STEPS.find(s => s.name === stepName); if (step) { console.log(` - ${step.name}: ${step.manualCmd}`); } } console.log(''); } } // ───────────────────────────────────────────────────────────────────────────── // Prompt Functions // ───────────────────────────────────────────────────────────────────────────── /** * Map of validator function names to their implementations. */ const VALIDATORS = { validatePort, validateCorsOrigins, validateDatabaseUrl, validateSessionSecret, validateRequired }; /** * Prompt the user for a single variable value with validation and re-prompt on failure. * * @param {readline.Interface} rl - The readline interface * @param {VariableDescriptor} descriptor - Variable metadata * @param {string|undefined} currentValue - Value from existing .env file * @param {string|undefined} derivedDefault - Computed default from infrastructure * @returns {Promise} The accepted value */ function promptVariable(rl, descriptor, currentValue, derivedDefault) { return new Promise((resolve) => { const label = descriptor.required ? '[REQUIRED]' : '[OPTIONAL]'; const effectiveDefault = currentValue || derivedDefault || descriptor.default || ''; const defaultSource = currentValue ? '[current]' : ''; const displayDefault = descriptor.sensitive && effectiveDefault ? maskSensitive(descriptor.name, effectiveDefault) : effectiveDefault; const ask = () => { console.log(` ${label} ${descriptor.name} — ${descriptor.description}`); if (descriptor.docUrl) { console.log(` ${descriptor.docUrl}`); } const defaultHint = displayDefault ? ` (default: ${displayDefault}${defaultSource ? ' ' + defaultSource : ''})` : ''; rl.question(` Value${defaultHint}: `, (answer) => { const value = answer.trim() || effectiveDefault; if (descriptor.required && !value) { console.log(' Error: This field is required.'); ask(); return; } if (descriptor.validator && value) { const validatorFn = VALIDATORS[descriptor.validator]; if (validatorFn && !validatorFn(value)) { console.log(` Error: Invalid value for ${descriptor.name}. Please try again.`); ask(); return; } } resolve(value); }); }; ask(); }); } /** * Prompt the user with a yes/no question. * * @param {readline.Interface} rl - The readline interface * @param {string} question - The question to display * @param {boolean} defaultYes - Whether the default answer is yes * @returns {Promise} true for yes, false for no */ function promptYesNo(rl, question, defaultYes) { return new Promise((resolve) => { const hint = defaultYes ? '[Y/n]' : '[y/N]'; const ask = () => { rl.question(` ${question} ${hint}: `, (answer) => { const trimmed = answer.trim().toLowerCase(); if (trimmed === '') { resolve(defaultYes); } else if (trimmed === 'y' || trimmed === 'yes') { resolve(true); } else if (trimmed === 'n' || trimmed === 'no') { resolve(false); } else { console.log(' Please answer y/yes or n/no.'); ask(); } }); }; ask(); }); } /** * Prompt the user about how to handle an existing file: overwrite, backup, or abort. * * @param {readline.Interface} rl - The readline interface * @param {string} filePath - Path to the existing file * @returns {Promise} 'overwrite', 'backup', or 'abort' */ function promptOverwrite(rl, filePath) { return new Promise((resolve) => { const ask = () => { rl.question(` ${filePath} already exists. (o)verwrite / (b)ackup / (a)bort? [b]: `, (answer) => { const trimmed = answer.trim().toLowerCase(); if (trimmed === '' || trimmed === 'b' || trimmed === 'backup') { resolve('backup'); } else if (trimmed === 'o' || trimmed === 'overwrite') { resolve('overwrite'); } else if (trimmed === 'a' || trimmed === 'abort') { resolve('abort'); } else { console.log(' Please answer o (overwrite), b (backup), or a (abort).'); ask(); } }); }; ask(); }); } /** * Prompt the user to continue or abort the current operation. * * @param {readline.Interface} rl - The readline interface * @param {string} context - Description of what will happen next * @returns {Promise} true for continue, false for abort */ function promptContinueOrAbort(rl, context) { return new Promise((resolve) => { const ask = () => { rl.question(` ${context} Continue or abort? [C/a]: `, (answer) => { const trimmed = answer.trim().toLowerCase(); if (trimmed === '' || trimmed === 'c' || trimmed === 'continue') { resolve(true); } else if (trimmed === 'a' || trimmed === 'abort') { resolve(false); } else { console.log(' Please answer c (continue) or a (abort).'); ask(); } }); }; ask(); }); } // ───────────────────────────────────────────────────────────────────────────── // File Generation & Writing // ───────────────────────────────────────────────────────────────────────────── /** * Generate .env file content from configured values. * * Produces output with: * - Group comment headers (# --- Group Name ---) * - KEY=value lines in group/descriptor order * - Values containing spaces, #, quote chars, $, or newlines wrapped in double quotes * with internal double quotes escaped as \" * - Optional variables omitted if no value in map AND no default in descriptor * - Unmanaged lines appended under '# Custom Variables' header * - Trailing newline, LF line endings throughout * * @param {Map} values - Variable name to value map * @param {string[]} groupOrder - Ordered list of group names * @param {VariableDescriptor[]} descriptors - All variable descriptors * @param {string[]} unmanagedLines - Lines to preserve from original file * @returns {string} The complete .env file content */ function generateEnvContent(values, groupOrder, descriptors, unmanagedLines) { const lines = []; for (const group of groupOrder) { const groupDescriptors = descriptors.filter(d => d.group === group); if (groupDescriptors.length === 0) continue; // Only include groups that have at least one variable with a value in the map // (skipped groups won't have any values in the map) const hasValues = groupDescriptors.some(d => { const val = values.get(d.name); return val !== undefined && val !== ''; }); if (!hasValues) continue; lines.push(`# --- ${group} ---`); for (const desc of groupDescriptors) { const value = values.get(desc.name); // Omit optional variables with no value in map AND no default in descriptor if (!desc.required && (value === undefined || value === '') && !desc.default) { continue; } // Resolve the effective value: use map value, fall back to descriptor default const effectiveValue = (value !== undefined && value !== '') ? value : (desc.default || ''); // Skip if still no effective value (required var with no value and no default) if (effectiveValue === '') continue; // Determine if value needs quoting: spaces, #, quote chars, $, or newlines const needsQuoting = /[\s#"'$\n]/.test(effectiveValue); if (needsQuoting) { const escaped = effectiveValue.replace(/"/g, '\\"'); lines.push(`${desc.name}="${escaped}"`); } else { lines.push(`${desc.name}=${effectiveValue}`); } } lines.push(''); } // Append unmanaged lines under Custom Variables header // Filter out any lines whose key matches a managed variable name (deduplication) if (unmanagedLines && unmanagedLines.length > 0) { const managedNames = new Set(descriptors.map(d => d.name)); const filtered = unmanagedLines.filter(line => { const trimmed = line.trim(); if (trimmed === '' || trimmed.startsWith('#')) return true; const eqIndex = trimmed.indexOf('='); if (eqIndex === -1) return true; const key = trimmed.substring(0, eqIndex).trim(); return !managedNames.has(key); }); if (filtered.length > 0) { lines.push('# Custom Variables'); for (const line of filtered) { lines.push(line); } lines.push(''); } } return lines.join('\n') + '\n'; } /** * Write content to an env file. * * @param {string} filePath - Path to write the file * @param {string} content - File content to write */ function writeEnvFile(filePath, content) { fs.writeFileSync(filePath, content, 'utf8'); } /** * Create a backup of an existing file. * Format: {filename}.backup.{YYYYMMDD_HHmmss} * If that path already exists, tries .bak.1, .bak.2, ... up to .bak.10. * * @param {string} filePath - Path to the file to back up * @returns {string} The backup file path used */ function createBackup(filePath) { const now = new Date(); const timestamp = now.getFullYear().toString() + String(now.getMonth() + 1).padStart(2, '0') + String(now.getDate()).padStart(2, '0') + '_' + String(now.getHours()).padStart(2, '0') + String(now.getMinutes()).padStart(2, '0') + String(now.getSeconds()).padStart(2, '0'); const backupPath = `${filePath}.backup.${timestamp}`; if (!fs.existsSync(backupPath)) { fs.copyFileSync(filePath, backupPath); return backupPath; } // Primary backup path exists — try numeric suffixes for (let i = 1; i <= 10; i++) { const fallbackPath = `${filePath}.bak.${i}`; if (!fs.existsSync(fallbackPath)) { fs.copyFileSync(filePath, fallbackPath); return fallbackPath; } } // All slots exhausted — overwrite the last one const lastPath = `${filePath}.bak.10`; fs.copyFileSync(filePath, lastPath); return lastPath; } // ───────────────────────────────────────────────────────────────────────────── // Provisioning Functions // ───────────────────────────────────────────────────────────────────────────── /** * Spawn a child process with a timeout. * * @param {string} command - The command to run * @param {string[]} args - Command arguments * @param {Object} opts - Spawn options (cwd, env, etc.) * @param {number} timeoutMs - Timeout in milliseconds * @returns {Promise<{code: number|null, stdout: string, stderr: string, timedOut: boolean}>} */ function runWithTimeout(command, args, opts, timeoutMs) { return new Promise((resolve) => { const proc = spawn(command, args, { ...opts, stdio: 'pipe' }); let stdout = ''; let stderr = ''; let killed = false; const timer = setTimeout(() => { killed = true; proc.kill('SIGTERM'); }, timeoutMs); proc.stdout.on('data', (d) => { stdout += d.toString(); }); proc.stderr.on('data', (d) => { stderr += d.toString(); }); proc.on('close', (code) => { clearTimeout(timer); resolve({ code: killed ? null : code, stdout, stderr, timedOut: killed }); }); proc.on('error', (err) => { clearTimeout(timer); resolve({ code: 1, stdout, stderr: stderr + err.message, timedOut: false }); }); }); } /** * Wait for Postgres to become ready by polling pg_isready. * * @param {number} timeoutMs - Maximum time to wait in milliseconds * @returns {Promise} true if ready, false if timeout exceeded */ function waitForPostgresReady(timeoutMs) { const start = Date.now(); const interval = 2000; return new Promise((resolve) => { const poll = () => { try { execSync('docker exec steam-postgres pg_isready -U steam -d cve_dashboard', { stdio: 'pipe' }); resolve(true); } catch { if (Date.now() - start >= timeoutMs) { resolve(false); } else { setTimeout(poll, interval); } } }; poll(); }); } /** * Provision the Postgres container via Docker Compose. * * @param {readline.Interface} rl - The readline interface * @param {InfraState} infraState - Detected infrastructure state * @returns {Promise<{started: boolean, ready: boolean}>} */ async function provisionPostgres(rl, infraState) { if (infraState.postgresRunning) { console.log(' Postgres container (steam-postgres) is already running.'); return { started: false, ready: true }; } if (!infraState.dockerAvailable) { console.log(' Error: Docker is not available. Cannot start Postgres container.'); return { started: false, ready: false }; } const shouldStart = await promptYesNo(rl, 'Start Postgres container (docker compose up -d)?', true); if (!shouldStart) { return { started: false, ready: false }; } console.log(' Starting Postgres container...'); try { const output = execSync('docker compose up -d', { cwd: process.cwd(), encoding: 'utf8', stdio: 'pipe' }); if (output.trim()) console.log(output.trim()); } catch (err) { console.error(' Error starting Postgres:', err.stderr || err.message); const cont = await promptContinueOrAbort(rl, 'Postgres failed to start.'); if (!cont) return { started: false, ready: false }; } console.log(' Waiting for Postgres to become ready...'); const ready = await waitForPostgresReady(30000); if (!ready) { console.log(' Warning: Postgres did not become ready within 30 seconds.'); } else { console.log(' Postgres is ready.'); } return { started: true, ready }; } /** * Execute the database schema DDL against Postgres. * * @param {string} databaseUrl - The DATABASE_URL connection string * @param {InfraState} infraState - Detected infrastructure state * @returns {Promise} true on success */ async function executeSchema(databaseUrl, infraState) { const schemaPath = path.join(process.cwd(), 'backend', 'db-schema.sql'); if (!fs.existsSync(schemaPath)) { console.log(' Error: backend/db-schema.sql not found. Skipping schema initialization.'); return false; } console.log(' Applying database schema...'); try { if (infraState.psqlAvailable) { execSync(`psql "${databaseUrl}" -f backend/db-schema.sql`, { cwd: process.cwd(), stdio: 'pipe', encoding: 'utf8' }); } else { // Fallback to docker exec execSync('docker exec -i steam-postgres psql -U steam -d cve_dashboard < backend/db-schema.sql', { cwd: process.cwd(), stdio: 'pipe', encoding: 'utf8', shell: true }); } console.log(' Schema applied successfully.'); return true; } catch (err) { console.error(' Error applying schema:', err.stderr || err.message); return false; } } /** * Install npm dependencies in backend and frontend. * * @param {readline.Interface} rl - The readline interface * @param {InfraState} infraState - Detected infrastructure state * @returns {Promise} true on success */ async function installDependencies(rl, infraState) { if (!infraState.npmAvailable) { console.log(' Error: npm is not available. Cannot install dependencies.'); return false; } // Check if both node_modules exist if (infraState.backendNodeModules && infraState.frontendNodeModules) { const reinstall = await promptYesNo(rl, 'Dependencies already installed. Reinstall?', false); if (!reinstall) { console.log(' Skipping dependency installation.'); return true; } } // Install backend dependencies if (!infraState.backendNodeModules || !(infraState.backendNodeModules && infraState.frontendNodeModules)) { console.log(' Installing backend dependencies...'); const backendResult = await runWithTimeout('npm', ['install', '--production'], { cwd: path.join(process.cwd(), 'backend'), env: process.env }, 120000); if (backendResult.timedOut) { console.log(' Error: Backend npm install timed out after 120 seconds.'); const cont = await promptContinueOrAbort(rl, 'Backend dependency installation failed.'); if (!cont) return false; } else if (backendResult.code !== 0) { const output = (backendResult.stdout + backendResult.stderr).split('\n').slice(-50).join('\n'); console.log(' Error installing backend dependencies:'); console.log(output); const cont = await promptContinueOrAbort(rl, 'Backend dependency installation failed.'); if (!cont) return false; } else { console.log(' Backend dependencies installed.'); } } // Install frontend dependencies if (!infraState.frontendNodeModules || !(infraState.backendNodeModules && infraState.frontendNodeModules)) { console.log(' Installing frontend dependencies...'); const frontendResult = await runWithTimeout('npm', ['install'], { cwd: path.join(process.cwd(), 'frontend'), env: process.env }, 120000); if (frontendResult.timedOut) { console.log(' Error: Frontend npm install timed out after 120 seconds.'); const cont = await promptContinueOrAbort(rl, 'Frontend dependency installation failed.'); if (!cont) return false; } else if (frontendResult.code !== 0) { const output = (frontendResult.stdout + frontendResult.stderr).split('\n').slice(-50).join('\n'); console.log(' Error installing frontend dependencies:'); console.log(output); const cont = await promptContinueOrAbort(rl, 'Frontend dependency installation failed.'); if (!cont) return false; } else { console.log(' Frontend dependencies installed.'); } } return true; } /** * Migrate data from SQLite to Postgres. * * @param {readline.Interface} rl - The readline interface * @param {string} databaseUrl - The DATABASE_URL connection string * @returns {Promise} true on success or skip */ async function migrateData(rl, databaseUrl) { const sqliteDbPath = path.join(process.cwd(), 'backend', 'cve_database.db'); if (!fs.existsSync(sqliteDbPath)) { console.log(' No legacy SQLite database found. Skipping migration.'); return true; } const shouldMigrate = await promptYesNo(rl, 'Legacy SQLite database found. Migrate data to Postgres?', true); if (!shouldMigrate) { return true; } if (!databaseUrl) { console.log(' Error: DATABASE_URL is not set. Cannot migrate.'); return false; } const maxRetries = 3; for (let attempt = 1; attempt <= maxRetries; attempt++) { console.log(` Running migration (attempt ${attempt}/${maxRetries})...`); const result = await runWithTimeout('node', ['backend/scripts/migrate-to-postgres.js'], { cwd: process.cwd(), env: { ...process.env, DATABASE_URL: databaseUrl } }, 300000); if (result.code === 0) { console.log(' Migration completed successfully.'); return true; } const output = (result.stdout + result.stderr).split('\n').slice(-200).join('\n'); console.error(` Migration attempt ${attempt}/${maxRetries} failed.`); if (output.trim()) console.log(output); if (attempt < maxRetries) { const retry = await promptYesNo(rl, 'Retry with schema reset?', true); if (!retry) return false; // Reset schema console.log(' Resetting schema...'); try { const resetCmd = 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;'; if (checkCommandExists('psql')) { execSync(`psql "${databaseUrl}" -c "${resetCmd}"`, { stdio: 'pipe' }); } else { execSync(`docker exec -i steam-postgres psql -U steam -d cve_dashboard -c "${resetCmd}"`, { stdio: 'pipe', shell: true }); } // Re-apply schema await executeSchema(databaseUrl, { psqlAvailable: checkCommandExists('psql') }); } catch (err) { console.error(' Error resetting schema:', err.message); } } } console.log(' Migration failed after all retries.'); return false; } /** * Determine if the frontend build can be skipped. * Returns true only if old env existed and all REACT_APP_* values are unchanged. * * @param {Map|null} oldFrontendEnv - Previous frontend env values * @param {Map} newFrontendEnv - New frontend env values * @returns {boolean} true if build can be skipped */ function shouldSkipFrontendBuild(oldFrontendEnv, newFrontendEnv) { if (!oldFrontendEnv) return false; const reactAppKeys = [...newFrontendEnv.keys()].filter(k => k.startsWith('REACT_APP_')); return reactAppKeys.every(key => oldFrontendEnv.get(key) === newFrontendEnv.get(key)); } /** * Build the frontend production bundle. * * @param {readline.Interface} rl - The readline interface * @param {InfraState} infraState - Detected infrastructure state * @param {Map|null} oldFrontendEnv - Previous frontend env values * @param {Map} newFrontendEnv - New frontend env values * @returns {Promise} true on success or skip */ async function buildFrontend(rl, infraState, oldFrontendEnv, newFrontendEnv) { if (shouldSkipFrontendBuild(oldFrontendEnv, newFrontendEnv)) { console.log(' Frontend REACT_APP_* values unchanged. Skipping build.'); return true; } console.log(' Building frontend production bundle...'); const result = await runWithTimeout('npm', ['run', 'build'], { cwd: path.join(process.cwd(), 'frontend'), env: process.env }, 300000); if (result.timedOut) { console.log(' Error: Frontend build timed out after 300 seconds.'); return false; } if (result.code !== 0) { const output = (result.stdout + result.stderr).split('\n').slice(-50).join('\n'); console.log(' Error building frontend:'); console.log(output); return false; } console.log(' Frontend build completed successfully.'); return true; } // ───────────────────────────────────────────────────────────────────────────── // Main Flow Orchestration // ───────────────────────────────────────────────────────────────────────────── /** * Main entry point — orchestrates all phases of the setup wizard. */ async function main() { // Phase 0: Preflight checks checkNodeVersion(); checkProjectRoot(); // Register SIGINT handler early to handle Ctrl+C gracefully at any point let rl = null; process.on('SIGINT', () => { console.log('\n\n Setup cancelled. No files were written.'); if (rl) rl.close(); process.exit(1); }); // Phase 1: State detection const infraState = detectInfraState(); // Parse existing env files const backendEnvPath = path.join(process.cwd(), 'backend', '.env'); const frontendEnvPath = path.join(process.cwd(), 'frontend', '.env'); const existingBackend = parseEnvFile(backendEnvPath); const existingFrontend = parseEnvFile(frontendEnvPath); // Parse docker-compose.yml for defaults const composePath = path.join(process.cwd(), 'docker-compose.yml'); const composeResult = parseDockerCompose(composePath); // Create readline interface rl = readline.createInterface({ input: process.stdin, output: process.stdout }); // Phase 2: Interactive configuration (wrapped in loop for restart on rejection) printWelcome(); let config; let confirmed = false; while (!confirmed) { config = { values: new Map(), skippedGroups: new Set(), existingBackend: existingBackend.managed, existingFrontend: existingFrontend.managed, unmanagedBackend: existingBackend.unmanaged, unmanagedFrontend: existingFrontend.unmanaged, derivedDefaults: {} }; let derivedDefaults = {}; for (const group of GROUP_ORDER) { // Prompt to skip optional groups if (OPTIONAL_GROUPS.includes(group)) { const configure = await promptYesNo(rl, `Configure ${group}?`, false); if (!configure) { config.skippedGroups.add(group); continue; } } const description = GROUP_DESCRIPTIONS[group] || ''; printGroupHeader(group, description); const groupDescriptors = VARIABLE_DESCRIPTORS.filter(d => d.group === group); for (const desc of groupDescriptors) { // Determine current value from existing env const currentValue = desc.target === 'backend' ? existingBackend.managed.get(desc.name) : existingFrontend.managed.get(desc.name); // Determine derived default for DATABASE_URL, CORS_ORIGINS, REACT_APP_API_BASE, REACT_APP_API_HOST const derivedDefault = derivedDefaults[desc.name] || undefined; const value = await promptVariable(rl, desc, currentValue, derivedDefault); if (value) { config.values.set(desc.name, value); } } // Compute derived defaults after Core Settings group (PORT, API_HOST confirmed) if (group === 'Core Settings') { const port = config.values.get('PORT') || '3001'; const apiHost = config.values.get('API_HOST') || 'localhost'; derivedDefaults = computeDerivedDefaults(port, apiHost, composeResult); config.derivedDefaults = derivedDefaults; } } // Phase 3: Summary and confirmation printSummary(config, infraState); confirmed = await promptYesNo(rl, 'Apply this configuration?', true); if (!confirmed) { const restart = await promptYesNo(rl, 'Restart configuration from the beginning?', true); if (!restart) { console.log(' Configuration cancelled.'); rl.close(); process.exit(1); } // Loop continues — restart configuration console.log(''); console.log(' Restarting configuration...'); console.log(''); } } // Phase 4: Write env files const skippedSteps = []; // Prepare backend env values const backendValues = new Map(); const frontendValues = new Map(); for (const [name, value] of config.values) { const desc = VARIABLE_DESCRIPTORS.find(d => d.name === name); if (desc) { if (desc.target === 'backend') backendValues.set(name, value); else frontendValues.set(name, value); } } const backendDescriptors = VARIABLE_DESCRIPTORS.filter(d => d.target === 'backend'); const frontendDescriptors = VARIABLE_DESCRIPTORS.filter(d => d.target === 'frontend'); // Write backend .env if (infraState.backendEnvExists) { const action = await promptOverwrite(rl, backendEnvPath); if (action === 'abort') { console.log(' Aborted.'); rl.close(); process.exit(1); } if (action === 'backup') { const backupPath = createBackup(backendEnvPath); console.log(` Backup created: ${backupPath}`); } } const backendContent = generateEnvContent(backendValues, GROUP_ORDER, backendDescriptors, config.unmanagedBackend); writeEnvFile(backendEnvPath, backendContent); console.log(` Written: ${backendEnvPath}`); // Write frontend .env if (infraState.frontendEnvExists) { const action = await promptOverwrite(rl, frontendEnvPath); if (action === 'abort') { console.log(' Aborted.'); rl.close(); process.exit(1); } if (action === 'backup') { const backupPath = createBackup(frontendEnvPath); console.log(` Backup created: ${backupPath}`); } } const frontendContent = generateEnvContent(frontendValues, GROUP_ORDER, frontendDescriptors, config.unmanagedFrontend); writeEnvFile(frontendEnvPath, frontendContent); console.log(` Written: ${frontendEnvPath}`); // Phase 5: Provisioning pipeline console.log(''); console.log('── Provisioning Pipeline ──'); console.log(''); let postgresStarted = false; // Postgres provisioning const pgResult = await provisionPostgres(rl, infraState); postgresStarted = pgResult.started; if (!pgResult.ready) { skippedSteps.push('Postgres container startup'); } // Schema initialization if (pgResult.ready) { const databaseUrl = config.values.get('DATABASE_URL') || ''; const schemaOk = await executeSchema(databaseUrl, infraState); if (!schemaOk) { skippedSteps.push('Database initialization'); } } else { skippedSteps.push('Database initialization'); } // Dependency installation const depsOk = await installDependencies(rl, infraState); if (!depsOk) { skippedSteps.push('Dependency installation'); } // Data migration const databaseUrl = config.values.get('DATABASE_URL') || ''; if (pgResult.ready && databaseUrl) { const migrationOk = await migrateData(rl, databaseUrl); if (!migrationOk) { skippedSteps.push('Data migration'); } } // Frontend build const oldFrontendEnv = infraState.frontendEnvExists ? existingFrontend.managed : null; const buildOk = await buildFrontend(rl, infraState, oldFrontendEnv, frontendValues); if (!buildOk) { skippedSteps.push('Frontend build'); } // Phase 6: Completion const port = config.values.get('PORT') || '3001'; printCompletionBanner(port, skippedSteps, postgresStarted); rl.close(); process.exit(0); } // ───────────────────────────────────────────────────────────────────────────── // Module Exports (for testability) // ───────────────────────────────────────────────────────────────────────────── if (require.main !== module) { module.exports = { VARIABLE_DESCRIPTORS, GROUP_ORDER, GROUP_DESCRIPTIONS, OPTIONAL_GROUPS, SENSITIVE_PATTERNS, PROVISIONING_STEPS, // Preflight checkNodeVersion, checkProjectRoot, checkCommandExists, // State Detection detectPostgresRunning, detectNodeModules, detectFrontendBuild, detectInfraState, // Parsing parseEnvFile, parseDockerCompose, resolveShellDefault, computeDerivedDefaults, // Validation validatePort, validateCorsOrigins, validateDatabaseUrl, validateSessionSecret, validateRequired, // Display maskSensitive, printWelcome, printGroupHeader, printSummary, printCompletionBanner, // Prompt promptVariable, promptYesNo, promptOverwrite, promptContinueOrAbort, // File Generation generateEnvContent, writeEnvFile, createBackup, // Provisioning runWithTimeout, waitForPostgresReady, provisionPostgres, executeSchema, installDependencies, migrateData, shouldSkipFrontendBuild, buildFrontend, // Main main }; } if (require.main === module) { main().catch(err => { console.error(`Error: ${err.message}`); process.exit(1); }); }