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