option('site-url') ?? setting('nitro_site_url', config('app.url', 'https://epicnabbo.nl')); $this->info('🔧 Nitro Config Generator'); $this->line('═══════════════════════════════════════════════'); // Check 1: Validate URL $this->addCheck(function () use ($siteUrl) { if (empty($siteUrl) || ! filter_var($siteUrl, FILTER_VALIDATE_URL)) { throw new \Exception("Invalid URL: {$siteUrl}"); } $this->line(" ✓ URL: {$siteUrl}"); }); $nitroService = new NitroUpdateService; $status = $nitroService->getStatus(); $webroot = $status['webroot'] ?? '/var/www/Client'; $buildPath = $status['build_path'] ?? '/var/www/atomcms/nitro-client/dist'; // Check 2: Webroot exists $this->addCheck(function () use ($webroot) { $result = Process::timeout(5)->run('test -d ' . escapeshellarg((string) $webroot)); if ($result->exitCode() !== 0) { throw new \Exception("Webroot does not exist: {$webroot}"); } $this->line(" ✓ Webroot exists: {$webroot}"); }); // Check 3: Webroot is writable $this->addCheck(function () use ($webroot) { $result = Process::timeout(5)->run('test -w ' . escapeshellarg((string) $webroot)); if ($result->exitCode() !== 0) { throw new \Exception("Webroot is not writable: {$webroot}"); } $this->line(' ✓ Webroot is writable'); }); // Check 4: Build path exists $this->addCheck(function () use ($buildPath) { $result = Process::timeout(5)->run('test -d ' . escapeshellarg((string) $buildPath)); if ($result->exitCode() !== 0) { throw new \Exception("Build path does not exist: {$buildPath}"); } $this->line(" ✓ Build path exists: {$buildPath}"); }); // Check both buildPath and webroot for example files $this->addCheck(function () use ($buildPath, $webroot) { // Map .example to .json fallback $fileMap = [ 'renderer-config.example' => 'renderer-config.json', 'ui-config.example' => 'ui-config.json', 'UITexts.example' => 'UITexts.example', ]; $allFound = true; foreach ($fileMap as $exampleFile => $jsonFile) { // Check .example file first $buildPathResult = Process::timeout(5)->run('test -f ' . escapeshellarg($buildPath . '/' . $exampleFile)); $webrootResult = Process::timeout(5)->run('test -f ' . escapeshellarg($webroot . '/' . $exampleFile)); if ($buildPathResult->exitCode() === 0) { $this->line(" ✓ Found in build path: {$exampleFile}"); } elseif ($webrootResult->exitCode() === 0) { $this->line(" ✓ Found in webroot: {$exampleFile}"); } elseif ($jsonFile !== $exampleFile) { // Check json fallback for .example files $jsonBuildResult = Process::timeout(5)->run('test -f ' . escapeshellarg($buildPath . '/' . $jsonFile)); $jsonWebrootResult = Process::timeout(5)->run('test -f ' . escapeshellarg($webroot . '/' . $jsonFile)); if ($jsonBuildResult->exitCode() === 0) { $this->line(" ✓ Found in build path: {$jsonFile}"); } elseif ($jsonWebrootResult->exitCode() === 0) { $this->line(" ✓ Found in webroot: {$jsonFile}"); } else { $this->warn(" ⚠ Missing: {$exampleFile} or {$jsonFile}"); $allFound = false; } } else { $this->warn(" ⚠ Missing: {$exampleFile}"); $allFound = false; } } if (! $allFound) { throw new \Exception('Some example files are missing'); } }); // Check 6: Check disk space $this->addCheck(function () use ($webroot) { $result = Process::timeout(10)->run("df -h {$webroot} | tail -1 | awk '{print $4}'"); if ($result->successful()) { $free = trim($result->output()); $this->line(" ✓ Free space: {$free}"); } }); // Check 7: Validate bundled/config gamedata files $this->addCheck(function () { $bundledConfigDir = '/var/www/Gamedata/bundled/config'; $configDir = '/var/www/Gamedata/config'; $requiredFiles = ['HabboAvatarActions.json', 'FurnitureData.json', 'ExternalTexts.json', 'ProductData.json', 'FigureData.json', 'FigureMap.json', 'EffectMap.json']; // Create bundled/config if it doesn't exist if (! is_dir($bundledConfigDir)) { mkdir($bundledConfigDir, 0755, true); $this->line(" 📁 Created directory: {$bundledConfigDir}"); } // Copy missing files from config to bundled/config foreach ($requiredFiles as $file) { $targetPath = $bundledConfigDir . '/' . $file; if (! file_exists($targetPath)) { $sourcePath = $configDir . '/' . $file; if (file_exists($sourcePath)) { copy($sourcePath, $targetPath); $this->line(" 📋 Copied: {$file} to bundled/config"); } } } $this->line(' ✓ Gamedata bundled/config files ready'); }); // Check 8: Validate existing config files $this->addCheck(function () use ($webroot) { $configFiles = ['renderer-config.json', 'ui-config.json', 'UITexts.json']; foreach ($configFiles as $file) { $path = $webroot . '/' . $file; $result = Process::timeout(5)->run('test -f ' . escapeshellarg($path)); if ($result->exitCode() === 0) { // Validate JSON $jsonCheck = Process::timeout(5)->run('python3 -c "import json; json.load(open(\'' . $path . '\'))" 2>&1 || echo "INVALID"'); if (str_contains($jsonCheck->output(), 'INVALID')) { $this->warn(" ⚠ Invalid JSON: {$file}"); } else { $this->line(" ✓ Valid JSON: {$file}"); } } else { $this->line(" - New file: {$file}"); } } }); // Run all checks $this->line(''); $this->info('Running pre-flight checks...'); $this->line('───────────────────────────────────────────────'); try { foreach ($this->checks as $check) { $check(); } $this->line('───────────────────────────────────────────────'); $this->info('✅ All checks passed!'); } catch (\Exception $e) { $this->error('❌ Check failed: ' . $e->getMessage()); $this->newLine(); $this->error('Please fix the issue before generating configs.'); return 1; } // Generate configs $this->newLine(); $this->info('Generating configuration files...'); $this->line('───────────────────────────────────────────────'); $protocol = str_starts_with((string) $siteUrl, 'https') ? 'https' : 'http'; $host = preg_replace('/^https?:\/\//', '', (string) $siteUrl); try { // Step 0: Sync latest example files from GitHub $this->syncExampleFromGithub($buildPath, $webroot); $rendererConfig = $this->generateRendererConfig($protocol, $host, $buildPath, $webroot); $uiConfig = $this->generateUiConfig($protocol, $host, $buildPath, $webroot); // Compare with existing deployed configs $this->compareConfigs($rendererConfig, 'renderer-config', $webroot); $this->compareConfigs($uiConfig, 'ui-config', $webroot); // Validate generated JSON $this->validateJson($rendererConfig, 'renderer-config'); $this->validateJson($uiConfig, 'ui-config'); $tempFile = '/tmp/nitro_config_' . uniqid(); file_put_contents($tempFile . '_renderer', json_encode($rendererConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); file_put_contents($tempFile . '_ui', json_encode($uiConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); // Copy generated configs $commands = [ 'cp "' . $tempFile . '_renderer" "' . $webroot . '/renderer-config.json"', 'cp "' . $tempFile . '_ui" "' . $webroot . '/ui-config.json"', ]; // Copy UITexts.json if it exists (check both buildPath and webroot) $uitextsInBuild = Process::timeout(5)->run('test -f "' . $buildPath . '/UITexts.example"')->exitCode() === 0; $uitextsInWebroot = Process::timeout(5)->run('test -f "' . $webroot . '/UITexts.example"')->exitCode() === 0; if ($uitextsInBuild) { $commands[] = 'cp "' . $buildPath . '/UITexts.example" "' . $webroot . '/UITexts.json"'; $this->line(' ✓ Generated: UITexts.json (from build path)'); } elseif ($uitextsInWebroot) { $commands[] = 'cp "' . $webroot . '/UITexts.example" "' . $webroot . '/UITexts.json"'; $this->line(' ✓ Generated: UITexts.json (from webroot)'); } $commands[] = 'rm "' . $tempFile . '_renderer" "' . $tempFile . '_ui"'; Process::timeout(10)->run(implode(' && ', $commands)); // Set proper ownership Process::timeout(10)->run("chown www-data:www-data {$webroot}/renderer-config.json {$webroot}/ui-config.json {$webroot}/UITexts.json 2>/dev/null"); setting('nitro_config_generated_at', now()->toIso8601String()); $this->line(' ✓ Generated: renderer-config.json'); $this->line(' ✓ Generated: ui-config.json'); // Verify generated files $this->verifyGeneratedFiles($webroot); $this->line('───────────────────────────────────────────────'); $this->info('✅ Configs generated successfully!'); Log::info('[NitroConfig] Generated successfully', [ 'webroot' => $webroot, 'host' => $host, ]); return 0; } catch (\Exception $e) { $this->error('❌ Generation failed: ' . $e->getMessage()); Log::error('[NitroConfig] Generation failed', ['error' => $e->getMessage()]); return 1; } } private function addCheck(callable $check): void { $this->checks[] = function () use ($check) { $check(); }; } private function validateJson(array $data, string $name): void { $encoded = json_encode($data, JSON_THROW_ON_ERROR); $decoded = json_decode($encoded, true); if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) { throw new \Exception("Invalid JSON generated for {$name}"); } $this->line(" ✓ Validated: {$name}"); } private function verifyGeneratedFiles(string $webroot): void { $files = ['renderer-config.json', 'ui-config.json', 'UITexts.json']; $allValid = true; foreach ($files as $file) { $path = $webroot . '/' . $file; $result = Process::timeout(5)->run('test -f ' . escapeshellarg($path)); if ($result->exitCode() === 0) { $size = Process::timeout(5)->run('stat -c%s ' . escapeshellarg($path)); $sizeStr = trim($size->output()) . ' bytes'; $this->line(" ✓ Verified: {$file} ({$sizeStr})"); } else { $this->warn(" ⚠ Missing: {$file}"); $allValid = false; } } if (! $allValid) { throw new \Exception('Some files were not generated'); } } private function generateRendererConfig(string $protocol, string $host, string $buildPath, string $webroot): array { $httpProtocol = $protocol === 'https' ? 'https' : 'http'; $wssProtocol = $protocol === 'https' ? 'wss' : 'ws'; $wsHost = 'ws.' . $host; $rendererConfig = []; // Check build path first, then webroot for .example file $examplePath = $buildPath . '/renderer-config.example'; if (Process::timeout(5)->run('test -f ' . escapeshellarg($examplePath))->exitCode() !== 0) { $examplePath = $webroot . '/renderer-config.example'; } // Fallback to .json version (newer Nitro-V3 format) if (Process::timeout(5)->run('test -f ' . escapeshellarg($examplePath))->exitCode() !== 0) { $jsonPath = $buildPath . '/renderer-config.json'; if (Process::timeout(5)->run('test -f ' . escapeshellarg($jsonPath))->exitCode() === 0) { $examplePath = $jsonPath; } else { $jsonPath = $webroot . '/renderer-config.json'; if (Process::timeout(5)->run('test -f ' . escapeshellarg($jsonPath))->exitCode() === 0) { $examplePath = $jsonPath; } } } // Load COMPLETE example file as base if (Process::timeout(5)->run('test -f ' . escapeshellarg($examplePath))->exitCode() === 0) { $content = file_get_contents($examplePath); // Fix invalid escape sequences (literal \n, \r, \t in JSON values) $content = $this->fixInvalidJsonEscapeSequences($content); $rendererConfig = @json_decode($content, true) ?: []; $this->line(' ✓ Loaded renderer config from: ' . basename($examplePath) . ' (' . count($rendererConfig) . ' keys)'); } else { $this->warn(' ⚠ renderer-config.example/json not found'); return []; } // Recursively replace ALL URLs in the entire config $rendererConfig = $this->replaceUrlsRecursively($rendererConfig, $httpProtocol, $wssProtocol, $host, $wsHost); // Auto-detect asset directory names on disk and fix paths $rendererConfig = $this->autoDetectAssetPaths($rendererConfig, $webroot); // Special handling for socket.url - use ws subdomain if (isset($rendererConfig['socket.url'])) { $rendererConfig['socket.url'] = $wssProtocol . '://ws.' . $host; } // Auto-detect local asset paths from Gamedata directory $gamedataBase = $httpProtocol . '://' . $host; // Check what directories exist in Gamedata and set URLs accordingly $gamedataPath = '/var/www/Gamedata'; if (is_dir($gamedataPath)) { // Check for bundled directory - serve via /gamedata/bundled (nginx alias) if (is_dir($gamedataPath . '/bundled')) { $rendererConfig['asset.url'] = $gamedataBase . '/gamedata/bundled'; } // JSON config files are in /gamedata/config/ if (is_dir($gamedataPath . '/config')) { $rendererConfig['gamedata.url'] = $gamedataBase . '/gamedata/config'; } // Check for c_images directory - serve via /gamedata/c_images if (is_dir($gamedataPath . '/c_images')) { $rendererConfig['image.library.url'] = $gamedataBase . '/gamedata/c_images/'; // Use icons folder for furni icons if (is_dir($gamedataPath . '/icons')) { $rendererConfig['hof.furni.url'] = $gamedataBase . '/gamedata/icons'; } else { $rendererConfig['hof.furni.url'] = $gamedataBase . '/gamedata/c_images/dcr/hof_furni'; } } // Fix furni icon path - icons are directly in hof.furni folder, not in icons subfolder if (isset($rendererConfig['furni.asset.icon.url'])) { $rendererConfig['furni.asset.icon.url'] = '${hof.furni.url}/%libname%%param%_icon.png'; } // Fix sound machine samples path - sounds are in /gamedata/sounds/ if (isset($rendererConfig['external.samples.url'])) { $rendererConfig['external.samples.url'] = $gamedataBase . '/gamedata/sounds/sound_machine_sample_%sample%.mp3'; } // Check for images directory if (is_dir($gamedataPath . '/images')) { $rendererConfig['images.url'] = $gamedataBase . '/gamedata/images'; } } // Add missing keys that might not be in the example if (! isset($rendererConfig['external.plugins'])) { $rendererConfig['external.plugins'] = []; } // Add YouTube API key from settings $youtubeApiKey = setting('youtube_api_key', ''); if (! empty($youtubeApiKey)) { $rendererConfig['youtube.api.key'] = $youtubeApiKey; $this->line(' ✓ Added YouTube API key to renderer config'); } // Ensure pet.types matches the exact required list in order if (isset($rendererConfig['pet.types'])) { $requiredPetTypes = [ 'dog', 'cat', 'croco', 'terrier', 'bear', 'pig', 'lion', 'rhino', 'spider', 'turtle', 'chicken', 'frog', 'dragon', 'monster', 'monkey', 'horse', 'monsterplant', 'bunnyeaster', 'bunnyevil', 'bunnydepressed', 'bunnylove', 'pigeongood', 'pigeonevil', 'demonmonkey', 'bearbaby', 'terrierbaby', 'gnome', 'leprechaun', 'kittenbaby', 'puppybaby', 'pigletbaby', 'haloompa', 'fools', 'pterosaur', 'velociraptor', 'cow', 'dragondog', 'pkmshaymin2', 'LeetEendjes', 'pkmnentei', 'squirtle', 'LeetBH', 'LeetCaviaaa', 'LeetFantj', 'LeetHotelMario', 'LeetUil', 'LeetWolf', 'pokemon_mewblu', 'LeetBB', 'LeetHotelMari1', 'LeetMewtw', 'LeetPikachu', 'LeetYos', 'LeetE', 'LeetMewt1', 'LeetPen', 'slendermn', 'pkmnPAPI0', 'pkmnPAPI1', 'pkmnPAPI2', 'pkmnPAPI3', 'pkmnPAPI4', 'pokmn_mew', 'pkmashhhpet', 'pkmbeautfly', 'pkmcelebipe', 'pkmdarkraip', 'pkmeeveepet', 'pkmjirachip', 'pkmpichupet', 'pkmriolupet', 'pkmshayminp', 'pkmtogepipe', 'pkmvictinip', 'slenderm1', 'LeetEendj16', 'pkmnente1', 'LeetBa', 'babymeisje', 'babyBH', 'bb_hbx', 'LeetUi1', ]; $rendererConfig['pet.types'] = $requiredPetTypes; $this->line(' ✓ Set pet.types to exact required list'); } return $rendererConfig; } private function generateUiConfig(string $protocol, string $host, string $buildPath, string $webroot): array { $httpProtocol = $protocol === 'https' ? 'https' : 'http'; $wssProtocol = $protocol === 'https' ? 'wss' : 'ws'; $wsHost = 'ws.' . $host; $uiConfig = []; // Check build path first, then webroot for .example file $examplePath = $buildPath . '/ui-config.example'; if (Process::timeout(5)->run('test -f ' . escapeshellarg($examplePath))->exitCode() !== 0) { $examplePath = $webroot . '/ui-config.example'; } // Fallback to .json version (newer Nitro-V3 format) if (Process::timeout(5)->run('test -f ' . escapeshellarg($examplePath))->exitCode() !== 0) { $jsonPath = $buildPath . '/ui-config.json'; if (Process::timeout(5)->run('test -f ' . escapeshellarg($jsonPath))->exitCode() === 0) { $examplePath = $jsonPath; } else { $jsonPath = $webroot . '/ui-config.json'; if (Process::timeout(5)->run('test -f ' . escapeshellarg($jsonPath))->exitCode() === 0) { $examplePath = $jsonPath; } } } // Load COMPLETE example file as base if (Process::timeout(5)->run('test -f ' . escapeshellarg($examplePath))->exitCode() === 0) { $content = file_get_contents($examplePath); // Fix invalid escape sequences $content = $this->fixInvalidJsonEscapeSequences($content); $uiConfig = @json_decode($content, true) ?: []; $this->line(' ✓ Loaded ui config from: ' . basename($examplePath) . ' (' . count($uiConfig) . ' keys)'); } else { $this->warn(' ⚠ ui-config.example/json not found'); return []; } // Recursively replace ALL URLs in the entire config $uiConfig = $this->replaceUrlsRecursively($uiConfig, $httpProtocol, $wssProtocol, $host, $wsHost); return $uiConfig; } private function fixInvalidJsonEscapeSequences(string $content): string { // The example file has literal backslash + any letter in JSON values // which breaks JSON. We need to escape all of these. $backslash = chr(92); $letters = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z']; foreach ($letters as $letter) { $content = str_replace($backslash . $letter, $backslash . $backslash . $letter, $content); } return $content; } private function replaceUrlsRecursively(array $config, string $httpProtocol, string $wsProtocol, string $host, string $wsHost): array { foreach ($config as &$value) { if (is_array($value)) { // Handle nested arrays (like navigator.room.models) if ($this->isAssociativeArray($value)) { $value = $this->replaceUrlsRecursively($value, $httpProtocol, $wsProtocol, $host, $wsHost); } else { // Handle arrays of URLs $value = array_map(function ($item) use ($httpProtocol, $wsProtocol, $host, $wsHost) { if (is_string($item)) { return $this->replaceUrl($item, $httpProtocol, $wsProtocol, $host, $wsHost); } return $item; }, $value); } } elseif (is_string($value)) { $value = $this->replaceUrl($value, $httpProtocol, $wsProtocol, $host, $wsHost); } } return $config; } private function replaceUrl(string $url, string $httpProtocol, string $wsProtocol, string $host, string $wsHost): string { // Replace ws/wss URLs with ws subdomain if (str_starts_with($url, 'ws://') || str_starts_with($url, 'wss://')) { $path = parse_url($url, PHP_URL_PATH) ?? '/'; return $wsProtocol . '://' . $wsHost . ($path !== '/' ? $path : ''); } // Replace localhost in all URLs $url = preg_replace('#https?://localhost(?::\d+)?#', $httpProtocol . '://' . $host, $url); $url = preg_replace('#wss?://localhost(?::\d+)?#', $wsProtocol . '://' . $wsHost, (string) $url); $url = preg_replace('#localhost(?::\d+)?#', $host, (string) $url); // Fix broken escape sequences in URL paths (from invalid JSON in example files) // Pattern: /public\nitro-assets\gamedata or any variation $url = preg_replace('#/public\\n[a-z_-]+\\\\gamedata#i', '/gamedata', (string) $url); $url = preg_replace('#/public\\\\[a-z_-]+\\\\gamedata#i', '/gamedata', (string) $url); $url = preg_replace('#/nitro-assets\\\\gamedata#i', '/gamedata', (string) $url); $url = preg_replace('#/nitro\\\\[a-z_-]+#i', '/gamedata', (string) $url); // Fix known asset path patterns $url = str_replace('/public/nitro-assets/gamedata', '/gamedata', $url); $url = str_replace('/swf/gamedata', '/gamedata', $url); // Clean up any remaining backslashes in URLs $url = str_replace('\\', '/', $url); // Clean up any remaining double slashes (but keep protocol slashes) $url = preg_replace('#([^:])//+#', '$1/', $url); return $url; } private function isAssociativeArray(array $arr): bool { if ($arr === []) { return false; } return array_keys($arr) !== range(0, count($arr) - 1); } private function autoDetectAssetPaths(array $config, string $webroot): array { // Check multiple possible gamedata locations $possiblePaths = [ $webroot . '/gamedata', '/var/www/Gamedata', '/var/www/gamedata', ]; $gamedataPath = array_find($possiblePaths, fn ($path) => is_dir($path)); if (! $gamedataPath) { return $config; } $assetDir = opendir($gamedataPath); $actualDirs = []; while (($entry = readdir($assetDir)) !== false) { if ($entry !== '.' && $entry !== '..' && is_dir($gamedataPath . '/' . $entry)) { $actualDirs[strtolower($entry)] = $entry; } } closedir($assetDir); $pathChecks = [ 'pet.asset.url' => 'pets', 'furni.asset.url' => 'furniture', 'avatar.asset.url' => 'clothes', 'avatar.asset.effect.url' => 'effect', 'generic.asset.url' => 'generic_custom', ]; foreach ($config as $key => &$value) { if (! is_string($value) || ! isset($pathChecks[$key])) { continue; } $expectedDir = $pathChecks[$key]; $lowerExpected = strtolower($expectedDir); $actualName = null; // Special case: "figure" is often used instead of "clothes" for avatars if ($lowerExpected === 'clothes' && isset($actualDirs['figure'])) { $actualName = $actualDirs['figure']; } elseif (isset($actualDirs[$lowerExpected])) { $actualName = $actualDirs[$lowerExpected]; } else { foreach ($actualDirs as $actualLower => $actual) { if (str_starts_with($actualLower, rtrim($lowerExpected, 's')) || str_starts_with(rtrim($actualLower, 's'), rtrim($lowerExpected, 's'))) { $actualName = $actual; break; } } } if ($actualName && $actualName !== $expectedDir) { $value = str_replace("/{$expectedDir}/", "/{$actualName}/", $value); $this->line(" 🔍 Auto-detected: {$key} -> /{$actualName}/ (was /{$expectedDir}/)"); } } return $config; } private function syncExampleFromGithub(string $buildPath, string $webroot): void { $this->info('Syncing latest examples from GitHub...'); $rendererRepo = setting('nitro_github_url', ''); $repo = $this->parseRepoFromUrl($rendererRepo); if (! $repo) { $this->warn(' ⚠ No GitHub repo configured, skipping sync'); return; } $branch = setting('nitro_github_branch', 'main'); $examples = [ 'renderer-config.example' => 'renderer-config.example', 'ui-config.example' => 'ui-config.example', 'UITexts.example' => 'UITexts.json', ]; foreach (array_keys($examples) as $remoteFile) { $tempFile = '/tmp/' . $remoteFile . '_' . uniqid(); $fetched = false; // Try multiple paths: root, public/, nitro-client/dist/, Nitro-V3 paths $paths = ['', 'public/', 'nitro-client/dist/', 'dist/', 'src/', 'assets/']; foreach ($paths as $path) { $url = "https://raw.githubusercontent.com/{$repo}/{$branch}/{$path}{$remoteFile}"; $result = Process::timeout(15)->run("curl -sL -o {$tempFile} '{$url}'"); if ($result->successful() && file_exists($tempFile) && filesize($tempFile) > 10) { $content = file_get_contents($tempFile); $data = @json_decode($content, true); if (is_array($data) && $data !== []) { // Save to both buildPath and webroot file_put_contents($buildPath . '/' . $remoteFile, $content); file_put_contents($webroot . '/' . $remoteFile, $content); $this->line(" ✓ Synced: {$remoteFile} (" . count($data) . " keys from {$path}{$remoteFile})"); $fetched = true; break; } } @unlink($tempFile); } if (! $fetched) { $this->line(" - Skipped: {$remoteFile} (not found in any path)"); } } } private function parseRepoFromUrl(string $url): ?string { if (preg_match('/github\.com\/([^\/]+\/[^\/\?#]+)/', $url, $matches)) { return rtrim($matches[1], '/'); } return null; } private function compareConfigs(array $generated, string $name, string $webroot): void { $currentPath = $webroot . '/' . $name . '.json'; if (! file_exists($currentPath)) { $this->line(" ℹ {$name}: Geen bestaande config — nieuwe generatie"); return; } $current = @json_decode(file_get_contents($currentPath), true); if (! is_array($current)) { $this->warn(" ⚠ {$name}: Bestaande config is ongeldig JSON"); return; } $newKeys = array_diff(array_keys($generated), array_keys($current)); $removedKeys = array_diff(array_keys($current), array_keys($generated)); $changedKeys = []; foreach (array_intersect(array_keys($generated), array_keys($current)) as $key) { if ($generated[$key] !== $current[$key]) { $changedKeys[] = $key; } } if ($newKeys !== []) { $this->line(" 🆕 {$name}: " . count($newKeys) . ' nieuwe key(s): ' . implode(', ', array_slice($newKeys, 0, 10))); if (count($newKeys) > 10) { $this->line(' ... en ' . (count($newKeys) - 10) . ' meer'); } } if ($removedKeys !== []) { $this->line(" 🗑 {$name}: " . count($removedKeys) . ' verwijderde key(s): ' . implode(', ', array_slice($removedKeys, 0, 5))); } if ($changedKeys !== []) { $this->line(" 🔄 {$name}: " . count($changedKeys) . ' gewijzigde key(s): ' . implode(', ', array_slice($changedKeys, 0, 5))); } if ($newKeys === [] && $removedKeys === [] && $changedKeys === []) { $this->line(" ✓ {$name}: Geen wijzigingen"); } } }