UNPKG

figma-docker-init

Version:

Quick-start Docker setup for Figma-exported React/Vite/TypeScript projects

1,149 lines (999 loc) 41.1 kB
#!/usr/bin/env node import fs from 'fs'; import path from 'path'; import { createRequire } from 'module'; const require = createRequire(import.meta.url); import { fileURLToPath } from 'url'; import { findProjectRoot, getFigmaDockerDir, getTemplatesDir, resolveTemplatePath, normalizePath, getRelativeFromRoot } from './src/lib/path-resolver.js'; import { templateCache } from './src/lib/template-cache.js'; import { ensureFigmaDockerStructure } from './src/lib/directory-manager.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const TEMPLATES_DIR = path.join(__dirname, 'templates'); // ============================================================================= // CUSTOM ERROR CLASSES // ============================================================================= // Functions: ValidationError, ConfigError // Purpose: Custom error types for consistent error handling across the application // ============================================================================= /** * Custom error class for validation errors */ class ValidationError extends Error { constructor(message) { super(message); this.name = 'ValidationError'; } } /** * Custom error class for configuration errors */ class ConfigError extends Error { constructor(message) { super(message); this.name = 'ConfigError'; } } // ============================================================================= // INPUT VALIDATION AND SANITIZATION UTILITIES // ============================================================================= // Functions: sanitizeString, validateTemplateName, validateProjectDirectory, validatePort, validateProjectName, sanitizeTemplateVariable, validateFilePath // Purpose: Ensure all user inputs are safe and valid before processing // ============================================================================= /** * Sanitizes a string by removing null bytes, control characters, and trimming whitespace. * @param {string} input - The input string to sanitize * @param {number} maxLength - Maximum allowed length (default: 255) * @returns {string} The sanitized string * @throws {Error} If input is not a string or exceeds maxLength */ function sanitizeString(input, maxLength = 255) { if (typeof input !== 'string') { throw new ValidationError('Input must be a string'); } // Remove null bytes and control characters const sanitized = input.replace(/[\x00-\x1F\x7F]/g, '').trim(); if (sanitized.length > maxLength) { throw new ValidationError(`Input exceeds maximum length of ${maxLength} characters`); } return sanitized; } /** * Validates and sanitizes a template name. * @param {string} templateName - The template name to validate * @returns {string} The validated and sanitized template name * @throws {Error} If template name contains invalid characters */ function validateTemplateName(templateName) { const sanitized = sanitizeString(templateName, 50); // Only allow alphanumeric characters, hyphens, and underscores if (!/^[a-zA-Z0-9_-]+$/.test(sanitized)) { throw new ValidationError('Template name contains invalid characters. Only alphanumeric characters, hyphens, and underscores are allowed.'); } return sanitized; } /** * Validates and sanitizes a project directory path. * @param {string} projectDir - The project directory path to validate * @returns {string} The validated and resolved project directory path * @throws {Error} If directory is outside current working directory */ function validateProjectDirectory(projectDir) { const sanitized = sanitizeString(projectDir, 4096); const resolvedPath = path.resolve(sanitized); // Prevent directory traversal attacks if (!resolvedPath.startsWith(process.cwd())) { throw new ValidationError('Project directory must be within the current working directory'); } return resolvedPath; } /** * Validates a port number. * @param {string|number} port - The port number to validate * @returns {number} The validated port number * @throws {Error} If port is not a valid number between 1 and 65535 */ function validatePort(port) { const portStr = String(port).trim(); const numPort = parseInt(port, 10); // Check if the original string is a valid number representation // Allow integers and floats, but reject strings with non-numeric suffixes const isValidNumber = !isNaN(Number(portStr)); if (!isValidNumber || isNaN(numPort) || numPort < 1 || numPort > 65535) { throw new ValidationError('Port must be a valid number between 1 and 65535'); } return numPort; } /** * Validates and sanitizes a project name. * @param {string} name - The project name to validate * @returns {string} The validated and sanitized project name * @throws {Error} If project name contains invalid characters */ function validateProjectName(name) { const sanitized = sanitizeString(name, 100); // Allow alphanumeric, hyphens, underscores, and dots if (!/^[a-zA-Z0-9._-]+$/.test(sanitized)) { throw new ValidationError('Project name contains invalid characters'); } return sanitized; } /** * Sanitizes template variable values for safe replacement. * @param {*} value - The value to sanitize * @returns {*} The sanitized value */ function sanitizeTemplateVariable(value) { if (typeof value === 'string') { // Validate string length if (value.length > 1000) { throw new ValidationError('Template variable exceeds maximum length of 1000 characters'); } // Escape special characters that could be used for injection return value.replace(/[<>]/g, '').trim(); } if (typeof value === 'boolean' || typeof value === 'number') { return value; } // Convert other types to string and sanitize return sanitizeString(String(value), 1000); } /** * Validates a file path to ensure it's within the allowed base directory. * @param {string} filePath - The file path to validate * @param {string} baseDir - The base directory the path must be within * @returns {string} The validated and resolved file path * @throws {Error} If file path is outside the allowed directory */ function validateFilePath(filePath, baseDir) { const sanitized = sanitizeString(filePath, 4096); const resolvedPath = path.resolve(baseDir, sanitized); // Ensure path is within base directory if (!resolvedPath.startsWith(path.resolve(baseDir))) { throw new ValidationError('File path is outside allowed directory'); } return resolvedPath; } // ============================================================================= // CONFIGURATION PARSING FUNCTIONS // ============================================================================= // Functions: parseConfig, parseViteConfig, parseRollupConfig, parseWebpackConfig, detectBuildOutputDir // Purpose: Extract build configuration from various build tool config files // ============================================================================= /** * Parse configuration file to extract values using regex pattern * @param {string} configPath - Full path to config file (with or without extension) * @param {RegExp} pattern - Regex pattern to extract value * @returns {Promise<string|null>} Extracted value or null if not found */ async function parseConfig(configPath, pattern) { const extensions = ['js', 'ts']; // If path already has extension, try it directly if (configPath.endsWith('.js') || configPath.endsWith('.ts')) { try { const content = await fs.promises.readFile(configPath, 'utf-8'); const match = content.match(pattern); return match ? match[1] : null; } catch (error) { return null; } } // Try adding .js and .ts extensions for (const ext of extensions) { try { const fullPath = `${configPath}.${ext}`; const content = await fs.promises.readFile(fullPath, 'utf-8'); const match = content.match(pattern); return match ? match[1] : null; } catch (error) { // Try next extension continue; } } return null; } /** * Parses Vite configuration to extract build output directory. * @param {string} projectDir - The project directory path * @returns {Promise<string|null>} The build output directory or null if not found */ async function parseViteConfig(projectDir) { const configPath = path.join(projectDir, 'vite.config'); return await parseConfig( configPath, /build\s*:\s*{[^}]*outDir\s*:\s*['"]([^'"]+)['"]/ ); } /** * Parses Rollup configuration to extract build output directory. * @param {string} projectDir - The project directory path * @returns {Promise<string|null>} The build output directory or null if not found */ async function parseRollupConfig(projectDir) { const configPath = path.join(projectDir, 'rollup.config'); return await parseConfig( configPath, /output\s*:\s*{[^}]*dir\s*:\s*['"]([^'"]+)['"]/ ); } /** * Parses Webpack configuration to extract build output directory. * @param {string} projectDir - The project directory path * @returns {Promise<string|null>} The build output directory or null if not found */ async function parseWebpackConfig(projectDir) { const configPath = path.join(projectDir, 'webpack.config'); return await parseConfig( configPath, /output\s*:\s*{[^}]*path\s*:\s*path\.resolve\([^,]+,\s*['"]([^'"]+)['"]/ ); } /** * Detects the build output directory by trying different build tool configurations. * @param {string} projectDir - The project directory path * @returns {Promise<string|null>} The detected build output directory or null */ async function detectBuildOutputDir(projectDir) { // Try Vite first let outputDir = await parseViteConfig(projectDir); if (outputDir) return outputDir; // Try Rollup outputDir = await parseRollupConfig(projectDir); if (outputDir) return outputDir; // Try Webpack outputDir = await parseWebpackConfig(projectDir); if (outputDir) return outputDir; return null; } // ============================================================================= // PROJECT DETECTION AND ANALYSIS FUNCTIONS // ============================================================================= // Functions: detectProjectValues // Purpose: Auto-detect project settings from package.json and config files // ============================================================================= /** * Detects project values from package.json and other configuration files. * @param {string} projectDir - The project directory path (default: '.') * @returns {Promise<Object>} Object containing detected project values */ async function detectProjectValues(projectDir = '.') { const values = {}; // Validate project directory let validatedProjectDir; try { validatedProjectDir = validateProjectDirectory(projectDir); } catch (error) { log(`Invalid project directory: ${error.message}`, colors.red); throw error; } // Detect PROJECT_NAME from package.json try { const packagePath = path.join(validatedProjectDir, 'package.json'); if (fs.existsSync(packagePath)) { const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8')); if (pkg.name) { values.PROJECT_NAME = validateProjectName(pkg.name); } else { values.PROJECT_NAME = 'my-app'; } } else { // No package.json found, use default values.PROJECT_NAME = 'my-app'; } } catch (error) { const packagePath = path.join(validatedProjectDir, 'package.json'); log(`Warning: Could not read or parse package.json at ${packagePath}. Error: ${error.message}. This may be due to invalid JSON syntax, missing file, or permission issues. Using default project name 'my-app'.`, colors.yellow); values.PROJECT_NAME = 'my-app'; } // Detect BUILD_OUTPUT_DIR dynamically values.BUILD_OUTPUT_DIR = await detectBuildOutputDir(validatedProjectDir) || 'dist'; // Detect FRAMEWORK, TYPESCRIPT, UI_LIBRARY, and DEPENDENCY_COUNT from package.json dependencies try { const packagePath = path.join(validatedProjectDir, 'package.json'); if (fs.existsSync(packagePath)) { const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8')); const deps = { ...pkg.dependencies, ...pkg.devDependencies }; const allDeps = Object.keys(deps); // Calculate dependency count values.DEPENDENCY_COUNT = allDeps.length; // Detect TypeScript values.TYPESCRIPT = allDeps.some(dep => dep.includes('typescript') || dep.includes('@types/')); // Detect UI Library if (deps['@mui/material'] || deps['@mui/core']) { values.UI_LIBRARY = 'Material-UI'; } else if (deps['antd'] || deps['@ant-design/icons']) { values.UI_LIBRARY = 'Ant Design'; } else if (deps['@chakra-ui/react']) { values.UI_LIBRARY = 'Chakra UI'; } else if (deps['@mantine/core']) { values.UI_LIBRARY = 'Mantine'; } else if (deps['react-bootstrap'] || deps['bootstrap']) { values.UI_LIBRARY = 'Bootstrap'; } else if (deps['tailwindcss']) { values.UI_LIBRARY = 'Tailwind CSS'; } else { values.UI_LIBRARY = 'none'; } // Enhanced FRAMEWORK detection with build tools if (deps['next']) { values.FRAMEWORK = 'next.js'; } else if (deps['vite']) { if (deps['react']) { values.FRAMEWORK = 'react-vite'; } else if (deps['vue']) { values.FRAMEWORK = 'vue-vite'; } else if (deps['svelte']) { values.FRAMEWORK = 'svelte-vite'; } else { values.FRAMEWORK = 'vite'; } } else if (deps['webpack'] || deps['webpack-cli']) { if (deps['react']) { values.FRAMEWORK = 'react-webpack'; } else if (deps['vue']) { values.FRAMEWORK = 'vue-webpack'; } else { values.FRAMEWORK = 'webpack'; } } else if (deps['rollup']) { if (deps['react']) { values.FRAMEWORK = 'react-rollup'; } else if (deps['vue']) { values.FRAMEWORK = 'vue-rollup'; } else if (deps['svelte']) { values.FRAMEWORK = 'svelte-rollup'; } else { values.FRAMEWORK = 'rollup'; } } else if (deps['react']) { values.FRAMEWORK = 'react'; } else if (deps['vue']) { values.FRAMEWORK = 'vue'; } else if (deps['svelte']) { values.FRAMEWORK = 'svelte'; } else { values.FRAMEWORK = 'vanilla'; } } else { // No package.json found, use defaults values.FRAMEWORK = 'vanilla'; values.TYPESCRIPT = false; values.UI_LIBRARY = 'none'; values.DEPENDENCY_COUNT = 0; } } catch (error) { const packagePath = path.join(validatedProjectDir, 'package.json'); log(`Warning: Could not read or parse package.json at ${packagePath} for project detection. Error: ${error.message}. This may be due to invalid JSON syntax, missing file, or permission issues. Using default framework detection values.`, colors.yellow); values.FRAMEWORK = 'vanilla'; values.TYPESCRIPT = false; values.UI_LIBRARY = 'none'; values.DEPENDENCY_COUNT = 0; } // Assign dynamic ports const dynamicPorts = await assignDynamicPorts(); Object.assign(values, dynamicPorts); return values; } // ============================================================================= // TEMPLATE VALIDATION AND PROCESSING FUNCTIONS // ============================================================================= // Functions: validateTemplate, checkBuildCompatibility, replaceTemplateVariables // Purpose: Validate and process template files with variable replacement // ============================================================================= /** * Validates a template by checking for required variables and syntax errors. * @param {string} templatePath - Path to the template directory * @param {Object} variables - Template variables object * @returns {Object} Validation result with errors and warnings arrays */ function validateTemplate(templatePath, variables) { // Updated required variables to include new per-project variables const requiredVars = [ 'PROJECT_NAME', 'BUILD_OUTPUT_DIR', 'FRAMEWORK', 'TYPESCRIPT', 'UI_LIBRARY', 'DEPENDENCY_COUNT', 'DEV_PORT', 'PROD_PORT', 'NGINX_PORT' ]; const errors = []; const warnings = []; // Note: PROJECT_ROOT and FIGMA_DOCKER_DIR are auto-added by replaceTemplateVariables // so they don't need to be in requiredVars // Check for required variables const missingVars = requiredVars.filter(varName => !(varName in variables)); if (missingVars.length > 0) { errors.push(`Missing required variables: ${missingVars.join(', ')}`); } // Check template files for syntax errors and undefined variables let files; try { files = fs.readdirSync(templatePath); } catch (error) { throw new Error(`Failed to read template directory at ${templatePath}. Error: ${error.message}. This may be due to directory not found, permission issues, or invalid path.`); } files.forEach(file => { const filePath = path.join(templatePath, file); let isFile; try { isFile = fs.statSync(filePath).isFile(); } catch (error) { throw new Error(`Failed to stat file "${file}" at ${filePath}. Error: ${error.message}. This may be due to file not found or permission issues.`); } if (isFile) { try { // Validate file path before reading validateFilePath(filePath, templatePath); const content = fs.readFileSync(filePath, 'utf8'); const variableRegex = /\{\{(\w+)\}\}/g; let match; const foundVars = new Set(); while ((match = variableRegex.exec(content)) !== null) { const varName = match[1]; foundVars.add(varName); if (!(varName in variables)) { warnings.push(`Undefined template variable "${varName}" found in file "${file}". This may cause incomplete template processing.`); } } // Check for unmatched braces const openBraces = (content.match(/\{\{/g) || []).length; const closeBraces = (content.match(/\}\}/g) || []).length; if (openBraces !== closeBraces) { errors.push(`Template syntax error in "${file}": Unmatched template braces ({{ and }}). Found ${openBraces} opening braces and ${closeBraces} closing braces.`); } // Check for potentially dangerous content if (content.includes('<script') || content.includes('javascript:')) { warnings.push(`Potentially unsafe content detected in "${file}". Please review template content for security.`); } } catch (error) { const filePath = path.join(templatePath, file); errors.push(`Failed to validate template file "${file}" at ${filePath}. Error: ${error.message}. This may be due to file read permission issues, invalid file path, or corrupted file content.`); } } }); return { errors, warnings }; } /** * Checks build compatibility between framework and build output directory. * @param {string} framework - The detected framework * @param {string} buildOutputDir - The build output directory * @returns {Object} Compatibility check result with errors and warnings arrays */ function checkBuildCompatibility(framework, buildOutputDir) { const errors = []; const warnings = []; // Basic compatibility checks if (framework.includes('vite') && !buildOutputDir) { warnings.push('Vite framework detected but no build output directory specified'); } if (framework.includes('next.js') && buildOutputDir !== 'out') { warnings.push('Next.js typically uses "out" as build directory, but detected different'); } // Add more specific checks as needed return { errors, warnings }; } /** * Replaces template variables in content with provided values. * @param {string} content - The template content * @param {Object} variables - Variables to replace * @param {string} templatePath - Optional template path for caching * @returns {string} Content with variables replaced */ function replaceTemplateVariables(content, variables, templatePath = null) { // Check cache first if templatePath is provided if (templatePath) { const cached = templateCache.get(templatePath, variables); if (cached !== null) { return cached; } } // Add new template variables for per-project installation const projectRoot = findProjectRoot(); const figmaDockerDir = getFigmaDockerDir(); const enhancedVariables = { ...variables, PROJECT_ROOT: projectRoot || process.cwd(), FIGMA_DOCKER_DIR: figmaDockerDir, PROJECT_ROOT_RELATIVE: projectRoot ? normalizePath(projectRoot) : '.', FIGMA_DOCKER_DIR_RELATIVE: projectRoot ? getRelativeFromRoot(figmaDockerDir, projectRoot) : '.figma-docker' }; let result = content; const regex = /\{\{(\w+)\}\}/g; let match; while ((match = regex.exec(content)) !== null) { const variableName = match[1]; let replacement = enhancedVariables[variableName]; // Validate and sanitize template variables if (replacement !== undefined) { try { replacement = sanitizeTemplateVariable(replacement); } catch (error) { log(`Warning: Failed to sanitize template variable "${variableName}". Error: ${error.message}. This may be due to invalid variable value type or length. Keeping original placeholder.`, colors.yellow); replacement = `{{${variableName}}}`; // Keep original placeholder on sanitization failure } } else { replacement = `{{${variableName}}}`; // Keep original placeholder if variable not found } result = result.replace(new RegExp(`\\{\\{${variableName}\\}\\}`, 'g'), replacement); } // Cache the result if templatePath is provided if (templatePath) { templateCache.set(templatePath, variables, result); } return result; } // ============================================================================= // PORT MANAGEMENT FUNCTIONS // ============================================================================= // Functions: checkPortAvailability, findAvailablePort, assignDynamicPorts // Purpose: Manage port allocation and availability checking // ============================================================================= /** * Checks if a port is available for binding. * @param {number} port - The port number to check * @returns {Promise<boolean>} True if port is available, false otherwise */ function checkPortAvailability(port) { return new Promise((resolve) => { // Validate port before checking availability try { validatePort(port); } catch (error) { // Only log validation errors in non-test environments const isTestEnv = process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined; if (!isTestEnv) { log(`Invalid port for availability check: ${error.message}`, colors.red); } resolve(false); return; } const net = require('net'); const server = net.createServer(); server.listen(port, '127.0.0.1', () => { server.close(); resolve(true); // Port is available }); server.on('error', (error) => { // Suppress logging during test runs to avoid noise const isTestEnv = process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined; if (!isTestEnv) { log(`Port ${port} availability check failed: ${error.message}`, colors.yellow); } resolve(false); // Port is in use or invalid }); }); } /** * Finds an available port starting from a given port number. * @param {number} startPort - The port number to start searching from * @param {number} maxAttempts - Maximum number of attempts (default: 100) * @returns {Promise<number>} The first available port found * @throws {Error} If no available port is found within maxAttempts */ async function findAvailablePort(startPort, maxAttempts = 100) { let eaccesCount = 0; const maxEaccesAttempts = 5; // If we get 5 EACCES errors in a row, jump to unprivileged ports for (let i = 0; i < maxAttempts; i++) { let port = startPort + i; // If we're getting repeated EACCES errors on privileged ports, jump to unprivileged range if (eaccesCount >= maxEaccesAttempts && port < 1024) { port = 3000 + i; eaccesCount = 0; // Reset counter after jumping } // Skip privileged ports (< 1024) if we've detected permission issues if (eaccesCount >= maxEaccesAttempts && port < 1024) { continue; } try { const net = require('net'); const server = net.createServer(); // Try to bind to the port synchronously-ish const available = await new Promise((resolve) => { const timeout = setTimeout(() => { server.close(); resolve(false); }, 100); // 100ms timeout per port check server.listen(port, '127.0.0.1', () => { clearTimeout(timeout); server.close(); resolve(true); }); server.on('error', (error) => { clearTimeout(timeout); // Track EACCES errors (permission denied on privileged ports) if (error.code === 'EACCES' && port < 1024) { eaccesCount++; } else { eaccesCount = 0; // Reset on other errors } resolve(false); }); }); if (available) { return port; } // If we've hit too many EACCES errors, jump to unprivileged range if (eaccesCount >= maxEaccesAttempts && port < 1024) { i = -1; // Reset loop to start from unprivileged ports startPort = 3000; eaccesCount = 0; } } catch (error) { // Continue to next port on any error continue; } } throw new Error(`Could not find available port starting from ${startPort}`); } /** * Assigns dynamic ports for development, production, and nginx services. * @returns {Promise<Object>} Object containing assigned port numbers */ async function assignDynamicPorts() { const defaultPorts = { DEV_PORT: 3000, PROD_PORT: 8080, NGINX_PORT: 8888 // Changed from 80 to avoid privileged port }; const assignedPorts = {}; const isTestEnv = process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined; for (const [key, defaultPort] of Object.entries(defaultPorts)) { const isAvailable = await checkPortAvailability(defaultPort); if (isAvailable) { assignedPorts[key] = defaultPort; } else { try { // For privileged ports, start searching from 3000 const startPort = defaultPort < 1024 ? 3000 : defaultPort + 1; assignedPorts[key] = await findAvailablePort(startPort); // Only log port changes in non-test environments if (!isTestEnv) { log(`${colors.yellow}Port ${defaultPort} is in use, assigned ${assignedPorts[key]} instead${colors.reset}`); } } catch (error) { // Only log errors in non-test environments if (!isTestEnv) { log(`${colors.red}Error finding available port for ${key}: ${error.message}${colors.reset}`); } assignedPorts[key] = defaultPort; // Fallback to default } } } return assignedPorts; } // ============================================================================= // CLI INTERFACE FUNCTIONS // ============================================================================= // Functions: showHelp, showVersion, listTemplates // Purpose: Command-line interface and user interaction // ============================================================================= /** * Displays help information for the CLI tool. */ function showHelp() { log(` ${colors.bold}${colors.blue}Figma Docker Init${colors.reset} Quick-start Docker setup for Figma-exported React/Vite/TypeScript projects ${colors.bold}Usage:${colors.reset} figma-docker-init [template] [options] ${colors.bold}Templates:${colors.reset} basic Basic Docker setup with minimal configuration ui-heavy Optimized for UI-heavy applications with advanced caching ${colors.bold}Options:${colors.reset} -h, --help Show this help message -v, --version Show version number --list List available templates ${colors.bold}Examples:${colors.reset} figma-docker-init basic figma-docker-init ui-heavy figma-docker-init --list `); } /** * Displays version information. */ function showVersion() { const packagePath = path.join(__dirname, 'package.json'); if (fs.existsSync(packagePath)) { const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8')); log(`figma-docker-init v${pkg.version}`, colors.blue); } else { log('figma-docker-init v1.0.0', colors.blue); } } /** * Lists available templates. */ function listTemplates() { log(`${colors.bold}Available Templates:${colors.reset}\n`); // Get package templates directory const templatesDir = getTemplatesDir(); if (!fs.existsSync(templatesDir)) { log(`Error: Templates directory not found at ${templatesDir}. Please ensure the templates directory exists and is accessible.`, colors.red); return; } const templates = fs.readdirSync(templatesDir).filter(item => { return fs.statSync(path.join(templatesDir, item)).isDirectory(); }); if (templates.length === 0) { log('No templates available', colors.yellow); return; } log(`${colors.dim}Templates location: ${templatesDir}${colors.reset}\n`); templates.forEach(template => { log(` ${colors.blue}${template}${colors.reset}`); }); } // ============================================================================= // UTILITIES // ============================================================================= // Functions: log, colors // Purpose: Logging and console output formatting // ============================================================================= // Color codes for terminal output const colors = { green: '\x1b[32m', blue: '\x1b[34m', yellow: '\x1b[33m', red: '\x1b[31m', reset: '\x1b[0m', bold: '\x1b[1m' }; /** * Logs a message to the console with optional color formatting. * @param {string} message - The message to log * @param {string} color - The color code to use (default: reset) */ function log(message, color = colors.reset) { console.log(`${color}${message}${colors.reset}`); } // ============================================================================= // MAIN APPLICATION LOGIC // ============================================================================= // Functions: copyTemplate, main // Purpose: Core workflow orchestration and CLI entry point // ============================================================================= /** * Copies and processes a template to the target directory. * @param {string} templateName - Name of the template to copy * @param {string} targetDir - Target directory (default: '.') * @returns {Promise<void>} */ async function copyTemplate(templateName, targetDir = '.') { // Validate template name const validatedTemplateName = validateTemplateName(templateName); // Validate target directory const validatedTargetDir = validateProjectDirectory(targetDir); // Ensure .figma-docker directory structure exists const projectRoot = findProjectRoot(validatedTargetDir) || validatedTargetDir; const directories = ensureFigmaDockerStructure(projectRoot); log(`${colors.blue}Created .figma-docker directory structure at: ${directories.root}${colors.reset}`); // Use path-resolver to find template (checks .figma-docker first, then package templates) const templatePath = resolveTemplatePath(validatedTemplateName); if (!fs.existsSync(templatePath)) { log(`Template "${validatedTemplateName}" not found!`, colors.red); const templatesDir = getTemplatesDir(); const availableTemplates = fs.existsSync(templatesDir) ? fs.readdirSync(templatesDir).filter(item => fs.statSync(path.join(templatesDir, item)).isDirectory()) : []; log(`Available templates: ${availableTemplates.join(', ') || 'none'}`, colors.yellow); process.exit(1); } log(`${colors.bold}${colors.blue}Setting up Docker configuration for "${validatedTemplateName}" template...${colors.reset}\n`); // Detect project values const projectValues = await detectProjectValues(validatedTargetDir); // Validate template const validation = validateTemplate(templatePath, projectValues); if (validation.errors.length > 0) { log(`${colors.red}Template validation errors:${colors.reset}`); validation.errors.forEach(error => log(` ${colors.red}${colors.reset} ${error}`)); process.exit(1); } if (validation.warnings.length > 0) { log(`${colors.yellow}Template validation warnings:${colors.reset}`); validation.warnings.forEach(warning => log(` ${colors.yellow}${colors.reset} ${warning}`)); } // Check build compatibility const compatibility = checkBuildCompatibility(projectValues.FRAMEWORK, projectValues.BUILD_OUTPUT_DIR); if (compatibility.errors.length > 0) { log(`${colors.red}Build compatibility errors:${colors.reset}`); compatibility.errors.forEach(error => log(` ${colors.red}${colors.reset} ${error}`)); process.exit(1); } if (compatibility.warnings.length > 0) { log(`${colors.yellow}Build compatibility warnings:${colors.reset}`); compatibility.warnings.forEach(warning => log(` ${colors.yellow}${colors.reset} ${warning}`)); } // Process template files let files; try { files = fs.readdirSync(templatePath); } catch (error) { log(`Error: Failed to read template directory at ${templatePath}. Error: ${error.message}. This may be due to directory not found, permission issues, or invalid path.`, colors.red); throw error; } const copiedFiles = []; const skippedFiles = []; files.forEach(file => { const sourcePath = path.join(templatePath, file); // Write files to .figma-docker directory instead of project root const figmaDockerDir = getFigmaDockerDir(projectRoot); const targetPath = path.join(figmaDockerDir, file); try { // Validate file paths - get package templates directory const templatesDir = getTemplatesDir(); validateFilePath(sourcePath, templatesDir); validateFilePath(targetPath, figmaDockerDir); // Skip directories - only process files if (fs.statSync(sourcePath).isDirectory()) { return; } if (fs.existsSync(targetPath)) { log(` ${colors.yellow}Skipped${colors.reset} ${file} (already exists)`); skippedFiles.push(file); } else { // Read template content and replace variables let templateContent; try { templateContent = fs.readFileSync(sourcePath, 'utf8'); } catch (error) { log(`Error: Failed to read template file "${file}" from ${sourcePath}. Error: ${error.message}. This may be due to file not found, permission issues, or corrupted file.`, colors.red); throw error; } // Pass sourcePath for caching const processedContent = replaceTemplateVariables(templateContent, projectValues, sourcePath); // Write processed content to target file in .figma-docker try { fs.writeFileSync(targetPath, processedContent); log(` ${colors.green}Created${colors.reset} ${file} in .figma-docker/`); } catch (error) { log(`Error: Failed to write template file "${file}" to ${targetPath}. Error: ${error.message}. This may be due to insufficient permissions, disk space issues, or invalid file path.`, colors.red); throw error; } copiedFiles.push(file); } } catch (error) { log(`${colors.red}Error processing ${file}: ${error.message}${colors.reset}`); process.exit(1); } }); // Automatically create .env from .env.example in .figma-docker directory const envExamplePath = path.join(directories.root, '.env.example'); const envPath = path.join(directories.root, '.env'); if (fs.existsSync(envExamplePath) && !fs.existsSync(envPath)) { try { fs.copyFileSync(envExamplePath, envPath); log(`${colors.green}✓ Created .env from .env.example${colors.reset}`); } catch (error) { log(`${colors.yellow}⚠ Warning: Could not create .env file: ${error.message}${colors.reset}`); log(`${colors.yellow} Please manually copy .env.example to .env${colors.reset}`); } } else if (fs.existsSync(envPath)) { log(`${colors.blue}ℹ .env file already exists, skipping creation${colors.reset}`); } log(`\n${colors.bold}${colors.green}Setup Complete!${colors.reset}`); log(`${colors.bold}Files created:${colors.reset} ${copiedFiles.length}`); log(`${colors.bold}Files skipped:${colors.reset} ${skippedFiles.length}`); if (copiedFiles.length > 0) { log(`\n${colors.bold}Port Assignments:${colors.reset}`); log(` ${colors.blue}Development server:${colors.reset} http://localhost:${projectValues.DEV_PORT}`); log(` ${colors.blue}Production server:${colors.reset} http://localhost:${projectValues.PROD_PORT}`); log(` ${colors.blue}Nginx proxy:${colors.reset} http://localhost:${projectValues.NGINX_PORT}`); log(`\n${colors.bold}Next Steps:${colors.reset}`); log(`1. Review and customize the generated Docker configuration files`); log(`2. Update environment variables in .env if needed`); log(`3. Build and run your Docker container:`); log(` ${colors.blue}cd .figma-docker && docker-compose up -d --build${colors.reset}`); log(`\n${colors.bold}To view logs:${colors.reset}`); log(` ${colors.blue}docker-compose logs -f${colors.reset}`); if (fs.existsSync(path.join(validatedTargetDir, 'DOCKER.md'))) { log(`4. Read DOCKER.md for detailed documentation and advanced usage`); } } if (skippedFiles.length > 0) { log(`\n${colors.yellow}Note: Some files were skipped because they already exist.${colors.reset}`); log(`${colors.yellow}Remove existing files if you want to regenerate them.${colors.reset}`); // Still show DOCKER.md reference if it exists, even when files are skipped if (fs.existsSync(path.join(validatedTargetDir, 'DOCKER.md'))) { log(`${colors.yellow}Read DOCKER.md for detailed documentation and advanced usage${colors.reset}`); } } } /** * Main entry point for the CLI application. */ function main() { const args = process.argv.slice(2); if (args.length === 0 || args.includes('-h') || args.includes('--help')) { showHelp(); return; } if (args.includes('-v') || args.includes('--version')) { showVersion(); return; } if (args.includes('--list')) { listTemplates(); return; } const templateName = args[0]; if (!templateName) { log('Please specify a template name!', colors.red); showHelp(); process.exit(1); } // Validate current directory has package.json (basic sanity check) if (!fs.existsSync('./package.json')) { log(`${colors.yellow}Warning: No package.json found in current directory.${colors.reset}`); log(`${colors.yellow}Make sure you're in the root of your project.${colors.reset}\n`); } copyTemplate(templateName); } // Error handling process.on('uncaughtException', (error) => { log(`${colors.red}Error: ${error.message}${colors.reset}`); process.exit(1); }); process.on('unhandledRejection', (reason, promise) => { log(`${colors.red}Unhandled Rejection: ${reason}${colors.reset}`); process.exit(1); }); // Only run main() when this file is executed directly (not when imported for testing) // Check if this module is the main module being executed // Handle both direct execution and symlink execution (e.g., via npm bin) const isMainModule = process.argv[1] && ( import.meta.url === `file://${process.argv[1]}` || import.meta.url.endsWith(process.argv[1]) || fileURLToPath(import.meta.url) === path.resolve(process.argv[1]) || fileURLToPath(import.meta.url) === fs.realpathSync(process.argv[1]) ); if (isMainModule) { main(); } // ============================================================================= // MODULE EXPORTS // ============================================================================= export { // Custom Error Classes ValidationError, ConfigError, // Validation Functions sanitizeString, validateTemplateName, validateProjectDirectory, validatePort, validateProjectName, sanitizeTemplateVariable, validateFilePath, // Config Parsing Functions parseConfig, parseViteConfig, parseRollupConfig, parseWebpackConfig, detectBuildOutputDir, detectProjectValues, // Template Processing Functions validateTemplate, checkBuildCompatibility, replaceTemplateVariables, // Port Management Functions checkPortAvailability, findAvailablePort, assignDynamicPorts, // CLI Interface Functions showHelp, showVersion, listTemplates, // Main Logic Functions copyTemplate };