@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
text/typescript
/**
* 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}`);
}
}
}