/** * 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' }, external: { host: 'home.gregrjacobs.com', port: 2323, username: 'GregRJacobs', privateKeyPath: '~/.ssh/ds3627xs_gregrjacobs' } }; 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: 8099, sslPort: null, backendPort: 3099 }; 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 // Make sure to preserve newline structure content = content.replace(/\n\s*- ['"](\d+):443['"]/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 using ~/.ssh/config console.log('\n๐Ÿ” Connecting to Synology...'); const keyPath = expandTilde(sshConfig.privateKeyPath); console.log(` ๐Ÿ”‘ Using SSH key: ${keyPath}`); console.log( ` ๐Ÿ“ Target: ${sshConfig.username}@${sshConfig.host}:${sshConfig.port}` ); // Verify key file exists if (!fs.existsSync(keyPath)) { throw new Error(`SSH key file not found: ${keyPath}`); } try { const privateKeyContent = fs.readFileSync(keyPath, 'utf8'); const keySize = privateKeyContent.length; console.log(` ๐Ÿ“‚ Key file size: ${keySize} bytes`); if (keySize === 0) { throw new Error('SSH key file is empty'); } // Use node-ssh with private key directly await ssh.connect({ host: sshConfig.host, port: sshConfig.port, username: sshConfig.username, privateKey: privateKeyContent, readyTimeout: 60000, tryKeyboard: false }); console.log('โœ… Connected successfully'); } catch (connError) { console.error('\nโŒ SSH Connection Failed'); console.error(`Error: ${connError.message}`); console.error('\nPossible causes:'); console.error( '1. SSH public key not added to ~/.ssh/authorized_keys on the server' ); console.error('2. SSH key has wrong permissions (should be 600)'); console.error('3. SSH user home directory permissions are wrong'); console.error('\nVerify the key works manually:'); console.error( ` ssh -i ${keyPath} ${sshConfig.username}@${sshConfig.host} -p ${sshConfig.port} "whoami"` ); console.error( '\nIf that fails, the public key needs to be added on the server:' ); console.error( ` cat ~/.ssh/${path.basename(keyPath)}.pub | ssh ${sshConfig.username}@${sshConfig.host} -p ${sshConfig.port} "cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"` ); throw new Error(`SSH connection failed: ${connError.message}`); } // 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...'); // Count files for reporting let fileCount = 0; function countFiles(dir) { const files = fs.readdirSync(dir, { withFileTypes: true }); for (const file of files) { const fullPath = path.join(dir, file.name); if (file.isDirectory()) { countFiles(fullPath); } else { fileCount++; } } } countFiles(DIST_DIR); console.log(` Found ${fileCount} files in dist/`); // Create dist directory on remote await ssh.execCommand(`mkdir -p ${REMOTE_PATH}/dist`); // Transfer dist directory using rsync try { console.log(` ๐Ÿ“ก Transferring dist directory via rsync...`); const { execSync } = await import('child_process'); const expandedKeyPath = expandTilde(sshConfig.privateKeyPath); // Use rsync with SSH for reliable transfer // Key options: // - IdentitiesOnly=yes: Only use specified key, not ssh-agent keys // - rsync-path: Ensure we use the correct rsync binary on remote const rsyncCmd = `rsync -av --delete -e "ssh -p ${sshConfig.port} -i ${expandedKeyPath} -o StrictHostKeyChecking=no -o IdentitiesOnly=yes" "${DIST_DIR}/" "${sshConfig.username}@${sshConfig.host}:${REMOTE_PATH}/dist/" --rsync-path="/usr/bin/rsync"`; try { execSync(rsyncCmd, { stdio: 'inherit', shell: '/bin/bash' }); console.log(` โœ… Transferred dist directory successfully via rsync`); } catch (rsyncError) { console.log(` โŒ Rsync failed: ${rsyncError.message}`); throw rsyncError; } } catch (transferError) { console.log(` โŒ File transfer failed: ${transferError.message}`); throw new Error(`File transfer failed: ${transferError.message}`); } // 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`); // Use rsync for server files with exclusions try { console.log(` ๐Ÿ“ก Transferring server files via rsync...`); const { execSync } = await import('child_process'); const expandedKeyPath = expandTilde(sshConfig.privateKeyPath); // Use rsync with SSH for reliable transfer and exclusions // Key options: // - IdentitiesOnly=yes: Only use specified key, not ssh-agent keys // - rsync-path: Ensure we use the correct rsync binary on remote const rsyncCmd = `rsync -av --delete --exclude='node_modules' --exclude='tests' --exclude='.git' --exclude='dist' --exclude='build' -e "ssh -p ${sshConfig.port} -i ${expandedKeyPath} -o StrictHostKeyChecking=no -o IdentitiesOnly=yes" "${serverDir}/" "${sshConfig.username}@${sshConfig.host}:${REMOTE_PATH}/server/" --rsync-path="/usr/bin/rsync"`; try { execSync(rsyncCmd, { stdio: 'inherit', shell: '/bin/bash' }); console.log(` โœ… Backend files transferred successfully via rsync`); } catch (rsyncError) { console.log(` โŒ Rsync failed: ${rsyncError.message}`); throw rsyncError; } } catch (transferError) { console.log(` โŒ File transfer failed: ${transferError.message}`); throw new Error(`Backend file transfer failed: ${transferError.message}`); } // Now transfer config files console.log(' ๐Ÿ“ฆ Transferring configuration 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` } ]; // Transfer config files using cat over SSH (more reliable than SFTP) for (const file of filesToTransfer) { const fileName = path.basename(file.local); try { const fileContent = fs.readFileSync(file.local, 'utf8'); const catResult = await ssh.execCommand( `cat > '${file.remote}' << 'EOFMARKER'\n${fileContent}\nEOFMARKER` ); if (catResult.code !== 0) { throw new Error( `Failed to transfer ${fileName}: ${catResult.stderr}` ); } console.log(` โœ… ${fileName} (${fs.statSync(file.local).size} bytes)`); } catch (error) { throw new Error(`Failed to transfer ${fileName}: ${error.message}`); } } // 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();