471 lines
14 KiB
JavaScript
Executable File
471 lines
14 KiB
JavaScript
Executable File
#!/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(); |