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