UNPKG

@aerocorp/cli

Version:

AeroCorp CLI 5.1.0 - Future-Proofed Enterprise Infrastructure with Live Preview, Tunneling & Advanced DevOps

357 lines (297 loc) • 11.3 kB
/** * AeroCorp CLI 5.0.0 - Preview Service * Live preview functionality with tunneling support */ import express from 'express'; import { spawn, ChildProcess } from 'child_process'; import chokidar from 'chokidar'; import localtunnel from 'localtunnel'; import ngrok from 'ngrok'; import open from 'open'; import qrcode from 'qrcode-terminal'; import chalk from 'chalk'; import path from 'path'; import fs from 'fs-extra'; import { ConfigService } from './config'; export interface PreviewOptions { port?: number; tunnel?: 'localtunnel' | 'ngrok' | 'cloudflare' | 'none'; open?: boolean; qr?: boolean; watch?: boolean; subdomain?: string; auth?: string; } export interface PreviewSession { localUrl: string; publicUrl?: string; port: number; tunnel?: string; pid: number; startTime: Date; } export class PreviewService { private configService: ConfigService; private devServer?: ChildProcess; private expressApp?: express.Application; private tunnel?: any; private watcher?: chokidar.FSWatcher; constructor() { this.configService = new ConfigService(); } async startPreview(options: PreviewOptions = {}): Promise<PreviewSession> { const port = options.port || 3000; const localUrl = `http://localhost:${port}`; console.log(chalk.cyan.bold('\nšŸš€ Starting AeroCorp Preview Server')); console.log(chalk.gray(`Local URL: ${localUrl}`)); // Check if it's a Vite project const isViteProject = await fs.pathExists('vite.config.ts') || await fs.pathExists('vite.config.js'); const isNextProject = await fs.pathExists('next.config.js') || await fs.pathExists('next.config.ts'); const isReactProject = await fs.pathExists('package.json') && (await fs.readJson('package.json')).dependencies?.react; let devCommand: string; let devArgs: string[] = []; if (isViteProject) { devCommand = 'npm'; devArgs = ['run', 'dev', '--', '--port', port.toString(), '--host', '0.0.0.0']; console.log(chalk.blue('šŸ“¦ Detected Vite project')); } else if (isNextProject) { devCommand = 'npm'; devArgs = ['run', 'dev', '--', '--port', port.toString()]; console.log(chalk.blue('šŸ“¦ Detected Next.js project')); } else if (isReactProject) { devCommand = 'npm'; devArgs = ['start']; console.log(chalk.blue('šŸ“¦ Detected React project')); } else { // Fallback to Express server devCommand = 'node'; devArgs = ['server.js']; console.log(chalk.blue('šŸ“¦ Starting Express server')); } // Start development server this.devServer = spawn(devCommand, devArgs, { stdio: ['pipe', 'pipe', 'pipe'], shell: true, env: { ...process.env, PORT: port.toString() } }); // Handle server output this.devServer.stdout?.on('data', (data) => { const output = data.toString(); if (output.includes('Local:') || output.includes('ready') || output.includes('started')) { console.log(chalk.green('āœ… Development server started')); } }); this.devServer.stderr?.on('data', (data) => { const error = data.toString(); if (!error.includes('Warning') && !error.includes('deprecated')) { console.log(chalk.yellow('āš ļø '), error.trim()); } }); // Wait for server to start await this.waitForServer(port); const session: PreviewSession = { localUrl, port, pid: this.devServer.pid!, startTime: new Date() }; // Setup tunneling if requested if (options.tunnel && options.tunnel !== 'none') { session.publicUrl = await this.setupTunnel(port, options.tunnel, options.subdomain); session.tunnel = options.tunnel; } // Setup file watching for live reload if (options.watch !== false) { await this.setupFileWatcher(); } // Open browser if requested if (options.open) { const urlToOpen = session.publicUrl || session.localUrl; await open(urlToOpen); console.log(chalk.green(`🌐 Opened ${urlToOpen} in browser`)); } // Show QR code if requested if (options.qr && session.publicUrl) { console.log(chalk.cyan('\nšŸ“± QR Code for mobile access:')); qrcode.generate(session.publicUrl, { small: true }); } this.displayPreviewInfo(session); return session; } private async setupTunnel(port: number, tunnelType: string, subdomain?: string): Promise<string> { console.log(chalk.blue(`šŸ”— Setting up ${tunnelType} tunnel...`)); try { switch (tunnelType) { case 'localtunnel': this.tunnel = await localtunnel({ port, subdomain: subdomain || `aerocorp-${Date.now()}` }); console.log(chalk.green(`āœ… LocalTunnel: ${this.tunnel.url}`)); return this.tunnel.url; case 'ngrok': const ngrokUrl = await ngrok.connect({ port, subdomain, region: 'us' }); console.log(chalk.green(`āœ… Ngrok: ${ngrokUrl}`)); return ngrokUrl; case 'cloudflare': // For Cloudflare Tunnel, we'd need cloudflared binary console.log(chalk.yellow('āš ļø Cloudflare Tunnel requires cloudflared binary')); console.log(chalk.blue('šŸ’” Install: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/')); return `http://localhost:${port}`; default: throw new Error(`Unsupported tunnel type: ${tunnelType}`); } } catch (error) { console.log(chalk.red(`āŒ Tunnel setup failed: ${error.message}`)); console.log(chalk.yellow(`šŸ”„ Falling back to local preview only`)); return `http://localhost:${port}`; } } private async setupFileWatcher(): Promise<void> { const watchPaths = ['src', 'public', 'components', 'pages', 'styles']; const existingPaths = []; for (const watchPath of watchPaths) { if (await fs.pathExists(watchPath)) { existingPaths.push(watchPath); } } if (existingPaths.length === 0) { existingPaths.push('.'); } this.watcher = chokidar.watch(existingPaths, { ignored: /node_modules|\.git|dist|build/, persistent: true }); this.watcher.on('change', (filePath) => { console.log(chalk.blue(`šŸ”„ File changed: ${filePath}`)); }); console.log(chalk.green(`šŸ‘€ Watching ${existingPaths.join(', ')} for changes`)); } private async waitForServer(port: number, timeout: number = 30000): Promise<void> { const startTime = Date.now(); while (Date.now() - startTime < timeout) { try { const response = await fetch(`http://localhost:${port}`); if (response.ok || response.status === 404) { return; } } catch (error) { // Server not ready yet } await new Promise(resolve => setTimeout(resolve, 1000)); } throw new Error('Development server failed to start within timeout'); } private displayPreviewInfo(session: PreviewSession): void { console.log(chalk.cyan.bold('\nšŸŽÆ Preview Session Active')); console.log(chalk.white('─'.repeat(50))); console.log(chalk.white(`šŸ“ Local: ${session.localUrl}`)); if (session.publicUrl) { console.log(chalk.white(`🌐 Public: ${session.publicUrl}`)); console.log(chalk.white(`šŸ”— Tunnel: ${session.tunnel}`)); } console.log(chalk.white(`šŸ†” PID: ${session.pid}`)); console.log(chalk.white(`ā° Started: ${session.startTime.toLocaleTimeString()}`)); console.log(chalk.white('─'.repeat(50))); console.log(chalk.gray('Press Ctrl+C to stop the preview server')); } async stopPreview(): Promise<void> { console.log(chalk.blue('\nšŸ›‘ Stopping preview server...')); // Stop file watcher if (this.watcher) { await this.watcher.close(); console.log(chalk.green('āœ… File watcher stopped')); } // Close tunnel if (this.tunnel) { if (typeof this.tunnel.close === 'function') { this.tunnel.close(); } else { await ngrok.disconnect(); } console.log(chalk.green('āœ… Tunnel closed')); } // Stop development server if (this.devServer) { this.devServer.kill('SIGTERM'); console.log(chalk.green('āœ… Development server stopped')); } console.log(chalk.cyan('šŸ‘‹ Preview session ended')); } async listActiveSessions(): Promise<PreviewSession[]> { // In a real implementation, this would track active sessions // For now, return empty array return []; } async deployPreview(options: { name?: string; branch?: string; pr?: number; app?: string } = {}): Promise<string> { try { console.log(chalk.blue('šŸš€ Creating Coolify preview deployment...')); // Import CoolifyService const { CoolifyService } = await import('./coolify'); const coolifyService = new CoolifyService(); // Validate Coolify connection first const isHealthy = await coolifyService.healthCheck(); if (!isHealthy) { throw new Error('Coolify health check failed. Cannot create preview.'); } // Build the project console.log(chalk.blue('šŸ“¦ Building project...')); const buildProcess = spawn('npm', ['run', 'build'], { stdio: 'inherit', shell: true }); await new Promise((resolve, reject) => { buildProcess.on('close', (code) => { if (code === 0) { resolve(void 0); } else { reject(new Error(`Build failed with code ${code}`)); } }); }); // Create preview deployment via Coolify if (options.pr && options.app && options.branch) { const previewUrl = await coolifyService.createPreview({ pr: options.pr, app: options.app, branch: options.branch }); console.log(chalk.green('āœ… Coolify preview deployment created!')); console.log(chalk.white(`🌐 Preview URL: ${previewUrl}`)); return previewUrl; } else { // Fallback to regular deployment const deployResult = await coolifyService.deploy({ app: options.app || options.name, branch: options.branch, environment: 'preview' }); const previewUrl = deployResult.url || `https://preview-${Date.now()}.aerocorpindustries.org`; console.log(chalk.green('āœ… Preview deployment created!')); console.log(chalk.white(`🌐 Preview URL: ${previewUrl}`)); return previewUrl; } } catch (error) { throw new Error(`Preview deployment failed: ${error.message}`); } } /** * Destroy preview deployment */ async destroyPreview(prNumber: number, appUuid: string): Promise<void> { try { console.log(chalk.blue(`šŸ—‘ļø Destroying preview for PR #${prNumber}...`)); const { CoolifyService } = await import('./coolify'); const coolifyService = new CoolifyService(); await coolifyService.destroyPreview(prNumber, appUuid); console.log(chalk.green('āœ… Preview environment destroyed')); } catch (error) { throw new Error(`Preview destruction failed: ${error.message}`); } } }