UNPKG

roadkit

Version:

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

1,159 lines (998 loc) 34.6 kB
/** * Core project scaffolding engine for RoadKit. * * This module provides the main scaffolding functionality including project directory * structure creation, template file copying with customization, dependency installation * using Bun, theme and configuration application, and complete project generation * based on user choices. The engine ensures atomic operations with rollback capabilities * and comprehensive progress reporting. */ import path from 'path'; import { mkdir } from 'fs/promises'; import type { ProjectConfig, ProjectContext, ProjectGenerationResult, ProgressCallback, Logger, FileOperationResult, TemplateContext, } from '../types/config'; import { ConfigManager } from './config'; import { FileOperations } from '../utils/file-operations'; /** * Scaffolding options for customizing the generation process */ export interface ScaffoldingOptions { skipDependencyInstallation?: boolean; skipGitInit?: boolean; verbose?: boolean; dryRun?: boolean; maxConcurrency?: number; } /** * Template file information for processing */ interface TemplateFile { sourcePath: string; destPath: string; isDirectory: boolean; shouldProcess: boolean; } /** * Project scaffolding engine that orchestrates the complete project generation process * This class handles all aspects of project creation from initial setup to final validation */ export class ProjectScaffoldingEngine { private logger: Logger; private configManager: ConfigManager; private fileOperations: FileOperations; private progressCallback?: ProgressCallback; /** * Initialize the scaffolding engine with required dependencies * @param logger - Logger instance for tracking operations * @param progressCallback - Optional callback for progress updates */ constructor(logger: Logger, progressCallback?: ProgressCallback) { this.logger = logger; this.configManager = new ConfigManager(logger); this.fileOperations = new FileOperations(logger); this.progressCallback = progressCallback; } /** * Generates a complete Next.js roadmap project based on the provided configuration * * This is the main entry point for project scaffolding. It orchestrates all * generation steps including directory creation, template processing, dependency * installation, and project validation. The operation is atomic - if any step * fails, the entire operation is rolled back. * * @param config - Validated project configuration * @param options - Additional scaffolding options * @returns Comprehensive generation result with success status and details */ public async generateProject( config: ProjectConfig, options: ScaffoldingOptions = {} ): Promise<ProjectGenerationResult> { const startTime = new Date(); let context: ProjectContext | undefined; try { this.logger.info(`Starting project generation: ${config.name}`); this.reportProgress('Initializing', 0, 8); // Create project context context = await this.createProjectContext(config, startTime); // Validate output directory this.reportProgress('Validating output directory', 1, 8); await this.validateOutputDirectory(context); // Create project structure this.reportProgress('Creating project structure', 2, 8); await this.createProjectStructure(context); // Copy and process template files this.reportProgress('Processing template files', 3, 8); await this.processTemplateFiles(context, options); // Apply theme customizations this.reportProgress('Applying theme customizations', 4, 8); await this.applyThemeCustomizations(context); // Generate configuration files this.reportProgress('Generating configuration files', 5, 8); await this.generateConfigurationFiles(context); // Install dependencies this.reportProgress('Installing dependencies', 6, 8); if (!options.skipDependencyInstallation && !options.dryRun) { await this.installDependencies(context); } // Initialize git repository this.reportProgress('Initializing git repository', 7, 8); if (!options.skipGitInit && config.output.gitInit && !options.dryRun) { await this.initializeGitRepository(context); } // Final validation this.reportProgress('Validating generated project', 8, 8); await this.validateGeneratedProject(context); const duration = Date.now() - startTime.getTime(); const result: ProjectGenerationResult = { success: true, projectPath: context.outputPath, config: context.config, filesCreated: this.fileOperations.getOperations() .filter(op => op.success && op.operation === 'create') .map(op => op.path), filesModified: this.fileOperations.getOperations() .filter(op => op.success && (op.operation === 'copy' || op.operation === 'modify')) .map(op => op.path), duration, nextSteps: this.generateNextSteps(context), }; this.logger.success(`Project generated successfully: ${context.outputPath} (${duration}ms)`); this.reportProgress('Complete', 8, 8); return result; } catch (error) { const errorMsg = error instanceof Error ? error.message : 'Unknown error occurred'; this.logger.error('Project generation failed', error); // Attempt rollback let rollbackInfo; if (context) { try { this.logger.info('Attempting to rollback failed generation...'); await this.fileOperations.rollback(); rollbackInfo = { canRollback: true, rollbackInstructions: [ `cd "${path.dirname(context.outputPath)}"`, `rm -rf "${path.basename(context.outputPath)}"`, ], }; this.logger.success('Rollback completed successfully'); } catch (rollbackError) { this.logger.error('Rollback failed', rollbackError); rollbackInfo = { canRollback: false, rollbackInstructions: [ `Manually remove the directory: ${context.outputPath}`, 'Check for any partially created files or directories', ], }; } } const duration = Date.now() - startTime.getTime(); return { success: false, errors: [errorMsg], rollbackInfo, duration, config, }; } } /** * Creates the project context with all necessary paths and metadata */ private async createProjectContext(config: ProjectConfig, startTime: Date): Promise<ProjectContext> { const outputPath = path.resolve(config.output.directory); const templatePath = this.resolveTemplatePath(config.template); const themePath = this.resolveThemePath(config.theme); return { config, templatePath, themePath, outputPath, startTime, progress: { total: 8, current: 0, stage: 'Initializing', }, }; } /** * Validates and prepares the output directory for project generation */ private async validateOutputDirectory(context: ProjectContext): Promise<void> { const { config, outputPath } = context; this.logger.debug(`Validating output directory: ${outputPath}`); const validation = await this.configManager.validateOutputDirectory( outputPath, config.output.overwrite ); if (!validation.valid) { throw new Error(`Output directory validation failed: ${validation.errors.join(', ')}`); } // Create the output directory if it doesn't exist if (!validation.exists) { await this.fileOperations.createDirectory(outputPath, { overwrite: config.output.overwrite, }); } } /** * Creates the basic project directory structure */ private async createProjectStructure(context: ProjectContext): Promise<void> { const { outputPath } = context; this.logger.debug('Creating project directory structure'); // Standard Next.js project structure const directories = [ 'src', 'src/app', 'src/components', 'src/lib', 'src/types', 'src/styles', 'public', 'public/icons', 'public/images', '.next', 'node_modules', ]; for (const dir of directories) { const dirPath = path.join(outputPath, dir); await this.fileOperations.createDirectory(dirPath); } this.logger.success('Project directory structure created'); } /** * Processes and copies template files with customization */ private async processTemplateFiles( context: ProjectContext, options: ScaffoldingOptions ): Promise<void> { const { templatePath, outputPath, config } = context; this.logger.debug(`Processing template files from: ${templatePath}`); // Generate template context for string replacement const templateContext = this.configManager.generateTemplateContext(config); // Copy template directory with processing const results = await this.fileOperations.copyDirectory( templatePath, outputPath, templateContext, { overwrite: config.output.overwrite, dryRun: options.dryRun, filter: (filePath, isDirectory) => { // Filter out unwanted files/directories const basename = path.basename(filePath); const excludePatterns = [ 'node_modules', '.git', '.next', 'dist', 'build', '.DS_Store', 'Thumbs.db', ]; return !excludePatterns.some(pattern => basename.includes(pattern)); }, transform: async (content, filePath) => { // Apply additional transformations based on file type if (filePath.endsWith('.json')) { try { const parsed = JSON.parse(content); return JSON.stringify(parsed, null, 2); } catch { return content; } } return content; }, } ); const successCount = results.filter(r => r.success).length; const errorCount = results.filter(r => !r.success).length; if (errorCount > 0) { this.logger.warn(`Template processing completed with ${errorCount} errors out of ${results.length} operations`); } else { this.logger.success(`Template files processed: ${successCount} operations completed`); } } /** * Applies theme-specific customizations to the project */ private async applyThemeCustomizations(context: ProjectContext): Promise<void> { const { themePath, outputPath, config } = context; this.logger.debug(`Applying theme customizations: ${config.theme}`); try { // Check if theme directory exists const themeExists = await Bun.file(path.join(themePath, 'theme.json')).exists(); if (!themeExists) { this.logger.warn(`Theme configuration not found: ${themePath}, skipping theme customizations`); return; } // Load theme configuration const themeConfig = await Bun.file(path.join(themePath, 'theme.json')).json(); // Apply theme-specific files const themeResults = await this.fileOperations.copyDirectory( path.join(themePath, 'files'), outputPath, this.configManager.generateTemplateContext(config), { overwrite: true, filter: (filePath) => { // Only copy theme files that don't conflict with user customizations const relativePath = path.relative(path.join(themePath, 'files'), filePath); return !this.isUserCustomizedFile(relativePath, config); }, } ); // Apply theme-specific customizations to existing files await this.applyThemeStyleCustomizations(context, themeConfig); this.logger.success(`Theme customizations applied: ${config.theme}`); } catch (error) { this.logger.warn(`Failed to apply theme customizations: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Applies theme-specific style customizations */ private async applyThemeStyleCustomizations(context: ProjectContext, themeConfig: any): Promise<void> { const { outputPath, config } = context; // Update CSS variables with theme colors const cssVariablesPath = path.join(outputPath, 'src/styles/globals.css'); try { const cssContent = await Bun.file(cssVariablesPath).text(); let updatedCss = cssContent; // Replace CSS custom properties with user's color choices updatedCss = updatedCss.replace( /--primary:\s*[^;]+;/g, `--primary: ${config.customization.primaryColor};` ); updatedCss = updatedCss.replace( /--secondary:\s*[^;]+;/g, `--secondary: ${config.customization.secondaryColor};` ); await Bun.write(cssVariablesPath, updatedCss); this.logger.debug('CSS variables updated with theme customizations'); } catch (error) { this.logger.warn(`Failed to update CSS variables: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Generates all necessary configuration files for the project */ private async generateConfigurationFiles(context: ProjectContext): Promise<void> { const { outputPath, config } = context; this.logger.debug('Generating configuration files'); // Generate package.json const packageJson = this.configManager.generatePackageJson(config); await this.fileOperations.createFile( path.join(outputPath, 'package.json'), JSON.stringify(packageJson, null, 2), undefined, { overwrite: true } ); // Generate TypeScript configuration if enabled if (config.technical.typescript) { await this.generateTypeScriptConfig(context); } // Generate ESLint configuration if enabled if (config.technical.eslint) { await this.generateESLintConfig(context); } // Generate Prettier configuration if enabled if (config.technical.prettier) { await this.generatePrettierConfig(context); } // Generate Tailwind configuration if enabled if (config.technical.tailwind) { await this.generateTailwindConfig(context); } // Generate Next.js configuration await this.generateNextJsConfig(context); // Generate environment file template await this.generateEnvironmentFile(context); // Save project configuration for reference await this.configManager.saveConfigurationFile( config, path.join(outputPath, '.roadkit.json') ); this.logger.success('Configuration files generated'); } /** * Generates TypeScript configuration file */ private async generateTypeScriptConfig(context: ProjectContext): Promise<void> { const { outputPath } = context; const tsConfig = { compilerOptions: { target: 'es5', lib: ['dom', 'dom.iterable', 'es6'], allowJs: true, skipLibCheck: true, strict: true, noEmit: true, esModuleInterop: true, module: 'esnext', moduleResolution: 'bundler', resolveJsonModule: true, isolatedModules: true, jsx: 'preserve', incremental: true, plugins: [ { name: 'next', }, ], paths: { '@/*': ['./src/*'], }, }, include: ['next-env.d.ts', '**/*.ts', '**/*.tsx', '.next/types/**/*.ts'], exclude: ['node_modules'], }; await this.fileOperations.createFile( path.join(outputPath, 'tsconfig.json'), JSON.stringify(tsConfig, null, 2), undefined, { overwrite: true } ); } /** * Generates ESLint configuration file */ private async generateESLintConfig(context: ProjectContext): Promise<void> { const { outputPath } = context; const eslintConfig = { extends: ['next/core-web-vitals', '@typescript-eslint/recommended'], parser: '@typescript-eslint/parser', plugins: ['@typescript-eslint'], rules: { '@typescript-eslint/no-unused-vars': 'error', '@typescript-eslint/no-explicit-any': 'warn', }, }; await this.fileOperations.createFile( path.join(outputPath, '.eslintrc.json'), JSON.stringify(eslintConfig, null, 2), undefined, { overwrite: true } ); } /** * Generates Prettier configuration file */ private async generatePrettierConfig(context: ProjectContext): Promise<void> { const { outputPath } = context; const prettierConfig = { semi: true, trailingComma: 'es5', singleQuote: true, printWidth: 80, tabWidth: 2, useTabs: false, }; await this.fileOperations.createFile( path.join(outputPath, '.prettierrc.json'), JSON.stringify(prettierConfig, null, 2), undefined, { overwrite: true } ); } /** * Generates Tailwind CSS configuration file */ private async generateTailwindConfig(context: ProjectContext): Promise<void> { const { outputPath, config } = context; const tailwindConfig = `/** @type {import('tailwindcss').Config} */ module.exports = { darkMode: ["class"], content: [ './pages/**/*.{ts,tsx}', './components/**/*.{ts,tsx}', './app/**/*.{ts,tsx}', './src/**/*.{ts,tsx}', ], theme: { container: { center: true, padding: "2rem", screens: { "2xl": "1400px", }, }, extend: { colors: { border: "hsl(var(--border))", input: "hsl(var(--input))", ring: "hsl(var(--ring))", background: "hsl(var(--background))", foreground: "hsl(var(--foreground))", primary: { DEFAULT: "${config.customization.primaryColor}", foreground: "hsl(var(--primary-foreground))", }, secondary: { DEFAULT: "${config.customization.secondaryColor}", foreground: "hsl(var(--secondary-foreground))", }, destructive: { DEFAULT: "hsl(var(--destructive))", foreground: "hsl(var(--destructive-foreground))", }, muted: { DEFAULT: "hsl(var(--muted))", foreground: "hsl(var(--muted-foreground))", }, accent: { DEFAULT: "hsl(var(--accent))", foreground: "hsl(var(--accent-foreground))", }, popover: { DEFAULT: "hsl(var(--popover))", foreground: "hsl(var(--popover-foreground))", }, card: { DEFAULT: "hsl(var(--card))", foreground: "hsl(var(--card-foreground))", }, }, borderRadius: { lg: "var(--radius)", md: "calc(var(--radius) - 2px)", sm: "calc(var(--radius) - 4px)", }, fontFamily: { sans: ["${config.customization.fontFamily}", "ui-sans-serif", "system-ui"], }, keyframes: { "accordion-down": { from: { height: 0 }, to: { height: "var(--radix-accordion-content-height)" }, }, "accordion-up": { from: { height: "var(--radix-accordion-content-height)" }, to: { height: 0 }, }, }, animation: { "accordion-down": "accordion-down 0.2s ease-out", "accordion-up": "accordion-up 0.2s ease-out", }, }, }, plugins: [require("tailwindcss-animate")], }`; await this.fileOperations.createFile( path.join(outputPath, 'tailwind.config.js'), tailwindConfig, undefined, { overwrite: true } ); } /** * Generates Next.js configuration file */ private async generateNextJsConfig(context: ProjectContext): Promise<void> { const { outputPath, config } = context; let nextConfig = `/** @type {import('next').NextConfig} */ const nextConfig = { experimental: { appDir: true, },`; // Add PWA configuration if enabled if (config.features.pwa) { nextConfig += ` // PWA configuration pwa: { dest: 'public', register: true, skipWaiting: true, },`; } nextConfig += ` } module.exports = nextConfig`; await this.fileOperations.createFile( path.join(outputPath, 'next.config.js'), nextConfig, undefined, { overwrite: true } ); } /** * Generates environment file template */ private async generateEnvironmentFile(context: ProjectContext): Promise<void> { const { outputPath, config } = context; let envContent = `# Environment variables for ${config.name} # Copy this file to .env.local and fill in your values # Next.js Configuration NEXT_PUBLIC_APP_NAME="${config.name}" NEXT_PUBLIC_APP_DESCRIPTION="${config.description}" NEXT_PUBLIC_APP_VERSION="${config.version}" # Site Configuration NEXT_PUBLIC_SITE_URL=http://localhost:3000 NEXT_PUBLIC_PRIMARY_COLOR="${config.customization.primaryColor}" NEXT_PUBLIC_SECONDARY_COLOR="${config.customization.secondaryColor}" `; // Add feature-specific environment variables if (config.features.analytics) { envContent += ` # Analytics Configuration NEXT_PUBLIC_ANALYTICS_ID= `; } if (config.features.database) { envContent += ` # Database Configuration DATABASE_URL= `; } if (config.features.authentication) { envContent += ` # Authentication Configuration NEXTAUTH_URL=http://localhost:3000 NEXTAUTH_SECRET= `; } await this.fileOperations.createFile( path.join(outputPath, '.env.example'), envContent, undefined, { overwrite: true } ); } /** * Installs project dependencies using Bun with secure path handling * * SECURITY FIX: Replaced shell command execution with safe subprocess calls * to prevent command injection attacks. All paths are validated and sanitized. */ private async installDependencies(context: ProjectContext): Promise<void> { const { outputPath } = context; this.logger.info('Installing dependencies with Bun...'); try { // SECURITY: Validate and sanitize the output path to prevent injection const sanitizedPath = this.sanitizePath(outputPath); if (!sanitizedPath) { throw new Error(`Invalid output path for dependency installation: ${outputPath}`); } // SECURITY: Use spawn with explicit arguments instead of shell interpolation const proc = Bun.spawn({ cmd: ['bun', 'install'], cwd: sanitizedPath, stdout: 'pipe', stderr: 'pipe', env: { ...process.env, // Prevent potential environment injection NODE_ENV: 'development', }, }); const output = await proc.exited; if (output !== 0) { const stderr = await new Response(proc.stderr).text(); throw new Error(`Bun install failed with exit code ${output}: ${stderr}`); } const stdout = await new Response(proc.stdout).text(); this.logger.success('Dependencies installed successfully'); this.logger.debug('Bun install output:', stdout); } catch (error) { this.logger.error('Failed to install dependencies', error); throw new Error(`Dependency installation failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Initializes a git repository for the project with secure command execution * * SECURITY FIX: Replaced shell command execution with safe subprocess calls * to prevent command injection attacks. */ private async initializeGitRepository(context: ProjectContext): Promise<void> { const { outputPath } = context; this.logger.debug('Initializing git repository'); try { // SECURITY: Validate and sanitize the output path const sanitizedPath = this.sanitizePath(outputPath); if (!sanitizedPath) { throw new Error(`Invalid output path for git initialization: ${outputPath}`); } // SECURITY: Use spawn instead of shell interpolation for git init const initProc = Bun.spawn({ cmd: ['git', 'init'], cwd: sanitizedPath, stdout: 'pipe', stderr: 'pipe', }); const initResult = await initProc.exited; if (initResult !== 0) { const stderr = await new Response(initProc.stderr).text(); throw new Error(`Git init failed: ${stderr}`); } // Create initial .gitignore const gitignoreContent = `# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.pnp .pnp.js # testing /coverage # next.js /.next/ /out/ # production /build # misc .DS_Store *.pem # debug npm-debug.log* yarn-debug.log* yarn-error.log* # local env files .env*.local # vercel .vercel # typescript *.tsbuildinfo next-env.d.ts # roadkit .roadkit-temp/ `; await this.fileOperations.createFile( path.join(outputPath, '.gitignore'), gitignoreContent, undefined, { overwrite: true } ); // SECURITY: Use safe spawn for git add const addProc = Bun.spawn({ cmd: ['git', 'add', '.'], cwd: sanitizedPath, stdout: 'pipe', stderr: 'pipe', }); const addResult = await addProc.exited; if (addResult !== 0) { const stderr = await new Response(addProc.stderr).text(); this.logger.warn(`Git add failed: ${stderr}`); return; // Don't proceed with commit if add failed } // SECURITY: Use safe spawn for git commit with explicit message const commitProc = Bun.spawn({ cmd: ['git', 'commit', '-m', 'Initial commit: RoadKit generated project'], cwd: sanitizedPath, stdout: 'pipe', stderr: 'pipe', }); const commitResult = await commitProc.exited; if (commitResult !== 0) { const stderr = await new Response(commitProc.stderr).text(); this.logger.warn(`Git commit failed: ${stderr}`); } this.logger.success('Git repository initialized'); } catch (error) { this.logger.warn('Failed to initialize git repository', error); // Don't throw error as this is not critical for project functionality } } /** * Validates the generated project structure and files */ private async validateGeneratedProject(context: ProjectContext): Promise<void> { const { outputPath, config } = context; this.logger.debug('Validating generated project'); // Check for essential files const essentialFiles = [ 'package.json', 'next.config.js', 'src/app/page.tsx', '.env.example', ]; if (config.technical.typescript) { essentialFiles.push('tsconfig.json'); } if (config.technical.tailwind) { essentialFiles.push('tailwind.config.js'); } const missingFiles: string[] = []; for (const file of essentialFiles) { const filePath = path.join(outputPath, file); const exists = await Bun.file(filePath).exists(); if (!exists) { missingFiles.push(file); } } if (missingFiles.length > 0) { throw new Error(`Generated project is missing essential files: ${missingFiles.join(', ')}`); } // Validate package.json structure try { const packageJsonPath = path.join(outputPath, 'package.json'); const packageJson = await Bun.file(packageJsonPath).json(); if (!packageJson.name || !packageJson.version || !packageJson.scripts) { throw new Error('Generated package.json is missing required fields'); } } catch (error) { throw new Error(`Invalid package.json generated: ${error instanceof Error ? error.message : 'Unknown error'}`); } this.logger.success('Project validation completed successfully'); } /** * Generates next steps instructions for the user */ private generateNextSteps(context: ProjectContext): string[] { const { outputPath, config } = context; const projectName = path.basename(outputPath); const steps = [ `cd ${projectName}`, ]; if (!config.output.installDependencies) { steps.push('bun install'); } steps.push( 'cp .env.example .env.local', 'Edit .env.local with your configuration', 'bun dev', 'Open http://localhost:3000 in your browser' ); if (config.features.deployment) { steps.push('Deploy to Vercel: vercel --prod'); } return steps; } // Utility methods /** * Reports progress to the callback if provided */ private reportProgress(stage: string, current: number, total: number, message?: string): void { if (this.progressCallback) { this.progressCallback(stage, current, total, message); } } /** * Resolves the path to a template directory */ private resolveTemplatePath(template: string): string { // In a real implementation, this would resolve to actual template directories const templatesDir = path.join(process.cwd(), 'src', 'templates'); return path.join(templatesDir, template); } /** * Resolves the path to a theme directory */ private resolveThemePath(theme: string): string { // In a real implementation, this would resolve to actual theme directories const themesDir = path.join(process.cwd(), 'src', 'themes'); return path.join(themesDir, theme); } /** * Determines if a file should not be overwritten by theme customizations */ private isUserCustomizedFile(relativePath: string, config: ProjectConfig): boolean { // Files that should not be overwritten by themes const userCustomizedFiles = [ 'package.json', '.env.example', 'README.md', 'src/app/page.tsx', // User might have customized the main page ]; return userCustomizedFiles.includes(relativePath); } // ============================================================================ // SECURITY UTILITY METHODS // ============================================================================ /** * Sanitizes a file path to prevent path traversal and injection attacks * * This method validates and normalizes file paths to ensure they are safe * for use in file operations and subprocess calls. * * @param filePath - The file path to sanitize * @returns Sanitized path or null if invalid */ private sanitizePath(filePath: string): string | null { try { // Basic input validation if (!filePath || typeof filePath !== 'string') { return null; } // Remove null bytes and dangerous characters let cleaned = filePath.replace(/\x00/g, ''); // Check for path traversal attempts if (cleaned.includes('..') || cleaned.includes('~')) { this.logger.warn(`Path traversal attempt detected: ${filePath}`); return null; } // Normalize the path to resolve any remaining issues cleaned = path.normalize(cleaned); // Ensure the path is absolute for security if (!path.isAbsolute(cleaned)) { cleaned = path.resolve(cleaned); } // Additional validation - ensure path doesn't escape expected boundaries const allowedBasePath = process.cwd(); if (!cleaned.startsWith(allowedBasePath)) { this.logger.warn(`Path outside allowed boundary: ${filePath}`); return null; } return cleaned; } catch (error) { this.logger.error('Path sanitization failed', error); return null; } } /** * Validates that a command is safe to execute * * @param command - The command to validate * @returns True if the command is safe */ private validateCommand(command: string[]): boolean { // Allowlist of safe commands const allowedCommands = ['bun', 'git', 'node', 'npm', 'yarn', 'pnpm']; if (!command || command.length === 0) { return false; } const baseCommand = command[0]; return allowedCommands.includes(baseCommand); } /** * Safely executes a command with proper validation and error handling * * @param cmd - Command array to execute * @param cwd - Working directory (will be sanitized) * @param options - Additional spawn options * @returns Promise that resolves when command completes */ private async safeCommandExecution( cmd: string[], cwd: string, options: any = {} ): Promise<{ success: boolean; stdout: string; stderr: string; exitCode: number }> { try { // Validate command safety if (!this.validateCommand(cmd)) { throw new Error(`Unsafe command attempted: ${cmd.join(' ')}`); } // Sanitize working directory const sanitizedCwd = this.sanitizePath(cwd); if (!sanitizedCwd) { throw new Error(`Invalid working directory: ${cwd}`); } // Execute command with safe defaults const proc = Bun.spawn({ cmd, cwd: sanitizedCwd, stdout: 'pipe', stderr: 'pipe', env: { // Provide clean environment to prevent injection PATH: process.env.PATH, HOME: process.env.HOME, NODE_ENV: 'development', }, ...options, }); const exitCode = await proc.exited; const stdout = await new Response(proc.stdout).text(); const stderr = await new Response(proc.stderr).text(); return { success: exitCode === 0, stdout, stderr, exitCode, }; } catch (error) { this.logger.error('Safe command execution failed', error); return { success: false, stdout: '', stderr: error instanceof Error ? error.message : 'Unknown error', exitCode: -1, }; } } } /** * Factory function to create a ProjectScaffoldingEngine instance * @param logger - Logger instance for tracking operations * @param progressCallback - Optional callback for progress updates * @returns Configured ProjectScaffoldingEngine instance */ export const createProjectScaffoldingEngine = ( logger: Logger, progressCallback?: ProgressCallback ): ProjectScaffoldingEngine => { return new ProjectScaffoldingEngine(logger, progressCallback); };