UNPKG

ucg

Version:

Universal CRUD Generator - Express.js plugin and CLI tool for generating complete Node.js REST APIs with database models, controllers, routes, validators, and admin interface. Supports PostgreSQL, MySQL, SQLite with Sequelize, TypeORM, and Knex.js. Develo

833 lines (727 loc) • 26.6 kB
const express = require('express'); const session = require('express-session'); const path = require('path'); const fs = require('fs-extra'); const os = require('os'); const inquirer = require('inquirer'); const chalk = require('chalk'); const ora = require('ora'); const helmet = require('helmet'); const cors = require('cors'); const compression = require('compression'); const morgan = require('morgan'); const swaggerUi = require('swagger-ui-express'); const swaggerJsdoc = require('swagger-jsdoc'); const ConfigManager = require('./config-manager'); const DatabaseManager = require('./database-manager'); const ModelGenerator = require('./generators/model-generator'); const CrudGenerator = require('./generators/crud-generator'); const FileManager = require('./file-manager'); const TemplateManager = require('./template-manager'); class UCG { constructor(configDir = null) { this.configDir = configDir || process.env.UCG_CONFIG_DIR || path.join(os.homedir(), '.ucg'); this.configManager = new ConfigManager(this.configDir); this.databaseManager = new DatabaseManager(this.configManager); this.modelGenerator = new ModelGenerator(this.databaseManager, this.configManager); this.crudGenerator = new CrudGenerator(this.configManager, this.databaseManager); this.fileManager = new FileManager(this.configDir); this.templateManager = new TemplateManager(); this.app = null; this.server = null; } async init() { console.log(chalk.blue('šŸš€ Initializing UCG...')); // Ensure config directory exists await fs.ensureDir(this.configDir); // Setup user credentials if not exists const userExists = await this.configManager.userExists(); if (!userExists) { await this.setupUser(); } // Setup database configuration await this.setupDatabase(); console.log(chalk.green('āœ… UCG initialized successfully!')); console.log(chalk.yellow('Run "ucg start" or just "ucg" to start the admin interface.')); } async setupUser() { console.log(chalk.yellow('šŸ‘¤ Setting up admin user...')); const answers = await inquirer.default.prompt([ { type: 'input', name: 'username', message: 'Enter admin username:', validate: (input) => input.length >= 3 || 'Username must be at least 3 characters' }, { type: 'password', name: 'password', message: 'Enter admin password:', validate: (input) => input.length >= 6 || 'Password must be at least 6 characters' } ]); await this.configManager.saveUser(answers.username, answers.password); console.log(chalk.green('āœ… Admin user created successfully!')); } async setupDatabase() { console.log(chalk.yellow('šŸ—„ļø Setting up database configuration...')); const answers = await inquirer.default.prompt([ { type: 'list', name: 'type', message: 'Database type:', choices: [ { name: 'PostgreSQL', value: 'postgres' }, { name: 'MySQL', value: 'mysql' } ], default: 'postgres' }, { type: 'input', name: 'host', message: 'Database host:', default: 'localhost' }, { type: 'input', name: 'port', message: 'Database port:', default: (answers) => answers.type === 'mysql' ? '3306' : '5432' }, { type: 'input', name: 'database', message: 'Database name:', validate: (input) => input.length > 0 || 'Database name is required' }, { type: 'input', name: 'username', message: 'Database username:', validate: (input) => input.length > 0 || 'Username is required' }, { type: 'password', name: 'password', message: 'Database password:' }, { type: 'list', name: 'orm', message: 'Choose ORM:', choices: ['sequelize', 'typeorm', 'knex'], default: 'sequelize' } ]); // Test database connection const spinner = ora('Testing database connection...').start(); try { await this.databaseManager.testConnection(answers); spinner.succeed('Database connection successful!'); await this.configManager.saveDatabase(answers); console.log(chalk.green('āœ… Database configuration saved!')); } catch (error) { spinner.fail('Database connection failed!'); console.error(chalk.red('Error:'), error.message); const retry = await inquirer.default.prompt([{ type: 'confirm', name: 'retry', message: 'Would you like to try again?', default: true }]); if (retry.retry) { await this.setupDatabase(); } } } async startServer(port = 3000) { // Check if initialized const isInitialized = await this.configManager.isInitialized(); if (!isInitialized) { console.log(chalk.yellow('UCG is not initialized. Run "ucg init" first.')); await this.init(); } this.port = port; // Store port for Swagger config this.setupExpress(); return new Promise((resolve, reject) => { this.server = this.app.listen(port, (error) => { if (error) { reject(error); } else { console.log(chalk.green(`🌟 UCG Admin Interface started at http://localhost:${port}`)); console.log(chalk.blue('Press Ctrl+C to stop the server')); resolve(); } }); }); } setupExpress() { this.app = express(); // Security middleware (relaxed for admin interface) this.app.use(helmet({ contentSecurityPolicy: false, // Disable for admin interface compatibility crossOriginEmbedderPolicy: false, crossOriginOpenerPolicy: false, crossOriginResourcePolicy: false, referrerPolicy: false })); // CORS configuration this.app.use(cors({ origin: process.env.UCG_CORS_ORIGIN || 'http://localhost:3000', credentials: true, methods: ['GET', 'POST', 'PUT', 'DELETE'], allowedHeaders: ['Content-Type', 'Authorization'] })); // Compression middleware this.app.use(compression()); // Logging middleware (only in development) if (process.env.NODE_ENV !== 'production') { this.app.use(morgan('combined')); } // Body parsing middleware this.app.use(express.json({ limit: '10mb' })); this.app.use(express.urlencoded({ extended: true, limit: '10mb' })); // Session middleware with secure configuration const sessionConfig = { secret: process.env.UCG_SESSION_SECRET || 'ucg-secret-key-change-in-production', resave: false, saveUninitialized: false, name: 'ucg.sid', cookie: { secure: process.env.NODE_ENV === 'production', httpOnly: true, maxAge: 24 * 60 * 60 * 1000, // 24 hours sameSite: 'strict' } }; this.app.use(session(sessionConfig)); // Static files with cache headers this.app.use('/public', express.static(path.join(__dirname, '../public'), { maxAge: process.env.NODE_ENV === 'production' ? '1d' : 0, etag: true })); // View engine this.app.set('view engine', 'ejs'); this.app.set('views', path.join(__dirname, '../views')); // Disable X-Powered-By header this.app.disable('x-powered-by'); // Trust proxy if behind reverse proxy if (process.env.NODE_ENV === 'production') { this.app.set('trust proxy', 1); } // Routes this.setupRoutes(); // Auto-mount existing generated routes this.mountExistingGeneratedRoutes(); // Global error handler this.app.use(this.globalErrorHandler.bind(this)); } setupRoutes() { // Authentication middleware const requireAuth = (req, res, next) => { if (!req.session.user) { return res.redirect('/login'); } next(); }; // Login routes this.app.get('/login', (req, res) => { if (req.session.user) { return res.redirect('/'); } res.render('login', { error: null }); }); this.app.post('/login', async(req, res) => { try { const { username, password } = req.body; const isValid = await this.configManager.validateUser(username, password); if (isValid) { req.session.user = username; res.redirect('/'); } else { res.render('login', { error: 'Invalid credentials' }); } } catch (error) { res.render('login', { error: 'Login failed' }); } }); this.app.post('/logout', (req, res) => { req.session.destroy(); res.redirect('/login'); }); // Health check endpoint for the admin interface this.app.get('/health', (req, res) => { res.json({ status: 'OK', service: 'UCG Admin Interface', version: require('../package.json').version, timestamp: new Date().toISOString(), uptime: process.uptime(), session: !!req.session.user, environment: process.env.NODE_ENV || 'development' }); }); // API status endpoint this.app.get('/api/status', (req, res) => { res.json({ success: true, data: { initialized: true, databaseConnected: !!this.databaseManager, version: require('../package.json').version, nodeVersion: process.version, timestamp: new Date().toISOString() } }); }); // Dashboard this.app.get('/', requireAuth, async(req, res) => { try { const dbConfig = await this.configManager.getDatabase(); const tables = await this.databaseManager.getTables(); res.render('dashboard', { user: req.session.user, dbConfig, tables: tables || [], locals: { page: 'dashboard' } }); } catch (error) { res.render('dashboard', { user: req.session.user, dbConfig: null, tables: [], error: 'Failed to connect to database', locals: { page: 'dashboard' } }); } }); // Model generation this.app.get('/generate/model', requireAuth, async(req, res) => { try { const tables = await this.databaseManager.getTables(); res.render('generate-model', { user: req.session.user, tables: tables || [], locals: { page: 'generate-model' } }); } catch (error) { res.render('generate-model', { user: req.session.user, tables: [], error: 'Failed to fetch tables', locals: { page: 'generate-model' } }); } }); this.app.post('/generate/model', requireAuth, async(req, res) => { try { const { tableName, modelName, outputPath, preview } = req.body; if (preview === 'true') { const previewData = await this.modelGenerator.preview(tableName, modelName); res.json({ success: true, preview: previewData }); } else { const result = await this.modelGenerator.generate(tableName, modelName, outputPath); await this.fileManager.logGeneration('model', result); res.json({ success: true, result }); } } catch (error) { res.json({ success: false, error: error.message }); } }); // CRUD generation this.app.get('/generate/crud', requireAuth, async(req, res) => { try { const models = await this.fileManager.getGeneratedModels(); res.render('generate-crud', { user: req.session.user, models: models || [], locals: { page: 'generate-crud' } }); } catch (error) { res.render('generate-crud', { user: req.session.user, models: [], error: 'Failed to fetch models', locals: { page: 'generate-crud' } }); } }); this.app.post('/generate/crud', requireAuth, async(req, res) => { try { const { modelName, operations, outputPath, preview } = req.body; // Parse operations from different formats let parsedOperations; if (typeof operations === 'string') { try { if (operations.startsWith('[') || operations.startsWith('{')) { parsedOperations = JSON.parse(operations); } else { parsedOperations = operations.split(',').map(op => op.trim()); } } catch (e) { // If JSON parsing fails, try comma-separated parsedOperations = operations.split(',').map(op => op.trim()); } } else if (Array.isArray(operations)) { parsedOperations = operations; } else { parsedOperations = ['create', 'read', 'update', 'delete']; } if (preview === 'true') { const previewData = await this.crudGenerator.preview(modelName, parsedOperations); res.json({ success: true, preview: previewData }); } else { const result = await this.crudGenerator.generate(modelName, parsedOperations, outputPath); await this.fileManager.logGeneration('crud', result); res.json({ success: true, result }); } } catch (error) { res.json({ success: false, error: error.message }); } }); // Settings this.app.get('/settings', requireAuth, async(req, res) => { try { const dbConfig = await this.configManager.getDatabase(); res.render('settings', { user: req.session.user, dbConfig, locals: { page: 'settings' } }); } catch (error) { res.render('settings', { user: req.session.user, dbConfig: null, error: 'Failed to load configuration', locals: { page: 'settings' } }); } }); this.app.post('/settings/database', requireAuth, async(req, res) => { try { const dbConfig = req.body; // Ensure database type is included (default to postgres for backward compatibility) if (!dbConfig.type) { dbConfig.type = 'postgres'; } await this.databaseManager.testConnection(dbConfig); await this.configManager.saveDatabase(dbConfig); res.json({ success: true, message: 'Database configuration updated successfully' }); } catch (error) { res.json({ success: false, error: error.message }); } }); // Swagger documentation route this.setupSwaggerDocs(); } setupSwaggerDocs() { // Swagger configuration const swaggerOptions = { definition: { openapi: '3.0.0', info: { title: 'UCG Generated API Documentation', version: '1.0.0', description: 'API documentation for models and CRUD operations generated by UCG (Universal CRUD Generator)', contact: { name: 'UCG - Universal CRUD Generator', url: 'https://github.com/your-repo/ucg' } }, servers: [ { url: `http://localhost:${this.port || 3000}`, description: 'Development server' } ], components: { schemas: {}, securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' } } } }, apis: [] // We'll scan for generated swagger files dynamically }; // Scan for generated Swagger files const swaggerFiles = this.findSwaggerFiles(); if (swaggerFiles.length > 0) { swaggerOptions.apis = swaggerFiles; } try { const swaggerSpec = swaggerJsdoc(swaggerOptions); // Serve the swagger JSON at /docs/swagger.json FIRST (before Swagger UI) this.app.get('/docs/swagger.json', (req, res) => { res.setHeader('Content-Type', 'application/json'); res.send(swaggerSpec); }); // Serve swagger UI at /docs this.app.use('/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, { customSiteTitle: 'UCG API Documentation', customCss: ` .topbar-wrapper img { content: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBmaWxsPSIjMDA3YWNjIiBkPSJNMTIgMmMtNS41MiAwLTEwIDQuNDgtMTAgMTBzNC40OCAxMCAxMCAxMCAxMC00LjQ4IDEwLTEwUzE3LjUyIDIgMTIgMnptMCAxOGMtNC40MSAwLTgtMy41OS04LThzMy41OS04IDgtOCA4IDMuNTkgOCA4LTMuNTkgOC04IDh6Ii8+PHBhdGggZmlsbD0iIzAwN2FjYyIgZD0iTTEyIDZjLTMuMzEgMC02IDIuNjktNiA2czIuNjkgNiA2IDYgNi0yLjY5IDYtNi0yLjY5LTYtNi02em0wIDEwYy0yLjIxIDAtNC0xLjc5LTQtNHMxLjc5LTQgNC00IDQgMS43OSA0IDQtMS43OSA0LTQgNHoiLz48L3N2Zz4='); } .topbar { background-color: #1e40af; } .swagger-ui .topbar .download-url-wrapper { display: none; } `, swaggerOptions: { defaultModelsExpandDepth: 2, defaultModelExpandDepth: 2, docExpansion: 'list', filter: true, showRequestHeaders: true } })); console.log(chalk.green('šŸ“š Swagger documentation available at /docs')); } catch (error) { console.warn(chalk.yellow('āš ļø Swagger documentation setup failed:'), error.message); } } findSwaggerFiles() { const swaggerFiles = []; const searchPaths = [ path.join(process.cwd(), 'src/docs/swagger'), path.join(process.cwd(), 'docs/swagger'), path.join(process.cwd(), 'swagger') ]; for (const searchPath of searchPaths) { try { if (fs.existsSync(searchPath)) { const files = fs.readdirSync(searchPath); const jsFiles = files.filter(file => file.endsWith('.js') || file.endsWith('.swagger.js')); for (const file of jsFiles) { const fullPath = path.join(searchPath, file); swaggerFiles.push(fullPath); } } } catch (error) { // Ignore errors for non-existent directories } } return swaggerFiles; } async generateModel(options) { if (!options.table) { const tables = await this.databaseManager.getTables(); const { table } = await inquirer.default.prompt([{ type: 'list', name: 'table', message: 'Select table to generate model for:', choices: tables }]); options.table = table; } const result = await this.modelGenerator.generate(options.table, null, options.output); await this.fileManager.logGeneration('model', result); console.log(chalk.green('āœ… Model generated successfully!')); return result; } async generateCrud(options) { if (!options.model) { const modelsData = await this.fileManager.getGeneratedModels(); const modelChoices = modelsData.map(model => model.name); const { model } = await inquirer.default.prompt([{ type: 'list', name: 'model', message: 'Select model to generate CRUD for:', choices: modelChoices }]); options.model = model; } const result = await this.crudGenerator.generate(options.model, ['create', 'read', 'update', 'delete'], options.output); await this.fileManager.logGeneration('crud', result); console.log(chalk.green('āœ… CRUD generated successfully!')); return result; } async preview() { console.log(chalk.blue('šŸ” Preview functionality - use the web interface for better experience')); console.log(chalk.yellow('Run "ucg start" to access the preview feature')); } async rollback() { const generations = await this.fileManager.getGenerationHistory(); if (generations.length === 0) { console.log(chalk.yellow('No generations found to rollback')); return; } const { generation } = await inquirer.default.prompt([{ type: 'list', name: 'generation', message: 'Select generation to rollback:', choices: generations.map(gen => ({ name: `${gen.type} - ${gen.timestamp} - ${gen.files.length} files`, value: gen })) }]); const { confirm } = await inquirer.default.prompt([{ type: 'confirm', name: 'confirm', message: `Are you sure you want to rollback ${generation.files.length} files?`, default: false }]); if (confirm) { await this.fileManager.rollback(generation); console.log(chalk.green('āœ… Rollback completed successfully!')); } } async stop() { if (this.server) { this.server.close(); console.log(chalk.blue('šŸ›‘ UCG server stopped')); } } // Global error handler for the web interface globalErrorHandler(err, req, res, next) { // Log error console.error(`[${new Date().toISOString()}] Error:`, err); // Don't log during tests if (process.env.NODE_ENV !== 'test') { console.error(err.stack); } // Handle specific error types if (err.name === 'ValidationError') { return res.status(400).json({ success: false, message: 'Validation Error', errors: Object.values(err.errors || {}).map(e => e.message) }); } if (err.code === 'ECONNREFUSED') { return res.status(503).json({ success: false, message: 'Database connection failed' }); } // Check if it's an AJAX request if (req.xhr || req.get('Content-Type') === 'application/json') { return res.status(err.status || 500).json({ success: false, message: err.message || 'Internal Server Error', ...(process.env.NODE_ENV === 'development' && { stack: err.stack }) }); } // For HTML requests, render error page res.status(err.status || 500).render('error', { error: { status: err.status || 500, message: err.message || 'Internal Server Error', stack: process.env.NODE_ENV === 'development' ? err.stack : null }, user: req.session?.user || null }); } // Method to start web interface with better error handling and options async startWebInterface(port = 3000, options = {}) { try { // Check if initialized const isInitialized = await this.configManager.isInitialized(); if (!isInitialized && !options.skipInitCheck) { console.log(chalk.yellow('UCG is not initialized. Run "ucg init" first.')); if (options.autoInit) { await this.init(); } else { throw new Error('UCG not initialized'); } } this.port = port; // Store port for Swagger config this.setupExpress(); return new Promise((resolve, reject) => { this.server = this.app.listen(port, (error) => { if (error) { reject(error); } else { const actualPort = this.server.address().port; console.log(chalk.green(`🌟 UCG Admin Interface started at http://localhost:${actualPort}`)); console.log(chalk.blue('Press Ctrl+C to stop the server')); // Graceful shutdown handling this.setupGracefulShutdown(); resolve({ port: actualPort, server: this.server }); } }); }); } catch (error) { console.error(chalk.red('Failed to start web interface:'), error.message); throw error; } } // Setup graceful shutdown setupGracefulShutdown() { const shutdown = async(signal) => { console.log(chalk.yellow(`\nšŸ“” Received ${signal}. Starting graceful shutdown...`)); if (this.server) { this.server.close(async() => { console.log(chalk.blue('šŸ›‘ HTTP server closed')); // Close database connections if any try { if (this.databaseManager && this.databaseManager.disconnect) { await this.databaseManager.disconnect(); console.log(chalk.blue('šŸ“” Database connections closed')); } } catch (error) { console.error(chalk.red('Error closing database connections:'), error.message); } console.log(chalk.green('āœ… Graceful shutdown complete')); process.exit(0); }); } else { process.exit(0); } }; process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT')); } /** * Mount existing generated routes for standalone UCG server */ async mountExistingGeneratedRoutes() { const projectRoot = process.cwd(); const srcRoutesPath = path.join(projectRoot, 'src', 'routes'); try { // Check if routes directory exists if (!fs.existsSync(srcRoutesPath)) { return; } // Find all route files const routeFiles = fs.readdirSync(srcRoutesPath) .filter(file => file.endsWith('Routes.js') || (file.endsWith('.js') && file !== 'index.js')); if (routeFiles.length === 0) { return; } console.log(chalk.blue(`šŸ”„ Mounting ${routeFiles.length} existing generated route(s)...`)); for (const routeFile of routeFiles) { try { const routeFilePath = path.join(srcRoutesPath, routeFile); // Clear require cache for hot reloading delete require.cache[routeFilePath]; const routeModule = require(routeFilePath); // Extract route name for mounting (convert to kebab-case to match Swagger docs) const routeBaseName = path.basename(routeFile, '.js') .replace('Routes', '') .replace('routes', ''); const routeName = this.convertToKebabCase(routeBaseName); const mountPath = `/api/${routeName}`; // Mount the route in the application this.app.use(mountPath, routeModule); console.log(chalk.green(`āœ… Mounted route: ${mountPath} from ${routeFile}`)); } catch (error) { console.warn(chalk.yellow(`āš ļø Could not mount route ${routeFile}:`), error.message); } } console.log(chalk.green(`šŸŽ‰ Successfully mounted ${routeFiles.length} API route(s)`)); console.log(chalk.cyan(` Routes are now available at http://localhost:${this.port || 3000}/api/{model-name}`)); } catch (error) { console.warn(chalk.yellow('āš ļø Could not scan for existing routes:'), error.message); } } /** * Convert camelCase/PascalCase to kebab-case for URL consistency */ convertToKebabCase(str) { return str .replace(/([a-z0-9])([A-Z])/g, '$1-$2') .toLowerCase(); } } module.exports = UCG;