UNPKG

@dankupfer/create-dn-starter

Version:

Interactive CLI for creating modular React Native apps with Expo

391 lines (364 loc) 15.9 kB
import { execSync } from 'child_process'; import fs from 'fs-extra'; import chalk from 'chalk'; import ora from 'ora'; import path from 'path'; import { fileURLToPath } from 'url'; export class ProjectGenerator { options; templates; sourceRoot; targetDir; constructor(options, templates) { this.options = options; this.templates = templates; // Get the CLI directory - handle both development and published package structures const currentFilePath = fileURLToPath(import.meta.url); const cliSrcDir = path.dirname(currentFilePath); // In published packages, we're in node_modules/create-dn-starter/dist/ // In development, we're in cli/dist/ let cliDir; if (cliSrcDir.includes('node_modules')) { // Published package: go up one level from dist to package root cliDir = path.resolve(cliSrcDir, '..'); } else { // Development: go up one level from dist to cli root cliDir = path.resolve(cliSrcDir, '..'); } // Always use the bundled template this.sourceRoot = path.join(cliDir, 'template'); // Verify the bundled template exists if (!fs.existsSync(this.sourceRoot)) { throw new Error(`Bundled template not found at ${this.sourceRoot}. Please run 'npm run prepublishOnly' first.`); } this.targetDir = path.resolve(process.cwd(), options.projectName); } async generate() { const spinner = ora('Setting up project structure...').start(); try { await this.createProjectStructure(); spinner.succeed('Project structure created'); spinner.text = 'Copying template files...'; spinner.start(); await this.copyTemplateFiles(); if (fs.existsSync(this.targetDir)) { const files = fs.readdirSync(this.targetDir); files.forEach(file => console.log(chalk.yellow(` - ${file}`))); } spinner.succeed('Template files copied'); spinner.text = 'Generating module configuration...'; spinner.start(); await this.generateModuleConfig(); spinner.succeed('Module configuration generated'); // Apply selected template spinner.text = 'Applying selected template...'; spinner.start(); await this.applySelectedTemplate(); spinner.succeed('Template applied successfully'); // Clean up template selection system spinner.text = 'Cleaning up...'; spinner.start(); await this.cleanupTemplateSystem(); spinner.succeed('Project cleaned up'); // Install dependencies spinner.text = 'Installing dependencies...'; spinner.start(); await this.installDependencies(); spinner.succeed('Dependencies installed'); console.log(chalk.green.bold('✨ Project successfully created!')); console.log(chalk.cyan(`\nNext steps: 1. cd ${this.options.projectName} 2. npm start`)); } catch (error) { spinner.fail('Project creation failed'); console.error(chalk.red(`Error: ${error instanceof Error ? error.message : String(error)}`)); throw error; } } async createProjectStructure() { // Ensure target directory doesn't exist if (fs.existsSync(this.targetDir)) { throw new Error(`Directory ${this.targetDir} already exists`); } // Create main project directory fs.mkdirSync(this.targetDir, { recursive: true }); } getExcludePatterns() { // Get all template entry points from the loaded templates const templateFiles = this.templates.map(t => t.entryPoint); // Files related to the template selection system const templateSystemFiles = [ 'App.tsx', // We'll handle this separately 'src/config/appConfig.json', 'src/config/appConfig.types.ts' ]; return [...templateFiles, ...templateSystemFiles]; } async copyTemplateFiles() { const excludePatterns = this.getExcludePatterns(); const selectedTemplate = this.templates.find(t => t.id === this.options.template); // Copy base files (everything except modules and excluded files) await fs.copy(this.sourceRoot, this.targetDir, { filter: (src) => { const relativePath = path.relative(this.sourceRoot, src); // Skip node_modules and .expo WITHIN the template, not in the path TO the template if (relativePath.includes('node_modules') || relativePath.includes('.expo')) { return false; } // Skip the modules directory - we'll handle it separately if (relativePath.startsWith('src/modules/')) { return false; } // Only exclude specific files, not directories const isFile = path.extname(src) !== ''; if (isFile && excludePatterns.includes(relativePath)) { return false; } return true; } }); console.log('Copied base template files'); // Copy only the selected modules await this.copySelectedModules(selectedTemplate.modules); } async copySelectedModules(moduleIds) { // Load module definitions from the template const modulesConfigPath = path.join(this.sourceRoot, 'src', 'config', 'modules.ts'); if (!fs.existsSync(modulesConfigPath)) { console.warn('modules.ts not found, skipping module-specific copying'); return; } // Parse the modules.ts file to get module categories const moduleCategories = await this.parseModuleCategories(modulesConfigPath); const coreSourceDir = path.join(this.sourceRoot, 'src', 'modules', 'core'); const featureSourceDir = path.join(this.sourceRoot, 'src', 'modules', 'feature'); const coreTargetDir = path.join(this.targetDir, 'src', 'modules', 'core'); const featureTargetDir = path.join(this.targetDir, 'src', 'modules', 'feature'); // Ensure target directories exist fs.ensureDirSync(coreTargetDir); fs.ensureDirSync(featureTargetDir); for (const moduleId of moduleIds) { const category = moduleCategories[moduleId]; if (!category) { console.warn(`Module category not found for: ${moduleId}`); continue; } const sourceDir = category === 'core' ? coreSourceDir : featureSourceDir; const targetDir = category === 'core' ? coreTargetDir : featureTargetDir; const moduleSourcePath = path.join(sourceDir, moduleId); const moduleTargetPath = path.join(targetDir, moduleId); if (fs.existsSync(moduleSourcePath)) { await fs.copy(moduleSourcePath, moduleTargetPath); console.log(`Copied ${category} module: ${moduleId}`); } else { console.warn(`Module not found: ${moduleSourcePath}`); } } } async parseModuleCategories(modulesConfigPath) { const content = await fs.readFile(modulesConfigPath, 'utf-8'); const categories = {}; // Simple regex to extract module definitions const modulePattern = /\{\s*id:\s*['"]([^'"]+)['"]\s*,\s*name:\s*['"]([^'"]+)['"]\s*,\s*description:\s*['"]([^'"]+)['"]\s*,\s*category:\s*['"]([^'"]+)['"]/gs; let match; while ((match = modulePattern.exec(content)) !== null) { const [, id, , , category] = match; categories[id] = category; } return categories; } async generateModuleConfig() { const selectedTemplate = this.templates.find(t => t.id === this.options.template); const targetModulesPath = path.join(this.targetDir, 'src', 'config', 'modules.ts'); // Ensure the config directory exists const configDir = path.dirname(targetModulesPath); await fs.ensureDir(configDir); // Load the original modules config to get full module definitions const sourceModulesPath = path.join(this.sourceRoot, 'src', 'config', 'modules.ts'); if (!fs.existsSync(sourceModulesPath)) { console.warn('Source modules.ts not found, skipping module config generation'); return; } const sourceContent = await fs.readFile(sourceModulesPath, 'utf-8'); // Generate new modules.ts with only selected modules let modulesContent = `// Auto-generated module configuration for ${this.options.template} template // This file defines the modules included in this project export interface ModuleConfig { id: string; name: string; description: string; category: 'core' | 'feature'; enabled: boolean; dependencies?: string[]; importFn?: () => Promise<any>; version?: string; priority?: number; } export interface ModuleMetadata { name?: string; version?: string; features?: string[]; api?: any; } export enum ModuleState { PENDING = 'pending', LOADING = 'loading', LOADED = 'loaded', ERROR = 'error', UNLOADED = 'unloaded' } export interface LoadedModule { config: ModuleConfig; state: ModuleState; component?: any; metadata?: ModuleMetadata; error?: Error; loadTime?: number; } export const availableModules: ModuleConfig[] = [ `; // Better approach: split by modules and parse each one const arrayMatch = sourceContent.match(/export\s+const\s+availableModules\s*:\s*ModuleConfig\[\]\s*=\s*\[([\s\S]*?)\];/); if (arrayMatch) { const moduleArrayContent = arrayMatch[1]; // Split by objects (look for },\s*{) but be careful about the last one const moduleObjects = moduleArrayContent.split(/},\s*(?={)/).map((obj, index, array) => { // Add back the closing brace except for the last item return index < array.length - 1 ? obj + '}' : obj; }); for (const moduleObj of moduleObjects) { // Extract the module ID from this object const idMatch = moduleObj.match(/id:\s*['"]([^'"]+)['"]/); if (idMatch) { const moduleId = idMatch[1]; if (selectedTemplate.modules.includes(moduleId)) { // Clean up the object and add it const cleanObj = moduleObj.trim(); modulesContent += ` ${cleanObj}${cleanObj.endsWith('}') ? '' : '}'},\n`; } } } } modulesContent += `]; // Helper functions export const getModuleById = (id: string): ModuleConfig | undefined => { return availableModules.find(m => m.id === id); }; export const getEnabledModules = (): ModuleConfig[] => { return availableModules.filter(m => m.enabled); }; export const getModulesByCategory = (category: 'core' | 'feature'): ModuleConfig[] => { return availableModules.filter(m => m.category === category); }; export const getModuleImportFn = (id: string): (() => Promise<any>) | undefined => { const module = availableModules.find(m => m.id === id); return module?.importFn; }; export const hasModuleImportFn = (id: string): boolean => { const module = availableModules.find(m => m.id === id); return !!module?.importFn; }; // Additional helper functions for dependency resolution and load order export const resolveDependencies = (moduleId: string, visited = new Set<string>()): string[] => { if (visited.has(moduleId)) { return []; // Circular dependency protection } visited.add(moduleId); const module = getModuleById(moduleId); if (!module) { throw new Error(\`Module not found: \${moduleId}\`); } let deps: string[] = []; if (module.dependencies) { for (const dep of module.dependencies) { const resolvedDeps = resolveDependencies(dep, visited); deps = deps.concat(resolvedDeps); if (!deps.includes(dep)) { deps.push(dep); } } } return deps; }; export const getLoadOrder = (moduleIds: string[]): string[] => { const allDeps = new Set<string>(); for (const moduleId of moduleIds) { try { const deps = resolveDependencies(moduleId); deps.forEach(dep => allDeps.add(dep)); allDeps.add(moduleId); } catch (error) { console.error(\`Error resolving dependencies for \${moduleId}:\`, error); } } const sorted = Array.from(allDeps).sort((a, b) => { const moduleA = getModuleById(a); const moduleB = getModuleById(b); if (!moduleA || !moduleB) return 0; if (moduleA.category === 'core' && moduleB.category !== 'core') return -1; if (moduleA.category !== 'core' && moduleB.category === 'core') return 1; const priorityA = moduleA.priority ?? 999; const priorityB = moduleB.priority ?? 999; if (priorityA !== priorityB) return priorityA - priorityB; const aDeps = moduleA.dependencies || []; const bDeps = moduleB.dependencies || []; if (aDeps.includes(b)) return 1; if (bDeps.includes(a)) return -1; return a.localeCompare(b); }); return sorted; };`; await fs.writeFile(targetModulesPath, modulesContent, 'utf-8'); console.log('Generated module configuration with selected modules'); } async applySelectedTemplate() { const selectedTemplate = this.templates.find(t => t.id === this.options.template); if (!selectedTemplate) { throw new Error(`Template not found: ${this.options.template}`); } // Copy the selected template file as App.tsx const sourceTemplatePath = path.join(this.sourceRoot, selectedTemplate.entryPoint); const targetAppPath = path.join(this.targetDir, 'App.tsx'); if (!fs.existsSync(sourceTemplatePath)) { throw new Error(`Template file not found: ${sourceTemplatePath}`); } await fs.copy(sourceTemplatePath, targetAppPath); console.log(`Applied ${selectedTemplate.name} template`); // Update package.json with project name const packageJsonPath = path.join(this.targetDir, 'package.json'); if (fs.existsSync(packageJsonPath)) { const packageJson = await fs.readJson(packageJsonPath); packageJson.name = this.options.projectName; await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 }); console.log('Updated package.json with project name'); } } async cleanupTemplateSystem() { const excludePatterns = this.getExcludePatterns(); // Remove files that are only needed for the template selection system for (const file of excludePatterns) { // Skip App.tsx since we already handled it if (file === 'App.tsx') continue; const filePath = path.join(this.targetDir, file); if (fs.existsSync(filePath)) { await fs.remove(filePath); console.log(`Removed ${file}`); } } // Also remove module loader system if it exists (since we're not using it) const moduleLoaderPath = path.join(this.targetDir, 'src/config/moduleLoader.ts'); if (fs.existsSync(moduleLoaderPath)) { await fs.remove(moduleLoaderPath); console.log('Removed moduleLoader.ts'); } console.log('Cleaned up template selection system'); } async installDependencies() { process.chdir(this.targetDir); execSync('npm install', { stdio: 'inherit' }); } }