/** * Pokedex.Online Deployment Script * * Deploys the Vue 3 pokedex.online application to Synology NAS via SSH. * - Builds the Vue 3 application locally (npm run build) * - Connects to Synology using configured SSH hosts * - Transfers built files and Docker configuration via SFTP * - Manages Docker deployment with rollback on failure * - Performs health check to verify deployment * * Usage: * node code/utils/deploy-pokedex.js [--target internal|external] [--port 8080] [--ssl-port 8443] * npm run deploy:pokedex -- --target external --port 8081 --ssl-port 8444 * * 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 }; 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++; } } // 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.yml with custom ports * @param {number} port - HTTP port to map to container * @param {number|null} sslPort - HTTPS port to map to container (optional) * @returns {string} Modified docker-compose content */ function createModifiedDockerCompose(port, sslPort) { const originalPath = path.join(SOURCE_DIR, 'docker-compose.yml'); let content = fs.readFileSync(originalPath, 'utf8'); // Replace HTTP port mapping (handle both single and double quotes) content = content.replace(/- ['"](\d+):80['"]/, `- '${port}:80'`); // Replace HTTPS port mapping if SSL port provided if (sslPort !== null) { content = content.replace(/- ['"](\d+):443['"]/, `- '${sslPort}:443'`); } else { // Remove HTTPS port mapping if no SSL port specified content = content.replace(/\s*- ['"](\d+):443['"]/g, ''); } 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 (error) { 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(`๐Ÿ”Œ HTTP Port: ${config.port}`); if (config.sslPort) { console.log(`๐Ÿ”’ HTTPS Port: ${config.sslPort}`); } // 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 ); 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/`); // Now transfer config files const filesToTransfer = [ { local: path.join(SOURCE_DIR, 'Dockerfile'), remote: `${REMOTE_PATH}/Dockerfile` }, { 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 escapedContent = fileContent.replace(/'/g, "'\\''"); 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 check console.log('\n๐Ÿฅ Performing health check...'); const isHealthy = await healthCheck(sshConfig.host, config.port); if (!isHealthy) { throw new Error('Health check failed - container is not responding'); } console.log('โœ… Health check passed'); console.log(`\n๐ŸŽ‰ Deployment successful!`); console.log(`๐ŸŒ HTTP: http://${sshConfig.host}:${config.port}`); if (config.sslPort) { console.log(`๐Ÿ”’ HTTPS: https://${sshConfig.host}:${config.sslPort}`); } 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();