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
JavaScript
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;