UNPKG

@codehance/rapid-stack

Version:

A modern full-stack development toolkit for rapid application development

1,394 lines (1,200 loc) 49.6 kB
const Generator = require('yeoman-generator'); const path = require('path'); const fs = require('fs'); const { execSync } = require('child_process'); const semver = require('semver'); const axios = require('axios'); const { handlePrompt, validateRequiredFields } = require('../../lib/utils'); const BaseGenerator = require('../base'); module.exports = class extends BaseGenerator { constructor(args, opts) { super(args, opts); // Add debug option this.option('debug', { desc: 'Enable debug mode', type: Boolean, default: false }); // Track modified files this.modifiedFiles = []; // Initialize prompting flag this._isPromptingComplete = false; } destinationPath(...paths) { // First call the parent's destinationPath to get the base path const basePath = super.destinationPath(...paths); // Only prepend project name if: // 1. We have answers (prompting phase is complete) // 2. We have a project name // 3. The path doesn't already include the project name // 4. We're not in the initial setup phase if (this.answers?.projectName && !basePath.includes(this.answers.projectName) && this._isPromptingComplete) { // Prepend the project name to the path return path.join(process.cwd(), this.answers.projectName, ...paths); } return basePath; } async prompting() { // Get the currently installed Rails version let railsVersion; try { const railsVersionOutput = execSync('rails -v').toString(); // Extract version number from output (e.g., "Rails 8.0.2" -> "8.0.2") railsVersion = railsVersionOutput.match(/Rails\s+([\d.]+)/)?.[1]; } catch (error) { this.log('Error detecting Rails version:', error.message); process.exit(1); } // Set default project name to 'backend' this.answers = { projectName: 'backend', railsVersion: railsVersion }; // Set flag indicating prompting is complete this._isPromptingComplete = true; } async checkPrerequisites() { try { // Check Ruby const rubyVersion = execSync('ruby -v').toString(); this.log('Ruby version:', rubyVersion.trim()); // Check Rails try { const railsVersion = execSync('rails -v').toString(); this.log('Rails version:', railsVersion.trim()); } catch (error) { const { installRails } = await handlePrompt(this, [{ type: 'confirm', name: 'installRails', message: `Rails is not installed. Would you like to install Rails ${this.answers.railsVersion}?`, default: true }]); if (installRails) { this.log(`Installing Rails ${this.answers.railsVersion}...`); execSync(`gem install rails -v ${this.answers.railsVersion}`); this.log(`Rails ${this.answers.railsVersion} has been installed successfully.`); } else { this.log('Rails installation skipped. Please install Rails manually before continuing.'); process.exit(1); } } // Check MongoDB try { const mongoVersion = execSync('mongod --version').toString(); this.log('MongoDB is installed:', mongoVersion.split('\n')[0].trim()); } catch (error) { this.log('Warning: MongoDB is not installed. You will need to install MongoDB before running your application.'); const { continueMongo } = await handlePrompt(this, [{ type: 'confirm', name: 'continueMongo', message: 'Would you like to continue without MongoDB?', default: false }]); if (!continueMongo) { this.log('Please install MongoDB and try again.'); process.exit(1); } } } catch (error) { this.log('Error: Ruby is not installed. Please install Ruby before continuing.'); process.exit(1); } } async configuring() { const { projectName } = this.answers; const projectPath = path.join(process.cwd(), projectName); // Check if project exists if (fs.existsSync(projectPath)) { const { action } = await handlePrompt(this, [{ type: 'list', name: 'action', message: 'Project directory already exists. What would you like to do?', choices: [ { name: 'Update existing project', value: 'update' }, { name: 'Cancel installation', value: 'cancel' } ] }]); if (action === 'cancel') { this.log('Installation cancelled.'); process.exit(0); } } } async _setupRailsProject() { const { railsVersion, projectName } = this.answers; const projectPath = path.join(process.cwd(), projectName); if (!fs.existsSync(projectPath)) { const { confirmCreate } = await handlePrompt(this, [{ type: 'confirm', name: 'confirmCreate', message: `This will create a new Rails API application in: ${projectPath}\nAre you sure you want to continue?`, default: true }]); if (!confirmCreate) { this.log('Installation cancelled.'); process.exit(0); } fs.mkdirSync(projectPath); // Set Yeoman's destination root to the project directory this.destinationRoot(projectPath); // (Optional) Also change process.cwd if needed: process.chdir(projectPath); // Create new Rails API application const command = [ `rails _${railsVersion}_ new .`, '--api', '--skip-active-record', '--skip-test', '--skip-system-test', '--skip-bundle', '--skip-git' ].join(' '); execSync(command, { stdio: 'inherit' }); this.log('✓ Created new Rails API application'); } else { // Update destination root to the existing project this.destinationRoot(projectPath); this.log('Using existing Rails application'); } } async _setupGemfile() { const { railsVersion } = this.answers; try { const { confirmGemfile } = await handlePrompt(this, [{ type: 'confirm', name: 'confirmGemfile', message: 'Would you like to set up the Gemfile?', default: true }]); if (confirmGemfile) { // Helper function to fetch gem version from RubyGems API const getGemVersion = async (gemName) => { try { const response = await axios.get(`https://rubygems.org/api/v1/versions/${gemName}.json`); const versions = response.data; // Filter versions that are compatible with our Rails version const compatibleVersions = versions .filter(v => { // Skip pre-release versions if (v.number.includes('rc') || v.number.includes('beta') || v.number.includes('alpha') || v.number.includes('pre')) { return false; } // Check if the version has a Rails dependency const railsDependency = v.dependencies?.runtime?.find(d => d.name === 'rails'); if (!railsDependency) return true; // If no Rails dependency, assume compatible // Parse the Rails requirement const requirement = railsDependency.requirements; return semver.satisfies(railsVersion, requirement); }) .map(v => { // Clean up version number to ensure it's semantic const cleanVersion = v.number .replace(/\.a\d+$/, '') // Remove alpha versions .replace(/\.b\d+$/, '') // Remove beta versions .replace(/\.rc\d+$/, '') // Remove release candidates .replace(/\.pre\d+$/, '') // Remove pre-release versions .split('.') .slice(0, 3) // Only take first three parts (major.minor.patch) .join('.'); return { original: v.number, cleaned: cleanVersion }; }) .filter(v => semver.valid(v.cleaned)) // Only keep valid semantic versions .sort((a, b) => semver.compare(b.cleaned, a.cleaned)); // Sort by version, newest first if (compatibleVersions.length === 0) { this.log(`⚠️ No compatible version found for ${gemName} with Rails ${railsVersion}`); return null; } return `~> ${compatibleVersions[0].original}`; } catch (error) { this.log(`⚠️ Failed to fetch version for ${gemName}: ${error.message}`); return null; } }; // Define gem categories and their gems const gemCategories = { core: { name: 'Core Rails', gems: [ { name: 'rails', version: railsVersion }, { name: 'stringio' }, { name: 'bootsnap', options: 'require: false # Reduces boot times through caching' }, { name: 'tzinfo-data', options: 'platforms: %i[windows jruby] # Windows time zone data' } ] }, database: { name: 'Database', gems: [ { name: 'mongoid' }, { name: 'mongo', comment: '# MongoDB Ruby driver' } ] }, authentication: { name: 'Authentication', gems: [ { name: 'devise' }, { name: 'devise-jwt' }, { name: 'rotp' }, { name: 'rqrcode' } ] }, api: { name: 'API & GraphQL', gems: [ { name: 'graphql' }, { name: 'graphql-batch' }, { name: 'apollo_upload_server' }, { name: 'rack-cors' }, { name: 'email_validator' } ] }, http: { name: 'HTTP Clients', gems: [ { name: 'httparty' }, { name: 'vault' } ] }, email: { name: 'Email', gems: [ { name: 'postmark-rails' } ] }, server: { name: 'Server', gems: [ { name: 'puma' } ] }, development: { name: 'Development and Test Environment', group: ':development, :test', gems: [ { name: 'debug', options: 'platforms: %i[mri windows]' }, { name: 'rspec-rails' }, { name: 'factory_bot_rails' }, { name: 'faker' }, { name: 'database_cleaner-mongoid' }, { name: 'webmock' }, { name: 'dotenv-rails' }, { name: 'guard' }, { name: 'guard-rspec' }, { name: 'simplecov', options: 'require: false' }, { name: 'graphiql-rails' } ] }, devOnly: { name: 'Development Environment', group: ':development', gems: [ { name: 'propshaft' }, { name: 'rubocop' }, { name: 'rubocop-rails' }, { name: 'letter_opener_web' } ] } }; // Count total number of gems that need version fetching let totalGems = 0; let processedGems = 0; for (const [category, config] of Object.entries(gemCategories)) { totalGems += config.gems.filter(gem => !gem.version).length; } this.log('\nFetching gem versions...'); // Function to update progress const updateProgress = () => { processedGems++; const progress = Math.round((processedGems / totalGems) * 100); const progressBar = '█'.repeat(progress / 2) + '░'.repeat(50 - progress / 2); this.log(`\r[${progressBar}] ${progress}% - Processed ${processedGems}/${totalGems} gems`); }; // Fetch versions for all gems const gemVersions = {}; // Process each category for (const [category, config] of Object.entries(gemCategories)) { for (const gem of config.gems) { if (gem.version) { gemVersions[gem.name] = gem.version; } else { const version = await getGemVersion(gem.name); if (version) { gemVersions[gem.name] = version; } updateProgress(); } } } this.log('\nGenerating Gemfile...'); // Generate Gemfile content let gemfileContent = "source 'https://rubygems.org'\n\n"; // Process each category for (const [category, config] of Object.entries(gemCategories)) { // Add group if specified if (config.group) { gemfileContent += `group ${config.group} do\n`; } // Add category comment gemfileContent += `# ${config.name}\n`; // Add gems for (const gem of config.gems) { const version = gemVersions[gem.name] ? `"${gemVersions[gem.name]}"` : ''; const options = gem.options ? `, ${gem.options}` : ''; const comment = gem.comment ? ` ${gem.comment}` : ''; gemfileContent += ` gem '${gem.name}'${version ? `, ${version}` : ''}${options}${comment}\n`; } // Close group if opened if (config.group) { gemfileContent += 'end\n'; } gemfileContent += '\n'; } // Write the Gemfile const gemfilePath = this.destinationPath('Gemfile'); fs.writeFileSync(gemfilePath, gemfileContent); this.log(`✓ Updated Gemfile: ${this._fileLink('Gemfile')}`); } } catch (error) { this.log.error('Failed to setup Gemfile:', error); throw error; } } async _setupMongoid() { const { confirmMongoid } = await handlePrompt(this, [{ type: 'confirm', name: 'confirmMongoid', message: 'Would you like to set up MongoDB configuration?', default: true }]); if (confirmMongoid) { this._copyTemplateFile('mongoid.yml.erb', 'config/mongoid.yml', { projectName: this.answers.projectName }); this.log(`✓ Added MongoDB configuration: ${this._fileLink('config/mongoid.yml')}`); } } async _setupGraphQL() { const { confirmGraphQL } = await handlePrompt(this, [{ type: 'confirm', name: 'confirmGraphQL', message: 'Would you like to set up GraphQL API?', default: true }]); if (confirmGraphQL) { const context = { projectName: this.answers.projectName }; // Create necessary directories const directories = [ 'app/graphql/queries', 'app/graphql/concerns', 'app/graphql/mutations' ]; this.log('\nCreating directories...'); directories.forEach(dir => { const fullPath = this.destinationPath(dir); if (!fs.existsSync(fullPath)) { fs.mkdirSync(fullPath, { recursive: true }); this.log(`✓ Created directory: ${dir}`); } else { this.log(`Directory already exists: ${dir}`); } }); // Create shared GraphQL methods this._copyTemplateFile('app/graphql/shared_graphql_methods.rb.erb', 'app/graphql/concerns/shared_graphql_methods.rb', {}, { force: true }); this._copyTemplateFile('app/graphql/mutations/base_mutation.rb.erb', 'app/graphql/mutations/base_mutation.rb', {}, { force: true }); this._copyTemplateFile('app/graphql/queries/base_query.rb.erb', 'app/graphql/queries/base_query.rb', {}, { force: true }); this._copyTemplateFile( 'app/controllers/graphql_controller.rb.erb', 'app/controllers/graphql_controller.rb', { projectName: this.answers.projectName }, { force: true } ); this._copyTemplateFile('app/graphql/types/query_type.rb.erb', 'app/graphql/types/query_type.rb', context, { force: true }); this._copyTemplateFile('app/graphql/types/mutation_type.rb.erb', 'app/graphql/types/mutation_type.rb', context, { force: true }); } } async _setupRoutes() { const { confirmRoutes } = await handlePrompt(this, [{ type: 'confirm', name: 'confirmRoutes', message: 'Would you like to set up the routes configuration?', default: true }]); if (confirmRoutes) { this._copyTemplateFile('config/routes.rb.erb', 'config/routes.rb', {}); this.log(`✓ Updated routes configuration: ${this._fileLink('config/routes.rb')}`); } } async _setupCORS() { const { confirmCORS } = await handlePrompt(this, [{ type: 'confirm', name: 'confirmCORS', message: 'Would you like to set up CORS configuration?', default: true }]); if (confirmCORS) { this._copyTemplateFile('config/initializers/cors.rb.erb', 'config/initializers/cors.rb', {}); this.log(`✓ Added CORS configuration: ${this._fileLink('config/initializers/cors.rb')}`); } } async _setupJWTHelper() { const { confirmJWTHelper } = await handlePrompt(this, [{ type: 'confirm', name: 'confirmJWTHelper', message: 'Would you like to set up JWT test helper?', default: true }]); if (confirmJWTHelper) { try { // Ensure the support directory exists const supportDir = this.destinationPath('spec/support'); if (!fs.existsSync(supportDir)) { fs.mkdirSync(supportDir, { recursive: true }); } // Create JWT helper this._copyTemplateFile('spec/support/jwt_helper.rb.erb', 'spec/support/jwt_helper.rb', {}); this.log(`✓ Created JWT test helper: ${this._fileLink('spec/support/jwt_helper.rb')}`); } catch (error) { this.log('Error setting up JWT helper:', error.message); if (this.options.debug) { this.log('Stack trace:', error.stack); } // Don't exit the process, just log the error and continue } } } async _setupRSpec() { const { confirmRSpec } = await handlePrompt(this, [{ type: 'confirm', name: 'confirmRSpec', message: 'Would you like to set up RSpec for testing?', default: true }]); if (confirmRSpec) { try { // Create RSpec directories this.log('\nCreating RSpec directories...'); const specDirs = [ 'spec/models', 'spec/controllers', 'spec/requests', 'spec/support', 'spec/factories' ]; specDirs.forEach(dir => { const fullPath = this.destinationPath(dir); if (!fs.existsSync(fullPath)) { fs.mkdirSync(fullPath, { recursive: true }); this.log(`✓ Created directory: ${dir}`); } else { this.log(`Directory already exists: ${dir}`); } }); // Update spec_helper.rb with SimpleCov configuration this._copyTemplateFile('spec/spec_helper.rb.erb', 'spec/spec_helper.rb', {}); this.log('✓ Updated spec_helper.rb with SimpleCov configuration'); // Update rails_helper.rb with custom configuration this._copyTemplateFile('spec/rails_helper.rb.erb', 'spec/rails_helper.rb', {}); this.log('✓ Updated rails_helper.rb with custom configuration'); // Set up JWT helper await this._setupJWTHelper(); } catch (error) { this.log('Error setting up RSpec:', error.message); if (this.options.debug) { this.log('Stack trace:', error.stack); } // Don't exit the process, just log the error and continue } } } async _setupGraphQLHelper() { const { confirmGraphQLHelper } = await handlePrompt(this, [{ type: 'confirm', name: 'confirmGraphQLHelper', message: 'Would you like to set up GraphQL test helper?', default: true }]); if (confirmGraphQLHelper) { try { // Ensure the support directory exists const supportDir = this.destinationPath('spec/support'); if (!fs.existsSync(supportDir)) { fs.mkdirSync(supportDir, { recursive: true }); } // Create GraphQL helper this._copyTemplateFile('spec/support/graphql_helper.rb.erb', 'spec/support/graphql_helper.rb', { projectName: this.answers.projectName }); this.log(`✓ Created GraphQL test helper: ${this._fileLink('spec/support/graphql_helper.rb')}`); } catch (error) { this.log('Error setting up GraphQL helper:', error.message); if (this.options.debug) { this.log('Stack trace:', error.stack); } // Don't exit the process, just log the error and continue } } } async _setupMailers() { const { confirmMailers } = await handlePrompt(this, [{ type: 'confirm', name: 'confirmMailers', message: 'Would you like to set up email templates and mailers?', default: true }]); if (confirmMailers) { try { // Ensure the mailers directory exists const mailersPath = this.destinationPath('app/mailers'); if (!fs.existsSync(mailersPath)) { fs.mkdirSync(mailersPath, { recursive: true }); } // Create application mailer this._copyTemplateFile('app/mailers/application_mailer.rb.erb', 'app/mailers/application_mailer.rb', {}); this.log(`✓ Created application mailer: ${this._fileLink('app/mailers/application_mailer.rb')}`); // Create user mailer this._copyTemplateFile('app/mailers/user_mailer.rb.erb', 'app/mailers/user_mailer.rb', {}); this.log(`✓ Created user mailer: ${this._fileLink('app/mailers/user_mailer.rb')}`); // Create OTP email templates // Ensure the views directory exists const viewsPath = this.destinationPath('app/views/user_mailer'); if (!fs.existsSync(viewsPath)) { fs.mkdirSync(viewsPath, { recursive: true }); } // Create HTML template this.fs.copy( this.templatePath('app/views/user_mailer/otp_email.html.erb'), this.destinationPath('app/views/user_mailer/otp_email.html.erb') ); this.log(`✓ Created HTML email template: ${this._fileLink('app/views/user_mailer/otp_email.html.erb')}`); // Create text template this.fs.copy( this.templatePath('app/views/user_mailer/otp_email.text.erb'), this.destinationPath('app/views/user_mailer/otp_email.text.erb') ); this.log(`✓ Created text email template: ${this._fileLink('app/views/user_mailer/otp_email.text.erb')}`); // Force write to disk immediately this.fs.commit(() => { this._debugLog('Email templates copied successfully'); }); // Update development.rb with mailer configurations const devConfigPath = this.destinationPath('config/environments/development.rb'); if (fs.existsSync(devConfigPath)) { let content = fs.readFileSync(devConfigPath, 'utf8'); // Define the mailer configurations we want to set const mailerConfigs = [ { key: 'config.action_mailer.delivery_method', value: ':letter_opener_web' }, { key: 'config.action_mailer.perform_deliveries', value: 'true' }, { key: 'config.action_mailer.raise_delivery_errors', value: 'true' } ]; // Process each configuration mailerConfigs.forEach(config => { const regex = new RegExp(`^\\s*${config.key}\\s*=\\s*[^\\n]+$`, 'm'); const newLine = ` ${config.key} = ${config.value}`; if (content.match(regex)) { // Replace existing configuration content = content.replace(regex, newLine); } else { // Add new configuration after the first config.action_mailer line const insertRegex = /^(\s*config\.action_mailer\.[^\n]+)$/m; content = content.replace(insertRegex, `$1\n${newLine}`); } }); // Write the updated content back to the file fs.writeFileSync(devConfigPath, content, 'utf8'); this.log(`✓ Updated mailer configurations in development.rb`); } } catch (error) { this.log('Error setting up mailers:', error.message); if (this.options.debug) { this.log('Stack trace:', error.stack); } // Don't exit the process, just log the error and continue } } } async _setupGuard() { const { confirmGuard } = await handlePrompt(this, [{ type: 'confirm', name: 'confirmGuard', message: 'Would you like to set up Guard for automated testing?', default: true }]); if (confirmGuard) { try { // Run Guard initialization execSync('bundle exec guard init rspec', { stdio: 'inherit' }); this.log('✓ Initialized Guard'); // Create custom Guardfile this._copyTemplateFile('Guardfile.erb', 'Guardfile', {}); this.log(`✓ Created custom Guardfile: ${this._fileLink('Guardfile')}`); } catch (error) { this.log('Error setting up Guard:', error.message); if (this.options.debug) { this.log('Stack trace:', error.stack); } // Don't exit the process, just log the error and continue } } } async _setupGitIgnore() { this._copyTemplateFile('.gitignore.erb', '.gitignore', {}); this.log(`✓ Created .gitignore: ${this._fileLink('.gitignore')}`); } async _setupServices() { const { confirmServices } = await handlePrompt(this, [{ type: 'confirm', name: 'confirmServices', message: 'Would you like to set up services?', default: true }]); if (confirmServices) { try { // Create services directory const servicesPath = this.destinationPath('app/services'); if (!fs.existsSync(servicesPath)) { fs.mkdirSync(servicesPath, { recursive: true }); } // Create concerns directory const concernsPath = this.destinationPath('app/services/concerns'); if (!fs.existsSync(concernsPath)) { fs.mkdirSync(concernsPath, { recursive: true }); } this._copyTemplateFile('app/services/concerns/service_response.rb.erb', 'app/services/concerns/service_response.rb', {}); this._copyTemplateFile('app/services/concerns/paginatable.rb.erb', 'app/services/concerns/paginatable.rb', {}); this._copyTemplateFile('app/services/permission_checker.rb.erb', 'app/services/permission_checker.rb', {}); this.log('\nCreated services:'); this.log(`✓ Created permission checker: ${this._fileLink('app/services/permission_checker.rb')}`); this.log(`✓ Created service response: ${this._fileLink('app/services/concerns/service_response.rb')}`); this.log(`✓ Created paginatable: ${this._fileLink('app/services/concerns/paginatable.rb')}`); } catch (error) { this.log('Error setting up services:', error.message); if (this.options.debug) { this.log('Stack trace:', error.stack); } } } } async _generateGraphQL() { const { confirmGenerateGraphQL } = await handlePrompt(this, [{ type: 'confirm', name: 'confirmGenerateGraphQL', message: 'Would you like to generate GraphQL components?', default: true }]); if (confirmGenerateGraphQL) { try { // cd into the project directory process.chdir(this.destinationPath()); this.log(`✓ Changed directory to: ${this.destinationPath()}`); // Run GraphQL generator with force flag to skip confirmations this.log('\nRunning GraphQL generator...'); execSync('bundle exec rails generate graphql:install --skip-graphiql --skip-active-record --force', { stdio: 'inherit' }); // Wait a moment to ensure files are written execSync('sleep 1'); // Now set up our custom GraphQL files await this._setupGraphQL(true); // Pass true to force overwrite this.log('✓ Installed and configured GraphQL'); } catch (error) { this.log('\n=== Error in _generateGraphQL ==='); this.log(`Error Message: ${error.message}`); this.log(`Stack Trace: ${error.stack}`); } } } async _setupGraphQL(forceOverwrite = false) { // If called directly (not from _generateGraphQL), ask for confirmation let shouldProceed = forceOverwrite; if (!forceOverwrite) { const { confirmGraphQL } = await handlePrompt(this, [{ type: 'confirm', name: 'confirmGraphQL', message: 'Would you like to set up GraphQL API?', default: true }]); shouldProceed = confirmGraphQL; } if (shouldProceed) { const context = { projectName: this.answers.projectName }; // Create necessary directories const directories = [ 'app/graphql/queries', 'app/graphql/concerns', 'app/graphql/mutations' ]; this.log('\nCreating directories...'); directories.forEach(dir => { const fullPath = this.destinationPath(dir); if (!fs.existsSync(fullPath)) { fs.mkdirSync(fullPath, { recursive: true }); this.log(`✓ Created directory: ${dir}`); } }); // Always force overwrite when called from _generateGraphQL const options = { force: forceOverwrite }; // Create shared GraphQL methods this._copyTemplateFile( 'app/graphql/shared_graphql_methods.rb.erb', 'app/graphql/concerns/shared_graphql_methods.rb', {}, options ); this._copyTemplateFile( 'app/graphql/mutations/base_mutation.rb.erb', 'app/graphql/mutations/base_mutation.rb', {}, options ); this._copyTemplateFile( 'app/graphql/queries/base_query.rb.erb', 'app/graphql/queries/base_query.rb', {}, options ); this._copyTemplateFile( 'app/controllers/graphql_controller.rb.erb', 'app/controllers/graphql_controller.rb', { projectName: this.answers.projectName }, options ); this._copyTemplateFile( 'app/graphql/types/query_type.rb.erb', 'app/graphql/types/query_type.rb', context, options ); this._copyTemplateFile( 'app/graphql/types/mutation_type.rb.erb', 'app/graphql/types/mutation_type.rb', context, options ); if (!forceOverwrite) { this.log(`✓ Set up GraphQL API with custom controllers and types`); } } } async _removeMasterKey() { this.log('\nRemoving master key...'); const { confirmRemoveMasterKey } = await handlePrompt(this, [{ type: 'confirm', name: 'confirmRemoveMasterKey', message: 'Would you like to remove the master key?', default: true }]); if (confirmRemoveMasterKey) { try { const masterKeyPath = this.destinationPath('config/master.key'); const credentialsPath = this.destinationPath('config/credentials.yml.enc'); // Remove master.key if it exists if (fs.existsSync(masterKeyPath)) { fs.unlinkSync(masterKeyPath); this._debugLog('Removed master.key file'); } // Remove credentials.yml.enc if it exists if (fs.existsSync(credentialsPath)) { fs.unlinkSync(credentialsPath); this._debugLog('Removed credentials.yml.enc file'); } } catch (error) { this.log('Error removing master key:', error.message); } } } async _setUpEnv() { const { confirmEnv } = await handlePrompt(this, [{ type: 'confirm', name: 'confirmEnv', message: 'Would you like to set up the .env file?', default: true }]); if (confirmEnv) { try { this.log('\nSetting up .env file...'); // Read the template file const envTemplate = fs.readFileSync(this.templatePath('env.development.erb'), 'utf8'); // Replace project name in the template const envContent = envTemplate.replace(/<%= this.answers?.projectName %>/g, this.answers?.projectName); // Write the .env.sample file fs.writeFileSync('.env.sample', envContent); this.log(`✓ .env.sample file created: ${this._fileLink('.env.sample')}`); } catch (error) { this.log('Error setting up .env file:', error.message); } } } async _setUpConfigHelper() { const { confirmConfigHelper } = await handlePrompt(this, [{ type: 'confirm', name: 'confirmConfigHelper', message: 'Would you like to set up the config helper?', default: true }]); if (confirmConfigHelper) { this.log('\nSetting up config helper...'); this._copyTemplateFile('lib/config_helper.rb.erb', 'lib/config_helper.rb', {}); this.log(`✓ Config helper created: ${this._fileLink('lib/config_helper.rb')}`); } } async _updateApplicationConfig() { const { confirmUpdateApplicationConfig } = await handlePrompt(this, [{ type: 'confirm', name: 'confirmUpdateApplicationConfig', message: 'Would you like to update the application config?', default: true }]); if (confirmUpdateApplicationConfig) { this.log('\nUpdating application config...'); const railsVersion = this.answers.railsVersion; const projectName = this.answers.projectName; const majorMinorVersion = railsVersion.split('.').slice(0, 2).join('.'); // Ensure the directory exists if (!fs.existsSync('config')) { this._debugLog('Creating config directory...'); fs.mkdirSync('config', { recursive: true }); } // Copy the template with proper context this._copyTemplateFile( 'config/application.rb.erb', 'config/application.rb', { projectName: projectName, railsVersion: railsVersion } ); this.log(`✓ Application config updated: ${this._fileLink('config/application.rb')}`); } } async _removeActiveRecordAppConfig() { const { confirmRemoveActiveRecordAppConfig } = await handlePrompt(this, [{ type: 'confirm', name: 'confirmRemoveActiveRecordAppConfig', message: 'Would you like to remove the Active Record app config?', default: true }]); if (confirmRemoveActiveRecordAppConfig) { // Now remove the ActiveRecord query log block from the file let appConfigUpdated = fs.readFileSync(this.destinationPath('config/application.rb'), 'utf8'); appConfigUpdated = appConfigUpdated.replace( /^\s*config\.active_record\.query_log_tags_enabled\s*=\s*true\s*\n\s*config\.active_record\.query_log_tags\s*=\s*\[[\s\S]*?\]\s*\n/m, '' ); fs.writeFileSync(this.destinationPath('config/application.rb'), appConfigUpdated, 'utf8'); this.log(`✓ Active Record app config removed: ${this._fileLink('config/application.rb')}`); } } async _setupDockerfile() { const { confirmDockerfile } = await handlePrompt(this, [{ type: 'confirm', name: 'confirmDockerfile', message: 'Would you like to set up the Dockerfile?', default: true }]); if (confirmDockerfile) { this._copyTemplateFile('Dockerfile.erb', 'Dockerfile', {}); this.log(`✓ Dockerfile created: ${this._fileLink('Dockerfile')}`); } } async _setupComposefiles() { const { confirmComposefiles } = await handlePrompt(this, [{ type: 'confirm', name: 'confirmComposefiles', message: 'Would you like to set up the compose files?', default: true }]); if (confirmComposefiles) { this._copyTemplateFile('docker-compose.prod.yml.erb', 'docker-compose.prod.yml', {}); this.log(`✓ Compose files created: ${this._fileLink('docker-compose.yml')}`); } } async _setupEnvFile() { try { // Read the template file const envTemplate = fs.readFileSync(this.templatePath('env.erb'), 'utf8'); // Generate secure random values const jwtSecretKey = execSync('openssl rand -hex 64').toString().trim(); const secretKeyBase = execSync('openssl rand -hex 64').toString().trim(); const railsMasterKey = execSync('openssl rand -hex 16').toString().trim(); // Replace the placeholders with generated values const envContent = envTemplate .replace('JWT_SECRET_KEY=', `JWT_SECRET_KEY=${jwtSecretKey}`) .replace('SECRET_KEY_BASE=', `SECRET_KEY_BASE=${secretKeyBase}`) .replace('RAILS_MASTER_KEY=', `RAILS_MASTER_KEY=${railsMasterKey}`); // Write the .env file const envPath = path.join(this.destinationPath(), '.env'); fs.writeFileSync(envPath, envContent); this.log(`✓ Created .env file with secure keys: ${this._fileLink('.env')}`); } catch (error) { this.log('Error setting up .env file:', error.message); if (this.options.debug) { this.log('Stack trace:', error.stack); } } } async install() { const { projectName } = this.answers; const projectPath = path.join(process.cwd(), projectName); try { validateRequiredFields(['config.app_name']); // Set up Rails project await this._setupRailsProject(); // Set up Gemfile await this._setupGemfile(); // Set up Mongoid await this._setupMongoid(); // Set up GraphQL await this._setupGraphQL(); // Set up Routes await this._setupRoutes(); // Set up CORS await this._setupCORS(); // Set up RSpec await this._setupRSpec(); // Set up GraphQL Helper await this._setupGraphQLHelper(); // Set up JWT Helper await this._setupJWTHelper(); // Set up Mailers await this._setupMailers(); // Install dependencies await this._bundleInstall(); // Set up Guard await this._setupGuard(); // Set up services await this._setupServices(); // Remove master key await this._removeMasterKey(); // Set up .env file await this._setUpEnv(); // Set up config helper await this._setUpConfigHelper(); // Update application config await this._updateApplicationConfig(); // Generate GraphQL components await this._generateGraphQL(); // Update application config await this._updateApplicationConfig(); // Remove Active Record app config await this._removeActiveRecordAppConfig(); // Set up Dockerfile await this._setupDockerfile(); // Set up compose files await this._setupComposefiles(); // Set up environment file await this._setupEnvFile(); // Set up gitignore await this._setupGitIgnore(); this._printSummary(projectPath, projectName); } catch (error) { this.log('Error during installation:', error.message); if (this.options.debug) { this.log('Stack trace:', error.stack); } process.exit(1); } } async _bundleInstall() { // make sure we are in the project directory process.chdir(this.destinationPath()); // Install dependencies const { runBundleInstall } = await handlePrompt(this, [{ type: 'confirm', name: 'runBundleInstall', message: 'Would you like to run bundle install now?', default: true }]); if (runBundleInstall) { this.log('Installing dependencies...'); execSync('bundle install', { stdio: 'inherit' }); this.log(`✓ Dependencies installed: ${this._fileLink('Gemfile.lock')}`); // Remove existing devise.rb if it exists const devisePath = this.destinationPath('config/initializers/devise.rb'); if (fs.existsSync(devisePath)) { fs.unlinkSync(devisePath); } // bundle exec rails g devise:install execSync('bundle exec rails g devise:install --f', { stdio: 'inherit' }); this.log(`✓ Devise installed: ${this._fileLink('config/initializers/devise.rb')}`); } } async _copyTemplateFile(templatePath, destinationPath, context = {}, options = { force: true }) { try { // Get the full template path const templateFullPath = this.templatePath(templatePath); const src = Array.isArray(templateFullPath) ? templateFullPath[0] : templateFullPath; // Get initial destination path let destinationFullPath = this.destinationPath(destinationPath); // Get the current working directory and project paths const cwd = process.cwd(); const projectPath = path.join(cwd, this.answers.projectName); // Check if the path is outside the project directory AND doesn't already contain project name if (!destinationFullPath.startsWith(projectPath) && !destinationFullPath.includes(`/${this.answers.projectName}/`)) { // Remove any existing project name to prevent duplication const pathWithoutProject = destinationFullPath.replace(cwd, '').replace(/^\//, ''); destinationFullPath = path.join(projectPath, pathWithoutProject); } // Check if file exists and handle overwrite if (fs.existsSync(destinationFullPath) && !options.force) { const { shouldOverwrite } = await handlePrompt(this, [{ type: 'confirm', name: 'shouldOverwrite', message: `File ${destinationPath} already exists. Would you like to overwrite it?`, default: false }]); if (!shouldOverwrite) { this.log(`⏭️ Skipping ${destinationPath} (file already exists)`); return; } } // Ensure destination directory exists const destDir = path.dirname(destinationFullPath); if (!fs.existsSync(destDir)) { fs.mkdirSync(destDir, { recursive: true }); } // Delete the existing file if it exists to prevent merging if (fs.existsSync(destinationFullPath)) { fs.unlinkSync(destinationFullPath); } // Read the template content let content = fs.readFileSync(src, 'utf8'); // Special handling for mongoid.yml database names if (templatePath.includes('mongoid.yml')) { content = content .replace(/rapid_stack_dev/g, `${this.answers.projectName}_development`) .replace(/rapid_stack_test/g, `${this.answers.projectName}_test`); } else { // Process ERB-style template variables for other files Object.entries(context).forEach(([key, value]) => { // Handle different ERB patterns const patterns = [ // Standard ERB pattern new RegExp(`<%=\\s*${key}\\s*%>`, 'g'), // ERB with method calls (e.g., <%= projectName.camelize %>) new RegExp(`<%=\\s*${key}\\.\\w+\\s*%>`, 'g'), // ERB with complex expressions (e.g., <%= railsVersion.split('.')[0..1].join('.') %>) new RegExp(`<%=\\s*${key}\\s*\\.[\\w\\[\\]\\(')\\.\\s]+%>`, 'g') ]; patterns.forEach(pattern => { const matches = content.match(pattern); if (matches) { matches.forEach(match => { // Extract any method calls or expressions const expressionMatch = match.match(/<%=\s*(\w+)(\.[\w\[\]\(\)\.\s]+)?\s*%>/); if (expressionMatch) { const [, varName, expression] = expressionMatch; if (expression) { // Handle complex expressions const expr = expression.replace(/^\s*\./, ''); // Remove leading dot if (expr.includes('split') && expr.includes('join')) { // Handle split and join operations const parts = expr.split('.').map(p => p.trim()); if (parts[0] === 'split' && parts[2] === 'join') { const splitChar = parts[1].match(/['"]([^'"]*)['"]/)?.[1] || '.'; const joinChar = parts[3].match(/['"]([^'"]*)['"]/)?.[1] || '.'; const arrayRange = parts[1].match(/\[(\d+\.\.\d+)\]/)?.[1]; if (arrayRange) { const [start, end] = arrayRange.split('..').map(Number); const result = value.split(splitChar).slice(start, end + 1).join(joinChar); content = content.replace(match, result); } } } else if (expr === 'camelize') { // Handle camelize method content = content.replace(match, this._camelize(value)); } } else { // Simple variable replacement content = content.replace(match, value); } } }); } }); }); // Process any remaining ERB expressions that might have been missed content = content .replace(/<%= projectName\.camelize %>/g, this._camelize(this.answers.projectName)) .replace(/<%= railsVersion\.split\('\.'\)\[0\.\.1\]\.join\('\.'\) %>/g, this.answers.railsVersion.split('.').slice(0, 2).join('.')); } // Write the processed content to the destination fs.writeFileSync(destinationFullPath, content, 'utf8'); this.log(`✓ Created ${destinationPath}`); this.modifiedFiles.push(destinationPath); } catch (error) { this.log(`Error copying template ${templatePath}: ${error.message}`); throw error; } } _printSummary(installPath, projectName) { const customFiles = [ { path: 'Gemfile', description: 'Custom Gemfile with MongoDB, GraphQL, and testing gems' }, { path: 'config/mongoid.yml', description: 'MongoDB configuration with environment-specific settings' }, { path: 'config/initializers/cors.rb', description: 'CORS configuration with environment-specific origins' }, { path: 'config/initializers/devise.rb', description: 'Devise authentication configuration' }, { path: 'app/graphql', description: 'GraphQL API setup with base types and schema' }, { path: 'config/routes.rb', description: 'Routes configuration with GraphQL and health endpoints' }, { path: 'config/application.rb', description: 'Application configuration without Active Record' }, { path: 'lib/config_helper.rb', description: 'Config helper for environment-specific configuration' }, { path: '.env.sample', description: 'Sample environment variables file' }, { path: '.rspec', description: 'RSpec configuration file' }, { path: 'spec/rails_helper.rb', description: 'Rails-specific RSpec configuration' }, { path: 'spec/spec_helper.rb', description: 'General RSpec configuration' } ]; this.log('\n=== Installation Summary ==='); this.log('\nProject Setup:'); this.log(`✓ Created new Rails API application: ${projectName}`); this.log(`✓ Location: ${installPath}`); this.log('\nKey Features Installed:'); this.log('✓ API-only Rails application'); this.log('✓ MongoDB integration with Mongoid'); this.log('✓ GraphQL API with base schema'); this.log('✓ Devise authentication framework'); this.log('✓ Testing framework (RSpec)'); this.log('✓ Development tools (Rubocop, Guard)'); this.log('✓ CORS configuration for cross-origin requests'); this.log('✓ Routes configured for GraphQL and health endpoints'); this.log('✓ Application configured for MongoDB (without Active Record)'); this.log('✓ Environment variables setup (no master.key)'); this.log('\nCustomized Files:'); customFiles.forEach(file => { this.log(`✓ ${file.path}`); this.log(` └─ ${file.description}`); }); this.log('\nNext Steps:'); this.log('1. cd into your project directory:'); this.log(` cd ${projectName}`); this.log('2. Create a .env.development.local file based on .env.sample'); this.log('3. Ensure MongoDB is running on your system'); this.log('4. Run `rails s` to start the server'); this.log('\nAvailable Endpoints (after starting server):'); this.log('• API: http://localhost:3000'); this.log('• GraphiQL: http://localhost:3000/graphiql (in development)'); this.log('• Health Check: http://localhost:3000/health'); this.log('• Letter Opener: http://localhost:3000/letter_opener (in development)'); } _debugLog(message) { if (this.options.debug) { this.log(`[DEBUG] ${message}`); } } // Helper method to convert project name to camelcase _camelize(str) { return str.split(/[-_]/).map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase() ).join(''); } // Helper method to create clickable file links _fileLink(filePath) { const fullPath = this.destinationPath(filePath); return `file://${fullPath}`; } };