#!/usr/bin/env node // Copyright 2025 Timothé Lapetite and contributors // Released under the MIT license https://opensource.org/license/MIT/ // NOTE : This code was generated by Claude AI. const fs = require('fs'); const path = require('path'); const PLUGIN_NAME = 'PCGExtendedToolkit'; const MODULE_PREFIX = 'PCGEx'; const EDITOR_SUFFIX = 'Editor'; const PLATFORMS = { runtime: ['Win64', 'Mac', 'IOS', 'Android', 'Linux', 'LinuxArm64'], editor: ['Win64', 'Mac', 'Linux'] }; // Resolve paths relative to script location (assumes script is in plugin root or Config/) const scriptDir = __dirname; const pluginRoot = fs.existsSync(path.join(scriptDir, `${PLUGIN_NAME}.uplugin`)) ? scriptDir : path.dirname(scriptDir); // Project root is typically two levels up from plugin (Plugins/PCGExtendedToolkit/) const projectRoot = path.dirname(path.dirname(pluginRoot)); const PATHS = { uplugin: path.join(pluginRoot, `${PLUGIN_NAME}.uplugin`), modulesDir: path.join(pluginRoot, 'Source'), binaries: path.join(pluginRoot, 'Binaries'), intermediate: path.join(pluginRoot, 'Intermediate'), // Project-level config takes priority, falls back to plugin config projectSubmodulesConfig: path.join(projectRoot, 'Config', 'PCGExSubModulesConfig.ini'), pluginSubmodulesConfig: path.join(pluginRoot, 'Config', 'PCGExSubModulesConfig.ini'), pluginsDeps: path.join(pluginRoot, 'Config', 'PluginsDeps.ini') }; // ============================================================================= // Fuzzy Matching // ============================================================================= function levenshteinDistance(a, b) { const matrix = []; for (let i = 0; i <= b.length; i++) { matrix[i] = [i]; } for (let j = 0; j <= a.length; j++) { matrix[0][j] = j; } for (let i = 1; i <= b.length; i++) { for (let j = 1; j <= a.length; j++) { if (b.charAt(i - 1) === a.charAt(j - 1)) { matrix[i][j] = matrix[i - 1][j - 1]; } else { matrix[i][j] = Math.min( matrix[i - 1][j - 1] + 1, // substitution matrix[i][j - 1] + 1, // insertion matrix[i - 1][j] + 1 // deletion ); } } } return matrix[b.length][a.length]; } function findClosestMatch(name, candidates, maxDistance = 3) { let best = null; let bestDistance = Infinity; for (const candidate of candidates) { const distance = levenshteinDistance(name.toLowerCase(), candidate.toLowerCase()); if (distance < bestDistance && distance <= maxDistance) { bestDistance = distance; best = candidate; } } return best; } // ============================================================================= // INI Parsing // ============================================================================= function resolveSubmodulesConfigPath() { // Check project-level config first if (fs.existsSync(PATHS.projectSubmodulesConfig)) { return { path: PATHS.projectSubmodulesConfig, isProjectLevel: true }; } // Fall back to plugin config if (fs.existsSync(PATHS.pluginSubmodulesConfig)) { return { path: PATHS.pluginSubmodulesConfig, isProjectLevel: false }; } return null; } function parseSubModulesConfig(filePath) { const modules = []; const content = fs.readFileSync(filePath, 'utf8'); for (const line of content.split('\n')) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith(';')) continue; // Support both "ModuleName=1" and plain "ModuleName" formats const parts = trimmed.split('='); const moduleName = parts[0].trim(); if (parts.length === 1 || parts[1].trim() === '1') { if (moduleName.startsWith(MODULE_PREFIX)) { modules.push(moduleName); } } } return modules; } function parsePluginsDeps(filePath) { const deps = new Map(); // moduleName -> [pluginNames] if (!fs.existsSync(filePath)) { console.warn(`PluginsDeps.ini not found at: ${filePath} - skipping plugin dependencies`); return deps; } const content = fs.readFileSync(filePath, 'utf8'); let currentModule = null; for (const line of content.split('\n')) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith(';')) continue; // Section header: [ModuleName] const sectionMatch = trimmed.match(/^\[(.+)\]$/); if (sectionMatch) { currentModule = sectionMatch[1]; if (!deps.has(currentModule)) { deps.set(currentModule, []); } continue; } // Plugin name under current section if (currentModule && trimmed) { deps.get(currentModule).push(trimmed); } } return deps; } // ============================================================================= // Module Discovery // ============================================================================= function getAvailableModules() { if (!fs.existsSync(PATHS.modulesDir)) { return []; } return fs.readdirSync(PATHS.modulesDir).filter(name => { const fullPath = path.join(PATHS.modulesDir, name); return fs.statSync(fullPath).isDirectory() && name.startsWith(MODULE_PREFIX); }); } function moduleExists(moduleName) { const modulePath = path.join(PATHS.modulesDir, moduleName); return fs.existsSync(modulePath) && fs.statSync(modulePath).isDirectory(); } function getEditorCompanion(moduleName) { if (moduleName.endsWith(EDITOR_SUFFIX)) return null; const editorName = moduleName + EDITOR_SUFFIX; return moduleExists(editorName) ? editorName : null; } function scanBuildCsDependencies(moduleName) { const buildFile = path.join(PATHS.modulesDir, moduleName, `${moduleName}.Build.cs`); if (!fs.existsSync(buildFile)) return []; const content = fs.readFileSync(buildFile, 'utf8'); const deps = []; // Match: PublicDependencyModuleNames.AddRange(new[] { ... }) or similar const blockPattern = /(?:Public|Private)DependencyModuleNames\s*\.\s*AddRange\s*\(\s*new\s*(?:string)?\s*\[\s*\]\s*\{([^}]*)\}/g; const namePattern = /"(PCGEx\w+)"/g; let blockMatch; while ((blockMatch = blockPattern.exec(content)) !== null) { const arrayContent = blockMatch[1]; let nameMatch; while ((nameMatch = namePattern.exec(arrayContent)) !== null) { const dep = nameMatch[1]; if (dep !== moduleName && !deps.includes(dep)) { deps.push(dep); } } } return deps; } // ============================================================================= // Module Resolution // ============================================================================= function validateRequestedModules(requestedModules, availableModules) { const valid = []; const invalid = []; for (const moduleName of requestedModules) { if (moduleExists(moduleName)) { valid.push(moduleName); } else { const suggestion = findClosestMatch(moduleName, availableModules); invalid.push({ name: moduleName, suggestion }); } } return { valid, invalid }; } function resolveAllModules(requestedModules) { const resolved = new Set(); const queue = [...requestedModules]; while (queue.length > 0) { const moduleName = queue.shift(); if (resolved.has(moduleName)) continue; if (!moduleExists(moduleName)) { continue; // Already warned during validation } resolved.add(moduleName); // Add editor companion if exists const editor = getEditorCompanion(moduleName); if (editor && !resolved.has(editor)) { queue.push(editor); } // Add PCGEx dependencies const deps = scanBuildCsDependencies(moduleName); for (const dep of deps) { if (!resolved.has(dep)) { queue.push(dep); } } } return resolved; } // ============================================================================= // Uplugin Generation // ============================================================================= function loadExistingUplugin() { if (!fs.existsSync(PATHS.uplugin)) { console.error(`.uplugin not found at: ${PATHS.uplugin}`); process.exit(1); } return fs.readFileSync(PATHS.uplugin, 'utf8'); } function buildModuleEntry(name) { const isEditor = name.endsWith(EDITOR_SUFFIX); return { Name: name, Type: isEditor ? 'Editor' : 'Runtime', LoadingPhase: 'Default', PlatformAllowList: isEditor ? [...PLATFORMS.editor] : [...PLATFORMS.runtime] }; } function buildPluginEntry(name) { return { Name: name, Enabled: true }; } function resolveRequiredPlugins(modules, pluginsDeps) { const plugins = new Set(['PCG']); // PCG is always required for (const moduleName of modules) { const required = pluginsDeps.get(moduleName); if (required) { for (const plugin of required) { plugins.add(plugin); } } } return plugins; } function generateUplugin(existingUplugin, modules, plugins) { // Sort modules: umbrella first, then alphabetically const sortedModules = Array.from(modules).sort((a, b) => { const aIsUmbrella = a === PLUGIN_NAME || a === PLUGIN_NAME + EDITOR_SUFFIX; const bIsUmbrella = b === PLUGIN_NAME || b === PLUGIN_NAME + EDITOR_SUFFIX; if (aIsUmbrella && !bIsUmbrella) return -1; if (!aIsUmbrella && bIsUmbrella) return 1; if (aIsUmbrella && bIsUmbrella) { // Main umbrella before editor umbrella return a === PLUGIN_NAME ? -1 : 1; } return a.localeCompare(b); }); // Build new uplugin preserving all existing metadata const newUplugin = { ...existingUplugin }; // Always include umbrella modules const allModules = new Set(sortedModules); allModules.add(PLUGIN_NAME); allModules.add(PLUGIN_NAME + EDITOR_SUFFIX); newUplugin.Modules = Array.from(allModules) .sort((a, b) => { const aIsUmbrella = a === PLUGIN_NAME || a === PLUGIN_NAME + EDITOR_SUFFIX; const bIsUmbrella = b === PLUGIN_NAME || b === PLUGIN_NAME + EDITOR_SUFFIX; if (aIsUmbrella && !bIsUmbrella) return -1; if (!aIsUmbrella && bIsUmbrella) return 1; if (aIsUmbrella && bIsUmbrella) return a === PLUGIN_NAME ? -1 : 1; return a.localeCompare(b); }) .map(buildModuleEntry); newUplugin.Plugins = Array.from(plugins) .sort((a, b) => { // PCG first, then alphabetically if (a === 'PCG') return -1; if (b === 'PCG') return 1; return a.localeCompare(b); }) .map(buildPluginEntry); return newUplugin; } // ============================================================================= // Build Artifact Cleanup // ============================================================================= function deleteFolderRecursive(folderPath) { if (!fs.existsSync(folderPath)) { return false; } fs.rmSync(folderPath, { recursive: true, force: true }); return true; } function cleanBuildArtifacts() { console.log('\n[CLEANUP] .uplugin changed - invalidating build artifacts...'); let cleaned = false; if (deleteFolderRecursive(PATHS.binaries)) { console.log(` Deleted: ${PATHS.binaries}`); cleaned = true; } if (deleteFolderRecursive(PATHS.intermediate)) { console.log(` Deleted: ${PATHS.intermediate}`); cleaned = true; } if (!cleaned) { console.log(' No build artifacts to clean.'); } } // ============================================================================= // Main // ============================================================================= function main() { console.log(`Generating ${PLUGIN_NAME}.uplugin...`); console.log(`Plugin root: ${pluginRoot}`); console.log(`Project root: ${projectRoot}`); // Resolve config path (project-level or plugin-level) const configInfo = resolveSubmodulesConfigPath(); if (!configInfo) { console.error(`PCGExSubModulesConfig.ini not found.`); console.error(`Searched:`); console.error(` - ${PATHS.projectSubmodulesConfig}`); console.error(` - ${PATHS.pluginSubmodulesConfig}`); process.exit(1); } const configSource = configInfo.isProjectLevel ? 'project' : 'plugin'; console.log(`Using ${configSource}-level config: ${configInfo.path}`); // Get available modules for validation and suggestions const availableModules = getAvailableModules(); console.log(`Available modules on disk: ${availableModules.length}`); // Parse configs const requestedModules = parseSubModulesConfig(configInfo.path); console.log(`Requested modules: ${requestedModules.length}`); // Validate requested modules const { valid, invalid } = validateRequestedModules(requestedModules, availableModules); if (invalid.length > 0) { console.warn(`\n[WARNING] Invalid module names in PCGExSubModulesConfig.ini:`); for (const { name, suggestion } of invalid) { if (suggestion) { console.warn(` - '${name}' not found. Did you mean '${suggestion}'?`); } else { console.warn(` - '${name}' not found.`); } } console.warn(''); } const pluginsDeps = parsePluginsDeps(PATHS.pluginsDeps); console.log(`Plugin dependencies defined for: ${pluginsDeps.size} modules`); // Resolve all modules (with deps and editor companions) const allModules = resolveAllModules(valid); console.log(`Resolved modules (with dependencies): ${allModules.size}`); // Resolve required plugins const requiredPlugins = resolveRequiredPlugins(allModules, pluginsDeps); console.log(`Required plugins: ${Array.from(requiredPlugins).join(', ')}`); // Load existing uplugin content (as string for comparison) const existingContent = loadExistingUplugin(); const existingUplugin = JSON.parse(existingContent); // Generate new uplugin const newUplugin = generateUplugin(existingUplugin, allModules, requiredPlugins); const newContent = JSON.stringify(newUplugin, null, ' '); // Check if content changed const hasChanged = existingContent !== newContent; if (hasChanged) { // Clean build artifacts before writing new uplugin // cleanBuildArtifacts(); // Write new uplugin fs.writeFileSync(PATHS.uplugin, newContent, 'utf8'); console.log(`\nGenerated ${PATHS.uplugin}`); } else { console.log(`\n.uplugin unchanged - skipping write.`); } console.log(` Modules: ${newUplugin.Modules.length}`); console.log(` Plugins: ${newUplugin.Plugins.length}`); // Exit with warning code if there were invalid modules if (invalid.length > 0) { process.exit(0); // Still succeeds, but you could use exit(1) to fail the build } } main();