* * This file is part of a Moko Consulting project. * * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION * DEFGROUP: MokoPlatform.Enterprise * INGROUP: MokoPlatform.Lib * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform * PATH: /lib/Enterprise/SourceResolver.php * BRIEF: Resolve the root-level source directory across repos (source/, src/, htdocs/) */ declare(strict_types=1); namespace MokoCli; /** * Source Directory Resolver * * Provides a single, consistent fallback chain for locating the root-level * source directory in any MokoCli repository. The preferred directory * is `source/`, with legacy `src/` and `htdocs/` as fallbacks. * * This class exists because Joomla extensions use `src/` for namespace * autoloading (e.g. administrator/components/com_foo/src/). Renaming our * root-level source directory to `source/` avoids that collision. During * the transition period, repos may still use `src/`, so all tooling must * check both. * * Usage: * $dir = SourceResolver::resolve($repoRoot); // 'source', 'src', or 'htdocs' * $abs = SourceResolver::resolveAbsolute($repoRoot); // full path or null * $xmls = SourceResolver::globSource($repoRoot, '*.xml'); // glob under first match * $path = SourceResolver::findUnderSource($repoRoot, 'core/modules'); // subpath lookup * * @since 09.02.00 */ class SourceResolver { /** * Ordered candidate directories. source/ is preferred, src/ is legacy fallback. * * When the migration is complete and all repos use source/, the 'src' * entry can be removed from this list. * * @var string[] */ private const CANDIDATES = ['source', 'src', 'htdocs']; /** Cache of API-resolved entry points keyed by "org/repo". */ private static array $apiCache = []; /** * Resolve the source directory name for a repository root. * * Resolution order: * 1. Gitea Manifest API `entry_point` (when GA_TOKEN/GITEA_TOKEN + GITHUB_REPOSITORY are set) * 2. First candidate directory that exists on the filesystem * 3. 'source' as the default (e.g. for new repos being scaffolded) * * @param string $root Absolute path to the repository root. * @return string Directory name (e.g. 'source', 'src', 'htdocs'). */ public static function resolve(string $root): string { // Try API first (CI environments where token + repo are available) $apiResult = self::resolveFromApi($root); if ($apiResult !== null) { return $apiResult; } // Filesystem fallback foreach (self::CANDIDATES as $candidate) { if (is_dir("{$root}/{$candidate}")) { return $candidate; } } return 'source'; } /** * Query the MokoGitea Manifest API for the entry_point field. * * Only attempts the call when GA_TOKEN or GITEA_TOKEN is set. Results are * cached per org/repo for the lifetime of the process. * * @param string $root Repository root (used to derive org/repo from git remote). * @return string|null Directory name from entry_point, or null if unavailable. */ public static function resolveFromApi(string $root): ?string { $token = getenv('GA_TOKEN') ?: getenv('GITEA_TOKEN') ?: ''; if ($token === '') { return null; } [$org, $repo] = self::resolveOrgRepo($root); if ($org === '' || $repo === '') { return null; } $cacheKey = "{$org}/{$repo}"; if (array_key_exists($cacheKey, self::$apiCache)) { return self::$apiCache[$cacheKey]; } $baseUrl = rtrim(getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech', '/'); $url = "{$baseUrl}/api/v1/repos/{$org}/{$repo}/manifest"; $ctx = stream_context_create([ 'http' => [ 'header' => "Authorization: token {$token}\r\nAccept: application/json\r\n", 'timeout' => 5, 'ignore_errors' => true, ], ]); $body = @file_get_contents($url, false, $ctx); if ($body === false) { self::$apiCache[$cacheKey] = null; return null; } $status = 0; if (isset($http_response_header[0])) { preg_match('/\d{3}/', $http_response_header[0], $m); $status = (int) ($m[0] ?? 0); } if ($status < 200 || $status >= 300) { self::$apiCache[$cacheKey] = null; return null; } $data = json_decode($body, true); $entryPoint = $data['entry_point'] ?? ''; // Normalize: "source/" → "source", "cli/" → "cli" $result = ($entryPoint !== '') ? rtrim($entryPoint, '/') : null; self::$apiCache[$cacheKey] = $result; return $result; } /** * Resolve org/repo from GITHUB_REPOSITORY env or git remote. * * @return array{0: string, 1: string} */ private static function resolveOrgRepo(string $root): array { $envRepo = getenv('GITHUB_REPOSITORY') ?: ''; if ($envRepo !== '' && str_contains($envRepo, '/')) { return explode('/', $envRepo, 2); } $remoteUrl = trim((string) @shell_exec( 'git -C ' . escapeshellarg($root) . ' remote get-url origin 2>/dev/null' )); if ($remoteUrl !== '' && preg_match('#[/:]([^/]+)/([^/]+?)(?:\.git)?$#', $remoteUrl, $m)) { return [$m[1], $m[2]]; } return ['', '']; } /** * Resolve the source directory as an absolute path. * * @param string $root Absolute path to the repository root. * @return string|null Absolute path to the source directory, or null if none exists. */ public static function resolveAbsolute(string $root): ?string { foreach (self::CANDIDATES as $candidate) { $path = "{$root}/{$candidate}"; if (is_dir($path)) { return $path; } } return null; } /** * Glob for files under the source directory. * * Checks each candidate directory in order and returns matches from the * first candidate that produces results. This replaces patterns like: * * glob("{$root}/src/*.xml") * * With the backwards-compatible: * * SourceResolver::globSource($root, '*.xml') * * @param string $root Absolute path to the repository root. * @param string $pattern Glob pattern relative to the source directory. * @return string[] Matched file paths (may be empty). */ public static function globSource(string $root, string $pattern): array { foreach (self::CANDIDATES as $candidate) { $dir = "{$root}/{$candidate}"; if (!is_dir($dir)) { continue; } $matches = glob("{$dir}/{$pattern}") ?: []; if ($matches !== []) { return $matches; } } return []; } /** * Find a subpath under any source directory candidate. * * Useful for locating platform-specific subdirectories like * `core/modules/` (Dolibarr) or `media/templates/` (Joomla client themes) * regardless of whether the repo uses `source/` or `src/`. * * @param string $root Absolute path to the repository root. * @param string $subpath Relative path to look for (e.g. 'core/modules', 'index.ts'). * @return string|null Absolute path if found, null otherwise. */ public static function findUnderSource(string $root, string $subpath): ?string { foreach (self::CANDIDATES as $candidate) { $full = "{$root}/{$candidate}/{$subpath}"; if (file_exists($full) || is_dir($full)) { return $full; } } return null; } /** * Get the ordered list of candidate directory names. * * Useful for workflows or scripts that need to iterate candidates * themselves (e.g. building find/grep patterns). * * @return string[] */ public static function getCandidates(): array { return self::CANDIDATES; } /** * Check whether the resolved source directory is a legacy name (src/). * * @param string $root Absolute path to the repository root. * @return bool True if the repo uses src/ instead of source/. */ public static function isLegacy(string $root): bool { $resolved = self::resolve($root); return $resolved === 'src'; } /** * Emit a deprecation warning to stderr if the repo still uses src/. * * CLI tools should call this after resolving the source directory so * that maintainers know to rename src/ → source/. * * @param string $root Absolute path to the repository root. */ public static function warnIfLegacy(string $root): void { if (self::isLegacy($root)) { fwrite(STDERR, "⚠ WARNING: This repo uses src/ which is deprecated. Rename to source/ per MokoCli conventions.\n"); } } }