/** * Pokedex.Online Deployment Script * * Deploys the Vue 3 pokedex.online application with backend to Synology NAS via SSH. * - Builds the Vue 3 application locally (npm run build) * - Connects to Synology using configured SSH hosts * - Transfers built files, backend code, and Docker configuration via SFTP * - Manages multi-container Docker deployment (frontend + backend) with rollback on failure * - Performs health checks on both frontend and backend containers * * Usage: * node code/utils/deploy-pokedex.js [--target internal|external] [--port 8080] [--ssl-port 8443] [--backend-port 3000] * npm run deploy:pokedex -- --target external --port 8081 --ssl-port 8444 --backend-port 3001 * * Examples: * npm run deploy:pokedex # Deploy to internal (10.0.0.81) on port 8080 * npm run deploy:pokedex -- --target external # Deploy to external (home.gregrjacobs.com) * npm run deploy:pokedex -- --port 8081 # Deploy to internal on port 8081 * npm run deploy:pokedex -- --port 8080 --ssl-port 8443 # Deploy with HTTPS on port 8443 * npm run deploy:pokedex -- --target external --port 3000 --ssl-port 3443 */ import { NodeSSH } from 'node-ssh'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import http from 'http'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // Configuration const SSH_HOSTS = { internal: { host: '10.0.0.81', port: 2323, username: 'GregRJacobs', privateKeyPath: '~/.ssh/ds3627xs_gregrjacobs', password: 'J@Cubs88' }, external: { host: 'home.gregrjacobs.com', port: 2323, username: 'GregRJacobs', privateKeyPath: '~/.ssh/ds3627xs_gregrjacobs', password: 'J@Cubs88' } }; const REMOTE_PATH = '/volume1/docker/pokedex-online/base'; const CONTAINER_NAME = 'pokedex-online'; const SOURCE_DIR = path.resolve(__dirname, '../websites/pokedex.online'); const DIST_DIR = path.join(SOURCE_DIR, 'dist'); /** * Parse command line arguments * @returns {Object} Parsed arguments */ function parseArgs() { const args = process.argv.slice(2); const config = { target: 'internal', port: 8080, sslPort: null, backendPort: 3000 }; for (let i = 0; i < args.length; i++) { if (args[i] === '--target' && args[i + 1]) { config.target = args[i + 1]; i++; } else if (args[i] === '--port' && args[i + 1]) { config.port = parseInt(args[i + 1], 10); i++; } else if (args[i] === '--ssl-port' && args[i + 1]) { config.sslPort = parseInt(args[i + 1], 10); i++; } else if (args[i] === '--backend-port' && args[i + 1]) { config.backendPort = parseInt(args[i + 1], 10); i++; } } // Validate target if (!SSH_HOSTS[config.target]) { throw new Error( `Invalid target: ${config.target}. Must be 'internal' or 'external'.` ); } // Validate port if (isNaN(config.port) || config.port < 1 || config.port > 65535) { throw new Error( `Invalid port: ${config.port}. Must be between 1 and 65535.` ); } // Validate SSL port if provided if ( config.sslPort !== null && (isNaN(config.sslPort) || config.sslPort < 1 || config.sslPort > 65535) ) { throw new Error( `Invalid SSL port: ${config.sslPort}. Must be between 1 and 65535.` ); } return config; } /** * Expand tilde in file paths * @param {string} filepath - Path potentially starting with ~ * @returns {string} Expanded path */ function expandTilde(filepath) { if (filepath.startsWith('~/')) { return path.join(process.env.HOME, filepath.slice(2)); } return filepath; } /** * Create modified docker-compose.production.yml with custom ports * @param {number} port - HTTP port to map to frontend container * @param {number|null} sslPort - HTTPS port to map to frontend container (optional) * @param {number} backendPort - Port to map to backend container * @returns {string} Modified docker-compose content */ function createModifiedDockerCompose(port, sslPort, backendPort) { const originalPath = path.join(SOURCE_DIR, 'docker-compose.production.yml'); let content = fs.readFileSync(originalPath, 'utf8'); // Replace frontend HTTP port mapping content = content.replace( /(frontend:[\s\S]*?ports:[\s\S]*?- ['"])(\d+)(:80['"])/, `$1${port}$3` ); // Replace frontend HTTPS port mapping if SSL port provided if (sslPort !== null) { content = content.replace( /(frontend:[\s\S]*?ports:[\s\S]*?- ['"])(\d+)(:443['"])/, `$1${sslPort}$3` ); } else { // Remove HTTPS port mapping line if no SSL port specified content = content.replace(/\s*- ['"](\d+):443['"]\n?/g, ''); } // Replace backend port mapping content = content.replace( /(backend:[\s\S]*?ports:[\s\S]*?- ['"])(\d+)(:3000['"])/, `$1${backendPort}$3` ); return content; } /** * Perform HTTP health check * @param {string} host - Host to check * @param {number} port - Port to check * @param {number} retries - Number of retries * @returns {Promise} True if healthy */ async function healthCheck(host, port, retries = 5) { for (let i = 0; i < retries; i++) { try { await new Promise((resolve, reject) => { const req = http.get( `http://${host}:${port}`, { timeout: 5000 }, res => { if (res.statusCode === 200) { resolve(); } else { reject(new Error(`HTTP ${res.statusCode}`)); } } ); req.on('error', reject); req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout')); }); }); return true; } catch { if (i < retries - 1) { console.log( `โณ Health check attempt ${i + 1}/${retries} failed, retrying in 3s...` ); await new Promise(resolve => setTimeout(resolve, 3000)); } } } return false; } /** * Main deployment function */ async function deploy() { const ssh = new NodeSSH(); let previousImage = null; let containerExisted = false; try { // Parse arguments const config = parseArgs(); const sshConfig = SSH_HOSTS[config.target]; console.log('๐Ÿš€ Starting Pokedex.Online deployment'); console.log( `๐Ÿ“ก Target: ${config.target} (${sshConfig.host}:${sshConfig.port})` ); console.log(`๐Ÿ”Œ Frontend Port: ${config.port}`); if (config.sslPort) { console.log(`๐Ÿ”’ HTTPS Port: ${config.sslPort}`); } console.log(`๐Ÿ”Œ Backend Port: ${config.backendPort}`); // Connect to Synology console.log('\n๐Ÿ” Connecting to Synology...'); await ssh.connect({ host: sshConfig.host, port: sshConfig.port, username: sshConfig.username, privateKeyPath: expandTilde(sshConfig.privateKeyPath), password: sshConfig.password, tryKeyboard: true }); console.log('โœ… Connected successfully'); // Build Vue 3 application console.log('\n๐Ÿ”จ Building Vue 3 application...'); console.log(` Source: ${SOURCE_DIR}`); console.log(` Output: ${DIST_DIR}`); const { execSync } = await import('child_process'); try { // Check if node_modules exists if (!fs.existsSync(path.join(SOURCE_DIR, 'node_modules'))) { console.log(' ๐Ÿ“ฆ Installing dependencies...'); execSync('npm install', { cwd: SOURCE_DIR, stdio: 'inherit' }); } // Build the application console.log(' โš™๏ธ Running build...'); execSync('npm run build', { cwd: SOURCE_DIR, stdio: 'inherit' }); // Verify dist directory exists if (!fs.existsSync(DIST_DIR)) { throw new Error('Build failed - dist directory not found'); } console.log(' โœ… Build completed successfully'); } catch (error) { throw new Error(`Build failed: ${error.message}`); } // Check if container exists and capture current image console.log('\n๐Ÿ“ฆ Checking for existing container...'); console.log(` Container name: ${CONTAINER_NAME}`); try { const result = await ssh.execCommand( `/usr/local/bin/docker inspect --format='{{.Image}}' ${CONTAINER_NAME} || /usr/bin/docker inspect --format='{{.Image}}' ${CONTAINER_NAME}` ); console.log(` Command exit code: ${result.code}`); if (result.stdout) console.log(` Stdout: ${result.stdout.trim()}`); if (result.stderr) console.log(` Stderr: ${result.stderr.trim()}`); if (result.code === 0 && result.stdout.trim()) { previousImage = result.stdout.trim(); containerExisted = true; console.log( `โœ… Found existing container (image: ${previousImage.substring(0, 12)}...)` ); } else { console.log('โ„น๏ธ No existing container found'); } } catch (error) { console.log(` Error: ${error.message}`); console.log('โ„น๏ธ No existing container found'); } // Create remote directory console.log('\n๐Ÿ“ Creating remote directory...'); const mkdirResult = await ssh.execCommand(`mkdir -p ${REMOTE_PATH}`); console.log(` Command: mkdir -p ${REMOTE_PATH}`); if (mkdirResult.stdout) console.log(` Output: ${mkdirResult.stdout}`); if (mkdirResult.stderr) console.log(` Stderr: ${mkdirResult.stderr}`); console.log(` โœ… Directory ready`); // Create modified docker-compose.yml const modifiedDockerCompose = createModifiedDockerCompose( config.port, config.sslPort, config.backendPort ); const tempDockerComposePath = path.join( SOURCE_DIR, 'docker-compose.tmp.yml' ); fs.writeFileSync(tempDockerComposePath, modifiedDockerCompose); // Transfer files console.log('\n๐Ÿ“ค Transferring files...'); // First transfer the dist directory console.log(' ๐Ÿ“ฆ Transferring dist directory...'); const distFiles = []; function getDistFiles(dir, baseDir = DIST_DIR) { const files = fs.readdirSync(dir, { withFileTypes: true }); for (const file of files) { const fullPath = path.join(dir, file.name); if (file.isDirectory()) { getDistFiles(fullPath, baseDir); } else { const relativePath = path.relative(baseDir, fullPath); distFiles.push({ local: fullPath, remote: `${REMOTE_PATH}/dist/${relativePath.replace(/\\/g, '/')}` }); } } } getDistFiles(DIST_DIR); console.log(` Found ${distFiles.length} files in dist/`); // Create dist directory on remote await ssh.execCommand(`mkdir -p ${REMOTE_PATH}/dist`); // Transfer dist files let transferred = 0; for (const file of distFiles) { try { // Create remote directory for this file const remoteDir = path.dirname(file.remote); await ssh.execCommand(`mkdir -p ${remoteDir}`); await ssh.putFile(file.local, file.remote); transferred++; if (transferred % 10 === 0) { console.log( ` ๐Ÿ“ Transferred ${transferred}/${distFiles.length} files...` ); } } catch (error) { console.log( ` โš ๏ธ Failed to transfer ${path.relative(DIST_DIR, file.local)}: ${error.message}` ); } } console.log(` โœ… Transferred ${transferred} files from dist/`); // Transfer backend server files console.log(' ๐Ÿ“ฆ Transferring backend server files...'); const serverDir = path.join(SOURCE_DIR, 'server'); await ssh.execCommand(`mkdir -p ${REMOTE_PATH}/server`); // Transfer server package files const serverFiles = [ 'package.json', 'oauth-proxy.js', 'gamemaster-api.js', '.env.example' ]; for (const file of serverFiles) { const localPath = path.join(serverDir, file); const remotePath = `${REMOTE_PATH}/server/${file}`; if (fs.existsSync(localPath)) { await ssh.putFile(localPath, remotePath); console.log(` โœ… server/${file}`); } } // Transfer server subdirectories const serverDirs = ['middleware', 'routes', 'utils', 'data']; for (const dir of serverDirs) { const localDir = path.join(serverDir, dir); if (fs.existsSync(localDir)) { await ssh.execCommand(`mkdir -p ${REMOTE_PATH}/server/${dir}`); const files = fs.readdirSync(localDir, { withFileTypes: true }); for (const file of files) { if (file.isFile()) { const localFile = path.join(localDir, file.name); const remoteFile = `${REMOTE_PATH}/server/${dir}/${file.name}`; await ssh.putFile(localFile, remoteFile); } } console.log(` โœ… server/${dir}/ (${files.length} files)`); } } console.log(` โœ… Backend files transferred`); // Now transfer config files const filesToTransfer = [ { local: path.join(SOURCE_DIR, 'Dockerfile.frontend'), remote: `${REMOTE_PATH}/Dockerfile.frontend` }, { local: path.join(SOURCE_DIR, 'server', 'Dockerfile'), remote: `${REMOTE_PATH}/server/Dockerfile` }, { local: path.join(SOURCE_DIR, 'nginx.conf'), remote: `${REMOTE_PATH}/nginx.conf` }, { local: tempDockerComposePath, remote: `${REMOTE_PATH}/docker-compose.yml` } ]; for (const file of filesToTransfer) { try { await ssh.putFile(file.local, file.remote); console.log(` โœ… ${path.basename(file.local)}`); } catch { // If SFTP fails, fall back to cat method console.log( ` โš ๏ธ SFTP failed for ${path.basename(file.local)}, using cat fallback...` ); const fileContent = fs.readFileSync(file.local, 'utf8'); const catResult = await ssh.execCommand( `cat > '${file.remote}' << 'EOFMARKER'\n${fileContent}\nEOFMARKER` ); if (catResult.stdout) console.log(` Output: ${catResult.stdout}`); if (catResult.stderr) console.log(` Stderr: ${catResult.stderr}`); if (catResult.code !== 0) { throw new Error( `Failed to transfer ${path.basename(file.local)}: ${catResult.stderr}` ); } console.log( ` โœ… ${path.basename(file.local)} (${fs.statSync(file.local).size} bytes)` ); } } // Clean up temp file fs.unlinkSync(tempDockerComposePath); // Stop existing container first to avoid port conflicts if (containerExisted) { console.log('\n๐Ÿ›‘ Stopping existing container...'); const stopResult = await ssh.execCommand( `cd ${REMOTE_PATH} && /usr/local/bin/docker compose down || /usr/local/bin/docker-compose down` ); if (stopResult.stdout) console.log(` ${stopResult.stdout.trim()}`); console.log(' โœ… Container stopped'); } // Deploy with docker-compose console.log('\n๐Ÿณ Building and starting Docker container...'); console.log(` Working directory: ${REMOTE_PATH}`); // Try Docker Compose V2 first (docker compose), then fall back to V1 (docker-compose) // Use full paths for Synology console.log(' Attempting: /usr/local/bin/docker compose up -d --build'); let deployResult = await ssh.execCommand( `cd ${REMOTE_PATH} && /usr/local/bin/docker compose up -d --build || /usr/local/bin/docker-compose up -d --build || /usr/bin/docker compose up -d --build`, { stream: 'both' } ); console.log('\n ๐Ÿ“‹ Docker Output:'); if (deployResult.stdout) { deployResult.stdout.split('\n').forEach(line => { if (line.trim()) console.log(` ${line}`); }); } if (deployResult.stderr) { console.log('\n โš ๏ธ Docker Stderr:'); deployResult.stderr.split('\n').forEach(line => { if (line.trim()) console.log(` ${line}`); }); } console.log(` Exit code: ${deployResult.code}`); if (deployResult.code !== 0) { throw new Error(`Docker deployment failed: ${deployResult.stderr}`); } console.log('\nโœ… Container started'); // Health checks console.log('\n๐Ÿฅ Performing health checks...'); console.log(' Checking frontend...'); const frontendHealthy = await healthCheck(sshConfig.host, config.port); if (!frontendHealthy) { throw new Error('Frontend health check failed - container is not responding'); } console.log(' โœ… Frontend healthy'); console.log(' Checking backend...'); const backendHealthy = await healthCheck(sshConfig.host, config.backendPort); if (!backendHealthy) { throw new Error('Backend health check failed - container is not responding'); } console.log(' โœ… Backend healthy'); console.log('\nโœ… All health checks passed'); console.log(`\n๐ŸŽ‰ Deployment successful!`); console.log(`๐ŸŒ Frontend: http://${sshConfig.host}:${config.port}`); if (config.sslPort) { console.log(`๐Ÿ”’ HTTPS: https://${sshConfig.host}:${config.sslPort}`); } console.log(`๐Ÿ”Œ Backend: http://${sshConfig.host}:${config.backendPort}`); ssh.dispose(); } catch (error) { console.error('\nโŒ Deployment failed:', error.message); // Rollback if (previousImage) { console.log('\n๐Ÿ”„ Rolling back to previous image...'); try { await ssh.execCommand( `cd ${REMOTE_PATH} && docker-compose down && docker tag ${previousImage} pokedex-online:latest && docker-compose up -d` ); console.log('โœ… Rollback successful'); } catch (rollbackError) { console.error('โŒ Rollback failed:', rollbackError.message); } } else if (containerExisted === false) { console.log('\n๐Ÿงน Cleaning up failed deployment...'); try { await ssh.execCommand( `cd ${REMOTE_PATH} && docker-compose down --volumes --remove-orphans` ); console.log('โœ… Cleanup successful'); } catch (cleanupError) { console.error('โŒ Cleanup failed:', cleanupError.message); } } ssh.dispose(); process.exit(1); } } // Run deployment deploy();