Files
cve-dashboard/configure.js

2016 lines
71 KiB
JavaScript
Raw Normal View History

#!/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 SQLitePostgres 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<string, string>} values - Variable name entered value
* @property {Set<string>} skippedGroups - Groups the user declined
* @property {Map<string, string>} existingBackend - Parsed from existing backend/.env
* @property {Map<string, string>} 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<string, string>, 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<string>} 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<boolean>} 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<string>} '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<boolean>} 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<string, string>} 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<boolean>} 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<boolean>} 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<boolean>} 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<boolean>} 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<string, string>|null} oldFrontendEnv - Previous frontend env values
* @param {Map<string, string>} 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<string, string>|null} oldFrontendEnv - Previous frontend env values
* @param {Map<string, string>} newFrontendEnv - New frontend env values
* @returns {Promise<boolean>} 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);
});
}