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