settings = app(SettingsService::class); $this->settings->clearInstanceCache(); $this->clientRepo = 'duckietm/Nitro-V3'; $this->rendererRepo = 'duckietm/Nitro_Render_V3'; $this->gitlabHost = 'gitlab.epicnabbo.nl/duckietm'; $basePath = $this->detectBasePath(); $this->clientPath = $this->resolvePath(setting('nitro_client_path', $basePath . '/nitro-client')); $this->rendererPath = $this->resolvePath(setting('nitro_renderer_path', $basePath . '/nitro-renderer')); $this->clientWebRoot = $this->resolvePath(setting('nitro_webroot', '/var/www/Client')); $this->historyService = app(UpdateHistoryService::class); } private function isDirAccessible(string $path): bool { if ($path === '' || $path === '0') { return false; } $result = Process::timeout(5)->run('test -d ' . escapeshellarg($path) . ' && echo "yes" || echo "no"'); return trim($result->output()) === 'yes'; } private function detectBasePath(): string { $possiblePaths = [ base_path(), '/var/www/atomcms', '/var/www/html', '/var/www', dirname(base_path()), ]; foreach ($possiblePaths as $path) { if ($this->isDirAccessible($path)) { $clientCheck = Process::timeout(5)->run('test -d ' . escapeshellarg($path . '/nitro-client') . ' && echo "yes" || echo "no"'); $rendererCheck = Process::timeout(5)->run('test -d ' . escapeshellarg($path . '/nitro-renderer') . ' && echo "yes" || echo "no"'); if (trim($clientCheck->output()) === 'yes' || trim($rendererCheck->output()) === 'yes') { return $path; } } } return base_path(); } private function resolvePath(string $path): string { if (str_starts_with($path, '/')) { if ($this->isDirAccessible($path)) { return $path; } $parts = explode('/', trim($path, '/')); $root = '/' . $parts[0]; if (! $this->isDirAccessible($root)) { return base_path() . '/' . ltrim($path, '/'); } return $path; } return base_path() . '/' . ltrim($path, '/'); } public function isConfigured(): bool { return $this->isDirAccessible($this->clientPath) || $this->isDirAccessible($this->rendererPath) || $this->isDirAccessible($this->clientWebRoot); } public function getClientPath(): string { return $this->clientPath; } public function getRendererPath(): string { return $this->rendererPath; } public function getClientWebRoot(): string { return $this->clientWebRoot; } public function getStatus(): array { $buildPath = setting('nitro_build_path', '/var/www/atomcms/nitro-client/dist'); $webroot = $this->clientWebRoot; // Use shell commands to bypass open_basedir $checkDir = function (string $path): bool { $result = Process::timeout(5)->run('test -d ' . escapeshellarg($path) . ' && echo "yes" || echo "no"'); return trim($result->output()) === 'yes'; }; $checkFile = function (string $path): bool { $result = Process::timeout(5)->run('test -f ' . escapeshellarg($path) . ' && echo "yes" || echo "no"'); return trim($result->output()) === 'yes'; }; // Check nginx config via shell to bypass open_basedir $nginxCheck = Process::timeout(5)->run('test -f /etc/nginx/sites-enabled/cms.conf -o -f /etc/nginx/sites-enabled/atom.conf -o -f /etc/nginx/conf.d/atom.conf && echo "yes" || echo "no"'); $nginxValid = trim($nginxCheck->output()) === 'yes'; // Check open_basedir - fixed if empty OR includes all required paths $openBaseDir = ini_get('open_basedir'); $clientPath = $this->clientPath; $rendererPath = $this->rendererPath; $openBasedirFixed = in_array($openBaseDir, ['', '0', false], true) || ( str_contains($openBaseDir, $clientPath) && str_contains($openBaseDir, $rendererPath) ); Log::info('[NitroUpdate] open_basedir check:', [ 'open_basedir' => $openBaseDir, 'clientPath' => $clientPath, 'rendererPath' => $rendererPath, 'fixed' => $openBasedirFixed, ]); // Build files (check for hashed filenames like index-CLCACmcI.js) $assetsPath = $buildPath . '/assets'; $indexJsCheck = Process::timeout(5)->run('test -f ' . escapeshellarg($buildPath . '/index.js') . ' && echo "yes" || echo "no"'); $indexJsHashCheck = Process::timeout(5)->run('ls ' . escapeshellarg($assetsPath) . '/index-*.js 2>/dev/null | head -1'); $hasIndexJs = trim($indexJsCheck->output()) === 'yes' || ! in_array(trim($indexJsHashCheck->output()), ['', '0'], true); $rendererJsCheck = Process::timeout(5)->run('test -f ' . escapeshellarg($buildPath . '/renderer.js') . ' && echo "yes" || echo "no"'); $rendererJsHashCheck = Process::timeout(5)->run('ls ' . escapeshellarg($assetsPath) . '/nitro-renderer-*.js 2>/dev/null | head -1'); $hasRendererJs = trim($rendererJsCheck->output()) === 'yes' || ! in_array(trim($rendererJsHashCheck->output()), ['', '0'], true); $vendorJsCheck = Process::timeout(5)->run('test -f ' . escapeshellarg($buildPath . '/vendor.js') . ' && echo "yes" || echo "no"'); $vendorJsHashCheck = Process::timeout(5)->run('ls ' . escapeshellarg($assetsPath) . '/vendor-*.js 2>/dev/null | head -1'); $hasVendorJs = trim($vendorJsCheck->output()) === 'yes' || ! in_array(trim($vendorJsHashCheck->output()), ['', '0'], true); // Check for CSS files $hasCssCheck = Process::timeout(5)->run('find ' . escapeshellarg((string) $buildPath) . " -name '*.css' -type f 2>/dev/null | head -1"); $hasCss = ! in_array(trim($hasCssCheck->output()), ['', '0'], true); // Renderer (monorepo - packages are built into client, so just check node_modules) $hasRendererDist = $checkDir($this->rendererPath . '/node_modules'); $hasRendererIndex = $checkDir($this->rendererPath . '/packages'); // Config files $rendererConfigValid = $checkFile($webroot . '/renderer-config.json'); $uiConfigValid = $checkFile($webroot . '/ui-config.json'); $nitroConfigValid = $checkFile($webroot . '/UITexts.json'); $hasRendererExample = $checkFile($buildPath . '/renderer-config.example') || $checkFile($webroot . '/renderer-config.example'); $hasUiExample = $checkFile($buildPath . '/ui-config.example') || $checkFile($webroot . '/ui-config.example'); // Assets $hasImageAssets = $checkDir($webroot . '/assets') || $checkDir($buildPath . '/assets'); $hasFavicon = $checkFile($webroot . '/favicon.ico') || $checkFile($buildPath . '/favicon.ico'); // System $webrootExists = $checkDir($webroot); $webrootWritableCheck = Process::timeout(5)->run('test -w ' . escapeshellarg($webroot) . ' && echo "yes" || echo "no"'); $webrootWritable = trim($webrootWritableCheck->output()) === 'yes'; $webrootFileCount = $this->countFiles($webroot); $buildSize = $this->getDirSize($buildPath); $webrootSize = $this->getDirSize($webroot); // Socket URL from renderer config $socketUrl = 'N/A'; if ($rendererConfigValid) { $configResult = Process::timeout(5)->run('cat ' . escapeshellarg($webroot . '/renderer-config.json') . ' 2>/dev/null'); if ($configResult->successful()) { $config = json_decode($configResult->output(), true); $socketUrl = $config['socket.url'] ?? 'N/A'; } } // Check for gamedata - could be a symlink or directory $symlinkValid = false; // Check if gamedata exists as symlink or directory $gamedataCheck = Process::timeout(5)->run('test -e ' . escapeshellarg($webroot . '/gamedata') . ' && echo "yes" || echo "no"'); if (trim($gamedataCheck->output()) === 'yes') { $symlinkValid = true; } else { // Try alternative locations $altPaths = [ '/var/www/Gamedata', '/var/www/gamedata', '/var/www/atomcms/public/gamedata', ]; foreach ($altPaths as $altPath) { $altCheck = Process::timeout(5)->run('test -d ' . escapeshellarg($altPath) . ' && echo "yes" || echo "no"'); if (trim($altCheck->output()) === 'yes') { $symlinkValid = true; break; } } } return [ 'client_path' => $this->clientPath, 'renderer_path' => $this->rendererPath, 'build_path' => $buildPath, 'webroot' => $webroot, 'client_commit' => $this->getCommit($this->clientPath), 'renderer_commit' => $this->getCommit($this->rendererPath), 'client_installed' => $checkDir($this->clientPath . '/.git'), 'renderer_installed' => $checkDir($this->rendererPath . '/.git'), 'build_exists' => $checkDir($buildPath), 'deployed' => $rendererConfigValid, 'symlink_valid' => $symlinkValid, 'client_node_modules' => $checkDir($this->clientPath . '/node_modules'), 'renderer_node_modules' => $checkDir($this->rendererPath . '/node_modules'), 'client_latest_commit' => null, 'renderer_latest_commit' => null, 'last_checked' => setting('nitro_last_checked'), 'nginx_config_valid' => $nginxValid, 'open_basedir_fixed' => $openBasedirFixed, 'has_index_js' => $hasIndexJs, 'has_renderer_js' => $hasRendererJs, 'has_vendor_js' => $hasVendorJs, 'has_css_file' => $hasCss, 'vite_config_valid' => $checkFile($this->clientPath . '/vite.config.js') || $checkFile($this->clientPath . '/vite.config.mjs'), 'renderer_config_valid' => $rendererConfigValid, 'ui_config_valid' => $uiConfigValid, 'nitro_config_valid' => $nitroConfigValid, 'has_uitexts_json' => $nitroConfigValid, 'has_renderer_example' => $hasRendererExample, 'has_uiconfig_example' => $hasUiExample, 'has_image_assets' => $hasImageAssets, 'has_favicon' => $hasFavicon, 'assets_writable' => $webrootWritable, 'assets_file_count' => $webrootFileCount, 'has_renderer_dist' => $hasRendererDist, 'has_renderer_index' => $hasRendererIndex, 'webroot_exists' => $webrootExists, 'webroot_writable' => $webrootWritable, 'webroot_file_count' => $webrootFileCount, 'build_size' => $buildSize, 'webroot_size' => $webrootSize, 'socket_url' => $socketUrl, 'websocket_accessible' => $this->checkWebSocketAccessible($socketUrl), 'emulator_connected' => $this->checkEmulatorConnected(), ]; } private function checkWebSocketAccessible(string $socketUrl): bool { if (in_array($socketUrl, ['', '0', 'N/A'], true)) { return false; } try { $wsUrl = $socketUrl; if (str_starts_with($wsUrl, 'wss://')) { $wsUrl = 'https://' . substr($wsUrl, 6); } elseif (str_starts_with($wsUrl, 'ws://')) { $wsUrl = 'http://' . substr($wsUrl, 4); } $host = parse_url($wsUrl, PHP_URL_HOST); $port = parse_url($wsUrl, PHP_URL_PORT); if (! $port) { $port = str_starts_with($socketUrl, 'wss://') ? 443 : 80; } $socket = @fsockopen($host, $port, $errno, $errstr, 5); if ($socket) { fclose($socket); return true; } } catch (\Exception $e) { // Fallback to curl check } // Fallback: try curl try { $ch = curl_init($socketUrl); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, 5); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 3); curl_setopt($ch, CURLOPT_NOBODY, true); curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); return in_array($httpCode, [200, 301, 302, 400, 404]); } catch (\Exception) { return false; } } private function checkEmulatorConnected(): bool { try { $rconService = new RconService; return $rconService->isConnected(); } catch (\Exception) { return false; } } private function countFiles(string $path): int { $check = Process::timeout(10)->run('test -d ' . escapeshellarg($path) . ' && echo "yes" || echo "no"'); if (trim($check->output()) !== 'yes') { return 0; } $result = Process::timeout(10)->run('find ' . escapeshellarg($path) . ' -type f 2>/dev/null | wc -l'); return (int) trim($result->output() ?? '0'); } private function getDirSize(string $path): int { $check = Process::timeout(10)->run('test -d ' . escapeshellarg($path) . ' && echo "yes" || echo "no"'); if (trim($check->output()) !== 'yes') { return 0; } $result = Process::timeout(10)->run('du -sb ' . escapeshellarg($path) . ' 2>/dev/null | cut -f1'); return (int) trim($result->output() ?? '0'); } public function updateNitro(bool $forceBuild = false): array { $lockFile = '/tmp/nitro-update.lock'; if (file_exists($lockFile)) { $lockAge = time() - filemtime($lockFile); if ($lockAge < 300) { $pid = @file_get_contents($lockFile); return ['success' => false, 'error' => "Update al bezig (PID: {$pid})"]; } @unlink($lockFile); } file_put_contents($lockFile, getmypid()); try { Log::info('[Nitro] Starting update'); $this->historyService->log('nitro', 'update', null, 'pending', 'Update gestart'); $this->ensureVpsRequirements(); $this->setupDirectories(); $this->syncRepos(); $this->installDependencies(); $this->fixKnownBugs(); $buildResult = $this->buildClient(); if ($buildResult['success']) { $this->deployClient(); $this->generateConfigs(); $this->settings->set('nitro_client_commit', $this->getCommit($this->clientPath)); $this->settings->set('nitro_renderer_commit', $this->getCommit($this->rendererPath)); Log::info('[Nitro] Update completed successfully'); $this->historyService->log('nitro', 'update', 'client', 'success', 'Update succesvol'); return ['success' => true, 'message' => 'Nitro client succesvol geüpdatet!']; } $this->historyService->log('nitro', 'update', 'client', 'failed', $buildResult['error'] ?? 'Build mislukt'); return $buildResult; } catch (\Exception $e) { Log::error('[Nitro] Update failed', ['error' => $e->getMessage()]); $this->historyService->log('nitro', 'update', null, 'failed', $e->getMessage()); return ['success' => false, 'error' => $e->getMessage()]; } finally { @unlink($lockFile); } } private function ensureVpsRequirements(): array { $actions = []; $errors = []; $nodeCheck = Process::timeout(5)->run('which node && node --version'); if (! $nodeCheck->successful()) { $errors[] = 'Node.js niet geïnstalleerd'; Process::timeout(60)->run('curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && apt-get install -y nodejs 2>&1'); $nodeCheck2 = Process::timeout(5)->run('which node && node --version'); if ($nodeCheck2->successful()) { $actions[] = 'Node.js geïnstalleerd: ' . trim($nodeCheck2->output()); } } else { $actions[] = 'Node.js: ' . trim($nodeCheck->output()); } $yarnCheck = Process::timeout(5)->run('which yarn && yarn --version'); if (! $yarnCheck->successful()) { $actions[] = 'Yarn wordt geïnstalleerd...'; Process::timeout(60)->run('npm install -g yarn 2>&1'); $yarnCheck2 = Process::timeout(5)->run('which yarn && yarn --version'); if ($yarnCheck2->successful()) { $actions[] = 'Yarn geïnstalleerd: ' . trim($yarnCheck2->output()); } } else { $actions[] = 'Yarn: ' . trim($yarnCheck->output()); } $npmCacheDir = Process::timeout(5)->run('echo $HOME'); $npmHome = trim($npmCacheDir->output()) ?: '/root'; Process::timeout(5)->run('mkdir -p ' . escapeshellarg($npmHome . '/.npm')); Process::timeout(5)->run('mkdir -p /var/www/.npm'); Process::timeout(5)->run('chown -R www-data:www-data /var/www/.npm'); Process::timeout(10)->run('git config --global --add safe.directory "*" 2>/dev/null || true'); return [ 'actions' => $actions, 'errors' => $errors, ]; } private function setupDirectories(): void { $dirs = [ $this->clientPath => 'Nitro Client', $this->rendererPath => 'Nitro Renderer', $this->clientWebRoot => 'Client Webroot', ]; foreach ($dirs as $path => $label) { $existsCheck = Process::timeout(5)->run('test -d ' . escapeshellarg($path) . ' && echo "yes" || echo "no"'); if (trim($existsCheck->output()) !== 'yes') { Log::info("[Nitro] Creating {$label} directory: {$path}"); Process::timeout(10)->run('mkdir -p ' . escapeshellarg($path)); } Process::timeout(10)->run('chown -R www-data:www-data ' . escapeshellarg($path)); } // Create gamedata symlink if it doesn't exist $gamedataSymlink = $this->clientWebRoot . '/gamedata'; $gamedataCheck = Process::timeout(5)->run('test -e ' . escapeshellarg($gamedataSymlink) . ' && echo "yes" || echo "no"'); if (trim($gamedataCheck->output()) !== 'yes') { // Try to find gamedata folder $possibleGamedata = [ '/var/www/Gamedata', '/var/www/gamedata', '/var/www/atomcms/public/gamedata', base_path() . '/../Gamedata', ]; foreach ($possibleGamedata as $gdPath) { $gdCheck = Process::timeout(5)->run('test -d ' . escapeshellarg($gdPath) . ' && echo "yes" || echo "no"'); if (trim($gdCheck->output()) === 'yes') { Process::timeout(10)->run('ln -s ' . escapeshellarg($gdPath) . ' ' . escapeshellarg($gamedataSymlink)); Log::info('[Nitro] Created gamedata symlink: ' . $gdPath . ' -> ' . $gamedataSymlink); break; } } } } private function syncRepos(): void { $repos = [ ['path' => $this->rendererPath, 'repo' => $this->rendererRepo], ['path' => $this->clientPath, 'repo' => $this->clientRepo], ]; Process::timeout(5)->run('git config --global --add safe.directory "*" 2>/dev/null || true'); foreach ($repos as $r) { $gitCheck = Process::timeout(5)->run('test -d ' . escapeshellarg($r['path']) . '/.git && echo "yes" || echo "no"'); $isGitRepo = trim($gitCheck->output()) === 'yes'; if ($isGitRepo) { Log::info("[Nitro] Updating {$r['repo']}"); $fetchResult = Process::timeout(120)->run('cd ' . escapeshellarg($r['path']) . ' && git fetch nitro-v3 2>&1'); $resetResult = Process::timeout(60)->run('cd ' . escapeshellarg($r['path']) . ' && git checkout main 2>&1 || git checkout master 2>&1 || true'); $pullResult = Process::timeout(120)->run('cd ' . escapeshellarg($r['path']) . ' && git reset --hard nitro-v3/main 2>&1 || git reset --hard nitro-v3/master 2>&1 || true'); if (! $pullResult->successful()) { Log::warning("[Nitro] Update failed, re-cloning {$r['repo']}"); Process::timeout(30)->run('rm -rf ' . escapeshellarg($r['path'])); $cloneResult = Process::timeout(300)->run( "git clone --branch main --depth 1 https://{$this->gitlabHost}/{$r['repo']}.git " . escapeshellarg($r['path']) . ' 2>&1', ); } } else { Log::info("[Nitro] Cloning {$r['repo']}"); $dirCheck = Process::timeout(5)->run('test -d ' . escapeshellarg($r['path']) . ' && echo "yes" || echo "no"'); if (trim($dirCheck->output()) === 'yes') { Process::timeout(30)->run('rm -rf ' . escapeshellarg($r['path'])); } Process::timeout(300)->run( "git clone --branch main --depth 1 https://{$this->gitlabHost}/{$r['repo']}.git " . escapeshellarg($r['path']) . ' 2>&1', ); } Process::timeout(10)->run('chown -R www-data:www-data ' . escapeshellarg($r['path'])); } } private function fixKnownBugs(): void { $this->fixChatsCss(); // Fix 1: Chats.css case sensitivity $indexFile = $this->clientPath . '/src/index.tsx'; if (file_exists($indexFile)) { $content = file_get_contents($indexFile); if (str_contains($content, './css/chat/Chats.css')) { $content = str_replace('./css/chat/Chats.css', './css/chat/chats.css', $content); file_put_contents($indexFile, $content); Log::info('[Nitro] Fixed Chats.css case'); } } // Fix 2: Duplicate CatalogView export $catalogFile = $this->clientPath . '/src/components/catalog/CatalogView.tsx'; if (file_exists($catalogFile)) { $content = file_get_contents($catalogFile); $lines = explode("\n", $content); $cleanLines = []; $seenExport = false; foreach ($lines as $line) { if (str_contains($line, 'export const CatalogView')) { if ($seenExport) { continue; } $seenExport = true; } $cleanLines[] = $line; } file_put_contents($catalogFile, implode("\n", $cleanLines)); Log::info('[Nitro] Fixed duplicate CatalogView'); } // Fix 3: Create/update vite config $this->updateViteConfig(); // Fix 4: Create symlink for renderer $this->createYarnLink(); // Fix 5: Add missing renderer stubs $this->addMissingRendererStubs(); // Fix 6: Duplicate FurniEditorResultEvent (case sensitivity conflict) $duplicateFile = $this->rendererPath . '/packages/communication/src/messages/incoming/furniture/FurniEditorResultEvent.ts'; if (file_exists($duplicateFile)) { unlink($duplicateFile); $furnitureIndexFile = $this->rendererPath . '/packages/communication/src/messages/incoming/furniture/index.ts'; if (file_exists($furnitureIndexFile)) { $content = file_get_contents($furnitureIndexFile); $content = str_replace("export * from './FurniEditorResultEvent';\n", '', $content); file_put_contents($furnitureIndexFile, $content); } Log::info('[Nitro] Fixed duplicate FurniEditorResultEvent'); } // Fix 7: Rename Chats.css to lowercase $upperCaseCss = $this->clientPath . '/src/css/chat/Chats.css'; $lowerCaseCss = $this->clientPath . '/src/css/chat/chats.css'; if (file_exists($upperCaseCss) && ! file_exists($lowerCaseCss)) { rename($upperCaseCss, $lowerCaseCss); Log::info('[Nitro] Fixed Chats.css case sensitivity'); } // Fix 8: Remove all duplicate FurniEditor files from furniture/ (keep only furnieditor/) $outgoingDir = $this->rendererPath . '/packages/communication/src/messages/outgoing/furniture'; $incomingDir = $this->rendererPath . '/packages/communication/src/messages/incoming/furniture'; $outgoingDupes = ['BySprite', 'Create', 'Delete', 'Detail', 'Interactions', 'Search', 'Update']; foreach ($outgoingDupes as $name) { $file = $outgoingDir . '/FurniEditor' . $name . 'Composer.ts'; if (file_exists($file)) { unlink($file); } } if (file_exists($outgoingDir . '/index.ts')) { $content = file_get_contents($outgoingDir . '/index.ts'); $lines = explode("\n", $content); $cleanLines = array_filter($lines, fn ($line) => ! str_contains((string) $line, 'FurniEditor')); file_put_contents($outgoingDir . '/index.ts', implode("\n", $cleanLines)); } $incomingDupes = ['Detail', 'Interactions', 'Result', 'Search']; foreach ($incomingDupes as $name) { $file = $incomingDir . '/FurniEditor' . $name . 'Event.ts'; if (file_exists($file)) { unlink($file); } } if (file_exists($incomingDir . '/index.ts')) { $content = file_get_contents($incomingDir . '/index.ts'); $lines = explode("\n", $content); $cleanLines = array_filter($lines, fn ($line) => ! str_contains((string) $line, 'FurniEditor')); file_put_contents($incomingDir . '/index.ts', implode("\n", $cleanLines)); } Log::info('[Nitro] Removed duplicate FurniEditor files from furniture/'); } private function fixChatsCss(): void { $chatCssFile = $this->clientPath . '/src/css/chat/chats.css'; if (! file_exists($chatCssFile)) { return; } $content = file_get_contents($chatCssFile); $modified = false; $content = preg_replace('/\/\/\s*normal\s*\n\s*\.message/', '.message', $content, -1, $count); if ($count > 0) { $modified = true; } $content = preg_replace('/\/\/\s*whisper\s*\n\s*\.message/', '.message', $content, -1, $count); if ($count > 0) { $modified = true; } $content = preg_replace('/\/\/\s*shout\s*\n\s*\.message/', '.message', $content, -1, $count); if ($count > 0) { $modified = true; } if ($modified) { file_put_contents($chatCssFile, $content); Log::info('[Nitro] Fixed chat CSS comments'); } } private function addMissingRendererStubs(): void { $outgoingDir = $this->rendererPath . '/packages/communication/src/messages/outgoing/furniture'; $incomingDir = $this->rendererPath . '/packages/communication/src/messages/incoming/furniture'; $outgoingFurnieditorDir = $this->rendererPath . '/packages/communication/src/messages/outgoing/furnieditor'; $incomingFurnieditorDir = $this->rendererPath . '/packages/communication/src/messages/incoming/furnieditor'; if (! is_dir($outgoingDir)) { mkdir($outgoingDir, 0755, true); } if (! is_dir($incomingDir)) { mkdir($incomingDir, 0755, true); } $composers = ['BySprite', 'Create', 'Delete', 'Detail', 'Interactions', 'Search', 'Update']; $composerLines = []; foreach ($composers as $name) { $furnieditorFile = $outgoingFurnieditorDir . '/FurniEditor' . $name . 'Composer.ts'; $stubFile = $outgoingDir . '/FurniEditor' . $name . 'Composer.ts'; if (! file_exists($furnieditorFile) && ! file_exists($stubFile)) { file_put_contents($stubFile, "import { IMessageComposer } from '@nitrots/api';\nexport class FurniEditor{$name}Composer implements IMessageComposer {\n constructor(k: any) {}\n dispose() {}\n getMessageArray() { return []; }\n}\n"); $composerLines[] = "export * from './FurniEditor{$name}Composer';"; } } if ($composerLines !== []) { $composerContent = implode("\n", $composerLines) . "\n"; file_put_contents($outgoingDir . '/index.ts', $composerContent); } $events = ['Detail', 'Interactions', 'Result', 'Search']; $eventLines = []; foreach ($events as $name) { $furnieditorFile = $incomingFurnieditorDir . '/FurniEditor' . $name . 'Event.ts'; $stubFile = $incomingDir . '/FurniEditor' . $name . 'Event.ts'; if (! file_exists($furnieditorFile) && ! file_exists($stubFile)) { file_put_contents($stubFile, "import { IMessageEvent } from '@nitrots/api';\nimport { MessageEvent } from '@nitrots/events';\nexport class FurniEditor{$name}Event extends MessageEvent implements IMessageEvent {\n constructor(callBack: Function) { super(callBack, (k: any) => k); }\n getParser() { return this.parser; }\n}\n"); $eventLines[] = "export * from './FurniEditor{$name}Event';"; } } if ($eventLines !== []) { $eventContent = implode("\n", $eventLines) . "\n"; file_put_contents($incomingDir . '/index.ts', $eventContent); } Log::info('[Nitro] Added missing renderer stubs'); } private function updateViteConfig(): void { $viteConfigPath = $this->clientPath . '/vite.config.mjs'; $content = file_exists($viteConfigPath) ? file_get_contents($viteConfigPath) : ''; if (str_contains($content, '@nitrots/nitro-renderer') && str_contains($content, '/var/www/nitro-renderer')) { return; } $viteConfig = <<<'CONFIG' import react from "@vitejs/plugin-react"; import { resolve } from "path"; import { defineConfig } from "vite"; const rendererPath = "/var/www/nitro-renderer"; export default defineConfig({ plugins: [react()], server: { fs: { allow: [resolve(__dirname), rendererPath] }, proxy: { "/api": { target: "http://localhost:3000", changeOrigin: true } }, }, resolve: { alias: [ { find: "@nitrots/nitro-renderer", replacement: resolve(rendererPath, "src") }, { find: "@nitrots/api", replacement: resolve(rendererPath, "packages", "api", "src") }, { find: "@nitrots/assets", replacement: resolve(rendererPath, "packages", "assets", "src") }, { find: "@nitrots/avatar", replacement: resolve(rendererPath, "packages", "avatar", "src") }, { find: "@nitrots/camera", replacement: resolve(rendererPath, "packages", "camera", "src") }, { find: "@nitrots/communication", replacement: resolve(rendererPath, "packages", "communication", "src") }, { find: "@nitrots/configuration", replacement: resolve(rendererPath, "packages", "configuration", "src") }, { find: "@nitrots/events", replacement: resolve(rendererPath, "packages", "events", "src") }, { find: "@nitrots/localization", replacement: resolve(rendererPath, "packages", "localization", "src") }, { find: "@nitrots/room", replacement: resolve(rendererPath, "packages", "room", "src") }, { find: "@nitrots/session", replacement: resolve(rendererPath, "packages", "session", "src") }, { find: "@nitrots/sound", replacement: resolve(rendererPath, "packages", "sound", "src") }, { find: "@nitrots/utils/src", replacement: resolve(rendererPath, "packages", "utils", "src") }, { find: "@nitrots/utils", replacement: resolve(rendererPath, "packages", "utils", "src") }, { find: "@", replacement: resolve(__dirname, "src") }, { find: "~", replacement: resolve(__dirname, "node_modules") }, { find: "pixi.js", replacement: resolve(rendererPath, "node_modules", "pixi.js") }, { find: "pixi-filters", replacement: resolve(rendererPath, "node_modules", "pixi-filters") }, { find: "howler", replacement: resolve(rendererPath, "node_modules", "howler") }, ], }, build: { assetsInlineLimit: 102400, chunkSizeWarningLimit: 200000, rollupOptions: { output: { assetFileNames: "src/assets/[name]-[hash].[ext]", manualChunks: (id) => { if (id.includes("node_modules")) { if (id.includes("@nitrots") || id.includes("nitro-renderer")) { return "nitro-renderer"; } return "vendor"; } }, }, }, }, }); CONFIG; file_put_contents($this->clientPath . '/vite.config.mjs', $viteConfig); Process::timeout(5)->run('chmod 644 ' . escapeshellarg($this->clientPath . '/vite.config.mjs')); Process::timeout(5)->run('chown www-data:www-data ' . escapeshellarg($this->clientPath . '/vite.config.mjs')); Log::info('[Nitro] Updated vite.config.mjs'); } private function createYarnLink(): void { $nitrotsDir = $this->clientPath . '/node_modules/@nitrots'; if (! is_dir($nitrotsDir)) { mkdir($nitrotsDir, 0755, true); } Process::timeout(10)->run('rm -rf ' . escapeshellarg($nitrotsDir) . '/*'); Process::timeout(30)->run( 'cp -r ' . escapeshellarg($this->rendererPath . '/node_modules/@nitrots/*') . ' ' . escapeshellarg($nitrotsDir) . '/', ); Process::timeout(10)->run('chown -R www-data:www-data ' . escapeshellarg($nitrotsDir)); Log::info('[Nitro] Copied @nitrots packages'); } private function installDependencies(): void { Log::info('[Nitro] Cleaning renderer node_modules'); Process::timeout(30)->run('rm -rf ' . escapeshellarg($this->rendererPath . '/node_modules')); Log::info('[Nitro] Installing renderer deps'); $rendererYarn = Process::timeout(300)->run( 'cd ' . escapeshellarg($this->rendererPath) . ' && yarn install --ignore-engines 2>&1', ); if (! $rendererYarn->successful()) { Log::warning('[Nitro] Yarn failed for renderer, trying npm'); Process::timeout(300)->run( 'cd ' . escapeshellarg($this->rendererPath) . ' && npm install --legacy-peer-deps 2>&1', ); } Process::timeout(10)->run('chown -R www-data:www-data ' . escapeshellarg($this->rendererPath)); Log::info('[Nitro] Cleaning client node_modules'); Process::timeout(30)->run('rm -rf ' . escapeshellarg($this->clientPath . '/node_modules')); Log::info('[Nitro] Installing client deps'); $clientYarn = Process::timeout(300)->run( 'cd ' . escapeshellarg($this->clientPath) . ' && yarn install --ignore-engines 2>&1', ); if (! $clientYarn->successful()) { Log::warning('[Nitro] Yarn failed for client, trying npm'); Process::timeout(300)->run( 'cd ' . escapeshellarg($this->clientPath) . ' && npm install --legacy-peer-deps 2>&1', ); } $this->createYarnLink(); Process::timeout(10)->run('chown -R www-data:www-data ' . escapeshellarg($this->clientPath)); } public function buildClient(): array { Log::info('[Nitro] Building client'); $yarnBuild = Process::timeout(600)->run( 'cd ' . escapeshellarg($this->clientPath) . ' && yarn build 2>&1', ); if ($yarnBuild->successful()) { Process::timeout(10)->run('chown -R www-data:www-data ' . escapeshellarg($this->clientPath)); Log::info('[Nitro] Build successful (yarn)'); return ['success' => true, 'method' => 'yarn']; } Log::warning('[Nitro] Yarn build failed, trying npm build'); $npmBuild = Process::timeout(600)->run( 'cd ' . escapeshellarg($this->clientPath) . ' && npm run build 2>&1', ); if ($npmBuild->successful()) { Process::timeout(10)->run('chown -R www-data:www-data ' . escapeshellarg($this->clientPath)); Log::info('[Nitro] Build successful (npm)'); return ['success' => true, 'method' => 'npm']; } $output = $yarnBuild->output() . $yarnBuild->errorOutput() . "\n" . $npmBuild->output() . $npmBuild->errorOutput(); Log::error('[Nitro] Build failed', ['output' => substr($output, 0, 500)]); return [ 'success' => false, 'error' => 'Build mislukt: ' . substr($output, 0, 300), ]; } public function deployClient(): void { Log::info('[Nitro] Deploying client'); // Clear and copy Process::timeout(30)->run('rm -rf ' . escapeshellarg($this->clientWebRoot) . '/*'); Process::timeout(60)->run( 'cp -r ' . escapeshellarg($this->clientPath . '/dist') . '/* ' . escapeshellarg($this->clientWebRoot) . '/ 2>&1', ); // Always add/update toolbar color CSS variables to root $indexFile = $this->clientWebRoot . '/index.html'; if (file_exists($indexFile)) { $content = file_get_contents($indexFile); $cssVars = ''; // Replace existing CSS vars or add new if (preg_match('/