UNPKG

create-roadkit

Version:

Beautiful Next.js roadmap website generator with full-screen kanban boards, dark/light mode, and static export

652 lines (562 loc) 20.9 kB
/** * CLI integration and orchestration layer for RoadKit. * * This module provides the main command-line interface that orchestrates * the complete project scaffolding process. It includes interactive prompts, * progress indicators, error handling, and user feedback systems. * The CLI provides a seamless experience for generating Next.js roadmap projects. */ import { select, input, confirm, checkbox } from '@inquirer/prompts'; import chalk from 'chalk'; import ora from 'ora'; import boxen from 'boxen'; import path from 'path'; import type { UserChoices, ProjectConfig, ProjectGenerationResult, Logger, Template, Theme, AVAILABLE_TEMPLATES, AVAILABLE_THEMES, } from '../types/config'; import { ConfigManager, createDefaultLogger } from '../core/config'; import { ProjectScaffoldingEngine } from '../core/scaffolding'; /** * CLI options that can be passed from command line arguments */ export interface CLIOptions { name?: string; template?: Template; theme?: Theme; output?: string; skipPrompts?: boolean; verbose?: boolean; dryRun?: boolean; overwrite?: boolean; skipInstall?: boolean; skipGit?: boolean; } /** * Enhanced logger implementation with colored output for CLI * This provides better visual feedback during the scaffolding process */ export class CLILogger implements Logger { private verbose: boolean; constructor(verbose = false) { this.verbose = verbose; } info(message: string, data?: unknown): void { console.log(chalk.blue('ℹ'), message); if (this.verbose && data) { console.log(chalk.gray(JSON.stringify(data, null, 2))); } } warn(message: string, data?: unknown): void { console.log(chalk.yellow('⚠'), message); if (this.verbose && data) { console.log(chalk.gray(JSON.stringify(data, null, 2))); } } error(message: string, error?: Error | unknown): void { console.error(chalk.red('✖'), message); if (this.verbose && error) { console.error(chalk.red(error instanceof Error ? error.stack || error.message : String(error))); } } debug(message: string, data?: unknown): void { if (this.verbose) { console.log(chalk.gray('🐛'), chalk.gray(message)); if (data) { console.log(chalk.gray(JSON.stringify(data, null, 2))); } } } success(message: string, data?: unknown): void { console.log(chalk.green('✓'), message); if (this.verbose && data) { console.log(chalk.gray(JSON.stringify(data, null, 2))); } } } /** * Main CLI orchestrator that handles the complete user experience * This class manages the interactive prompts, validation, and project generation */ export class RoadKitCLI { private logger: CLILogger; private configManager: ConfigManager; private scaffoldingEngine: ProjectScaffoldingEngine; private spinner?: any; /** * Initialize the CLI with a logger instance * @param verbose - Enable verbose logging output */ constructor(verbose = false) { this.logger = new CLILogger(verbose); this.configManager = new ConfigManager(this.logger); this.scaffoldingEngine = new ProjectScaffoldingEngine(this.logger, this.progressCallback.bind(this)); } /** * Main entry point for the CLI application * This orchestrates the complete user experience from prompts to project generation * * @param options - CLI options from command line arguments * @returns Generation result with success status and details */ public async run(options: CLIOptions = {}): Promise<ProjectGenerationResult> { try { // Display welcome message this.displayWelcome(); // Collect user choices through interactive prompts or CLI options const userChoices = await this.collectUserChoices(options); // Validate and create project configuration const config = await this.validateAndCreateConfig(userChoices); // Confirm project generation with user if (!options.skipPrompts) { await this.confirmProjectGeneration(config); } // Generate the project const result = await this.generateProject(config, options); // Display final results this.displayResults(result); return result; } catch (error) { if (this.spinner) { this.spinner.fail('Project generation failed'); } const errorMsg = error instanceof Error ? error.message : 'Unknown error occurred'; this.logger.error('CLI execution failed', error); return { success: false, errors: [errorMsg], }; } } /** * Displays the welcome message and RoadKit branding */ private displayWelcome(): void { const welcomeMessage = `${chalk.bold.blue('🛣️ RoadKit')} ${chalk.gray('Generate beautiful Next.js roadmap websites')} Welcome to the RoadKit project generator! This tool will help you create a fully functional, customizable roadmap website built with Next.js, TypeScript, and Tailwind CSS. `; console.log(boxen(welcomeMessage, { padding: 1, margin: 1, borderStyle: 'round', borderColor: 'blue', })); } /** * Collects user choices through interactive prompts or uses provided CLI options * * @param options - CLI options that may override prompts * @returns Complete user choices for project generation */ private async collectUserChoices(options: CLIOptions): Promise<UserChoices> { this.logger.info('Collecting project configuration...'); // Project name const projectName = options.name || await input({ message: 'What is your project name?', validate: (value: string) => { if (!value.trim()) return 'Project name is required'; if (!/^[a-zA-Z0-9-_]+$/.test(value)) { return 'Project name must contain only alphanumeric characters, hyphens, and underscores'; } return true; }, }); // Project description const description = await input({ message: 'Provide a brief description of your roadmap:', validate: (value: string) => { if (!value.trim()) return 'Description is required'; if (value.length > 200) return 'Description must be 200 characters or less'; return true; }, }); // Author information const authorName = await input({ message: 'Author name:', validate: (value: string) => value.trim() ? true : 'Author name is required', }); const authorEmail = await input({ message: 'Author email (optional):', validate: (value: string) => { if (!value.trim()) return true; // Optional field const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return emailRegex.test(value) ? true : 'Please enter a valid email address'; }, }); const authorUrl = await input({ message: 'Author website URL (optional):', validate: (value: string) => { if (!value.trim()) return true; // Optional field try { new URL(value); return true; } catch { return 'Please enter a valid URL'; } }, }); // Template selection const template = options.template || await select({ message: 'Choose a project template:', choices: [ { name: 'Basic - Simple roadmap with essential features', value: 'basic' }, { name: 'Advanced - Feature-rich with analytics and SEO', value: 'advanced' }, { name: 'Enterprise - Full-featured with authentication and database', value: 'enterprise' }, { name: 'Custom - Minimal template for custom builds', value: 'custom' }, ], }) as Template; // Theme selection const theme = options.theme || await select({ message: 'Choose a visual theme:', choices: [ { name: 'Modern - Clean, contemporary design', value: 'modern' }, { name: 'Classic - Traditional, professional look', value: 'classic' }, { name: 'Minimal - Simple, focused design', value: 'minimal' }, { name: 'Corporate - Business-oriented styling', value: 'corporate' }, ], }) as Theme; // Output directory const outputDirectory = options.output || await input({ message: 'Output directory:', default: `./${projectName}`, validate: (value: string) => { if (!value.trim()) return 'Output directory is required'; return true; }, }); // Feature selection const features = await this.collectFeatureChoices(template); // Customization options const customization = await this.collectCustomizationChoices(); // Technical configuration const technical = await this.collectTechnicalChoices(); // Additional options const overwrite = options.overwrite ?? await confirm({ message: 'Overwrite existing directory if it exists?', default: false, }); const gitInit = options.skipGit ? false : await confirm({ message: 'Initialize git repository?', default: true, }); const installDependencies = options.skipInstall ? false : await confirm({ message: 'Install dependencies after generation?', default: true, }); return { projectName, description, author: { name: authorName, email: authorEmail || undefined, url: authorUrl || undefined, }, template, theme, outputDirectory, features, customization, technical, overwrite, gitInit, installDependencies, }; } /** * Collects feature choices based on the selected template */ private async collectFeatureChoices(template: Template): Promise<UserChoices['features']> { // Define default features based on template const templateDefaults = { basic: ['seo', 'api', 'testing'], advanced: ['seo', 'api', 'testing', 'analytics', 'deployment'], enterprise: ['seo', 'api', 'testing', 'analytics', 'deployment', 'authentication', 'database'], custom: [], }; const selectedFeatures = await checkbox({ message: 'Select additional features:', choices: [ { name: 'Analytics integration', value: 'analytics', checked: templateDefaults[template].includes('analytics') }, { name: 'SEO optimization', value: 'seo', checked: templateDefaults[template].includes('seo') }, { name: 'Progressive Web App (PWA)', value: 'pwa', checked: templateDefaults[template].includes('pwa') }, { name: 'Authentication system', value: 'authentication', checked: templateDefaults[template].includes('authentication') }, { name: 'Database integration', value: 'database', checked: templateDefaults[template].includes('database') }, { name: 'API routes', value: 'api', checked: templateDefaults[template].includes('api') }, { name: 'Testing setup', value: 'testing', checked: templateDefaults[template].includes('testing') }, { name: 'Deployment configuration', value: 'deployment', checked: templateDefaults[template].includes('deployment') }, ], }); return { analytics: selectedFeatures.includes('analytics'), seo: selectedFeatures.includes('seo'), pwa: selectedFeatures.includes('pwa'), authentication: selectedFeatures.includes('authentication'), database: selectedFeatures.includes('database'), api: selectedFeatures.includes('api'), testing: selectedFeatures.includes('testing'), deployment: selectedFeatures.includes('deployment'), }; } /** * Collects visual customization choices */ private async collectCustomizationChoices(): Promise<UserChoices['customization']> { const primaryColor = await input({ message: 'Primary color (hex code):', default: '#3b82f6', validate: (value: string) => { const hexRegex = /^#[0-9A-Fa-f]{6}$/; return hexRegex.test(value) ? true : 'Please enter a valid hex color (e.g., #3b82f6)'; }, }); const secondaryColor = await input({ message: 'Secondary color (hex code):', default: '#64748b', validate: (value: string) => { const hexRegex = /^#[0-9A-Fa-f]{6}$/; return hexRegex.test(value) ? true : 'Please enter a valid hex color (e.g., #64748b)'; }, }); const fontFamily = await select({ message: 'Choose a font family:', choices: [ { name: 'Inter - Modern, clean sans-serif', value: 'inter' }, { name: 'Roboto - Google\'s signature font', value: 'roboto' }, { name: 'Open Sans - Friendly, readable font', value: 'open-sans' }, { name: 'Poppins - Geometric, modern look', value: 'poppins' }, ], }) as UserChoices['customization']['fontFamily']; const logoUrl = await input({ message: 'Logo URL (optional):', validate: (value: string) => { if (!value.trim()) return true; // Optional field try { new URL(value); return true; } catch { return 'Please enter a valid URL'; } }, }); const faviconUrl = await input({ message: 'Favicon URL (optional):', validate: (value: string) => { if (!value.trim()) return true; // Optional field try { new URL(value); return true; } catch { return 'Please enter a valid URL'; } }, }); return { primaryColor, secondaryColor, fontFamily, logoUrl: logoUrl || undefined, faviconUrl: faviconUrl || undefined, }; } /** * Collects technical configuration choices */ private async collectTechnicalChoices(): Promise<UserChoices['technical']> { const technicalFeatures = await checkbox({ message: 'Select technical features:', choices: [ { name: 'TypeScript', value: 'typescript', checked: true }, { name: 'ESLint', value: 'eslint', checked: true }, { name: 'Prettier', value: 'prettier', checked: true }, { name: 'Tailwind CSS', value: 'tailwind', checked: true }, { name: 'shadcn/ui components', value: 'shadcnUi', checked: true }, ], }); return { typescript: technicalFeatures.includes('typescript'), eslint: technicalFeatures.includes('eslint'), prettier: technicalFeatures.includes('prettier'), tailwind: technicalFeatures.includes('tailwind'), shadcnUi: technicalFeatures.includes('shadcnUi'), }; } /** * Validates user choices and creates project configuration */ private async validateAndCreateConfig(userChoices: UserChoices): Promise<ProjectConfig> { this.logger.info('Validating configuration...'); const validation = this.configManager.validateConfiguration(userChoices); if (!validation.success) { const errorMessages = validation.errors?.map(err => `${err.field}: ${err.message}`).join('\n') || 'Unknown validation errors'; throw new Error(`Configuration validation failed:\n${errorMessages}`); } if (!validation.config) { throw new Error('Failed to generate valid configuration'); } this.logger.success('Configuration validated successfully'); return validation.config; } /** * Displays project configuration summary and asks for confirmation */ private async confirmProjectGeneration(config: ProjectConfig): Promise<void> { const summary = ` ${chalk.bold('Project Configuration Summary:')} ${chalk.cyan('Basic Information:')} Name: ${config.name} Description: ${config.description} Author: ${config.author.name}${config.author.email ? ` <${config.author.email}>` : ''} ${chalk.cyan('Template & Theme:')} Template: ${config.template} Theme: ${config.theme} ${chalk.cyan('Features:')} Analytics: ${config.features.analytics ? chalk.green('Yes') : chalk.gray('No')} SEO: ${config.features.seo ? chalk.green('Yes') : chalk.gray('No')} PWA: ${config.features.pwa ? chalk.green('Yes') : chalk.gray('No')} Authentication: ${config.features.authentication ? chalk.green('Yes') : chalk.gray('No')} Database: ${config.features.database ? chalk.green('Yes') : chalk.gray('No')} ${chalk.cyan('Output:')} Directory: ${config.output.directory} Install Dependencies: ${config.output.installDependencies ? chalk.green('Yes') : chalk.gray('No')} Git Repository: ${config.output.gitInit ? chalk.green('Yes') : chalk.gray('No')} `; console.log(boxen(summary, { padding: 1, margin: 1, borderStyle: 'round', borderColor: 'cyan', })); const confirmed = await confirm({ message: 'Generate project with this configuration?', default: true, }); if (!confirmed) { throw new Error('Project generation cancelled by user'); } } /** * Generates the project using the scaffolding engine */ private async generateProject(config: ProjectConfig, options: CLIOptions): Promise<ProjectGenerationResult> { this.logger.info('Starting project generation...'); const scaffoldingOptions = { skipDependencyInstallation: options.skipInstall || false, skipGitInit: options.skipGit || false, verbose: options.verbose || false, dryRun: options.dryRun || false, }; return await this.scaffoldingEngine.generateProject(config, scaffoldingOptions); } /** * Progress callback for the scaffolding engine * Updates the spinner with current progress information */ private progressCallback(stage: string, current: number, total: number, message?: string): void { if (this.spinner) { this.spinner.stop(); } const progress = `[${current}/${total}]`; const text = message ? `${stage}: ${message}` : stage; this.spinner = ora(`${progress} ${text}`).start(); if (current === total) { this.spinner.succeed(`${progress} ${stage} completed`); this.spinner = undefined; } } /** * Displays the final generation results to the user */ private displayResults(result: ProjectGenerationResult): void { if (result.success) { this.displaySuccessResults(result); } else { this.displayErrorResults(result); } } /** * Displays success results with next steps */ private displaySuccessResults(result: ProjectGenerationResult): void { const { projectPath, duration, nextSteps, filesCreated, filesModified } = result; const successMessage = `${chalk.bold.green('🎉 Project Generated Successfully!')} ${chalk.cyan('Project Details:')} Path: ${projectPath} Duration: ${duration}ms Files Created: ${filesCreated?.length || 0} Files Modified: ${filesModified?.length || 0} ${chalk.cyan('Next Steps:')} ${nextSteps?.map(step => ` ${chalk.gray('$')} ${step}`).join('\n') || ''} ${chalk.gray('Your Next.js roadmap project is ready to use!')} ${chalk.gray('Visit the project directory and follow the next steps above.')} `; console.log(boxen(successMessage, { padding: 1, margin: 1, borderStyle: 'round', borderColor: 'green', })); } /** * Displays error results with rollback information */ private displayErrorResults(result: ProjectGenerationResult): void { const { errors, rollbackInfo, duration } = result; let errorMessage = `${chalk.bold.red('❌ Project Generation Failed')} ${chalk.cyan('Error Details:')} ${errors?.map(error => ` • ${error}`).join('\n') || 'Unknown error occurred'} Duration: ${duration}ms `; if (rollbackInfo) { errorMessage += ` ${chalk.cyan('Rollback Information:')} Can Rollback: ${rollbackInfo.canRollback ? chalk.green('Yes') : chalk.red('No')} `; if (rollbackInfo.rollbackInstructions) { errorMessage += `${chalk.cyan('Cleanup Instructions:')} ${rollbackInfo.rollbackInstructions.map(instruction => ` ${chalk.gray('$')} ${instruction}`).join('\n')} `; } } console.log(boxen(errorMessage, { padding: 1, margin: 1, borderStyle: 'round', borderColor: 'red', })); } } /** * Factory function to create a RoadKitCLI instance * @param verbose - Enable verbose logging output * @returns Configured RoadKitCLI instance */ export const createRoadKitCLI = (verbose = false): RoadKitCLI => { return new RoadKitCLI(verbose); }; /** * Main CLI entry point function * This is typically called from a bin script or main application entry * * @param options - CLI options from command line parsing * @returns Promise that resolves when CLI execution completes */ export const runCLI = async (options: CLIOptions = {}): Promise<void> => { const cli = createRoadKitCLI(options.verbose); try { const result = await cli.run(options); // Exit with appropriate code process.exit(result.success ? 0 : 1); } catch (error) { console.error(chalk.red('Fatal error:'), error instanceof Error ? error.message : 'Unknown error'); process.exit(1); } };