1482 lines
47 KiB
JavaScript
1482 lines
47 KiB
JavaScript
#!/usr/bin/env node
|
|
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
// =============================================================================
|
|
// Constants & Configuration
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Ordered list of variable groups presented during the wizard flow.
|
|
*/
|
|
const GROUP_ORDER = [
|
|
'Core Settings',
|
|
'Database',
|
|
'Session',
|
|
'NVD API',
|
|
'Ivanti Integration',
|
|
'Atlas Integration',
|
|
'Jira Integration',
|
|
'CARD Integration',
|
|
'GitLab Integration',
|
|
'Frontend Settings'
|
|
];
|
|
|
|
/**
|
|
* One-line description per group (max 120 characters each).
|
|
*/
|
|
const GROUP_DESCRIPTIONS = {
|
|
'Core Settings': 'Server port, hostname, and CORS configuration',
|
|
'Database': 'PostgreSQL connection string for persistent storage',
|
|
'Session': 'Secret key for signing session cookies',
|
|
'NVD API': 'National Vulnerability Database API key for CVE lookups',
|
|
'Ivanti Integration': 'RiskSense platform credentials for vulnerability sync',
|
|
'Atlas Integration': 'Atlas InfoSec API for action plan management',
|
|
'Jira Integration': 'Jira Data Center for ticket creation and tracking',
|
|
'CARD Integration': 'CARD asset ownership API for host lookups',
|
|
'GitLab Integration': 'GitLab API for feedback submission (bug reports)',
|
|
'Frontend Settings': 'React app API endpoint configuration'
|
|
};
|
|
|
|
/**
|
|
* Groups that present a skip prompt before entering.
|
|
*/
|
|
const SKIPPABLE_GROUPS = [
|
|
'NVD API',
|
|
'Ivanti Integration',
|
|
'Atlas Integration',
|
|
'Jira Integration',
|
|
'CARD Integration',
|
|
'GitLab Integration'
|
|
];
|
|
|
|
/**
|
|
* Variables whose values are masked in display (passwords, secrets, API keys, tokens).
|
|
*/
|
|
const SENSITIVE_VARS = [
|
|
'SESSION_SECRET',
|
|
'NVD_API_KEY',
|
|
'IVANTI_API_KEY',
|
|
'ATLAS_API_PASS',
|
|
'JIRA_API_TOKEN',
|
|
'JIRA_PAT',
|
|
'CARD_API_PASS',
|
|
'GITLAB_PAT',
|
|
'DATABASE_URL'
|
|
];
|
|
|
|
/**
|
|
* Complete registry of all 32 managed environment variables with metadata.
|
|
* Each descriptor contains:
|
|
* name — environment variable name
|
|
* group — which GROUP_ORDER group it belongs to
|
|
* required — whether a non-empty value is mandatory
|
|
* default — factory default value (null if none; 'derived' handled at runtime)
|
|
* description — max 120 chars explaining what the variable controls
|
|
* docUrl — URL or instruction for obtaining the value (null if N/A)
|
|
* sensitive — whether to mask the value in display
|
|
* validator — name of validation function to apply (null if none)
|
|
*/
|
|
const VARIABLE_DESCRIPTORS = [
|
|
// --- Core Settings ---
|
|
{
|
|
name: 'PORT',
|
|
group: 'Core Settings',
|
|
required: true,
|
|
default: '3001',
|
|
description: 'TCP port the backend Express server listens on',
|
|
docUrl: null,
|
|
sensitive: false,
|
|
validator: 'validatePort'
|
|
},
|
|
{
|
|
name: 'API_HOST',
|
|
group: 'Core Settings',
|
|
required: true,
|
|
default: 'localhost',
|
|
description: 'Hostname or IP address the backend binds to',
|
|
docUrl: null,
|
|
sensitive: false,
|
|
validator: null
|
|
},
|
|
{
|
|
name: 'CORS_ORIGINS',
|
|
group: 'Core Settings',
|
|
required: true,
|
|
default: null, // derived from frontend port at runtime
|
|
description: 'Comma-separated list of allowed CORS origins for the backend',
|
|
docUrl: null,
|
|
sensitive: false,
|
|
validator: 'validateCorsOrigins'
|
|
},
|
|
|
|
// --- Database ---
|
|
{
|
|
name: 'DATABASE_URL',
|
|
group: 'Database',
|
|
required: true,
|
|
default: null, // derived from docker-compose.yml or fallback
|
|
description: 'PostgreSQL connection string (or "sqlite" for SQLite mode)',
|
|
docUrl: null,
|
|
sensitive: true,
|
|
validator: 'validateDatabaseUrl'
|
|
},
|
|
|
|
// --- Session ---
|
|
{
|
|
name: 'SESSION_SECRET',
|
|
group: 'Session',
|
|
required: true,
|
|
default: null,
|
|
description: 'Secret key for signing session cookies — generate with: openssl rand -base64 32',
|
|
docUrl: null,
|
|
sensitive: true,
|
|
validator: 'validateSessionSecret'
|
|
},
|
|
|
|
// --- NVD API ---
|
|
{
|
|
name: 'NVD_API_KEY',
|
|
group: 'NVD API',
|
|
required: false,
|
|
default: null,
|
|
description: 'API key to increase NVD rate limit from 5 to 50 requests per 30 seconds',
|
|
docUrl: 'https://nvd.nist.gov/developers/request-an-api-key',
|
|
sensitive: true,
|
|
validator: null
|
|
},
|
|
|
|
// --- Ivanti Integration ---
|
|
{
|
|
name: 'IVANTI_API_KEY',
|
|
group: 'Ivanti Integration',
|
|
required: false,
|
|
default: null,
|
|
description: 'RiskSense API key from your profile settings (does not expire like session cookies)',
|
|
docUrl: 'https://platform4.risksense.com — Profile > API Keys',
|
|
sensitive: true,
|
|
validator: null
|
|
},
|
|
{
|
|
name: 'IVANTI_CLIENT_ID',
|
|
group: 'Ivanti Integration',
|
|
required: false,
|
|
default: '1550',
|
|
description: 'RiskSense client/organization ID for API requests',
|
|
docUrl: 'https://platform4.risksense.com — visible in URL after login',
|
|
sensitive: false,
|
|
validator: null
|
|
},
|
|
{
|
|
name: 'IVANTI_FIRST_NAME',
|
|
group: 'Ivanti Integration',
|
|
required: false,
|
|
default: null,
|
|
description: 'First name of the service account user for Ivanti API authentication',
|
|
docUrl: null,
|
|
sensitive: false,
|
|
validator: null
|
|
},
|
|
{
|
|
name: 'IVANTI_LAST_NAME',
|
|
group: 'Ivanti Integration',
|
|
required: false,
|
|
default: null,
|
|
description: 'Last name of the service account user for Ivanti API authentication',
|
|
docUrl: null,
|
|
sensitive: false,
|
|
validator: null
|
|
},
|
|
{
|
|
name: 'IVANTI_BU_FILTER',
|
|
group: 'Ivanti Integration',
|
|
required: false,
|
|
default: 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM',
|
|
description: 'Comma-separated BU values to sync from Ivanti into the local findings cache',
|
|
docUrl: null,
|
|
sensitive: false,
|
|
validator: null
|
|
},
|
|
{
|
|
name: 'IVANTI_MANAGED_BUS',
|
|
group: 'Ivanti Integration',
|
|
required: false,
|
|
default: 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM',
|
|
description: 'Comma-separated BUs considered "managed" for drift classification in the archive',
|
|
docUrl: null,
|
|
sensitive: false,
|
|
validator: null
|
|
},
|
|
{
|
|
name: 'IVANTI_SKIP_TLS',
|
|
group: 'Ivanti Integration',
|
|
required: false,
|
|
default: 'false',
|
|
description: 'Set to true if behind Charter SSL inspection proxy (disables TLS cert verification)',
|
|
docUrl: null,
|
|
sensitive: false,
|
|
validator: null
|
|
},
|
|
|
|
// --- Atlas Integration ---
|
|
{
|
|
name: 'ATLAS_API_URL',
|
|
group: 'Atlas Integration',
|
|
required: false,
|
|
default: null,
|
|
description: 'Base URL for the Atlas InfoSec API (e.g. https://atlas-infosec.caas.charterlab.com)',
|
|
docUrl: 'https://atlas-infosec.caas.charterlab.com — API documentation',
|
|
sensitive: false,
|
|
validator: null
|
|
},
|
|
{
|
|
name: 'ATLAS_API_USER',
|
|
group: 'Atlas Integration',
|
|
required: false,
|
|
default: null,
|
|
description: 'Service account username for Atlas API Basic Auth',
|
|
docUrl: null,
|
|
sensitive: false,
|
|
validator: null
|
|
},
|
|
{
|
|
name: 'ATLAS_API_PASS',
|
|
group: 'Atlas Integration',
|
|
required: false,
|
|
default: null,
|
|
description: 'Service account password for Atlas API Basic Auth',
|
|
docUrl: null,
|
|
sensitive: true,
|
|
validator: null
|
|
},
|
|
{
|
|
name: 'ATLAS_SKIP_TLS',
|
|
group: 'Atlas Integration',
|
|
required: false,
|
|
default: 'false',
|
|
description: 'Set to true if behind Charter SSL inspection proxy (disables TLS cert verification)',
|
|
docUrl: null,
|
|
sensitive: false,
|
|
validator: null
|
|
},
|
|
|
|
// --- Jira Integration ---
|
|
{
|
|
name: 'JIRA_BASE_URL',
|
|
group: 'Jira Integration',
|
|
required: false,
|
|
default: null,
|
|
description: 'Base URL of the Jira Data Center instance (VPN or Charter Network required)',
|
|
docUrl: 'Jira instance URL — requires VPN or Charter Network connection',
|
|
sensitive: false,
|
|
validator: null
|
|
},
|
|
{
|
|
name: 'JIRA_AUTH_METHOD',
|
|
group: 'Jira Integration',
|
|
required: false,
|
|
default: 'basic',
|
|
description: 'Authentication method: "basic" for service account or "pat" for Personal Access Token',
|
|
docUrl: null,
|
|
sensitive: false,
|
|
validator: null
|
|
},
|
|
{
|
|
name: 'JIRA_API_USER',
|
|
group: 'Jira Integration',
|
|
required: false,
|
|
default: null,
|
|
description: 'Service account username for Jira Basic Auth (used when JIRA_AUTH_METHOD=basic)',
|
|
docUrl: null,
|
|
sensitive: false,
|
|
validator: null
|
|
},
|
|
{
|
|
name: 'JIRA_API_TOKEN',
|
|
group: 'Jira Integration',
|
|
required: false,
|
|
default: null,
|
|
description: 'Service account password/token for Jira Basic Auth',
|
|
docUrl: null,
|
|
sensitive: true,
|
|
validator: null
|
|
},
|
|
{
|
|
name: 'JIRA_PAT',
|
|
group: 'Jira Integration',
|
|
required: false,
|
|
default: null,
|
|
description: 'Personal Access Token for Jira (used when JIRA_AUTH_METHOD=pat, requires ATLSUP approval)',
|
|
docUrl: 'PAT naming convention: Function - Team - ATLSUP-XXXXX',
|
|
sensitive: true,
|
|
validator: null
|
|
},
|
|
{
|
|
name: 'JIRA_PROJECT_KEY',
|
|
group: 'Jira Integration',
|
|
required: false,
|
|
default: null,
|
|
description: 'Default Jira project key for creating issues from the dashboard',
|
|
docUrl: null,
|
|
sensitive: false,
|
|
validator: null
|
|
},
|
|
{
|
|
name: 'JIRA_ISSUE_TYPE',
|
|
group: 'Jira Integration',
|
|
required: false,
|
|
default: 'Task',
|
|
description: 'Default issue type when creating Jira tickets from the dashboard',
|
|
docUrl: null,
|
|
sensitive: false,
|
|
validator: null
|
|
},
|
|
{
|
|
name: 'JIRA_SKIP_TLS',
|
|
group: 'Jira Integration',
|
|
required: false,
|
|
default: 'false',
|
|
description: 'Set to true if behind Charter SSL inspection proxy (disables TLS cert verification)',
|
|
docUrl: null,
|
|
sensitive: false,
|
|
validator: null
|
|
},
|
|
|
|
// --- CARD Integration ---
|
|
{
|
|
name: 'CARD_API_URL',
|
|
group: 'CARD Integration',
|
|
required: false,
|
|
default: null,
|
|
description: 'Base URL for the CARD asset ownership API (card.charter.com or staging)',
|
|
docUrl: 'https://card.charter.com — service account must be onboarded with CARD team',
|
|
sensitive: false,
|
|
validator: null
|
|
},
|
|
{
|
|
name: 'CARD_API_USER',
|
|
group: 'CARD Integration',
|
|
required: false,
|
|
default: null,
|
|
description: 'Service account username for CARD API OAuth token acquisition',
|
|
docUrl: null,
|
|
sensitive: false,
|
|
validator: null
|
|
},
|
|
{
|
|
name: 'CARD_API_PASS',
|
|
group: 'CARD Integration',
|
|
required: false,
|
|
default: null,
|
|
description: 'Service account password for CARD API OAuth token acquisition',
|
|
docUrl: null,
|
|
sensitive: true,
|
|
validator: null
|
|
},
|
|
{
|
|
name: 'CARD_SKIP_TLS',
|
|
group: 'CARD Integration',
|
|
required: false,
|
|
default: 'false',
|
|
description: 'Set to true if behind Charter SSL inspection proxy (disables TLS cert verification)',
|
|
docUrl: null,
|
|
sensitive: false,
|
|
validator: null
|
|
},
|
|
|
|
// --- GitLab Integration ---
|
|
{
|
|
name: 'GITLAB_URL',
|
|
group: 'GitLab Integration',
|
|
required: false,
|
|
default: 'http://steam-gitlab.charterlab.com',
|
|
description: 'Base URL of the GitLab instance for feedback submission',
|
|
docUrl: null,
|
|
sensitive: false,
|
|
validator: null
|
|
},
|
|
{
|
|
name: 'GITLAB_PROJECT_ID',
|
|
group: 'GitLab Integration',
|
|
required: false,
|
|
default: null,
|
|
description: 'Numeric project ID from GitLab project settings (Settings > General)',
|
|
docUrl: 'GitLab project > Settings > General — numeric Project ID',
|
|
sensitive: false,
|
|
validator: null
|
|
},
|
|
{
|
|
name: 'GITLAB_PAT',
|
|
group: 'GitLab Integration',
|
|
required: false,
|
|
default: null,
|
|
description: 'GitLab Personal Access Token with "api" scope for creating issues',
|
|
docUrl: 'GitLab > Preferences > Access Tokens — requires "api" scope',
|
|
sensitive: true,
|
|
validator: null
|
|
},
|
|
|
|
// --- Frontend Settings ---
|
|
{
|
|
name: 'REACT_APP_API_BASE',
|
|
group: 'Frontend Settings',
|
|
required: true,
|
|
default: null, // derived from PORT at runtime
|
|
description: 'Full URL to the backend API including /api path (used by React fetch calls)',
|
|
docUrl: null,
|
|
sensitive: false,
|
|
validator: null
|
|
},
|
|
{
|
|
name: 'REACT_APP_API_HOST',
|
|
group: 'Frontend Settings',
|
|
required: true,
|
|
default: null, // derived from PORT at runtime
|
|
description: 'Backend host URL without /api path (used for direct file/download URLs)',
|
|
docUrl: null,
|
|
sensitive: false,
|
|
validator: null
|
|
}
|
|
];
|
|
|
|
// =============================================================================
|
|
// Parsing Functions
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Resolves shell variable substitution syntax ${VAR:-default} by extracting
|
|
* the default value. Returns the original string if the pattern is not found.
|
|
*
|
|
* @param {string} str — input string potentially containing ${VAR:-default}
|
|
* @returns {string} — the extracted default value, or the original string
|
|
*/
|
|
function resolveShellDefault(str) {
|
|
const match = str.match(/\$\{[^:}]+:-([^}]+)\}/);
|
|
return match ? match[1] : str;
|
|
}
|
|
|
|
/**
|
|
* Parses a docker-compose.yml file to extract Postgres service configuration.
|
|
* Uses a simple line-by-line state machine to find the postgres service and
|
|
* extract POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB, and the host port.
|
|
*
|
|
* @param {string} filePath — path to docker-compose.yml
|
|
* @returns {{ user: string, password: string, database: string, port: string } | null}
|
|
*/
|
|
function parseDockerCompose(filePath) {
|
|
let content;
|
|
try {
|
|
content = fs.readFileSync(filePath, 'utf8');
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
|
|
const lines = content.split('\n');
|
|
|
|
// State machine states
|
|
let inServices = false;
|
|
let inPostgres = false;
|
|
let inEnvironment = false;
|
|
let inPorts = false;
|
|
let postgresIndent = -1;
|
|
let sectionIndent = -1;
|
|
|
|
let user = null;
|
|
let password = null;
|
|
let database = null;
|
|
let port = null;
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
const trimmed = line.trimStart();
|
|
const indent = line.length - trimmed.length;
|
|
|
|
// Look for services: block
|
|
if (trimmed === 'services:' || trimmed.startsWith('services:')) {
|
|
inServices = true;
|
|
continue;
|
|
}
|
|
|
|
if (!inServices) continue;
|
|
|
|
// Look for postgres service (a line like " postgres:" under services)
|
|
if (!inPostgres) {
|
|
if (indent > 0 && /^postgres\s*:/.test(trimmed)) {
|
|
inPostgres = true;
|
|
postgresIndent = indent;
|
|
continue;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// If we're in the postgres service, check if we've left it
|
|
// (a line at the same or lesser indent that isn't blank)
|
|
if (trimmed.length > 0 && indent <= postgresIndent) {
|
|
// We've exited the postgres service block
|
|
break;
|
|
}
|
|
|
|
// Look for environment: or ports: section within postgres
|
|
if (!inEnvironment && !inPorts) {
|
|
if (/^environment\s*:/.test(trimmed)) {
|
|
inEnvironment = true;
|
|
sectionIndent = indent;
|
|
continue;
|
|
}
|
|
if (/^ports\s*:/.test(trimmed)) {
|
|
inPorts = true;
|
|
sectionIndent = indent;
|
|
continue;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Inside environment section
|
|
if (inEnvironment) {
|
|
// Check if we've left the environment section
|
|
if (trimmed.length > 0 && indent <= sectionIndent) {
|
|
inEnvironment = false;
|
|
// Check if this line starts a new section
|
|
if (/^ports\s*:/.test(trimmed)) {
|
|
inPorts = true;
|
|
sectionIndent = indent;
|
|
continue;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Parse environment variables (format: KEY: value or KEY: ${VAR:-default})
|
|
const envMatch = trimmed.match(/^(POSTGRES_\w+)\s*:\s*(.+)$/);
|
|
if (envMatch) {
|
|
const key = envMatch[1];
|
|
let value = envMatch[2].trim();
|
|
// Remove surrounding quotes if present
|
|
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
(value.startsWith("'") && value.endsWith("'"))) {
|
|
value = value.slice(1, -1);
|
|
}
|
|
value = resolveShellDefault(value);
|
|
|
|
if (key === 'POSTGRES_USER') user = value;
|
|
else if (key === 'POSTGRES_PASSWORD') password = value;
|
|
else if (key === 'POSTGRES_DB') database = value;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Inside ports section
|
|
if (inPorts) {
|
|
// Check if we've left the ports section
|
|
if (trimmed.length > 0 && indent <= sectionIndent) {
|
|
inPorts = false;
|
|
// Check if this line starts a new section
|
|
if (/^environment\s*:/.test(trimmed)) {
|
|
inEnvironment = true;
|
|
sectionIndent = indent;
|
|
continue;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Parse port mapping (format: - "host:container" or - host:container)
|
|
const portMatch = trimmed.match(/^-\s*"?(\d+)\s*:\s*\d+"?/);
|
|
if (portMatch) {
|
|
port = portMatch[1];
|
|
}
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Return null if we couldn't extract all required values
|
|
if (!user || !password || !database || !port) {
|
|
return null;
|
|
}
|
|
|
|
return { user, password, database, port };
|
|
}
|
|
|
|
/**
|
|
* Set of all managed variable names for O(1) lookup during env file parsing.
|
|
*/
|
|
const MANAGED_VARIABLE_NAMES = new Set(VARIABLE_DESCRIPTORS.map(d => d.name));
|
|
|
|
/**
|
|
* Parses a .env file, separating managed variables from unmanaged lines.
|
|
*
|
|
* For each non-empty line:
|
|
* - Lines starting with '#' are treated as comments
|
|
* - Other lines are split on the first '=' to extract key and value
|
|
* - Surrounding double quotes are stripped from the value if present
|
|
* - If the key matches a managed variable name, it goes into the managed Map
|
|
* - Otherwise, the raw line (and any preceding comment lines) go into unmanaged
|
|
*
|
|
* @param {string} filePath — path to the .env file
|
|
* @returns {{ managed: Map<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) {
|
|
printGroupHeader(group);
|
|
|
|
// For skippable groups, ask if user wants to configure
|
|
if (SKIPPABLE_GROUPS.includes(group)) {
|
|
const configure = await promptYesNo(rl, `Configure ${group}?`, true);
|
|
if (!configure) {
|
|
skippedGroups.add(group);
|
|
console.log(` Skipping ${group}.`);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Get variables for this group
|
|
const groupVars = VARIABLE_DESCRIPTORS.filter(d => d.group === group);
|
|
|
|
for (const descriptor of groupVars) {
|
|
// Determine the effective current value for pre-filling
|
|
let currentValue = null;
|
|
|
|
// Check existing env values first
|
|
if (descriptor.group === 'Frontend Settings') {
|
|
if (existingFrontend.managed.has(descriptor.name)) {
|
|
currentValue = existingFrontend.managed.get(descriptor.name);
|
|
}
|
|
} else {
|
|
if (existingBackend.managed.has(descriptor.name)) {
|
|
currentValue = existingBackend.managed.get(descriptor.name);
|
|
}
|
|
}
|
|
|
|
// If no current value, determine derived defaults for special variables
|
|
if (currentValue === null && descriptor.default === null) {
|
|
if (descriptor.name === 'DATABASE_URL') {
|
|
currentValue = null; // Will use derived default below
|
|
} else if (descriptor.name === 'CORS_ORIGINS') {
|
|
if (confirmedPort !== null) {
|
|
currentValue = null; // Will use derived default below
|
|
}
|
|
} else if (descriptor.name === 'REACT_APP_API_BASE') {
|
|
if (confirmedPort !== null) {
|
|
currentValue = null; // Will use derived default below
|
|
}
|
|
} else if (descriptor.name === 'REACT_APP_API_HOST') {
|
|
if (confirmedPort !== null) {
|
|
currentValue = null; // Will use derived default below
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build a modified descriptor with the effective default for derived variables
|
|
let effectiveDescriptor = descriptor;
|
|
|
|
if (descriptor.name === 'DATABASE_URL' && currentValue === null && descriptor.default === null) {
|
|
const sourceMsg = databaseUrlSource === 'compose'
|
|
? ' (auto-derived from docker-compose.yml)'
|
|
: ' (fallback from deploy script — docker-compose.yml could not be used)';
|
|
effectiveDescriptor = {
|
|
...descriptor,
|
|
default: derivedDatabaseUrl,
|
|
description: descriptor.description + sourceMsg
|
|
};
|
|
} else if (descriptor.name === 'CORS_ORIGINS' && currentValue === null && descriptor.default === null) {
|
|
effectiveDescriptor = { ...descriptor, default: 'http://localhost:3000' };
|
|
} else if (descriptor.name === 'REACT_APP_API_BASE' && currentValue === null && descriptor.default === null) {
|
|
const port = confirmedPort || '3001';
|
|
effectiveDescriptor = { ...descriptor, default: `http://localhost:${port}/api` };
|
|
} else if (descriptor.name === 'REACT_APP_API_HOST' && currentValue === null && descriptor.default === null) {
|
|
const port = confirmedPort || '3001';
|
|
effectiveDescriptor = { ...descriptor, default: `http://localhost:${port}` };
|
|
}
|
|
|
|
const result = await promptVariable(rl, effectiveDescriptor, currentValue);
|
|
|
|
// Store the result if non-empty (or if required)
|
|
if (result !== '') {
|
|
values.set(descriptor.name, result);
|
|
}
|
|
|
|
// Track confirmed PORT for deriving other defaults
|
|
if (descriptor.name === 'PORT') {
|
|
confirmedPort = result || '3001';
|
|
}
|
|
}
|
|
|
|
// After Core Settings group completes, derive defaults for later groups
|
|
if (group === 'Core Settings') {
|
|
// CORS_ORIGINS default is already handled inline above
|
|
// REACT_APP_API_BASE and REACT_APP_API_HOST defaults use confirmedPort
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// 7. Display summary
|
|
// -----------------------------------------------------------------------
|
|
printSummary(values, skippedGroups);
|
|
|
|
// -----------------------------------------------------------------------
|
|
// 8. Confirmation
|
|
// -----------------------------------------------------------------------
|
|
const confirmed = await promptYesNo(rl, 'Write these settings to disk?', false);
|
|
|
|
if (!confirmed) {
|
|
// Ask restart or exit
|
|
const restartChoice = await promptYesNo(rl, 'Restart the wizard from the beginning?', true);
|
|
if (restartChoice) {
|
|
restart = true;
|
|
continue;
|
|
} else {
|
|
rl.close();
|
|
console.log('\nExiting without writing files.');
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// 9. Handle existing files — prompt for overwrite/backup
|
|
// -----------------------------------------------------------------------
|
|
let writeBackend = true;
|
|
let writeFrontend = true;
|
|
|
|
if (fs.existsSync(backendEnvPath)) {
|
|
const choice = await promptOverwrite(rl, backendEnvPath);
|
|
if (choice === 'skip') {
|
|
writeBackend = false;
|
|
} else if (choice === 'backup') {
|
|
const backupPath = createBackup(backendEnvPath);
|
|
console.log(` Backup created: ${backupPath}`);
|
|
}
|
|
}
|
|
|
|
if (fs.existsSync(frontendEnvPath)) {
|
|
const choice = await promptOverwrite(rl, frontendEnvPath);
|
|
if (choice === 'skip') {
|
|
writeFrontend = false;
|
|
} else if (choice === 'backup') {
|
|
const backupPath = createBackup(frontendEnvPath);
|
|
console.log(` Backup created: ${backupPath}`);
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// 10. Write files
|
|
// -----------------------------------------------------------------------
|
|
|
|
// Separate variables into backend and frontend
|
|
const backendVars = new Map();
|
|
const frontendVars = new Map();
|
|
|
|
for (const [key, value] of values) {
|
|
const descriptor = VARIABLE_DESCRIPTORS.find(d => d.name === key);
|
|
if (descriptor && descriptor.group === 'Frontend Settings') {
|
|
frontendVars.set(key, value);
|
|
} else {
|
|
backendVars.set(key, value);
|
|
}
|
|
}
|
|
|
|
// Backend groups = all groups except Frontend Settings
|
|
const backendGroups = GROUP_ORDER.filter(g => g !== 'Frontend Settings');
|
|
const frontendGroups = ['Frontend Settings'];
|
|
|
|
if (writeBackend) {
|
|
const backendContent = generateEnvContent(
|
|
backendVars,
|
|
backendGroups,
|
|
GROUP_DESCRIPTIONS,
|
|
existingBackend.unmanaged,
|
|
skippedGroups
|
|
);
|
|
const result = writeEnvFile(backendEnvPath, backendContent);
|
|
if (!result.success) {
|
|
console.error(`Error: ${result.error}`);
|
|
rl.close();
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
if (writeFrontend) {
|
|
const frontendContent = generateEnvContent(
|
|
frontendVars,
|
|
frontendGroups,
|
|
GROUP_DESCRIPTIONS,
|
|
existingFrontend.unmanaged,
|
|
skippedGroups
|
|
);
|
|
const result = writeEnvFile(frontendEnvPath, frontendContent);
|
|
if (!result.success) {
|
|
console.error(`Error: ${result.error}`);
|
|
rl.close();
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// 11. Success message
|
|
// -----------------------------------------------------------------------
|
|
console.log('\nConfiguration complete!\n');
|
|
console.log('Next steps:');
|
|
console.log(' 1. Run `node backend/setup.js` to initialize the database');
|
|
console.log(' 2. Start the servers with `./start-servers.sh`');
|
|
console.log('');
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 12. Exit code 0
|
|
// -------------------------------------------------------------------------
|
|
rl.close();
|
|
process.exit(0);
|
|
}
|
|
|
|
// =============================================================================
|
|
// Exports (conditional — only when required as a module, not when run directly)
|
|
// =============================================================================
|
|
|
|
if (typeof require !== 'undefined' && require.main !== module) {
|
|
module.exports = {
|
|
VARIABLE_DESCRIPTORS,
|
|
GROUP_ORDER,
|
|
GROUP_DESCRIPTIONS,
|
|
SKIPPABLE_GROUPS,
|
|
SENSITIVE_VARS,
|
|
MANAGED_VARIABLE_NAMES,
|
|
VALIDATORS,
|
|
resolveShellDefault,
|
|
parseDockerCompose,
|
|
parseEnvFile,
|
|
validatePort,
|
|
validateCorsOrigins,
|
|
validateDatabaseUrl,
|
|
validateSessionSecret,
|
|
validateRequired,
|
|
printWelcome,
|
|
printGroupHeader,
|
|
printSummary,
|
|
maskSensitive,
|
|
promptVariable,
|
|
promptYesNo,
|
|
promptOverwrite,
|
|
needsQuoting,
|
|
generateEnvContent,
|
|
createBackup,
|
|
writeEnvFile,
|
|
main
|
|
};
|
|
}
|
|
|
|
// Run the wizard when executed directly
|
|
if (require.main === module) {
|
|
main();
|
|
}
|