$modules * @return array{created: int, updated: int, removed: int, unchanged: int} */ public function publish(string $runtimeModulesDir, array $modules, string $mode = 'symlink'): array { $normalizedMode = strtolower(trim($mode)); if ($normalizedMode === '') { $normalizedMode = 'symlink'; } if (!in_array($normalizedMode, ['symlink', 'copy'], true)) { throw new RuntimeException("Unsupported module asset publish mode '{$mode}'. Use 'symlink' or 'copy'."); } if (is_link($runtimeModulesDir) && !unlink($runtimeModulesDir)) { throw new RuntimeException("Cannot remove existing runtime modules symlink: {$runtimeModulesDir}"); } if (!is_dir($runtimeModulesDir) && !mkdir($runtimeModulesDir, 0775, true) && !is_dir($runtimeModulesDir)) { throw new RuntimeException("Cannot create runtime modules directory: {$runtimeModulesDir}"); } $result = ['created' => 0, 'updated' => 0, 'removed' => 0, 'unchanged' => 0]; $desired = []; foreach ($modules as $manifest) { if (!$manifest instanceof ModuleManifest) { continue; } $moduleWebDir = rtrim($manifest->basePath, '/') . '/web'; if (!is_dir($moduleWebDir)) { continue; } $desired[$manifest->id] = $moduleWebDir; } $entries = scandir($runtimeModulesDir); if ($entries === false) { throw new RuntimeException("Cannot scan runtime modules directory: {$runtimeModulesDir}"); } foreach ($entries as $entry) { if ($entry === '.' || $entry === '..') { continue; } if (isset($desired[$entry])) { continue; } $path = $runtimeModulesDir . '/' . $entry; $this->removePath($path); $result['removed']++; } foreach ($desired as $moduleId => $moduleWebDir) { $targetPath = $runtimeModulesDir . '/' . $moduleId; $targetExists = file_exists($targetPath) || is_link($targetPath); if ($targetExists && $normalizedMode === 'symlink' && is_link($targetPath)) { $targetReal = realpath($targetPath); $sourceReal = realpath($moduleWebDir); if ($targetReal !== false && $sourceReal !== false && $targetReal === $sourceReal) { $result['unchanged']++; continue; } } if ($targetExists) { $this->removePath($targetPath); } if ($normalizedMode === 'symlink') { if (!@symlink($moduleWebDir, $targetPath)) { throw new RuntimeException( "Failed to symlink module assets for '{$moduleId}' ({$moduleWebDir} -> {$targetPath}). " . "Set APP_MODULE_ASSET_MODE=copy if symlinks are unavailable." ); } } else { $this->copyDirectory($moduleWebDir, $targetPath); } if ($targetExists) { $result['updated']++; } else { $result['created']++; } } return $result; } private function removePath(string $path): void { if (is_link($path)) { if (!unlink($path)) { throw new RuntimeException("Cannot remove symlink: {$path}"); } return; } if (is_file($path)) { if (!unlink($path)) { throw new RuntimeException("Cannot remove file: {$path}"); } return; } if (!is_dir($path)) { return; } $entries = scandir($path); if ($entries === false) { throw new RuntimeException("Cannot scan directory: {$path}"); } foreach ($entries as $entry) { if ($entry === '.' || $entry === '..') { continue; } $this->removePath($path . '/' . $entry); } if (!rmdir($path)) { throw new RuntimeException("Cannot remove directory: {$path}"); } } private function copyDirectory(string $sourceDir, string $targetDir): void { if (!is_dir($sourceDir)) { throw new RuntimeException("Source module asset directory not found: {$sourceDir}"); } if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true) && !is_dir($targetDir)) { throw new RuntimeException("Cannot create target module asset directory: {$targetDir}"); } $entries = scandir($sourceDir); if ($entries === false) { throw new RuntimeException("Cannot scan source module asset directory: {$sourceDir}"); } foreach ($entries as $entry) { if ($entry === '.' || $entry === '..') { continue; } $source = $sourceDir . '/' . $entry; $target = $targetDir . '/' . $entry; if (is_link($source)) { $linkTarget = readlink($source); if ($linkTarget === false || !symlink($linkTarget, $target)) { throw new RuntimeException("Cannot copy symlink '{$source}' to '{$target}'"); } continue; } if (is_dir($source)) { $this->copyDirectory($source, $target); continue; } if (!is_file($source)) { continue; } if (!copy($source, $target)) { throw new RuntimeException("Cannot copy file '{$source}' to '{$target}'"); } } } }