@dankupfer/create-dn-starter
Version:
Interactive CLI for creating modular React Native apps with Expo
391 lines (364 loc) • 15.9 kB
JavaScript
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' });
}
}