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